* fix(invoice-inbox): switch from pdfjs-dist to unpdf for PDF text extraction After three rounds of fighting pdfjs on Vercel (#407 stubbed DOM globals, #408 tried to ship the worker file via outputFileTracingIncludes), text extraction still failed in prod with "Setting up fake worker failed" — Next's tracer can't reliably include pdfjs-dist's worker file when the package is marked as a server external. unpdf is a serverless-first wrapper around pdfjs (by unjs) that ships its own bundled pdfjs build with no canvas/worker dependencies. Drop-in replacement: extractText returns merged page text directly. - Remove DOM stubs, serverExternalPackages, outputFileTracingIncludes - Replace pdfjs-dist with unpdf (no transitive deps) - Update test mock from getDocument → extractText All 45 invoice-inbox unit tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(invoice-inbox): make unpdf import static, rename pdfjs test labels Per Greptile review on #409: 1. CRITICAL: tryExtractPdfText was still using await import('unpdf'), a dynamic import. CLAUDE.md forbids dynamic imports in extensions precisely because Next.js bundling can't reliably trace them — which is the same class of failure that caused the pdfjs prod bug. unpdf bundles statically (no canvas/worker), so a top-level static import is safe and correct. 2. NIT: two test descriptions still said "pdfjs" after the mock rename. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
294 lines
10 KiB
TypeScript
294 lines
10 KiB
TypeScript
// Deterministic Swedish invoice field extraction.
|
|
//
|
|
// Replaces the deleted AI classifier. We pull text out of the PDF with
|
|
// unpdf (a serverless-friendly pdfjs wrapper) and run regex extractors
|
|
// against it. Each extractor is independent — a missing field stays null
|
|
// rather than dragging down a neighbour. Validators (Luhn for
|
|
// org-nr/OCR/bankgiro) keep false positives near zero.
|
|
//
|
|
// Image-only PDFs and non-PDF mime types come back with all fields null.
|
|
// The inbox item is still created so the user can register manually.
|
|
|
|
import type { InvoiceExtractionResult } from '@/types'
|
|
import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
|
|
import { validateOcrReference, validateBankgiroNumber } from '@/lib/bankgiro/luhn'
|
|
import { extractText } from 'unpdf'
|
|
|
|
// Below this we treat the document as image-only / unreadable and skip
|
|
// regex extraction. The PDF text extractor returns near-zero text for
|
|
// scanned PDFs.
|
|
const MIN_TEXT_CHARS_FOR_EXTRACTION = 10
|
|
|
|
export interface ExtractionInput {
|
|
buffer: Buffer
|
|
mimeType: string
|
|
fileName: string
|
|
}
|
|
|
|
export interface ExtractionOutput {
|
|
data: InvoiceExtractionResult
|
|
/** Pulled from the PDF; null when the file isn't a text-based PDF. */
|
|
rawText: string | null
|
|
}
|
|
|
|
/**
|
|
* Extract invoice fields from a PDF buffer. Returns an InvoiceExtractionResult
|
|
* whether or not anything matched — empty fields are null, lineItems is [],
|
|
* and totals are null. Never throws on parse failure (returns empty result).
|
|
*/
|
|
export async function extractInvoiceFields(input: ExtractionInput): Promise<ExtractionOutput> {
|
|
const text = await tryExtractPdfText(input)
|
|
|
|
if (!text || text.length < MIN_TEXT_CHARS_FOR_EXTRACTION) {
|
|
return { data: emptyResult(), rawText: text }
|
|
}
|
|
|
|
const data: InvoiceExtractionResult = {
|
|
supplier: {
|
|
name: extractSupplierName(text),
|
|
orgNumber: extractOrgNumber(text),
|
|
vatNumber: extractVatNumber(text),
|
|
address: null,
|
|
bankgiro: extractBankgiro(text),
|
|
plusgiro: extractPlusgiro(text),
|
|
},
|
|
invoice: {
|
|
invoiceNumber: extractInvoiceNumber(text),
|
|
invoiceDate: extractDate(text, /faktura(?:datum|date)|utfärdat/i),
|
|
dueDate: extractDate(text, /förfallo(?:datum|dag)|due\s*date|betala\s*senast/i),
|
|
paymentReference: extractOcrReference(text),
|
|
currency: extractCurrency(text),
|
|
},
|
|
lineItems: [],
|
|
totals: extractTotals(text),
|
|
vatBreakdown: extractVatBreakdown(text),
|
|
confidence: 0,
|
|
}
|
|
|
|
return { data, rawText: text }
|
|
}
|
|
|
|
function emptyResult(): InvoiceExtractionResult {
|
|
return {
|
|
supplier: {
|
|
name: null,
|
|
orgNumber: null,
|
|
vatNumber: null,
|
|
address: null,
|
|
bankgiro: null,
|
|
plusgiro: null,
|
|
},
|
|
invoice: {
|
|
invoiceNumber: null,
|
|
invoiceDate: null,
|
|
dueDate: null,
|
|
paymentReference: null,
|
|
currency: 'SEK',
|
|
},
|
|
lineItems: [],
|
|
totals: { subtotal: null, vatAmount: null, total: null },
|
|
vatBreakdown: [],
|
|
confidence: 0,
|
|
}
|
|
}
|
|
|
|
// ── PDF text extraction ─────────────────────────────────────────────
|
|
|
|
async function tryExtractPdfText(input: ExtractionInput): Promise<string | null> {
|
|
if (input.mimeType !== 'application/pdf') return null
|
|
|
|
try {
|
|
const { text } = await extractText(new Uint8Array(input.buffer), { mergePages: true })
|
|
return text.replace(/[ \t]+/g, ' ').trim()
|
|
} catch (err) {
|
|
console.warn('[invoice-inbox/extract] pdf text extraction failed:', err instanceof Error ? err.message : err)
|
|
return null
|
|
}
|
|
}
|
|
|
|
// ── Field extractors ────────────────────────────────────────────────
|
|
|
|
function extractOrgNumber(text: string): string | null {
|
|
const candidates = text.match(/\b\d{6}-?\d{4}\b/g) ?? []
|
|
for (const c of candidates) {
|
|
const normalized = normalizeOrgNumber(c)
|
|
if (normalized) return normalized
|
|
}
|
|
return null
|
|
}
|
|
|
|
function extractVatNumber(text: string): string | null {
|
|
const m = text.match(/\bSE\d{10}\d{2}\b/i)
|
|
return m ? m[0].toUpperCase() : null
|
|
}
|
|
|
|
function extractOcrReference(text: string): string | null {
|
|
// Anchor on "OCR" / "Referens" / "Bet.ref" labels; widen to any digit
|
|
// run on the same logical line if no labelled hit found.
|
|
const labelled = text.match(
|
|
/(?:OCR(?:-?nummer)?|Referens(?:nummer)?|Bet\.?\s*ref\.?|Betalningsreferens)[^\d\n]{0,40}(\d[\d\s]{3,30}\d)/i
|
|
)
|
|
if (labelled) {
|
|
const digits = labelled[1].replace(/\s/g, '')
|
|
if (validateOcrReference(digits)) return digits
|
|
}
|
|
// Fallback: look for any standalone digit run that passes Luhn (4-25 digits)
|
|
const candidates = text.match(/\b\d{4,25}\b/g) ?? []
|
|
for (const c of candidates) {
|
|
if (validateOcrReference(c)) return c
|
|
}
|
|
return null
|
|
}
|
|
|
|
function extractBankgiro(text: string): string | null {
|
|
const labelled = text.match(/Bankgiro(?:nr)?[^\d\n]{0,20}(\d{3,4}-?\d{4})/i)
|
|
if (labelled && validateBankgiroNumber(labelled[1])) {
|
|
return labelled[1].includes('-') ? labelled[1] : insertBankgiroHyphen(labelled[1])
|
|
}
|
|
// Fallback: any 7-8 digit number with hyphen that passes Luhn
|
|
const candidates = text.match(/\b\d{3,4}-\d{4}\b/g) ?? []
|
|
for (const c of candidates) {
|
|
if (validateBankgiroNumber(c)) return c
|
|
}
|
|
return null
|
|
}
|
|
|
|
function insertBankgiroHyphen(digits: string): string {
|
|
if (digits.length === 7) return `${digits.slice(0, 3)}-${digits.slice(3)}`
|
|
if (digits.length === 8) return `${digits.slice(0, 4)}-${digits.slice(4)}`
|
|
return digits
|
|
}
|
|
|
|
function extractPlusgiro(text: string): string | null {
|
|
const m = text.match(/Plusgiro(?:nr)?[^\d\n]{0,20}(\d{1,8}-\d)/i)
|
|
return m ? m[1] : null
|
|
}
|
|
|
|
function extractInvoiceNumber(text: string): string | null {
|
|
const m = text.match(
|
|
/(?:Faktura(?:nr|nummer)?|Invoice\s*(?:no|number|#))[^\w\n]{0,8}([A-Z0-9][A-Z0-9\-/]{2,20})/i
|
|
)
|
|
return m ? m[1].trim() : null
|
|
}
|
|
|
|
function extractDate(text: string, anchor: RegExp): string | null {
|
|
// Look for a date within ~40 chars of the anchor
|
|
const re = new RegExp(
|
|
`(?:${anchor.source})[^\\d\\n]{0,40}(\\d{4}[-/.]\\d{1,2}[-/.]\\d{1,2}|\\d{1,2}[-/.]\\d{1,2}[-/.]\\d{4})`,
|
|
'i'
|
|
)
|
|
const m = text.match(re)
|
|
if (!m) return null
|
|
return normalizeDate(m[1])
|
|
}
|
|
|
|
function normalizeDate(raw: string): string | null {
|
|
const sep = raw.match(/[-/.]/)
|
|
if (!sep) return null
|
|
const parts = raw.split(/[-/.]/).map((p) => p.trim())
|
|
if (parts.length !== 3) return null
|
|
let yyyy: string, mm: string, dd: string
|
|
if (parts[0].length === 4) {
|
|
[yyyy, mm, dd] = parts
|
|
} else if (parts[2].length === 4) {
|
|
[dd, mm, yyyy] = parts
|
|
} else {
|
|
return null
|
|
}
|
|
const m = mm.padStart(2, '0')
|
|
const d = dd.padStart(2, '0')
|
|
if (!/^\d{4}$/.test(yyyy) || !/^\d{2}$/.test(m) || !/^\d{2}$/.test(d)) return null
|
|
// Sanity check
|
|
const month = parseInt(m, 10)
|
|
const day = parseInt(d, 10)
|
|
if (month < 1 || month > 12 || day < 1 || day > 31) return null
|
|
return `${yyyy}-${m}-${d}`
|
|
}
|
|
|
|
function extractCurrency(text: string): string {
|
|
// Default SEK; only switch if a 3-letter currency code appears with an amount nearby
|
|
const m = text.match(/\b(EUR|USD|GBP|NOK|DKK|CHF)\b/i)
|
|
return m ? m[1].toUpperCase() : 'SEK'
|
|
}
|
|
|
|
function extractTotals(text: string): { subtotal: number | null; vatAmount: number | null; total: number | null } {
|
|
const total = findAmountNear(text, /(?:Att\s*betala|Totalt\s*att\s*betala|Summa\s*att\s*betala|Total(?:summa)?|Belopp\s*att\s*betala)/i)
|
|
const vatAmount = findAmountNear(text, /(?:Total\s*moms|Moms(?:\s*totalt)?|VAT(?:\s*total)?)/i)
|
|
const subtotal = findAmountNear(text, /(?:Netto(?:summa)?|Subtotal|Summa\s*excl(?:\.|usive)?\s*moms|Belopp\s*excl(?:\.|usive)?\s*moms)/i)
|
|
return { subtotal, vatAmount, total }
|
|
}
|
|
|
|
function findAmountNear(text: string, anchor: RegExp): number | null {
|
|
const re = new RegExp(`(?:${anchor.source})[^\\d\\n-]{0,60}([0-9][\\d\\s.,]*[0-9])`, 'i')
|
|
const m = text.match(re)
|
|
if (!m) return null
|
|
return parseSwedishAmount(m[1])
|
|
}
|
|
|
|
function parseSwedishAmount(raw: string): number | null {
|
|
// Swedish uses space as thousands sep and comma as decimal: "1 234,56".
|
|
// Also tolerate "1,234.56" (international) and "1234.56".
|
|
const cleaned = raw.replace(/\s/g, '')
|
|
let normalized: string
|
|
if (/,/.test(cleaned) && /\./.test(cleaned)) {
|
|
// Both present — assume thousands+decimal. Decide by last separator.
|
|
const lastComma = cleaned.lastIndexOf(',')
|
|
const lastDot = cleaned.lastIndexOf('.')
|
|
if (lastComma > lastDot) {
|
|
normalized = cleaned.replace(/\./g, '').replace(',', '.')
|
|
} else {
|
|
normalized = cleaned.replace(/,/g, '')
|
|
}
|
|
} else if (/,/.test(cleaned)) {
|
|
// Only comma — Swedish decimal
|
|
normalized = cleaned.replace(',', '.')
|
|
} else {
|
|
normalized = cleaned
|
|
}
|
|
const n = parseFloat(normalized)
|
|
return Number.isFinite(n) ? Math.round(n * 100) / 100 : null
|
|
}
|
|
|
|
function extractVatBreakdown(text: string): Array<{ rate: number; base: number; amount: number }> {
|
|
const out: Array<{ rate: number; base: number; amount: number }> = []
|
|
// Match patterns like "Moms 25% 800,00 200,00" or "25% moms 200,00"
|
|
const lineRe = /(?:Moms\s*)?(\d{1,2})\s*%[^\n\d-]{0,30}([0-9][\d\s.,]*[0-9])(?:[^\n\d-]{0,30}([0-9][\d\s.,]*[0-9]))?/gi
|
|
let m: RegExpExecArray | null
|
|
while ((m = lineRe.exec(text)) !== null) {
|
|
const rate = parseInt(m[1], 10)
|
|
if (![25, 12, 6, 0].includes(rate)) continue
|
|
const a = parseSwedishAmount(m[2])
|
|
const b = m[3] ? parseSwedishAmount(m[3]) : null
|
|
if (a == null) continue
|
|
// Two amounts: base then VAT amount. One amount: just VAT, derive base.
|
|
if (b != null) {
|
|
out.push({ rate, base: a, amount: b })
|
|
} else if (rate > 0) {
|
|
const base = Math.round((a / (rate / 100)) * 100) / 100
|
|
out.push({ rate, base, amount: a })
|
|
}
|
|
}
|
|
// Dedup by rate (keep first hit)
|
|
const seen = new Set<number>()
|
|
return out.filter((row) => {
|
|
if (seen.has(row.rate)) return false
|
|
seen.add(row.rate)
|
|
return true
|
|
})
|
|
}
|
|
|
|
function extractSupplierName(text: string): string | null {
|
|
// Heuristic: first non-blank, non-numeric line in the first 500 chars,
|
|
// skipping obvious header words.
|
|
const head = text.slice(0, 500)
|
|
const lines = head.split(/\n|(?:\s{4,})/).map((l) => l.trim()).filter(Boolean)
|
|
const skip = /^(faktura|invoice|kvitto|receipt|sida|page|datum|date)$/i
|
|
for (const line of lines) {
|
|
if (skip.test(line)) continue
|
|
if (/^\d/.test(line)) continue
|
|
if (line.length < 3 || line.length > 80) continue
|
|
return line
|
|
}
|
|
return null
|
|
}
|