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:
Jakob Wennberg
2026-09-08 16:26:09 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5.1
parent 26e29f47bc
commit b2b714f829
3 changed files with 28 additions and 1 deletions
@@ -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.