feat(invoice-inbox): receipt-aware extraction + OCR pipeline fixes (#1331)

Receipts and invoices were extracted through one invoice-shaped prompt
with no document classification. The extractor now also returns
documentKind, payment method (+ card last4), purchaseTime,
merchantCategory and legibility, validated with .catch(null) so a
hallucinated label degrades to unknown instead of sinking the parse.
The FieldsRail shows type and payment method above the editable fields.

Pipeline fixes, all verified against real failure paths:
- PDFs >3 pages: extract from a pdf-lib slice of the first 3 pages
  instead of skipping entirely (issue #553 gate); truncation recorded
  in extracted_data.pages and shown in the UI.
- Oversized images (>4 MB, over Bedrock's 5 MB cap): downscale to
  <=2000px JPEG via sharp before base64, instead of erroring to an
  empty result.
- HEIC/HEIF: attempt sharp transcode to JPEG; when libvips lacks HEIF
  (prebuilt binaries), fall through to today's behavior but show an
  explicit hint instead of silently blank fields.
- Oresavrundning: prompt rule + totals.roundingAmount so receipt totals
  reconcile with subtotal+VAT for exact-amount transaction matching.
- retry-extraction overwrites extracted_data wholesale: a confirm now
  guards against silently destroying manual field edits.

Deliberately NOT added: retry-on-transient-Bedrock-error; the SDK
already retries twice by default (maxRetries=2).

Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-05 14:20:40 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent b1f71912a2
commit 3b3adf96c7
9 changed files with 466 additions and 26 deletions
@@ -12,6 +12,25 @@ vi.mock('@anthropic-ai/bedrock-sdk', () => {
return { default: FakeBedrock }
})
// sharp is imported lazily by normalizeImageForExtraction. The default mock
// (no implementation → TypeError on .rotate()) mimics a build without HEIF
// support: normalization fails, the original buffer flows on. Individual
// tests install a working chain via sharpMock.mockImplementationOnce.
const sharpMock = vi.fn()
vi.mock('sharp', () => ({
default: (...args: unknown[]) => sharpMock(...args),
}))
function workingSharpChain(outputBuffer: Buffer) {
const chain = {
rotate: vi.fn(() => chain),
resize: vi.fn(() => chain),
jpeg: vi.fn(() => chain),
toBuffer: vi.fn().mockResolvedValue(outputBuffer),
}
return chain
}
const ORIG_AWS_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID
const ORIG_AWS_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY
@@ -182,6 +201,134 @@ describe('extractInvoiceFields', () => {
expect(data.lineItems[0].accountSuggestion).toBeNull()
})
// ── Receipt-aware classification fields (2026-08) ──────────
it('parses the classification fields when the model returns them', async () => {
mockCreate.mockReturnValueOnce(
aiResponse({
...VALID_RESULT,
documentKind: 'receipt',
merchantCategory: 'restaurant',
legibility: 'good',
purchaseTime: '12:41',
payment: { method: 'card', cardLast4: '1234' },
totals: { ...VALID_RESULT.totals, roundingAmount: -0.25 },
})
)
const { data } = await extractInvoiceFields({
buffer: Buffer.from('%PDF'),
mimeType: 'application/pdf',
fileName: 'kvitto.pdf',
})
expect(data.documentKind).toBe('receipt')
expect(data.merchantCategory).toBe('restaurant')
expect(data.legibility).toBe('good')
expect(data.purchaseTime).toBe('12:41')
expect(data.payment).toEqual({ method: 'card', cardLast4: '1234' })
expect(data.totals.roundingAmount).toBe(-0.25)
})
it('still parses cached outputs from before the classification fields existed', async () => {
// VALID_RESULT has none of the new fields: the whole document must
// validate, not fall back to the empty result.
mockCreate.mockReturnValueOnce(aiResponse(VALID_RESULT))
const { data } = await extractInvoiceFields({
buffer: Buffer.from('%PDF'),
mimeType: 'application/pdf',
fileName: 'old.pdf',
})
expect(data.supplier.name).toBe('Anthropic, PBC')
expect(data.documentKind).toBeUndefined()
})
it('degrades hallucinated classification values to null instead of failing the parse', async () => {
mockCreate.mockReturnValueOnce(
aiResponse({
...VALID_RESULT,
documentKind: 'parking_ticket',
merchantCategory: 'nightclub',
legibility: 'excellent',
purchaseTime: '25:99',
payment: { method: 'bitcoin', cardLast4: 'abcd' },
totals: { ...VALID_RESULT.totals, roundingAmount: 'noll' },
})
)
const { data } = await extractInvoiceFields({
buffer: Buffer.from('%PDF'),
mimeType: 'application/pdf',
fileName: 'kvitto.pdf',
})
// Amounts survived: the junk classification did not sink the document.
expect(data.totals.total).toBe(6.25)
expect(data.documentKind).toBeNull()
expect(data.merchantCategory).toBeNull()
expect(data.legibility).toBeNull()
expect(data.purchaseTime).toBeNull()
expect(data.payment).toEqual({ method: null, cardLast4: null })
expect(data.totals.roundingAmount).toBeNull()
})
// ── Image normalization (HEIC transcode + oversized downscale) ──
it('transcodes HEIC to JPEG and extracts when sharp can decode it', async () => {
const converted = Buffer.from('converted-jpeg-bytes')
sharpMock.mockImplementationOnce(() => workingSharpChain(converted))
mockCreate.mockReturnValueOnce(aiResponse(VALID_RESULT))
const { data } = await extractInvoiceFields({
buffer: Buffer.from('heic-bytes'),
mimeType: 'image/heic',
fileName: 'photo.heic',
})
expect(mockCreate).toHaveBeenCalledOnce()
const content = mockCreate.mock.calls[0][0].messages[0].content
expect(content[0].source.media_type).toBe('image/jpeg')
expect(content[0].source.data).toBe(converted.toString('base64'))
expect(data.supplier.name).toBe('Anthropic, PBC')
})
it('downscales oversized JPEGs before sending to Bedrock', async () => {
const converted = Buffer.from('downscaled-jpeg')
sharpMock.mockImplementationOnce(() => workingSharpChain(converted))
mockCreate.mockReturnValueOnce(aiResponse(VALID_RESULT))
await extractInvoiceFields({
buffer: Buffer.alloc(5 * 1024 * 1024, 1),
mimeType: 'image/jpeg',
fileName: 'big-photo.jpg',
})
expect(sharpMock).toHaveBeenCalledOnce()
const content = mockCreate.mock.calls[0][0].messages[0].content
expect(content[0].source.data).toBe(converted.toString('base64'))
})
it('keeps the original buffer when downscaling an oversized image fails', async () => {
// Default sharpMock throws: the original 5 MB buffer is still attempted.
mockCreate.mockReturnValueOnce(aiResponse(VALID_RESULT))
await extractInvoiceFields({
buffer: Buffer.alloc(5 * 1024 * 1024, 1),
mimeType: 'image/jpeg',
fileName: 'big-photo.jpg',
})
expect(mockCreate).toHaveBeenCalledOnce()
const content = mockCreate.mock.calls[0][0].messages[0].content
expect(content[0].source.media_type).toBe('image/jpeg')
})
it('does not invoke sharp for normal-sized supported images', async () => {
mockCreate.mockReturnValueOnce(aiResponse(VALID_RESULT))
await extractInvoiceFields({
buffer: Buffer.from('JPEG'),
mimeType: 'image/jpeg',
fileName: 'photo.jpg',
})
expect(sharpMock).not.toHaveBeenCalled()
})
// Restore env vars so other test files aren't affected.
afterAll(() => {
if (ORIG_AWS_ACCESS_KEY_ID) process.env.AWS_ACCESS_KEY_ID = ORIG_AWS_ACCESS_KEY_ID
@@ -121,20 +121,33 @@ beforeEach(() => {
})
describe('POST /upload: page-count gate (issue #553)', () => {
it('skips extraction and marks the row as skipped when PDF has more than 3 pages', async () => {
it('slices long PDFs to the first 3 pages and extracts, instead of skipping', async () => {
const captured: { row?: Record<string, unknown> } = {}
const supabase = makeSupabase(captured)
vi.mocked(extractInvoiceFields).mockResolvedValueOnce({
data: emptyResult(),
rawText: 'ok',
})
const req = await makeUploadRequest(6)
const res = await uploadRoute.handler(req, buildCtx(supabase))
const { status, body } = await parseJsonResponse<{ data: Record<string, unknown> }>(res)
const { status, body } = await parseJsonResponse<{
data: { extracted_data?: { pages?: { total: number; analyzed: number } } } & Record<string, unknown>
}>(res)
expect(status).toBe(200)
expect(extractInvoiceFields).not.toHaveBeenCalled()
expect(captured.row?.extraction_skipped).toBe(true)
expect(body.data.extraction_skipped).toBe(true)
expect(body.data.skip_reason).toBe('too_many_pages')
expect(extractInvoiceFields).toHaveBeenCalledOnce()
// The buffer handed to Bedrock is the sliced copy, not the original.
const sentBuffer = vi.mocked(extractInvoiceFields).mock.calls[0][0].buffer
const sentPdf = await PDFDocument.load(sentBuffer)
expect(sentPdf.getPageCount()).toBe(3)
// The row is a normal extracted row: the truncation is recorded in
// extracted_data.pages rather than as a skip.
expect(captured.row?.extraction_skipped).toBe(false)
expect(body.data.extraction_skipped).toBe(false)
expect(body.data.skip_reason).toBeNull()
expect(body.data.page_count).toBe(6)
expect(body.data.extracted_data?.pages).toEqual({ total: 6, analyzed: 3 })
})
it('runs extraction normally for PDFs at or below the page-count limit', async () => {
+60 -11
View File
@@ -90,6 +90,34 @@ async function countPdfPages(buffer: ArrayBuffer): Promise<number | null> {
}
}
// Long PDFs used to skip extraction entirely (issue #553). Invoice data
// almost always sits on the first page(s), so instead we extract from a
// slim copy of the first MAX_PAGES_FOR_AUTO_EXTRACT pages and record the
// truncation in extracted_data.pages. Returns null when slicing fails
// (encrypted/malformed PDF) so the caller can fall back to the old skip.
async function slicePdfForExtraction(
buffer: ArrayBuffer,
maxPages: number
): Promise<ArrayBuffer | null> {
try {
const src = await PDFDocument.load(buffer, { updateMetadata: false })
const dst = await PDFDocument.create()
const pages = await dst.copyPages(
src,
Array.from({ length: Math.min(maxPages, src.getPageCount()) }, (_, i) => i)
)
for (const page of pages) dst.addPage(page)
const bytes = await dst.save()
// Copy into a fresh ArrayBuffer: Uint8Array.buffer is ArrayBufferLike
// (possibly SharedArrayBuffer-backed) and may span more than the view.
const out = new ArrayBuffer(bytes.byteLength)
new Uint8Array(out).set(bytes)
return out
} catch {
return null
}
}
// Sandbox companies (24h anonymous demo accounts) skip the Bedrock extraction
// pipeline entirely. The document still uploads, the inbox row still lands,
// and the user can fill the fields in by hand, but no Claude tokens are
@@ -261,16 +289,26 @@ async function uploadAndExtract(
// extraction path, so the document is still stored and can be filled in
// manually. Highest priority (a hard paywall rule, not a heuristic).
const hasAiEntitlement = await hasCapability(supabase, companyId, CAPABILITY.ai)
// Skip-reason priority: no-AI-entitlement > sandbox > page-count > client opt-out.
// Long PDFs are sliced to their first pages instead of skipped, but only
// when extraction would actually run: slicing after an entitlement/sandbox/
// opt-out verdict would be wasted CPU.
const slicedBuffer =
gatedByPageCount && hasAiEntitlement && !sandbox && !opts.skipExtraction
? await slicePdfForExtraction(file.buffer, MAX_PAGES_FOR_AUTO_EXTRACT)
: null
// Skip-reason priority: no-AI-entitlement > sandbox > client opt-out >
// page-count. Opt-out now outranks the page gate (an opted-out caller never
// extracts regardless of length), and too_many_pages only fires when the
// slice fallback also failed (encrypted/malformed PDF).
const skipReason: 'no_ai_entitlement' | 'too_many_pages' | 'client_opt_out' | 'sandbox' | null =
!hasAiEntitlement
? 'no_ai_entitlement'
: sandbox
? 'sandbox'
: gatedByPageCount
? 'too_many_pages'
: opts.skipExtraction
? 'client_opt_out'
: opts.skipExtraction
? 'client_opt_out'
: gatedByPageCount && slicedBuffer == null
? 'too_many_pages'
: null
const skipExtraction = skipReason !== null
@@ -282,10 +320,13 @@ async function uploadAndExtract(
const { data: extracted, rawText } = skipExtraction
? { data: emptyResult(), rawText: null }
: await extractInvoiceFields({
buffer: Buffer.from(file.buffer),
buffer: Buffer.from(slicedBuffer ?? file.buffer),
mimeType: file.type,
fileName: file.name,
})
if (!skipExtraction && slicedBuffer != null && pageCount != null) {
extracted.pages = { total: pageCount, analyzed: MAX_PAGES_FOR_AUTO_EXTRACT }
}
// Supplier match by org-nr, then case-insensitive name (no AI fuzz).
let matchedSupplierId: string | null = null
@@ -843,9 +884,10 @@ export const invoiceInboxExtension: Extension = {
upload_source: 'file_upload',
})
// Same page-count gate as /upload (issue #553): attaching a 6-page
// sales report to an existing inbox row should not block on Bedrock.
// Sandbox companies skip Bedrock unconditionally.
// Same page handling as /upload (issue #553): long PDFs extract
// from a slice of their first pages; the skip only remains for
// unsliceable (encrypted/malformed) PDFs. Sandbox companies skip
// Bedrock unconditionally.
const pageCount =
file.type === 'application/pdf' ? await countPdfPages(buffer) : null
const gatedByPageCount =
@@ -855,12 +897,16 @@ export const invoiceInboxExtension: Extension = {
// skeleton; the attached document is still stored). Same paywall as
// the shared upload path above.
const hasAiEntitlement = await hasCapability(ctx.supabase, ctx.companyId, CAPABILITY.ai)
const slicedBuffer =
gatedByPageCount && hasAiEntitlement && !sandbox
? await slicePdfForExtraction(buffer, MAX_PAGES_FOR_AUTO_EXTRACT)
: null
const skipReason: 'no_ai_entitlement' | 'too_many_pages' | 'sandbox' | null =
!hasAiEntitlement
? 'no_ai_entitlement'
: sandbox
? 'sandbox'
: gatedByPageCount
: gatedByPageCount && slicedBuffer == null
? 'too_many_pages'
: null
const skipExtraction = skipReason !== null
@@ -868,10 +914,13 @@ export const invoiceInboxExtension: Extension = {
const { data: extracted } = skipExtraction
? { data: emptyResult() }
: await extractInvoiceFields({
buffer: Buffer.from(buffer),
buffer: Buffer.from(slicedBuffer ?? buffer),
mimeType: file.type,
fileName: file.name,
})
if (!skipExtraction && slicedBuffer != null && pageCount != null) {
extracted.pages = { total: pageCount, analyzed: MAX_PAGES_FOR_AUTO_EXTRACT }
}
const { error: linkError } = await ctx.supabase
.from('invoice_inbox_items')
@@ -54,7 +54,48 @@ export interface ExtractionOutput {
rawText: string | null
}
// Classification fields are nullable AND .catch(null): a hallucinated enum
// value must degrade to "unknown", never fail the whole document parse. The
// amount/date fields keep strict parsing on purpose: a malformed amount
// SHOULD reject the output rather than store garbage.
const DocumentKind = z
.enum(['receipt', 'supplier_invoice', 'government_letter', 'other'])
.nullable()
.catch(null)
const PaymentMethod = z
.enum(['card', 'swish', 'cash', 'invoice', 'other'])
.nullable()
.catch(null)
const MerchantCategory = z
.enum(['restaurant', 'cafe', 'taxi', 'parking', 'fuel', 'grocery', 'hotel', 'other'])
.nullable()
.catch(null)
const Legibility = z.enum(['good', 'partial', 'unreadable']).nullable().catch(null)
export const ExtractionSchema = z.object({
// All optional: raw model outputs cached before these fields existed must
// still validate (same convention as servicePeriodStart/End below). These
// route UI emphasis and clarifying questions only; they never book anything.
documentKind: DocumentKind.optional(),
merchantCategory: MerchantCategory.optional(),
legibility: Legibility.optional(),
purchaseTime: z
.string()
.regex(/^([01]\d|2[0-3]):[0-5]\d$/)
.nullable()
.catch(null)
.optional(),
payment: z
.object({
method: PaymentMethod,
// Length + digits-only, deliberately not the shared four-digit
// invariant from @/lib/invariants: this is the tail of a masked card
// number, not a BAS account and not a fiscal year.
cardLast4: z.string().length(4).regex(/^\d+$/).nullable().catch(null),
})
.nullable()
.catch(null)
.optional(),
supplier: z.object({
name: z.string().nullable(),
orgNumber: z.string().nullable(),
@@ -100,6 +141,9 @@ export const ExtractionSchema = z.object({
subtotal: z.number().nullable(),
vatAmount: z.number().nullable(),
total: z.number().nullable(),
// Öresavrundning line on Swedish receipts (can be negative). Optional so
// cached raw outputs from before the field still validate.
roundingAmount: z.number().nullable().catch(null).optional(),
}),
vatBreakdown: z.array(
z.object({
@@ -131,6 +175,11 @@ const SYSTEM_PROMPT = `You extract invoice and receipt fields from a single docu
Return ONLY a single JSON object that matches this schema exactly. No prose, no markdown fences, no commentary.
{
"documentKind": "receipt" | "supplier_invoice" | "government_letter" | "other" | null,
"merchantCategory": "restaurant" | "cafe" | "taxi" | "parking" | "fuel" | "grocery" | "hotel" | "other" | null,
"legibility": "good" | "partial" | "unreadable",
"purchaseTime": string | null, // "HH:MM" 24h, receipts only
"payment": { "method": "card" | "swish" | "cash" | "invoice" | "other" | null, "cardLast4": string | null } | null,
"supplier": {
"name": string | null,
"orgNumber": string | null, // 10 digits, no hyphen, only when issued by a Swedish entity
@@ -161,7 +210,8 @@ Return ONLY a single JSON object that matches this schema exactly. No prose, no
"totals": {
"subtotal": number | null, // amount excluding VAT
"vatAmount": number | null, // total VAT
"total": number | null // amount including VAT: what the buyer pays
"total": number | null, // amount including VAT: what the buyer actually pays
"roundingAmount": number | null // öresavrundning line, may be negative, e.g. -0.37
},
"vatBreakdown": [
{ "rate": number, "base": number, "amount": number } // rate as percent integer, e.g. 25 for 25%
@@ -172,6 +222,12 @@ VAT rate convention: BOTH lineItems[].vatRate AND vatBreakdown[].rate use the sa
Rules:
- Output JSON only. The first character must be '{' and the last must be '}'.
- documentKind: "receipt" = point-of-sale proof of a COMPLETED payment (kassakvitto, kortkvitto, taxi/parking slip, webshop order confirmation marked paid). "supplier_invoice" = a request for payment (has due date, OCR/payment reference, bankgiro, "Att betala senast"). "government_letter" = correspondence from a myndighet (Skatteverket, Bolagsverket, Försäkringskassan...). "other" = contracts, statements, reports. null only when truly indeterminate.
- merchantCategory: judge from the merchant name and line items (a receipt from "Prinsen" listing food and wine is "restaurant" even without the word). Use "other" when unsure. null for non-receipts.
- 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.
- Ö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.
- Dates: convert any format to YYYY-MM-DD. If the document only shows month/year, leave null.
@@ -186,6 +242,11 @@ Rules:
export function emptyResult(): InvoiceExtractionResult {
return {
documentKind: null,
merchantCategory: null,
legibility: null,
purchaseTime: null,
payment: null,
supplier: {
name: null,
orgNumber: null,
@@ -204,12 +265,64 @@ export function emptyResult(): InvoiceExtractionResult {
servicePeriodEnd: null,
},
lineItems: [],
totals: { subtotal: null, vatAmount: null, total: null },
totals: { subtotal: null, vatAmount: null, total: null, roundingAmount: null },
vatBreakdown: [],
confidence: 0,
}
}
// Anthropic rejects images above 5 MB (decoded bytes), and 12 MP phone photos
// routinely exceed that: before this step they errored out to an empty
// extraction. Downscaling to ≤2000px JPEG also cuts input tokens on every
// large image. HEIC/HEIF (iPhone default) is transcoded to JPEG when the
// local sharp/libvips build can decode it; prebuilt binaries usually cannot
// (patent licensing), in which case the caller falls through to the
// unsupported-type path exactly as before.
const IMAGE_DOWNSCALE_THRESHOLD_BYTES = 4 * 1024 * 1024
const IMAGE_MAX_DIMENSION = 2000
async function normalizeImageForExtraction(
input: ExtractionInput
): Promise<ExtractionInput> {
const isHeic = input.mimeType === 'image/heic' || input.mimeType === 'image/heif'
const isLargeSupportedImage =
input.mimeType.startsWith('image/') &&
SUPPORTED_MEDIA_TYPES.has(input.mimeType) &&
input.buffer.byteLength > IMAGE_DOWNSCALE_THRESHOLD_BYTES
if (!isHeic && !isLargeSupportedImage) return input
try {
// Lazy import: sharp is a native module and only a fraction of
// extractions need it; loading it at module scope would tax every
// cold start of the extension route bundle.
const sharp = (await import('sharp')).default
const converted = await sharp(input.buffer)
// Apply the EXIF orientation before it is lost in re-encoding:
// phone photos are routinely stored rotated.
.rotate()
.resize({
width: IMAGE_MAX_DIMENSION,
height: IMAGE_MAX_DIMENSION,
fit: 'inside',
withoutEnlargement: true,
})
.jpeg({ quality: 80 })
.toBuffer()
return { buffer: converted, mimeType: 'image/jpeg', fileName: input.fileName }
} catch (err) {
// HEIC without libheif lands here → caller hits the unsupported-type
// guard, same net behavior as before this step existed. For oversized
// JPEG/PNG the original buffer is still worth attempting.
log.warn('image normalization failed', {
file_name_hash: createHash('sha256').update(input.fileName).digest('hex').slice(0, 12),
mime_type: input.mimeType,
byte_length: input.buffer.byteLength,
error: err instanceof Error ? err.message : String(err),
})
return input
}
}
function buildContent(input: ExtractionInput) {
const base64 = input.buffer.toString('base64')
if (input.mimeType === 'application/pdf') {
@@ -240,8 +353,12 @@ function buildContent(input: ExtractionInput) {
* always returns an InvoiceExtractionResult. Empty fields are null.
*/
export async function extractInvoiceFields(
input: ExtractionInput
rawInput: ExtractionInput
): Promise<ExtractionOutput> {
// Transcodes HEIC when possible and downscales oversized images; a no-op
// for PDFs and normal-sized supported images.
const input = await normalizeImageForExtraction(rawInput)
if (!SUPPORTED_MEDIA_TYPES.has(input.mimeType)) {
return { data: emptyResult(), rawText: null }
}