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
+3
View File
@@ -769,3 +769,6 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-03] kompletteringsregel_20 with a positive basis and zero acquisition cohorts is refused rather than computed: reducing over an empty cohort set would claim a full write-off the cohort evidence does not support (IL 18 kap. 17 §), so such periods require manual review instead of an automatic deduction.
[2026-08-04] Balance-sheet synthetic result = complement of classes 1-2 (class 0/9/null rows included), not classes 3-8: a resultatavslut posted to 2099 without zeroing class 3-8 then self-cancels inside the residual instead of double-counting equity; mirrors balansrapport's residual definition (#1333)
[2026-08-04] v1 settings PATCH writes directly (no staging), following the v1 customers precedent: REST callers are already scope-gated, staging is an MCP segregation-of-duties concept.
[2026-08-01] OCR quick-fix "bounded retry on transient Bedrock errors" dropped: @anthropic-ai/bedrock-sdk already retries 429/5xx twice by default (maxRetries=2, not overridden in extract-invoice-fields.ts); duplicating it would triple worst-case latency for no reliability gain.
[2026-08-01] Page-count gate (issue #553) changed from skip-extraction to slice-first-3-pages via pdf-lib: invoice data sits on page 1, and a >3-page supplier invoice getting ZERO fields was the worse failure mode. too_many_pages skip remains only for unsliceable (encrypted/malformed) PDFs; truncation recorded in extracted_data.pages. Side effect: client_opt_out now outranks too_many_pages in skip-reason priority (an opted-out caller never extracts regardless of length).
[2026-08-01] Extraction classification fields (documentKind/payment/merchantCategory/legibility) validate with .catch(null) instead of strict enums: a hallucinated label must degrade to unknown, not sink the whole document parse; amounts keep strict parsing on purpose. HEIC handling = attempt sharp transcode at runtime, fall through to today's empty-extraction when libvips lacks HEIF (prebuilt binaries exclude it for patent reasons), with a UI hint replacing the silence.
@@ -1128,6 +1128,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
{selected ? (
<FieldsRail
item={selected}
docMime={docMime}
accountingMethod={accountingMethod}
onDelete={() => handleDelete(selected.id)}
onBookDirect={() => setBookDirectOpen(true)}
@@ -1741,6 +1742,7 @@ function EmptyPreview({
function FieldsRail({
item,
docMime,
accountingMethod,
onDelete,
onBookDirect,
@@ -1753,6 +1755,7 @@ function FieldsRail({
onRetryRequested,
}: {
item: InboxItem
docMime: string | null
accountingMethod: AccountingMethod
onDelete: () => void
onBookDirect: () => void
@@ -1787,6 +1790,9 @@ function FieldsRail({
!!extractedSupplierName
const handleRetry = async () => {
// Retry overwrites extracted_data wholesale server-side, including any
// manual field edits: make the user opt into that loss explicitly.
if (hasAnyExtractedField(data) && !confirm(t('retry_overwrite_confirm'))) return
setIsRetrying(true)
try {
const res = await fetch(
@@ -1835,6 +1841,38 @@ function FieldsRail({
</div>
)}
{/* AI classification: what kind of document this is and how it was
paid. Read-only context above the editable fields; absent for
extractions from before the fields existed. */}
{(data?.documentKind || data?.payment?.method || data?.pages) && (
<div className="border-b px-4 py-3 text-xs space-y-1">
{data?.documentKind && (
<div className="flex gap-2">
<span className="text-muted-foreground w-14 shrink-0">{t('doc_kind_label')}</span>
<span>{t(`doc_kind_${data.documentKind}`)}</span>
</div>
)}
{data?.payment?.method && (
<div className="flex gap-2">
<span className="text-muted-foreground w-14 shrink-0">{t('payment_label')}</span>
<span>
{t(`payment_${data.payment.method}`)}
{data.payment.cardLast4 ? ` •• ${data.payment.cardLast4}` : ''}
{data.purchaseTime ? ` · ${data.purchaseTime}` : ''}
</span>
</div>
)}
{data?.pages && (
<div className="text-muted-foreground">
{t('pages_partial_note', {
analyzed: data.pages.analyzed,
total: data.pages.total,
})}
</div>
)}
</div>
)}
{item.error_message && (
<div className="border-b border-destructive/30 bg-destructive/5 px-4 py-3 text-xs space-y-2">
<div className="flex items-start gap-2">
@@ -1894,15 +1932,28 @@ function FieldsRail({
)}
{/* Skipped-extraction hint: explains the empty fields and points the
user to the manual paths (transaction link or supplier invoice). */}
user to the manual paths (transaction link or supplier invoice).
Deliberately reason-agnostic: skip covers sandbox, BYO-extraction
and unsliceable PDFs, not just page count anymore. */}
{item.extraction_skipped && !isResolved && (
<div className="border-b bg-muted/30 px-4 py-2 text-xs text-muted-foreground">
AI-tolkning skippades p.g.a. dokumentets storlek (fler än 3 sidor).
Du kan koppla dokumentet till en transaktion eller skapa
leverantörsfaktura manuellt.
{t('skipped_hint')}
</div>
)}
{/* HEIC: extraction ran but Bedrock cannot read the format, so every
field came back empty with no error. Tell the user why instead of
leaving a silently blank rail (iPhone photos default to HEIC). */}
{!item.extraction_skipped &&
!isResolved &&
hasAi &&
(docMime === 'image/heic' || docMime === 'image/heif') &&
!hasAnyExtractedField(data) && (
<div className="border-b bg-muted/30 px-4 py-2 text-xs text-muted-foreground">
{t('heic_hint')}
</div>
)}
{/* Extracted fields */}
<div className="flex-1 overflow-y-auto px-4 py-3">
<h3 className="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-3">
@@ -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 }
}
+16 -1
View File
@@ -2797,7 +2797,22 @@
"document_loading": "Fetching the document…",
"document_load_failed": "The document is still stored but could not be shown right now.",
"email_body_label": "Email content",
"email_body_empty": "The email had no text."
"email_body_empty": "The email had no text.",
"doc_kind_label": "Type",
"doc_kind_receipt": "Receipt",
"doc_kind_supplier_invoice": "Supplier invoice",
"doc_kind_government_letter": "Government letter",
"doc_kind_other": "Other document",
"payment_label": "Paid with",
"payment_card": "Card",
"payment_swish": "Swish",
"payment_cash": "Cash",
"payment_invoice": "Invoiced",
"payment_other": "Other",
"pages_partial_note": "Extracted from the first {analyzed} of {total} pages.",
"heic_hint": "HEIC images cannot be AI-extracted yet. Upload the receipt as JPEG or PDF, or fill in the fields manually.",
"skipped_hint": "AI extraction did not run for this document. You can link the document to a transaction or create a supplier invoice manually.",
"retry_overwrite_confirm": "Re-running extraction overwrites the fields, including your own edits. Continue?"
},
"inbox_custom_domain": {
"title": "Custom inbox domain",
+16 -1
View File
@@ -2797,7 +2797,22 @@
"document_loading": "Hämtar underlaget…",
"document_load_failed": "Underlaget finns kvar men kunde inte visas just nu.",
"email_body_label": "Mejlets innehåll",
"email_body_empty": "Mejlet hade ingen text."
"email_body_empty": "Mejlet hade ingen text.",
"doc_kind_label": "Typ",
"doc_kind_receipt": "Kvitto",
"doc_kind_supplier_invoice": "Leverantörsfaktura",
"doc_kind_government_letter": "Myndighetsbrev",
"doc_kind_other": "Övrigt dokument",
"payment_label": "Betalsätt",
"payment_card": "Kort",
"payment_swish": "Swish",
"payment_cash": "Kontant",
"payment_invoice": "Faktureras",
"payment_other": "Annat",
"pages_partial_note": "Tolkad från de första {analyzed} av {total} sidorna.",
"heic_hint": "HEIC-bilder kan inte AI-tolkas ännu. Ladda upp kvittot som JPEG eller PDF, eller fyll i fälten manuellt.",
"skipped_hint": "AI-tolkning kördes inte för det här dokumentet. Du kan koppla dokumentet till en transaktion eller skapa leverantörsfaktura manuellt.",
"retry_overwrite_confirm": "Ny tolkning skriver över fälten, även ändringar du gjort själv. Fortsätta?"
},
"inbox_custom_domain": {
"title": "Egen domän för inkorgen",
+30
View File
@@ -3628,7 +3628,32 @@ export interface IngestResult {
// ── Invoice extraction (used by invoice-inbox extension and core utils) ──
export type ExtractedDocumentKind =
| 'receipt'
| 'supplier_invoice'
| 'government_letter'
| 'other'
export type ExtractedPaymentMethod = 'card' | 'swish' | 'cash' | 'invoice' | 'other'
export type ExtractedMerchantCategory =
| 'restaurant'
| 'cafe'
| 'taxi'
| 'parking'
| 'fuel'
| 'grocery'
| 'hotel'
| 'other'
export type ExtractedLegibility = 'good' | 'partial' | 'unreadable'
export interface InvoiceExtractionResult {
// Classification fields (2026-08): optional because extractions stored
// before they existed lack them. They route UI emphasis and clarifying
// questions only: never bookings.
documentKind?: ExtractedDocumentKind | null
merchantCategory?: ExtractedMerchantCategory | null
legibility?: ExtractedLegibility | null
purchaseTime?: string | null
payment?: { method: ExtractedPaymentMethod | null; cardLast4: string | null } | null
supplier: {
name: string | null
orgNumber: string | null
@@ -3654,10 +3679,15 @@ export interface InvoiceExtractionResult {
subtotal: number | null
vatAmount: number | null
total: number | null
// Öresavrundning line on Swedish receipts; negative when rounded down.
roundingAmount?: number | null
}
vatBreakdown: VatBreakdownItem[]
confidence: number
suggestedTemplateId?: string
// Set by the caller (not the model) when a long PDF was sliced before
// extraction: fields were read from the first `analyzed` of `total` pages.
pages?: { total: number; analyzed: number }
}
export interface ExtractedInvoiceLineItem {