* 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>
213 lines
8.7 KiB
TypeScript
213 lines
8.7 KiB
TypeScript
/**
|
|
* Correcting one field by hand must not cost the rest of the extraction.
|
|
*
|
|
* PATCH /items/:id/fields used to rebuild extracted_data from a hand-written
|
|
* list of six keys. Everything outside that list was destroyed on the first
|
|
* inline edit: documentKind, merchantCategory, legibility, purchaseTime,
|
|
* payment and suggestedTemplateId. Nothing surfaced the loss, and the
|
|
* classification cannot be recovered afterwards without re-running extraction.
|
|
*
|
|
* These tests pin the merge itself rather than the six names, so a field added
|
|
* to InvoiceExtractionResult later is covered without anyone remembering to
|
|
* come back here.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox'
|
|
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
|
import type { ExtensionContext } from '@/lib/extensions/types'
|
|
import type { InvoiceExtractionResult } from '@/types'
|
|
|
|
const fieldsRoute = invoiceInboxExtension.apiRoutes!.find(
|
|
(r) => r.method === 'PATCH' && r.path === '/items/:id/fields',
|
|
)!
|
|
|
|
function buildCtx(supabase: unknown): ExtensionContext {
|
|
return {
|
|
userId: 'user-1',
|
|
companyId: 'company-1',
|
|
extensionId: 'invoice-inbox',
|
|
supabase: supabase as ExtensionContext['supabase'],
|
|
emit: vi.fn(),
|
|
settings: { get: vi.fn(), set: vi.fn() },
|
|
storage: { from: vi.fn() } as unknown as ExtensionContext['storage'],
|
|
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as unknown as ExtensionContext['log'],
|
|
services: {},
|
|
} as unknown as ExtensionContext
|
|
}
|
|
|
|
function makeReq(body: unknown) {
|
|
return createMockRequest('/items/item-1/fields', {
|
|
method: 'PATCH',
|
|
searchParams: { _id: 'item-1' },
|
|
body,
|
|
})
|
|
}
|
|
|
|
/** A row the extractor filled in completely, classification and all. */
|
|
function fullExtraction(): InvoiceExtractionResult {
|
|
return {
|
|
documentKind: 'receipt',
|
|
merchantCategory: 'restaurant',
|
|
legibility: 'good',
|
|
purchaseTime: '2026-07-14T19:12:00Z',
|
|
payment: { method: 'card', cardLast4: '3667' },
|
|
suggestedTemplateId: 'tmpl-representation',
|
|
pages: { total: 1, analyzed: 1 },
|
|
supplier: {
|
|
name: 'Restaurang Riddaren AB',
|
|
orgNumber: '556812-9930',
|
|
vatNumber: null,
|
|
address: null,
|
|
bankgiro: null,
|
|
plusgiro: null,
|
|
},
|
|
invoice: {
|
|
invoiceNumber: '8841',
|
|
invoiceDate: '2026-07-14',
|
|
dueDate: null,
|
|
paymentReference: null,
|
|
currency: 'SEK',
|
|
},
|
|
lineItems: [
|
|
{
|
|
description: 'Restaurangnota',
|
|
quantity: 1,
|
|
unitPrice: 2264.15,
|
|
lineTotal: 2264.15,
|
|
vatRate: 6,
|
|
accountSuggestion: null,
|
|
},
|
|
],
|
|
totals: { subtotal: 2264.15, vatAmount: 135.85, total: 2400 },
|
|
vatBreakdown: [{ rate: 6, base: 2264.15, amount: 135.85 }],
|
|
confidence: 0.91,
|
|
}
|
|
}
|
|
|
|
beforeEach(() => vi.clearAllMocks())
|
|
|
|
describe('PATCH /items/:id/fields', () => {
|
|
it('keeps every field the edit did not mention', async () => {
|
|
const mock = createQueuedMockSupabase()
|
|
mock.enqueue({ data: { id: 'item-1', extracted_data: fullExtraction(), created_supplier_invoice_id: null } })
|
|
mock.enqueue({ data: { id: 'item-1', extracted_data: {} } })
|
|
|
|
const ctx = buildCtx(mock.supabase)
|
|
// The smallest possible edit: one character of the supplier name.
|
|
const res = await fieldsRoute.handler(makeReq({ supplier: { name: 'Restaurang Riddaren' } }), ctx)
|
|
expect(res.status).toBe(200)
|
|
|
|
const update = mock.calls.find((c) => c.method === 'update')
|
|
const merged = (update?.args?.[0] as { extracted_data: Record<string, unknown> }).extracted_data
|
|
|
|
// The classification the whole right pane reads: document type, the
|
|
// konteringskarta hint, and the provenance of the payment.
|
|
expect(merged.documentKind).toBe('receipt')
|
|
expect(merged.suggestedTemplateId).toBe('tmpl-representation')
|
|
expect(merged.merchantCategory).toBe('restaurant')
|
|
expect(merged.legibility).toBe('good')
|
|
expect(merged.purchaseTime).toBe('2026-07-14T19:12:00Z')
|
|
expect(merged.payment).toEqual({ method: 'card', cardLast4: '3667' })
|
|
})
|
|
|
|
it('loses nothing that was on the row before the edit', async () => {
|
|
// Pinned structurally: a field added to the extraction type tomorrow is
|
|
// covered without anyone editing this file.
|
|
const before = fullExtraction()
|
|
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: Record<string, unknown> }).extracted_data
|
|
for (const key of Object.keys(before)) {
|
|
expect(merged, `\`${key}\` was dropped by the merge`).toHaveProperty(key)
|
|
}
|
|
})
|
|
|
|
it('still applies the edit it was given', async () => {
|
|
const mock = createQueuedMockSupabase()
|
|
mock.enqueue({ data: { id: 'item-1', extracted_data: fullExtraction(), 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)
|
|
// A partial edit to one sub-object leaves its siblings intact.
|
|
expect(merged.totals?.vatAmount).toBe(135.85)
|
|
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({
|
|
data: { id: 'item-1', extracted_data: fullExtraction(), created_supplier_invoice_id: 'si-1' },
|
|
})
|
|
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('leverantörsfaktura')
|
|
})
|
|
})
|