From 1a3686dd45915a31bf52c2b3265144239c3232a8 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Mon, 31 Aug 2026 11:40:30 +0200 Subject: [PATCH] feat(inbox): promote a single prominent amount into the editable total (#2073) * feat(inbox): promote a single prominent amount into the editable total Follow-up to #2048 after founder review: the Belopp row was load-bearing for matching but read-only, so a misread amount could not be corrected, and an empty TOTALT still read as "extraction failed". - promoteSingleProminentAmount (extraction post-step, all intake paths): documentKind other/government_letter with no total and exactly one distinct nonzero prominent amount gets it copied into totals.total, stamped totalSource: 'prominent'. Multi-amount documents are left alone: picking one silently would invent a total. - provenance keeps the safety rails: matching demotes a promoted total back through the prominent-amounts fallback (0.85 discount, date guard, amountSource tag), so the nightly receipt-hunt still excludes these documents and confidence never presents as certainty. - the fields-PATCH route clears totalSource when a human edits TOTALT: a user-set amount is a verified total at full weight. - the read-only Belopp row now renders only for multi-amount documents, and filters zero amounts ("Totalt manadspris: 0 kr" noise). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Hqm9QgdyNAFaWiz6Ww7pgb * fix(inbox): review pass: concurrency-safe fields PATCH, zero-amount predicate CodeRabbit findings on #2073: - the fields-PATCH read-merge-write could let a racing autosave restore a stale extracted_data blob (including a totalSource stamp a concurrent TOTALT edit had just cleared). The update is now conditional on the trigger-maintained updated_at; zero rows matched returns 409 and the client's next debounced save re-reads. - hasAnyExtractedField now uses the same meaningful-amount predicate as the Belopp render filter, so a zero-only prominentAmounts list no longer suppresses the retry / upgrade affordances. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Hqm9QgdyNAFaWiz6Ww7pgb --------- Co-authored-by: Claude Fable 5 --- DECISIONS.md | 1 + .../general/InvoiceInboxWorkspace.tsx | 23 ++++++-- components/inbox/TransactionMatchPicker.tsx | 7 ++- .../__tests__/extract-invoice-fields.test.ts | 58 ++++++++++++++++++- .../__tests__/fields-patch-merge.test.ts | 54 +++++++++++++++++ extensions/general/invoice-inbox/index.ts | 24 +++++++- .../lib/extract-invoice-fields.ts | 45 +++++++++++++- .../__tests__/underlag-candidates.test.ts | 53 +++++++++++++++++ lib/agent-context/underlag-candidates.ts | 8 ++- lib/receipt-hunt/__tests__/select.test.ts | 18 ++++++ types/index.ts | 5 ++ 11 files changed, 282 insertions(+), 14 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index 94f07a49..f2a5798e 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1381,4 +1381,5 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-30] Receipt-hunt excludes prominent-amounts fallback candidates (amountSource tag on UnderlagCandidate): the nightly hunt scans outflows only and its 0.8 skip-adjudication threshold was calibrated for invoice totals, so a fallback pair (0.85 on date+printed-figure, no merchant) would auto-classify certain on a wrong-by-construction pairing. Fallback docs stay reachable via picker + agent candidates. [2026-08-31] Re-versioned the ignore_transaction CHECK pair to 20260831070000/070001 and rebuilt its value list from main's 20260830160000: three op-type CHECK pairs (book_skattekonto 130000, delete_draft_invoice 150000, update_salary_run 160000) landed on main after this branch's pair was written, and a wholesale re-created CHECK from a stale list applying last would silently revoke those op types on prod (the standing migration hazard from the #1411 rebuild). [2026-08-31] Login/register methods come from GoTrue (/auth/v1/settings + admin customProviders) instead of app-side flags; NEXT_PUBLIC_GOOGLE_AUTH_ENABLED removed (PR #1869): the Supabase dashboard becomes the single switch, an allowlist of auth-js provider ids filters non-login entries like anonymous_users, and hosted rendering is unchanged because Google is enabled in prod GoTrue. The Vercel env var stays set for old-build rollback safety; delete it after a few deploys. +[2026-08-31] Single prominent amount is PROMOTED into editable totals.total (totalSource='prominent') instead of living in a read-only Belopp row: Emil's call, an uncorrectable load-bearing value violated the prefill-override-editors rule. Provenance keeps matching fallback-grade (discount, date guard, hunt exclusion); a user edit of TOTALT clears the stamp. Multi-amount docs keep the Belopp row: promoting one of several figures would invent a total. [2026-08-31] Image-scan red fixed by bumping the node:22-alpine digest (alpine 3.23 to 3.24.1), not by widening the gate: the Dockerfile's apk-upgrade layer is frozen by the GHCR buildx layer cache, so a fix published after the last cache-busting change (libssl3 3.5.8-r0 for CVE-2026-14456) never reaches the published image until the FROM digest moves; the red scheduled scan is the designed alarm for exactly this bump. cron.Dockerfile gained the same apk upgrade (it had none). diff --git a/components/extensions/general/InvoiceInboxWorkspace.tsx b/components/extensions/general/InvoiceInboxWorkspace.tsx index 22d66fe0..6dc9c2f1 100644 --- a/components/extensions/general/InvoiceInboxWorkspace.tsx +++ b/components/extensions/general/InvoiceInboxWorkspace.tsx @@ -284,7 +284,10 @@ function hasAnyExtractedField(data: InvoiceExtractionResult | null): boolean { inv?.invoiceNumber || inv?.invoiceDate || inv?.dueDate || inv?.paymentReference || t?.subtotal != null || t?.vatAmount != null || t?.total != null || (data.lineItems?.length ?? 0) > 0 || (data.vatBreakdown?.length ?? 0) > 0 || - (data.prominentAmounts?.length ?? 0) > 0 + // Same meaningful-amount predicate as the Belopp row render filter: a + // zero-only prominentAmounts list must not count as "found something" + // and suppress the retry / upgrade affordances. + (data.prominentAmounts ?? []).some((a) => Number.isFinite(a.amount) && a.amount !== 0) ) } @@ -2912,7 +2915,8 @@ function FieldsRail({ {(data?.documentKind || data?.payment?.method || data?.pages || - (data?.totals?.total == null && (data?.prominentAmounts?.length ?? 0) > 0)) && ( + (data?.totals?.total == null && + (data?.prominentAmounts ?? []).some((a) => Number.isFinite(a.amount) && a.amount !== 0))) && (
{data?.documentKind && (
@@ -2930,14 +2934,21 @@ function FieldsRail({
)} - {/* Amounts read off a non-invoice document (bankintyg, avtal): - without this row the empty "Totalt" field reads as if extraction - missed them. */} - {data?.totals?.total == null && (data?.prominentAmounts?.length ?? 0) > 0 && ( + {/* Amounts read off a multi-amount non-invoice document (an AGI + besked listing lön/skatt/avgifter): no single figure is "the" + total, so they show here as context while TOTALT stays empty for + the user to settle. Single-amount documents don't render this: + their amount is promoted into the editable TOTALT field + (promoteSingleProminentAmount), which also hides this row via + the totals.total == null condition. Zero amounts are noise + ("Totalt månadspris: 0 kr"), same filter as matching applies. */} + {data?.totals?.total == null && + (data?.prominentAmounts ?? []).some((a) => Number.isFinite(a.amount) && a.amount !== 0) && (
{t('prominent_amounts_label')} {(data?.prominentAmounts ?? []) + .filter((a) => Number.isFinite(a.amount) && a.amount !== 0) .map((a) => a.label ? `${a.label}: ${formatCurrency(a.amount, data?.invoice?.currency ?? 'SEK')}` diff --git a/components/inbox/TransactionMatchPicker.tsx b/components/inbox/TransactionMatchPicker.tsx index 2b089a9f..02d74a3a 100644 --- a/components/inbox/TransactionMatchPicker.tsx +++ b/components/inbox/TransactionMatchPicker.tsx @@ -132,7 +132,12 @@ export default function TransactionMatchPicker({ const parsed = new Date(rawInvoiceDate) return Number.isNaN(parsed.getTime()) ? new Date() : parsed }, [rawInvoiceDate]) - const total = extractedData?.totals?.total ?? null + // A total promoted from the document's single prominent amount (totalSource + // 'prominent') is fallback-grade evidence, not an invoice total: demote it + // here so it is scored through the discounted fallback path below. A + // user-edited total has the stamp cleared and counts at full weight. + const total = + extractedData?.totalSource === 'prominent' ? null : (extractedData?.totals?.total ?? null) const receiptCurrency = (extractedData?.invoice?.currency ?? 'SEK').toUpperCase() const supplier = extractedData?.supplier?.name ?? null // Non-invoice documents (bankintyg, avtal) have no total but often show the diff --git a/extensions/general/invoice-inbox/__tests__/extract-invoice-fields.test.ts b/extensions/general/invoice-inbox/__tests__/extract-invoice-fields.test.ts index ea09720d..2f6b872e 100644 --- a/extensions/general/invoice-inbox/__tests__/extract-invoice-fields.test.ts +++ b/extensions/general/invoice-inbox/__tests__/extract-invoice-fields.test.ts @@ -122,7 +122,9 @@ describe('extractInvoiceFields', () => { expect(data.confidence).toBe(1) }) - it('keeps prominentAmounts from a non-invoice document (bankintyg/avtal)', async () => { + it('promotes a single prominent amount into the editable total (bankintyg/avtal)', async () => { + // Zero amounts are noise ("Totalt månadspris: 0 kr"), so this document + // still has exactly one meaningful figure and it becomes TOTALT. mockCreate.mockReturnValueOnce( aiResponse({ ...VALID_RESULT, @@ -132,7 +134,10 @@ describe('extractInvoiceFields', () => { lineItems: [], totals: { subtotal: null, vatAmount: null, total: null }, vatBreakdown: [], - prominentAmounts: [{ amount: 2500, label: 'Anslutnings-/Engångspris' }], + prominentAmounts: [ + { amount: 0, label: 'Totalt månadspris' }, + { amount: 2500, label: 'Anslutnings-/Engångspris' }, + ], }) ) const { data } = await extractInvoiceFields({ @@ -140,12 +145,59 @@ describe('extractInvoiceFields', () => { mimeType: 'application/pdf', fileName: 'affarsavtal.pdf', }) - expect(data.totals.total).toBeNull() + expect(data.totals.total).toBe(2500) + expect(data.totalSource).toBe('prominent') + // The source list is preserved: matching demotes the promoted total back + // through it, and re-extraction stays idempotent. expect(data.prominentAmounts).toEqual([ + { amount: 0, label: 'Totalt månadspris' }, { amount: 2500, label: 'Anslutnings-/Engångspris' }, ]) }) + it('does not promote when the document shows several distinct amounts', async () => { + mockCreate.mockReturnValueOnce( + aiResponse({ + ...VALID_RESULT, + documentKind: 'government_letter', + lineItems: [], + totals: { subtotal: null, vatAmount: null, total: null }, + vatBreakdown: [], + prominentAmounts: [ + { amount: 4568, label: 'Arbetsgivaravgift' }, + { amount: 8151, label: 'Skatt' }, + ], + }) + ) + const { data } = await extractInvoiceFields({ + buffer: Buffer.from('%PDF'), + mimeType: 'application/pdf', + fileName: 'agi-besked.pdf', + }) + expect(data.totals.total).toBeNull() + expect(data.totalSource).toBeUndefined() + }) + + it('never promotes on invoices or receipts', async () => { + // A receipt whose total was unreadable must not have a stray printed + // figure laundered into its total. + mockCreate.mockReturnValueOnce( + aiResponse({ + ...VALID_RESULT, + documentKind: 'receipt', + totals: { subtotal: null, vatAmount: null, total: null }, + prominentAmounts: [{ amount: 999, label: 'Pris' }], + }) + ) + const { data } = await extractInvoiceFields({ + buffer: Buffer.from('%PDF'), + mimeType: 'application/pdf', + fileName: 'kvitto.pdf', + }) + expect(data.totals.total).toBeNull() + expect(data.totalSource).toBeUndefined() + }) + it('degrades a hallucinated prominentAmounts shape to an empty list, not a parse failure', async () => { mockCreate.mockReturnValueOnce( aiResponse({ ...VALID_RESULT, prominentAmounts: [{ amount: 'tjugofemtusen' }] }) diff --git a/extensions/general/invoice-inbox/__tests__/fields-patch-merge.test.ts b/extensions/general/invoice-inbox/__tests__/fields-patch-merge.test.ts index 0040f458..11bef362 100644 --- a/extensions/general/invoice-inbox/__tests__/fields-patch-merge.test.ts +++ b/extensions/general/invoice-inbox/__tests__/fields-patch-merge.test.ts @@ -144,6 +144,60 @@ describe('PATCH /items/:id/fields', () => { expect(merged.supplier?.name).toBe('Restaurang Riddaren AB') }) + it('clears totalSource when the user edits TOTALT', async () => { + // A promoted prominent amount (totalSource 'prominent') is fallback-grade + // in matching; the moment a human sets the total it is a verified figure. + const before = { ...fullExtraction(), totalSource: 'prominent' as const } + const mock = createQueuedMockSupabase() + mock.enqueue({ data: { id: 'item-1', extracted_data: before, created_supplier_invoice_id: null } }) + mock.enqueue({ data: { id: 'item-1', extracted_data: {} } }) + + const ctx = buildCtx(mock.supabase) + await fieldsRoute.handler(makeReq({ totals: { total: 2500 } }), ctx) + + const update = mock.calls.find((c) => c.method === 'update') + const merged = (update?.args?.[0] as { extracted_data: InvoiceExtractionResult }).extracted_data + expect(merged.totals?.total).toBe(2500) + expect(merged.totalSource).toBeNull() + }) + + it('keeps totalSource when the edit touches another field', async () => { + const before = { ...fullExtraction(), totalSource: 'prominent' as const } + const mock = createQueuedMockSupabase() + mock.enqueue({ data: { id: 'item-1', extracted_data: before, created_supplier_invoice_id: null } }) + mock.enqueue({ data: { id: 'item-1', extracted_data: {} } }) + + const ctx = buildCtx(mock.supabase) + await fieldsRoute.handler(makeReq({ supplier: { name: 'SEB' } }), ctx) + + const update = mock.calls.find((c) => c.method === 'update') + const merged = (update?.args?.[0] as { extracted_data: InvoiceExtractionResult }).extracted_data + expect(merged.totalSource).toBe('prominent') + }) + + it('returns 409 when the row changed under the edit (optimistic concurrency)', async () => { + // The handler is read-merge-write over the whole jsonb blob; a racing + // autosave would otherwise restore stale fields (including a + // totalSource stamp a concurrent TOTALT edit had cleared). The update is + // conditional on updated_at; zero rows matched surfaces as a 409. + const mock = createQueuedMockSupabase() + mock.enqueue({ + data: { + id: 'item-1', + extracted_data: fullExtraction(), + created_supplier_invoice_id: null, + updated_at: '2026-08-31T09:00:00Z', + }, + }) + mock.enqueue({ data: null }) + + const ctx = buildCtx(mock.supabase) + const res = await fieldsRoute.handler(makeReq({ totals: { total: 2500 } }), ctx) + expect(res.status).toBe(409) + const { body } = await parseJsonResponse<{ error: string }>(res) + expect(body.error).toContain('samtidigt') + }) + it('refuses once the item became a supplier invoice', async () => { const mock = createQueuedMockSupabase() mock.enqueue({ diff --git a/extensions/general/invoice-inbox/index.ts b/extensions/general/invoice-inbox/index.ts index 3ac77001..91779665 100644 --- a/extensions/general/invoice-inbox/index.ts +++ b/extensions/general/invoice-inbox/index.ts @@ -801,7 +801,7 @@ export const invoiceInboxExtension: Extension = { const { data: item } = await ctx.supabase .from('invoice_inbox_items') - .select('id, extracted_data, created_supplier_invoice_id') + .select('id, extracted_data, created_supplier_invoice_id, updated_at') .eq('id', id) .eq('company_id', ctx.companyId) .maybeSingle() @@ -836,18 +836,38 @@ export const invoiceInboxExtension: Extension = { vatBreakdown: current.vatBreakdown ?? [], confidence: current.confidence ?? 0, } + // A human touching TOTALT settles it: the value stops being a + // promoted prominent amount (totalSource 'prominent', fallback-grade + // in matching) and becomes a user-verified total at full weight. + if (body.totals && 'total' in body.totals) { + merged.totalSource = null + } + // Optimistic concurrency on the row's trigger-maintained updated_at: + // this handler is read-merge-write over the whole jsonb blob, so a + // write racing another autosave would silently restore the loser's + // stale copy of every field it did not touch: including a + // totalSource: 'prominent' stamp a concurrent TOTALT edit had just + // cleared. Zero rows updated means the row moved under us; the client + // gets a 409 and its next debounced save re-reads and re-applies. const { data: updated, error: updateError } = await ctx.supabase .from('invoice_inbox_items') .update({ extracted_data: merged as unknown as Record }) .eq('id', id) .eq('company_id', ctx.companyId) + .eq('updated_at', (item as { updated_at: string }).updated_at) .select('id, extracted_data') - .single() + .maybeSingle() if (updateError) { return NextResponse.json({ error: updateError.message }, { status: 500 }) } + if (!updated) { + return NextResponse.json( + { error: 'Posten ändrades samtidigt av någon annan. Försök igen.' }, + { status: 409 } + ) + } return NextResponse.json({ data: updated }) }, diff --git a/extensions/general/invoice-inbox/lib/extract-invoice-fields.ts b/extensions/general/invoice-inbox/lib/extract-invoice-fields.ts index 8061b0ff..4eb4e7aa 100644 --- a/extensions/general/invoice-inbox/lib/extract-invoice-fields.ts +++ b/extensions/general/invoice-inbox/lib/extract-invoice-fields.ts @@ -173,8 +173,51 @@ export const ExtractionSchema = z.object({ ) .catch([]) .optional(), + // Set by promoteSingleProminentAmount (code, never the model): 'prominent' + // means totals.total was copied from the document's single prominent amount + // so the user gets one editable TOTALT field. Matching treats such a total + // as fallback-grade (discounted, date-guarded, hunt-excluded); a user edit + // of totals.total clears it. In the schema so re-validation paths + // (PUT /extracted-data, MCP set) don't silently strip the provenance and + // launder a printed figure into a full-weight invoice total. + totalSource: z.enum(['prominent']).nullable().catch(null).optional(), }) +/** + * Give non-invoice documents one editable amount field. + * + * A bankintyg or avtal has no "Att betala" total, so the extractor leaves + * totals.total null; but when the document shows exactly one distinct amount + * there is nothing ambiguous about which figure the user means, and leaving + * TOTALT empty while the amount hides in a read-only row read as "extraction + * failed" (and made a misread amount uncorrectable). Promote that single + * amount into totals.total, stamped totalSource: 'prominent' so matching + * keeps treating it as fallback-grade evidence and the fields-PATCH route can + * clear the stamp when a human edits the value. + * + * Multi-amount documents are left alone: picking one silently would invent a + * total the document does not have. + */ +export function promoteSingleProminentAmount( + data: InvoiceExtractionResult +): InvoiceExtractionResult { + if (data.documentKind !== 'other' && data.documentKind !== 'government_letter') return data + if (data.totals.total != null) return data + const distinct = [ + ...new Set( + (data.prominentAmounts ?? []) + .map((a) => a.amount) + .filter((a) => Number.isFinite(a) && a !== 0) + ), + ] + if (distinct.length !== 1) return data + return { + ...data, + totals: { ...data.totals, total: distinct[0] }, + totalSource: 'prominent', + } +} + // Agent-supplied extraction: accountSuggestion is preserved instead of forced // to null. Agents (unlike AI extractors) can reliably assign a BAS expense // account; the regex enforces the class-4-7 range required for cost accounts. @@ -631,7 +674,7 @@ export async function extractInvoiceFields( return { // accountSuggestion is null at this point, enforced by the schema's // .transform, so no post-validation coercion is needed. - data: { ...validated, confidence: 1 }, + data: promoteSingleProminentAmount({ ...validated, confidence: 1 }), rawText, model, } diff --git a/lib/agent-context/__tests__/underlag-candidates.test.ts b/lib/agent-context/__tests__/underlag-candidates.test.ts index 0a9e96ea..9aad235f 100644 --- a/lib/agent-context/__tests__/underlag-candidates.test.ts +++ b/lib/agent-context/__tests__/underlag-candidates.test.ts @@ -16,6 +16,7 @@ function extraction(partial: { vat?: number | null currency?: string prominentAmounts?: { amount: number; label: string | null }[] + totalSource?: 'prominent' | null }): InvoiceExtractionResult { return { supplier: { @@ -30,6 +31,7 @@ function extraction(partial: { totals: { subtotal: null, vatAmount: partial.vat ?? null, total: partial.total ?? null }, vatBreakdown: [], prominentAmounts: partial.prominentAmounts, + totalSource: partial.totalSource, confidence: 0.9, } as InvoiceExtractionResult } @@ -183,6 +185,57 @@ describe('scoreUnderlagCandidates', () => { expect(out[0].matchReasons.join(' ')).toContain('Anslutnings-/Engångspris') }) + it('scores a promoted total (totalSource prominent) as fallback-grade, not invoice-grade', () => { + // promoteSingleProminentAmount fills TOTALT for the UI, but matching must + // not mistake that for an invoice total: same discount, same tagging. + const out = scoreUnderlagCandidates( + { ...tx, description: 'AVGIFT', merchant_name: null, amount: -2500, date: '2026-08-26' }, + [ + { + id: 'item-promoted', + document_id: 'doc-promoted', + extracted_data: extraction({ + supplier: 'SEB', + date: '2026-08-26', + total: 2500, + totalSource: 'prominent', + prominentAmounts: [{ amount: 2500, label: 'Anslutnings-/Engångspris' }], + }), + channel_context: null, + }, + ], + ) + expect(out).toHaveLength(1) + expect(out[0].confidence).toBeLessThan(1) + expect(out[0].amountSource).toBe('prominent') + }) + + it('scores a user-corrected total at full weight', () => { + // The fields-PATCH route clears totalSource when a human edits TOTALT: + // from then on the amount is a verified total, no discount. + const out = scoreUnderlagCandidates( + { ...tx, description: 'AVGIFT', merchant_name: null, amount: -2500, date: '2026-08-26' }, + [ + { + id: 'item-corrected', + document_id: 'doc-corrected', + extracted_data: extraction({ + supplier: 'SEB', + date: '2026-08-26', + total: 2500, + totalSource: null, + prominentAmounts: [{ amount: 9999, label: 'Fel belopp' }], + }), + channel_context: null, + }, + ], + ) + expect(out).toHaveLength(1) + expect(out[0].confidence).toBe(1) + expect(out[0].amountSource).toBe('total') + expect(out[0].total_amount).toBe(2500) + }) + it('does not let a prominent amount alone carry a dateless document over the floor', () => { // Amount agreement without a date is weaker than a total + date pair; the // candidate surface trades recall for precision, so this stays in the diff --git a/lib/agent-context/underlag-candidates.ts b/lib/agent-context/underlag-candidates.ts index 4320964e..6e4ba7cd 100644 --- a/lib/agent-context/underlag-candidates.ts +++ b/lib/agent-context/underlag-candidates.ts @@ -111,7 +111,13 @@ function extractionSignals(extracted: InvoiceExtractionResult | null | undefined return { supplier: extracted?.supplier?.name?.trim() || null, date: extracted?.invoice?.invoiceDate ?? null, - total: extracted?.totals?.total ?? null, + // A total promoted from the document's single prominent amount + // (totalSource 'prominent') exists for the editable TOTALT field, not as + // invoice-grade evidence: score it through the fallback path below (the + // prominentAmounts list still carries it), which keeps the discount, the + // date guard, and the hunt's amountSource exclusion intact. A user-edited + // total has the stamp cleared and counts at full weight. + total: extracted?.totalSource === 'prominent' ? null : (extracted?.totals?.total ?? null), vat: extracted?.totals?.vatAmount ?? null, currency: (extracted?.invoice?.currency || 'SEK').toUpperCase(), // Non-invoice documents (bankintyg, avtal) carry no total but often show diff --git a/lib/receipt-hunt/__tests__/select.test.ts b/lib/receipt-hunt/__tests__/select.test.ts index 74813ed6..6dedb3fe 100644 --- a/lib/receipt-hunt/__tests__/select.test.ts +++ b/lib/receipt-hunt/__tests__/select.test.ts @@ -87,6 +87,24 @@ describe('selectProposals', () => { expect(selectProposals([tx()], [bankintyg], noSuppression)).toEqual([]) }) + it('never proposes on a PROMOTED total either (totalSource prominent)', () => { + // promoteSingleProminentAmount copies a single prominent amount into + // totals.total for the editable TOTALT field. That must not smuggle the + // document past the hunt's fallback exclusion: the provenance stamp + // demotes it back to fallback-grade in the shared scorer. + const promoted = item( + { id: 'item-promoted', document_id: 'doc-promoted' }, + { + supplier: { name: null }, + totals: { total: 438.75, vatAmount: null }, + totalSource: 'prominent', + documentKind: 'other', + prominentAmounts: [{ amount: 438.75, label: 'Insatt belopp' }], + }, + ) + expect(selectProposals([tx()], [promoted], noSuppression)).toEqual([]) + }) + it('skips a transaction that already has a live proposal', () => { const result = selectProposals([tx()], [item()], { claimedTransactionIds: new Set(['tx-1']), diff --git a/types/index.ts b/types/index.ts index 027b9664..af1c64a6 100644 --- a/types/index.ts +++ b/types/index.ts @@ -4398,6 +4398,11 @@ export interface InvoiceExtractionResult { // with no invoice-style total. Matching hint only, never booked. Optional: // extractions from before the field existed lack it. prominentAmounts?: ProminentAmount[] + // 'prominent' = totals.total was promoted from the document's single + // prominent amount (promoteSingleProminentAmount), not read off an invoice. + // Matching treats such a total as fallback-grade; cleared when a user edits + // totals.total. + totalSource?: 'prominent' | null confidence: number suggestedTemplateId?: string // Set by the caller (not the model) when a long PDF was sliced before