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:
Mattsson
2026-08-31 11:40:30 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 6a40b3c0ef
commit 1a3686dd45
11 changed files with 282 additions and 14 deletions
+1
View File
@@ -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')}`
+6 -1
View File
@@ -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({
+22 -2
View File
@@ -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
+7 -1
View File
@@ -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
+18
View File
@@ -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']),
+5
View File
@@ -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