fix(invoice-inbox): make the extraction prompt fill invoiceDate on receipts (#2429)
Receipts came back without a date on 46% of items in the last 30 days (75% via WhatsApp), while supplier invoices lost it on 0.4% and the purchaseTime on the very same receipts was filled almost every time. The prompt described invoice.invoiceDate as a bare ISO date under the invoice block, right beside a purchaseTime rule marked "receipts only", and the model read the asymmetry as "invoice-only". Describe the field as the invoice date or, on a receipt, the purchase date printed on it, and add an explicit receipts rule. Pin both in the extraction test. No schema or data-shape change; already-extracted items are not touched. Claude-Session: https://claude.ai/code/session_015bTBgZrofpGCgSUfAKN2H3 Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -1670,3 +1670,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-09-08] Medelantal anställda (Not 2, ÅRL 5:20 §) gets a whole-number override on arsredovisning_narratives (migration 20260908130127) instead of the free-text note override the support request asked for. Why: the number keeps the statutory sentence and the iXBRL MedelantaletAnstallda fact correct; free text would let a non-compliant note through and could not be tagged. One resolver (lib/salary/medelantal.ts resolveMedelantalAnstallda: override, else FTE average over employees) feeds the K2 and K3 note builders and the iXBRL input, which also reads the previous period's override so the jämförelseår column shows the same figure the previous year's document did. Rejected: rounding 0.5 up globally (silently changes every company's note and does nothing for the 148 of 195 aktiebolag with salary but no employees rows); asking the user to backdate employment_start (fixes one company, misstates the hire date).
|
||||
[2026-09-08] Issue #2413 BAS 2026 kontogrupp 12: kept 1249/1259/1269 in the catalog renamed after their free heads and dropped only 1241/1242/1251/1261, instead of removing all seven retired sub-accounts and moving the asset module's vehicle/computer defaults to BAS 2026 (1226/1224 on 1229): the asset module's DEFAULT_ACCOUNTS_BY_CATEGORY still books vehicles on 1240/1249 and computers on 1250/1259 (31 live assets in prod, guard test requires the triple in BAS_REFERENCE), so dropping the contra accounts would have forced a depreciation-default change into a label fix; that change is the founder's call and lives in #2414. The prod backfill renames only the exact catalog literal next to a free-labelled head, so old-BAS imports (1240 Bilar + 1249 Ack. avskr. bilar) and user renames stay untouched.
|
||||
[2026-09-08] Migration files must carry their own BEGIN/COMMIT when they use transaction-only statements (LOCK TABLE, SET LOCAL, SET CONSTRAINTS): CI replays each file with psql -f in autocommit and the Supabase branch runner does the same on prod, so the bare LOCK TABLE in 20260908113353 (#2413, PR #2419) failed both and stalled prod's migration queue behind it. Prod never recorded the failed version, so the file was deleted and re-issued as 20260908120449 rather than edited in place.
|
||||
[2026-09-08] Receipt purchase date: fixed the extraction prompt's description of invoice.invoiceDate (schema comment plus an explicit "invoiceDate on receipts" rule) instead of adding a receipt-specific purchaseDate field. Why: prod (last 30 days) showed receipts losing the date on 46% of items (75% via WhatsApp) while supplier invoices lost it on 0.4%, and purchaseTime was filled on nearly every affected receipt: the date was described as a bare ISO field under the invoice block beside a purchaseTime rule marked "receipts only", and the model read that as "invoice-only". A new field would need a schema change, a UI column and every consumer (WhatsApp M4 ack date, the Bokför entry-date default that falls back to today, the worklist) taught a second date; the prompt fix stops the class with no data-shape change. Existing undated items are not re-extracted by this; a backfill is a separate call.
|
||||
|
||||
@@ -238,6 +238,31 @@ describe('extractInvoiceFields', () => {
|
||||
expect(content[0].source.media_type).toBe('image/jpeg')
|
||||
})
|
||||
|
||||
it('tells the model that a receipt carries its purchase date in invoiceDate', async () => {
|
||||
// Prod, last 30 days as of 2026-09-08: supplier invoices lost invoiceDate
|
||||
// on 0.4% of items, receipts on 46% (75% via WhatsApp), while purchaseTime
|
||||
// was filled on nearly every one of those receipts. The field was described
|
||||
// as a bare ISO date under the invoice block, right next to a purchaseTime
|
||||
// rule marked "receipts only", and the model took the asymmetry literally.
|
||||
// Pin the receipt rule so a prompt edit cannot silently drop it again.
|
||||
mockCreate.mockReturnValueOnce(aiResponse(VALID_RESULT))
|
||||
await extractInvoiceFields({
|
||||
buffer: Buffer.from('JPEG'),
|
||||
mimeType: 'image/jpeg',
|
||||
fileName: 'kvitto.jpg',
|
||||
})
|
||||
const call = mockCreate.mock.calls[0][0]
|
||||
const system: string =
|
||||
typeof call.system === 'string'
|
||||
? call.system
|
||||
: call.system.map((block: { text: string }) => block.text).join('\n')
|
||||
expect(system).toContain(
|
||||
'"invoiceDate": string | null, // ISO date YYYY-MM-DD: the invoice date, or on a receipt the purchase date printed on it'
|
||||
)
|
||||
expect(system).toContain('- invoiceDate on receipts: the purchase date printed on the receipt')
|
||||
expect(system).toContain('The field is NOT invoice-only')
|
||||
})
|
||||
|
||||
it('sends document content for a PDF upload', async () => {
|
||||
mockCreate.mockReturnValueOnce(aiResponse(VALID_RESULT))
|
||||
await extractInvoiceFields({
|
||||
|
||||
@@ -358,7 +358,7 @@ Return ONLY a single JSON object that matches this schema exactly. No prose, no
|
||||
},
|
||||
"invoice": {
|
||||
"invoiceNumber": string | null, // include any suffix, e.g. "06655767-0007"
|
||||
"invoiceDate": string | null, // ISO date YYYY-MM-DD
|
||||
"invoiceDate": string | null, // ISO date YYYY-MM-DD: the invoice date, or on a receipt the purchase date printed on it
|
||||
"dueDate": string | null, // ISO date YYYY-MM-DD
|
||||
"paymentReference": string | null, // OCR / payment reference
|
||||
"currency": string, // ISO 4217 (SEK, USD, EUR, ...). Default "SEK" only if truly indeterminate.
|
||||
@@ -399,6 +399,7 @@ Rules:
|
||||
- legibility: "good" = all key amounts and the merchant are readable. "partial" = some key fields are cut off, blurry, or unreadable. "unreadable" = the document is mostly illegible (too blurry/dark/small). Judge the IMAGE quality, not whether fields exist on the document.
|
||||
- payment: only for documents that show how payment was made. "card" for kort/VISA/Mastercard; cardLast4 only when a masked card number like ****1234 is printed. "invoice" means the document says it will be billed separately.
|
||||
- purchaseTime: the HH:MM time printed on a receipt. null when absent.
|
||||
- invoiceDate on receipts: the purchase date printed on the receipt (the "Datum"/"Date" line, usually right next to the time, or the date on the card slip). The field is NOT invoice-only: every receipt carries a date, so fill it whenever one is printed, and leave null only when no date is printed or it is unreadable.
|
||||
- Öresavrundning: Swedish receipts often show an "Avrundning"/"Öresavrundning" line. "total" is ALWAYS the amount actually paid AFTER rounding; put the rounding line in totals.roundingAmount (negative when rounded down). When present: subtotal + vatAmount + roundingAmount = total.
|
||||
- Currency: detect from the document (symbol $/€/kr or explicit code). Use the ISO 4217 code. Do NOT default to SEK if the document clearly shows another currency.
|
||||
- "total" is the amount the buyer must pay (look for "Att betala", "Total", "Amount paid", "Amount due", "Balance"). Prefer this over Subtotal.
|
||||
|
||||
Reference in New Issue
Block a user