Update paddle, and support invoice line item

This commit is contained in:
Yaojia Wang
2026-02-03 21:28:06 +01:00
parent c4e3773df1
commit 35988b1ebf
41 changed files with 6832 additions and 48 deletions

View File

@@ -1,15 +1,30 @@
import apiClient from '../client'
import type { InferenceResponse } from '../types'
export interface ProcessDocumentOptions {
extractLineItems?: boolean
}
// Longer timeout for inference - line items extraction can take 60+ seconds
const INFERENCE_TIMEOUT_MS = 120000
export const inferenceApi = {
processDocument: async (file: File): Promise<InferenceResponse> => {
processDocument: async (
file: File,
options: ProcessDocumentOptions = {}
): Promise<InferenceResponse> => {
const formData = new FormData()
formData.append('file', file)
if (options.extractLineItems) {
formData.append('extract_line_items', 'true')
}
const { data } = await apiClient.post('/api/v1/infer', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
timeout: INFERENCE_TIMEOUT_MS,
})
return data
},

View File

@@ -182,6 +182,62 @@ export interface CrossValidationResult {
details: string[]
}
// Business Features Types (Line Items, VAT)
export interface LineItem {
row_index: number
description: string | null
quantity: string | null
unit: string | null
unit_price: string | null
amount: string | null
article_number: string | null
vat_rate: string | null
is_deduction: boolean
confidence: number
}
export interface LineItemsResult {
items: LineItem[]
header_row: string[]
total_amount: string | null
}
export interface VATBreakdown {
rate: number
base_amount: string | null
vat_amount: string
source: string
}
export interface VATSummary {
breakdowns: VATBreakdown[]
total_excl_vat: string | null
total_vat: string | null
total_incl_vat: string | null
confidence: number
}
export interface MathCheckResult {
rate: number
base_amount: number | null
expected_vat: number | null
actual_vat: number | null
is_valid: boolean
tolerance: number
}
export interface VATValidationResult {
is_valid: boolean
confidence_score: number
math_checks: MathCheckResult[]
total_check: boolean
line_items_vs_summary: boolean | null
amount_consistency: boolean | null
needs_review: boolean
review_reasons: string[]
}
export interface InferenceResult {
document_id: string
document_type: string
@@ -193,6 +249,10 @@ export interface InferenceResult {
visualization_url: string | null
errors: string[]
fallback_used: boolean
// Business features (optional, only when extract_line_items=true)
line_items: LineItemsResult | null
vat_summary: VATSummary | null
vat_validation: VATValidationResult | null
}
export interface InferenceResponse {

View File

@@ -1,7 +1,9 @@
import React, { useState, useRef } from 'react'
import { UploadCloud, FileText, Loader2, CheckCircle2, AlertCircle, Clock } from 'lucide-react'
import { UploadCloud, FileText, Loader2, CheckCircle2, AlertCircle, Clock, Table2 } from 'lucide-react'
import { Button } from './Button'
import { inferenceApi } from '../api/endpoints'
import { LineItemsTable } from './LineItemsTable'
import { VATSummaryCard } from './VATSummaryCard'
import type { InferenceResult } from '../api/types'
export const InferenceDemo: React.FC = () => {
@@ -10,6 +12,7 @@ export const InferenceDemo: React.FC = () => {
const [isProcessing, setIsProcessing] = useState(false)
const [result, setResult] = useState<InferenceResult | null>(null)
const [error, setError] = useState<string | null>(null)
const [extractLineItems, setExtractLineItems] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const handleFileSelect = (file: File | null) => {
@@ -50,9 +53,9 @@ export const InferenceDemo: React.FC = () => {
setError(null)
try {
const response = await inferenceApi.processDocument(selectedFile)
console.log('API Response:', response)
console.log('Visualization URL:', response.result?.visualization_url)
const response = await inferenceApi.processDocument(selectedFile, {
extractLineItems,
})
setResult(response.result)
} catch (err) {
setError(err instanceof Error ? err.message : 'Processing failed')
@@ -65,6 +68,7 @@ export const InferenceDemo: React.FC = () => {
setSelectedFile(null)
setResult(null)
setError(null)
setExtractLineItems(false)
}
const formatFieldName = (field: string): string => {
@@ -183,11 +187,34 @@ export const InferenceDemo: React.FC = () => {
)}
{selectedFile && !isProcessing && (
<div className="mt-6 flex gap-3 justify-end">
<Button variant="secondary" onClick={handleReset}>
Cancel
</Button>
<Button onClick={handleProcess}>Process Invoice</Button>
<div className="mt-6 space-y-4">
{/* Business Features Checkbox */}
<label className="flex items-center gap-3 p-4 bg-warm-bg/50 rounded-lg border border-warm-divider cursor-pointer hover:bg-warm-hover/50 transition-colors">
<input
type="checkbox"
checked={extractLineItems}
onChange={(e) => setExtractLineItems(e.target.checked)}
className="w-5 h-5 rounded border-warm-border text-warm-text-secondary focus:ring-warm-text-secondary"
/>
<div className="flex items-center gap-2">
<Table2 size={18} className="text-warm-text-secondary" />
<div>
<span className="font-medium text-warm-text-primary">
Extract Line Items & VAT
</span>
<p className="text-xs text-warm-text-muted mt-0.5">
Extract product/service rows, VAT breakdown, and cross-validation
</p>
</div>
</div>
</label>
<div className="flex gap-3 justify-end">
<Button variant="secondary" onClick={handleReset}>
Cancel
</Button>
<Button onClick={handleProcess}>Process Invoice</Button>
</div>
</div>
)}
</div>
@@ -274,6 +301,21 @@ export const InferenceDemo: React.FC = () => {
</div>
</div>
{/* Line Items */}
{result.line_items && (
<div className="bg-warm-card rounded-xl border border-warm-border p-6 shadow-sm">
<h3 className="text-lg font-bold text-warm-text-primary mb-5 flex items-center gap-2">
<span className="w-1 h-5 bg-warm-text-secondary rounded-full"></span>
<Table2 size={20} className="text-warm-text-secondary" />
Line Items
<span className="ml-auto text-sm font-normal text-warm-text-muted">
{result.line_items.items.length} item(s)
</span>
</h3>
<LineItemsTable lineItems={result.line_items} />
</div>
)}
{/* Visualization */}
{result.visualization_url && (
<div className="bg-warm-card rounded-xl border border-warm-border p-6 shadow-sm">
@@ -437,6 +479,20 @@ export const InferenceDemo: React.FC = () => {
</div>
)}
{/* VAT Summary */}
{result.vat_summary && (
<div className="bg-warm-card rounded-xl border border-warm-border p-6 shadow-sm">
<h3 className="text-lg font-bold text-warm-text-primary mb-4 flex items-center gap-2">
<span className="w-1 h-5 bg-warm-text-secondary rounded-full"></span>
VAT Summary
</h3>
<VATSummaryCard
vatSummary={result.vat_summary}
vatValidation={result.vat_validation}
/>
</div>
)}
{/* Errors */}
{result.errors.length > 0 && (
<div className="bg-warm-card rounded-xl border border-warm-border p-6 shadow-sm">

View File

@@ -0,0 +1,128 @@
import React from 'react'
import { CheckCircle2, MinusCircle } from 'lucide-react'
import type { LineItemsResult } from '../api/types'
interface LineItemsTableProps {
lineItems: LineItemsResult
}
export const LineItemsTable: React.FC<LineItemsTableProps> = ({ lineItems }) => {
if (!lineItems.items || lineItems.items.length === 0) {
return (
<div className="text-center py-8 text-warm-text-muted">
No line items found in this document
</div>
)
}
return (
<div className="space-y-4">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-warm-divider">
<th className="text-left py-3 px-4 font-semibold text-warm-text-muted text-xs uppercase tracking-wide">
#
</th>
<th className="text-left py-3 px-4 font-semibold text-warm-text-muted text-xs uppercase tracking-wide">
Description
</th>
<th className="text-right py-3 px-4 font-semibold text-warm-text-muted text-xs uppercase tracking-wide">
Qty
</th>
<th className="text-right py-3 px-4 font-semibold text-warm-text-muted text-xs uppercase tracking-wide">
Unit Price
</th>
<th className="text-right py-3 px-4 font-semibold text-warm-text-muted text-xs uppercase tracking-wide">
Amount
</th>
<th className="text-right py-3 px-4 font-semibold text-warm-text-muted text-xs uppercase tracking-wide">
VAT %
</th>
<th className="text-center py-3 px-4 font-semibold text-warm-text-muted text-xs uppercase tracking-wide">
Conf.
</th>
</tr>
</thead>
<tbody>
{lineItems.items.map((item) => (
<tr
key={`row-${item.row_index}`}
className={`border-b border-warm-divider hover:bg-warm-hover/50 transition-colors ${
item.is_deduction ? 'bg-red-50' : ''
}`}
>
<td className="py-3 px-4 text-warm-text-muted font-mono text-xs">
{item.row_index}
</td>
<td className="py-3 px-4 font-medium max-w-xs truncate">
<div className="flex items-center gap-2">
{item.is_deduction && (
<MinusCircle size={14} className="text-red-500 flex-shrink-0" />
)}
<span className={item.is_deduction ? 'text-red-600' : 'text-warm-text-primary'}>
{item.description || '-'}
</span>
</div>
</td>
<td className="py-3 px-4 text-right text-warm-text-primary font-mono">
{item.quantity || '-'}
{item.unit && (
<span className="text-warm-text-muted ml-1">{item.unit}</span>
)}
</td>
<td className="py-3 px-4 text-right text-warm-text-primary font-mono">
{item.unit_price || '-'}
</td>
<td className={`py-3 px-4 text-right font-bold font-mono ${
item.is_deduction ? 'text-red-600' : 'text-warm-text-primary'
}`}>
{item.amount || '-'}
</td>
<td className="py-3 px-4 text-right text-warm-text-secondary font-mono">
{item.vat_rate ? `${item.vat_rate}%` : '-'}
</td>
<td className="py-3 px-4 text-center">
<div className="flex items-center justify-center gap-1">
<CheckCircle2
size={14}
className={
item.confidence >= 0.8
? 'text-green-500'
: item.confidence >= 0.5
? 'text-yellow-500'
: 'text-red-500'
}
/>
<span
className={`text-xs font-medium ${
item.confidence >= 0.8
? 'text-green-600'
: item.confidence >= 0.5
? 'text-yellow-600'
: 'text-red-600'
}`}
>
{(item.confidence * 100).toFixed(0)}%
</span>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{lineItems.total_amount && (
<div className="flex justify-end pt-4 border-t border-warm-divider">
<div className="text-right">
<span className="text-sm text-warm-text-muted mr-4">Total:</span>
<span className="text-lg font-bold text-warm-text-primary font-mono">
{lineItems.total_amount} SEK
</span>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,188 @@
import React from 'react'
import { CheckCircle2, AlertCircle, AlertTriangle } from 'lucide-react'
import type { VATSummary, VATValidationResult } from '../api/types'
interface VATSummaryCardProps {
vatSummary: VATSummary
vatValidation?: VATValidationResult | null
}
export const VATSummaryCard: React.FC<VATSummaryCardProps> = ({
vatSummary,
vatValidation,
}) => {
const hasBreakdowns = vatSummary.breakdowns && vatSummary.breakdowns.length > 0
return (
<div className="space-y-4">
{/* VAT Breakdowns by Rate */}
{hasBreakdowns && (
<div className="space-y-2">
<h4 className="text-sm font-semibold text-warm-text-muted uppercase tracking-wide">
VAT Breakdown
</h4>
<div className="space-y-2">
{vatSummary.breakdowns.map((breakdown, index) => (
<div
key={index}
className="p-3 bg-warm-bg/70 rounded-lg border border-warm-divider"
>
<div className="flex justify-between items-center">
<span className="text-sm font-bold text-warm-text-secondary">
{breakdown.rate}% Moms
</span>
<span className="text-xs text-warm-text-muted px-2 py-0.5 bg-warm-selected rounded">
{breakdown.source}
</span>
</div>
<div className="mt-2 grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-warm-text-muted">Base: </span>
<span className="font-mono text-warm-text-primary">
{breakdown.base_amount ?? 'N/A'}
</span>
</div>
<div>
<span className="text-warm-text-muted">VAT: </span>
<span className="font-mono font-bold text-warm-text-primary">
{breakdown.vat_amount ?? 'N/A'}
</span>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Totals */}
<div className="pt-4 border-t border-warm-divider space-y-2">
{vatSummary.total_excl_vat && (
<div className="flex justify-between text-sm">
<span className="text-warm-text-muted">Excl. VAT:</span>
<span className="font-mono text-warm-text-primary">
{vatSummary.total_excl_vat}
</span>
</div>
)}
{vatSummary.total_vat && (
<div className="flex justify-between text-sm">
<span className="text-warm-text-muted">Total VAT:</span>
<span className="font-mono font-bold text-warm-text-secondary">
{vatSummary.total_vat}
</span>
</div>
)}
{vatSummary.total_incl_vat && (
<div className="flex justify-between text-sm pt-2 border-t border-warm-divider">
<span className="font-semibold text-warm-text-primary">Incl. VAT:</span>
<span className="font-mono font-bold text-warm-text-primary text-base">
{vatSummary.total_incl_vat}
</span>
</div>
)}
</div>
{/* Confidence */}
<div className="flex items-center gap-2 text-xs">
<CheckCircle2 size={14} className="text-warm-text-secondary" />
<span className="text-warm-text-muted">
Confidence: {(vatSummary.confidence * 100).toFixed(1)}%
</span>
</div>
{/* Validation Results */}
{vatValidation && (
<div className="pt-4 border-t border-warm-divider">
<h4 className="text-sm font-semibold text-warm-text-muted uppercase tracking-wide mb-3">
VAT Validation
</h4>
<div
className={`
p-3 rounded-lg mb-3 flex items-center gap-3
${
vatValidation.is_valid
? 'bg-green-50 border border-green-200'
: vatValidation.needs_review
? 'bg-yellow-50 border border-yellow-200'
: 'bg-red-50 border border-red-200'
}
`}
>
{vatValidation.is_valid ? (
<>
<CheckCircle2 size={20} className="text-green-600 flex-shrink-0" />
<span className="font-bold text-green-800 text-sm">
VAT Calculation Valid
</span>
</>
) : vatValidation.needs_review ? (
<>
<AlertTriangle size={20} className="text-yellow-600 flex-shrink-0" />
<span className="font-bold text-yellow-800 text-sm">
Needs Manual Review
</span>
</>
) : (
<>
<AlertCircle size={20} className="text-red-600 flex-shrink-0" />
<span className="font-bold text-red-800 text-sm">
Validation Failed
</span>
</>
)}
</div>
{/* Math Checks */}
{vatValidation.math_checks && vatValidation.math_checks.length > 0 && (
<div className="space-y-2 mb-3">
{vatValidation.math_checks.map((check, index) => (
<div
key={index}
className={`
p-2 rounded text-xs flex items-center justify-between
${
check.is_valid
? 'bg-green-50 border border-green-200'
: 'bg-red-50 border border-red-200'
}
`}
>
<span className={check.is_valid ? 'text-green-700' : 'text-red-700'}>
{check.rate}%: {check.base_amount?.toFixed(2) ?? 'N/A'} x {check.rate}% ={' '}
{check.expected_vat?.toFixed(2) ?? 'N/A'}
</span>
{check.is_valid ? (
<CheckCircle2 size={14} className="text-green-600" />
) : (
<AlertCircle size={14} className="text-red-600" />
)}
</div>
))}
</div>
)}
{/* Review Reasons */}
{vatValidation.review_reasons && vatValidation.review_reasons.length > 0 && (
<div className="space-y-1">
{vatValidation.review_reasons.map((reason, index) => (
<div
key={index}
className="text-xs text-yellow-700 bg-yellow-50 p-2 rounded border border-yellow-200"
>
{reason}
</div>
))}
</div>
)}
{/* Confidence Score */}
<div className="mt-3 text-xs text-warm-text-muted">
Validation confidence: {(vatValidation.confidence_score * 100).toFixed(1)}%
</div>
</div>
)}
</div>
)
}