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 <noreply@anthropic.com> 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hqm9QgdyNAFaWiz6Ww7pgb --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
6a40b3c0ef
commit
1a3686dd45
@@ -1381,4 +1381,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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).
|
||||
|
||||
@@ -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))) && (
|
||||
<div className="border-b px-4 py-3 text-xs space-y-1">
|
||||
{data?.documentKind && (
|
||||
<div className="flex gap-2">
|
||||
@@ -2930,14 +2934,21 @@ function FieldsRail({
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* 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) && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-muted-foreground w-14 shrink-0">{t('prominent_amounts_label')}</span>
|
||||
<span className="tabular-nums">
|
||||
{(data?.prominentAmounts ?? [])
|
||||
.filter((a) => Number.isFinite(a.amount) && a.amount !== 0)
|
||||
.map((a) =>
|
||||
a.label
|
||||
? `${a.label}: ${formatCurrency(a.amount, data?.invoice?.currency ?? 'SEK')}`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }] })
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<string, unknown> })
|
||||
.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 })
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user