Files
accounted/extensions/general/invoice-inbox/lib/extract-invoice-fields.ts
T
Mattsson fa7d4075cf Supp/invoice bfl errors (#390)
* feat(accounting): update accounting method validation and messaging for aktiebolag and enskild firma

* Remove AI subsystem and related code

- Deleted AI proposals and requests persistence logic from `lib/ai/proposals/persist.ts`.
- Removed re-validation logic for proposals in `lib/ai/proposals/re-validate.ts`.
- Cleaned up schemas related to AI flows in `lib/api/schemas.ts`.
- Removed AI-related fields from bookkeeping engine in `lib/bookkeeping/engine.ts`.
- Eliminated AI event types from `lib/events/types.ts`.
- Updated tests to reflect the removal of AI-related functionality in `lib/extensions/__tests__/sectors.test.ts`.
- Adjusted initialization logic in `lib/init.ts` to exclude AI proposal handler registration.
- Cleaned up transaction ingestion logic in `lib/transactions/ingest.ts` to remove AI flow checks.
- Updated helper functions in `tests/helpers.ts` to remove AI-related settings.
- Removed AI-related types and interfaces from `types/index.ts`.
- Added migration script to drop AI-related tables and settings from the database.

* fix(migrations): ensure foreign key constraint is dropped before removing AI tables

* feat(invoice-inbox): implement deterministic invoice field extraction and inbox provisioning

- Added `extract-invoice-fields.ts` for extracting fields from PDF invoices using regex and pdfjs-dist, replacing the previous AI classifier.
- Introduced `inbox-provisioning.ts` to manage company inbox addresses and rotation of inboxes using Supabase RPCs.
- Created `resend-inbound.ts` for handling inbound email events and attachments via the Resend API.
- Defined the extension manifest for the invoice inbox, specifying required environment variables and descriptions.
- Migrated database schema to remove AI-related columns and tighten the status enum in `invoice_inbox_items`.

* feat(invoice-inbox): remove AI-specific columns and tighten status enum

* fix(skattekonto): remove manual entry creation reference from transaction input

* fix(schemas): remove accounting method validation for aktiebolag in UpdateSettingsSchema
2026-05-05 09:53:37 +02:00

308 lines
11 KiB
TypeScript

// Deterministic Swedish invoice field extraction.
//
// Replaces the deleted AI classifier. We pull text out of the PDF with
// pdfjs-dist 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'
// Below this we treat the document as image-only / unreadable and skip
// regex extraction. pdfjs-dist 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 pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs')
const loadingTask = pdfjs.getDocument({
data: new Uint8Array(input.buffer),
isEvalSupported: false,
disableFontFace: true,
})
const pdf = await loadingTask.promise
const pages: string[] = []
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i)
const content = await page.getTextContent()
const pageText = content.items
.map((item) => ('str' in item ? item.str : ''))
.join(' ')
pages.push(pageText)
}
return pages.join('\n').replace(/[ \t]+/g, ' ').trim()
} catch (err) {
console.warn('[invoice-inbox/extract] pdfjs 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
}