/** * Tax rate of an incoming (AP) invoice as read from the DOCUMENT — shared by the * inbox list preview and the review screen. * * Only an e-invoice XML breakdown (tier 0) or a high-confidence provider result is * taken at face value. For text/OCR documents the AMOUNTS decide (gross vs net), because * the heuristic "first n % in the text" / a misread tax amount is unreliable — a stored * `taxes: [{ rate: 19 }]` next to gross == net must NOT claim "19 % detected". * * `certain` = the rate is backed by an authoritative breakdown or by amounts that agree * with each other. The review only WARNS about a differing tax pick when certain. */ export interface InboxTaxRate { rate: number | null certain: boolean /** EN16931 tax category of the dominant breakdown entry (S, AE, K, E, Z, O …), when known */ category: string source: 'document' | 'amounts' | 'tax-amount' | 'text' | 'none' } const num = (v: any): number => (v === '' || v == null ? NaN : Number(v)) const round1 = (r: number) => Math.round(r * 10) / 10 const MAX_PLAUSIBLE_RATE = 30 export const deriveInboxTaxRate = (input: { extracted?: any confidence?: any sourceTier?: number | null /** editable overrides from the review form (fall back to the extracted values) */ net?: any tax?: any gross?: any }): InboxTaxRate => { const ex = input?.extracted || {} const conf = input?.confidence || {} const breakdown = (Array.isArray(ex.taxes) ? ex.taxes : []).filter((x: any) => Number.isFinite(Number(x?.rate))) const dominant = breakdown.length ? [...breakdown].sort((a: any, b: any) => (Math.abs(Number(b?.basis)) || 0) - (Math.abs(Number(a?.basis)) || 0))[0] : null const category = String(dominant?.category || '') // 1. authoritative breakdown (e-invoice XML / high-confidence provider) if (dominant && (Number(input?.sourceTier) === 0 || Number(conf.taxes) >= 0.9)) { return { rate: Number(dominant.rate), certain: true, category, source: 'document' } } const net = Math.abs(num(input?.net ?? ex.lineTotal ?? ex.taxBasisTotal)) const gross = Math.abs(num(input?.gross ?? ex.grandTotal)) const tax = Math.abs(num(input?.tax ?? ex.taxTotal)) const taxPlausible = net > 0 && tax > 0 && tax / net <= MAX_PLAUSIBLE_RATE / 100 // 2. amounts: gross vs net if (net > 0 && gross > 0) { if (Math.abs(gross - net) < 0.01) { // gross == net → no VAT on the document … unless a plausible tax amount says the // net was misread as gross; then the rate is only a guess. if (taxPlausible && gross - tax > 0) return { rate: round1((tax / (gross - tax)) * 100), certain: false, category, source: 'tax-amount' } return { rate: 0, certain: true, category, source: 'amounts' } } if (gross > net) { const r = round1(((gross / net) - 1) * 100) if (r <= MAX_PLAUSIBLE_RATE) return { rate: r, certain: true, category, source: 'amounts' } } } // 3. tax amount vs net if (taxPlausible) return { rate: round1((tax / net) * 100), certain: !(gross > 0), category, source: 'tax-amount' } // 4. rate read from the text / lines — a hint only if (dominant && Number(dominant.rate) <= MAX_PLAUSIBLE_RATE && Number(conf.taxes) >= 0.5) { return { rate: Number(dominant.rate), certain: false, category, source: 'text' } } const lineRate = (Array.isArray(ex.lines) ? ex.lines : []).map((l: any) => Number(l?.taxRate)).find((r: number) => Number.isFinite(r) && r > 0) if (lineRate != null) return { rate: lineRate, certain: false, category, source: 'text' } return { rate: null, certain: false, category, source: 'none' } } /** Tax amount the review should start with: the extracted one unless it contradicts gross − net. */ export const saneInboxTaxTotal = (ex: any): number | '' => { // amounts that can be reconciled (net + tax = gross) are authoritative const fixed = reconcileInboxAmounts(ex) if (fixed.consistent && fixed.tax != null) return fixed.tax const net = num(ex?.lineTotal ?? ex?.taxBasisTotal) const gross = num(ex?.grandTotal) const tax = num(ex?.taxTotal) if (!Number.isFinite(tax)) return Number.isFinite(net) && Number.isFinite(gross) ? Math.round((gross - net) * 100) / 100 : '' if (Number.isFinite(net) && Number.isFinite(gross) && Math.abs(net + tax - gross) > 0.05) { const implausible = Math.abs(tax) >= Math.abs(net) || (net !== 0 && Math.abs(tax / net) > MAX_PLAUSIBLE_RATE / 100) if (implausible) return Math.round((gross - net) * 100) / 100 } return tax } export const formatInboxTaxRate = (rate: number | null | undefined): string => rate == null ? '' : String(Math.round(rate * 10) / 10).replace('.', ',') /* ------------------------------------------------------------------------------------------ * Amount reconciliation + review confidence * ---------------------------------------------------------------------------------------- */ const money2 = (n: number) => Math.round(n * 100) / 100 const finite = (v: any): number | null => { const n = num(v); return Number.isFinite(n) ? n : null } const plausibleRate = (net: number, tax: number) => { if (!(net > 0) || tax < 0) return false const r = (tax / net) * 100 return r <= MAX_PLAUSIBLE_RATE && Math.abs(r - Math.round(r * 2) / 2) <= 0.2 // x % or x.5 % } export interface ReconciledAmounts { net: number | null; tax: number | null; gross: number | null consistent: boolean /** which of net / tax / gross were replaced to make the three agree */ changed: Array<'net' | 'tax' | 'gross'> } /** * Net + tax must equal gross. A value read from a LEARNED position breaks that whenever the * vendor's totals block floats vertically with the number of line items (easybell: the "Netto" * box of a 3-line invoice sits on the VAT line of a 2-line invoice). Try the alternative * readings (label-based heuristic values, the tax breakdown) and, last, derive the odd one out. * `alt` = values of the label-based extraction before the learned regions were applied. */ export const reconcileInboxAmounts = (ex: any, alt: any = {}): ReconciledAmounts => { const cur = { net: finite(ex?.lineTotal ?? ex?.taxBasisTotal), tax: finite(ex?.taxTotal), gross: finite(ex?.grandTotal) } const fits = (n: number | null, t: number | null, g: number | null) => n != null && t != null && g != null && n > 0 && Math.abs(n + t - g) <= 0.02 if (fits(cur.net, cur.tax, cur.gross)) return { ...cur, consistent: true, changed: [] } const tb = Array.isArray(ex?.taxes) ? ex.taxes[0] : null const atb = Array.isArray(alt?.taxes) ? alt.taxes[0] : null const uniq = (xs: any[]) => [...new Set(xs.map(finite).filter((x): x is number => x != null))] const nets = uniq([cur.net, alt?.lineTotal, alt?.taxBasisTotal, tb?.basis, atb?.basis]) const taxes = uniq([cur.tax, alt?.taxTotal, tb?.amount, atb?.amount]) const grosses = uniq([cur.gross, alt?.grandTotal]) const diff = (n: number, t: number, g: number) => (['net', 'tax', 'gross'] as const).filter((k, i) => [n, t, g][i] !== [cur.net, cur.tax, cur.gross][i]) // fewest replacements first let best: ReconciledAmounts | null = null for (const g of grosses) for (const n of nets) for (const t of taxes) { if (!fits(n, t, g) || !plausibleRate(n, t)) continue const changed = diff(n, t, g) if (!best || changed.length < best.changed.length) best = { net: n, tax: t, gross: g, consistent: true, changed: [...changed] } } if (best) return best // derive the odd one out from the two others when that yields a sane VAT rate const { net, tax, gross } = cur if (gross != null && tax != null && gross > tax && plausibleRate(gross - tax, tax)) return { net: money2(gross - tax), tax, gross, consistent: true, changed: ['net'] } if (gross != null && net != null && gross >= net && plausibleRate(net, gross - net)) return { net, tax: money2(gross - net), gross, consistent: true, changed: ['tax'] } if (net != null && tax != null && gross == null && plausibleRate(net, tax)) return { net, tax, gross: money2(net + tax), consistent: true, changed: ['gross'] } return { ...cur, consistent: false, changed: [] } } /** * Review confidence in % — what the inbox badge shows. Only the fields a reviewer actually * checks count (weighted); internal helper keys with fixed heuristic confidences (currency 0.6, * taxBasisTotal 0.5, taxes, isTaxIncluded 0.7) used to drag a perfectly read document to ~85 %. * A missing key field counts as 0; amounts that add up are trusted, amounts that don't are not. */ export const inboxConfidenceScore = (extracted: any, confidence: any): number | null => { const conf = confidence || {} if (!extracted || !Object.keys(conf).length) return null const ex = extracted const c = (key: string, present: boolean) => present ? Math.max(0, Math.min(1, Number(conf[key] ?? 0.5))) : 0 const amounts = reconcileInboxAmounts(ex) const amt = (key: string, present: boolean, role: 'net' | 'tax' | 'gross') => { if (!present) return 0 if (!amounts.consistent) return Math.min(c(key, true), 0.4) return amounts.changed.includes(role) ? 0.8 : Math.max(c(key, true), 0.97) } const vendor = Math.max(c('seller.name', !!ex.seller?.name), c('seller.vatId', !!ex.seller?.vatId), c('paymentIban', !!ex.paymentIban)) const parts: Array<[number, number]> = [ [2, c('documentNo', !!ex.documentNo)], [1.5, c('issueDate', !!ex.issueDate)], [2, vendor], [2, amt('grandTotal', ex.grandTotal != null, 'gross')], [1, amt('lineTotal', (ex.lineTotal ?? ex.taxBasisTotal) != null, 'net')], [1, amt('taxTotal', ex.taxTotal != null || amounts.consistent, 'tax')] ] const total = parts.reduce((s, [w]) => s + w, 0) return Math.round((parts.reduce((s, [w, v]) => s + w * v, 0) / total) * 100) }