feat: unified document inbox, full BAS 2026, and document-transaction matching

- Expand BAS reference from ~180 to ~1,276 accounts (full BAS Kontoplan 2026)
  with K2 exclusion flags, per-class data files, and computed SRU codes
- Evolve invoice inbox into unified document inbox handling invoices, receipts,
  and government letters with AI-powered classification (Claude Haiku Vision)
- Add multi-pass document-to-transaction matching engine with greedy assignment
  for both supplier invoices (reference/amount/date/name) and receipts
  (weighted amount/merchant/date scoring)
- Add supplier invoice matching in transaction ingest pipeline
- Inject booking template suggestions into AI extraction prompts
- Surface matched documents in swipe categorization UI with one-tap booking
- Auto-activate missing BAS accounts during SIE import against full reference
- Add K2 filter toggle in Chart of Accounts manager
- Add receipt confirmation route with BFNAR representation fields
- Add database migrations for K2 support and document matching columns
- Remove obsolete extension migration scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-02-25 16:59:02 +01:00
co-authored by Claude Opus 4.6
parent 6956a757f3
commit 39e407644d
68 changed files with 19234 additions and 2412 deletions
@@ -46,10 +46,19 @@ export interface CategorizationContext {
recentHistory: { description: string; category: string }[]
}
export interface DocumentEnrichment {
type: 'receipt' | 'supplier_invoice'
merchantName?: string
lineItems?: Array<{ description: string; amount: number; category?: string; accountSuggestion?: string }>
vatBreakdown?: Array<{ rate: number; amount: number }>
isReverseCharge?: boolean
}
export interface EnrichedCategorizationContext extends CategorizationContext {
candidateTemplates: BookingTemplate[]
userAccountUsage: AccountUsageEntry[]
merchantHistory: MerchantHistoryEntry[]
documentData?: DocumentEnrichment
}
export interface CategorizationSuggestion {
@@ -223,6 +232,11 @@ export class AnthropicCategorizationProvider implements CategorizationProvider {
.join('\n')}`
: ''
// Build document enrichment context (from linked receipt or supplier invoice)
const documentContext = enriched?.documentData
? buildDocumentContext(enriched.documentData)
: ''
const systemPrompt = `Du är expert på svensk bokföring och kategorisering av banktransaktioner enligt BAS-kontoplanen.
Din uppgift är att kategorisera varje transaktion till rätt mall-ID (templateId) och BAS-konto.
@@ -243,7 +257,7 @@ MOMSHANTERING:
- Intäkter: Normalt 25% moms (utgående moms, MP1)
${NON_DEDUCTIBLE_RULES}
${documentContext}
REGLER:
1. Negativa belopp = utgifter, positiva = intäkter
2. VIKTIGT: Dessa transaktioner kommer från företagets bankkonto/kort. Anta ALLTID att de är affärsrelaterade. Klassificera ALDRIG som "private" — det beslutet tar användaren själv.
@@ -382,6 +396,42 @@ ${transactionList}`
}
}
function buildDocumentContext(doc: DocumentEnrichment): string {
const parts: string[] = []
const typeLabel = doc.type === 'receipt' ? 'KVITTO' : 'LEVERANTÖRSFAKTURA'
parts.push(`LÄNKAT DOKUMENT (${typeLabel}):`)
if (doc.merchantName) {
parts.push(`Handlare/leverantör: ${doc.merchantName}`)
}
if (doc.lineItems && doc.lineItems.length > 0) {
parts.push('Rader:')
for (const item of doc.lineItems) {
let line = `- ${item.description}: ${item.amount} kr`
if (item.accountSuggestion) line += ` (föreslaget konto: ${item.accountSuggestion})`
if (item.category) line += ` [${item.category}]`
parts.push(line)
}
}
if (doc.vatBreakdown && doc.vatBreakdown.length > 0) {
parts.push('Momsfördelning:')
for (const vat of doc.vatBreakdown) {
parts.push(`- ${vat.rate}%: ${vat.amount} kr`)
}
}
if (doc.isReverseCharge) {
parts.push(`VIKTIGT: Omvänd skattskyldighet (reverse charge). Använd dubbelkontering:
- Debitera 2645 (beräknad ingående moms) OCH kreditera 2614 (utgående moms, omvänd skattskyldighet)
- Mallen "purchase_eu_service_reverse_charge" ska användas om tillgänglig`)
}
return parts.join('\n') + '\n'
}
function isEnrichedContext(
ctx: CategorizationContext | EnrichedCategorizationContext
): ctx is EnrichedCategorizationContext {
@@ -11,6 +11,7 @@
import 'server-only'
import Anthropic from '@anthropic-ai/sdk'
import type { InvoiceExtractionResult, ExtractedInvoiceLineItem, VatBreakdownItem } from '../types'
import { buildTemplatePromptSection } from '@/lib/bookkeeping/template-prompt'
const anthropic = new Anthropic()
@@ -40,6 +41,8 @@ VIKTIGT:
- Belopp ska vara numeriska värden utan valutasymboler
- Ange konfidenstal (0.0-1.0) för hela extraheringen`
const templateSection = buildTemplatePromptSection()
const userPrompt = `Analysera denna leverantörsfaktura och extrahera strukturerad data.
Returnera ett JSON-objekt med följande struktur:
@@ -67,7 +70,8 @@ Returnera ett JSON-objekt med följande struktur:
"unitPrice": 100.00,
"lineTotal": 100.00,
"vatRate": 25,
"accountSuggestion": "BAS-kontonummer som 5410 eller null"
"accountSuggestion": "BAS-kontonummer som 5410 eller null",
"suggestedTemplateId": "mall-id eller null"
}
],
"totals": {
@@ -82,10 +86,13 @@ Returnera ett JSON-objekt med följande struktur:
"amount": 25.00
}
],
"confidence": 0.95
"confidence": 0.95,
"suggestedTemplateId": "mall-id för hela fakturan eller null"
}
KONTOKATEGORIER (BAS):
${templateSection}
KONTOKATEGORIER (BAS, backup om ingen mall matchar):
- 4000-4999: Varuinköp, material
- 5010: Lokalhyra
- 5410: Förbrukningsinventarier
@@ -212,6 +219,7 @@ function validateAndEnhanceResult(raw: unknown): InvoiceExtractionResult {
},
vatBreakdown: validateVatBreakdown(data.vatBreakdown),
confidence: validateNumber(data.confidence) || 0.5,
suggestedTemplateId: validateString(data.suggestedTemplateId) || undefined,
}
}
@@ -228,6 +236,7 @@ function validateLineItems(data: any): ExtractedInvoiceLineItem[] {
lineTotal: validateNumber(item.lineTotal) || 0,
vatRate: validateNumber(item.vatRate),
accountSuggestion: validateAccountNumber(item.accountSuggestion as string | undefined),
suggestedTemplateId: validateString(item.suggestedTemplateId as string | undefined) || undefined,
}))
.filter((item: ExtractedInvoiceLineItem) => item.lineTotal > 0 || item.description.length > 0)
}
@@ -0,0 +1,178 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { eventBus } from '@/lib/events/bus'
// Mock server-only
vi.mock('server-only', () => ({}))
// Mock receipt analyzer
vi.mock('../receipt-analyzer', () => ({
analyzeReceipt: vi.fn(),
}))
// Mock receipt categorizer
vi.mock('../receipt-categorizer', () => ({
processLineItems: vi.fn(),
}))
// Mock receipt matcher
vi.mock('../receipt-matcher', () => ({
autoMatchReceipts: vi.fn(),
}))
// Mock event bus
vi.mock('@/lib/events/bus', () => ({
eventBus: { emit: vi.fn(), clear: vi.fn() },
}))
import { processReceiptFromDocument } from '../receipt-pipeline'
import { analyzeReceipt } from '../receipt-analyzer'
import { processLineItems } from '../receipt-categorizer'
import { autoMatchReceipts } from '../receipt-matcher'
function createMockSupabase() {
const mockResult = { data: null, error: null }
const chain = {
insert: vi.fn().mockReturnThis(),
update: vi.fn().mockReturnThis(),
select: vi.fn().mockReturnThis(),
single: vi.fn().mockImplementation(() => Promise.resolve(mockResult)),
eq: vi.fn().mockReturnThis(),
is: vi.fn().mockReturnThis(),
lt: vi.fn().mockReturnThis(),
gte: vi.fn().mockReturnThis(),
lte: vi.fn().mockReturnThis(),
}
const supabase = {
from: vi.fn().mockReturnValue(chain),
}
return { supabase, chain, setResult: (data: unknown, error: unknown = null) => {
mockResult.data = data as null
mockResult.error = error as null
} }
}
const mockExtraction = {
merchant: {
name: 'ICA Maxi',
orgNumber: '556123-4567',
vatNumber: null,
isForeign: false,
},
receipt: {
date: '2024-06-15',
time: '14:30',
currency: 'SEK',
},
lineItems: [
{ description: 'Mjölk', quantity: 1, unitPrice: 15, lineTotal: 15, vatRate: 12, suggestedCategory: 'other' },
],
totals: { subtotal: 13.39, vatAmount: 1.61, total: 15 },
flags: { isRestaurant: false, isSystembolaget: false, isForeignMerchant: false },
confidence: 0.92,
}
describe('processReceiptFromDocument', () => {
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
vi.mocked(analyzeReceipt).mockResolvedValue(mockExtraction)
vi.mocked(processLineItems).mockReturnValue([
{ ...mockExtraction.lineItems[0], category: 'expense_other' as const, basAccount: '6991', confidence: 0.8 },
])
vi.mocked(autoMatchReceipts).mockReturnValue([])
})
it('creates receipt record with extracted data', async () => {
const { supabase, chain, setResult } = createMockSupabase()
const receipt = {
id: 'receipt-1',
user_id: 'user-1',
status: 'extracted',
merchant_name: 'ICA Maxi',
total_amount: 15,
receipt_date: '2024-06-15',
}
// First from() = receipts insert
setResult(receipt)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await processReceiptFromDocument(supabase as any, 'user-1', 'base64data', 'image/jpeg', {
documentId: 'doc-1',
source: 'email',
emailFrom: 'sender@example.com',
storageUrl: 'https://storage.example.com/file.jpg',
})
expect(result.receipt.id).toBe('receipt-1')
expect(analyzeReceipt).toHaveBeenCalledWith('base64data', 'image/jpeg')
expect(processLineItems).toHaveBeenCalledWith(mockExtraction.lineItems)
// Verify receipt insert includes source and email_from
expect(chain.insert).toHaveBeenCalledWith(
expect.objectContaining({
source: 'email',
email_from: 'sender@example.com',
document_id: 'doc-1',
})
)
})
it('emits receipt.extracted event', async () => {
const { supabase, setResult } = createMockSupabase()
const receipt = { id: 'receipt-1', user_id: 'user-1' }
setResult(receipt)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await processReceiptFromDocument(supabase as any, 'user-1', 'base64data', 'image/jpeg', {
documentId: 'doc-1',
source: 'upload',
storageUrl: 'https://storage.example.com/file.jpg',
})
expect(eventBus.emit).toHaveBeenCalledWith({
type: 'receipt.extracted',
payload: expect.objectContaining({
receipt,
documentId: 'doc-1',
confidence: 0.92,
userId: 'user-1',
}),
})
})
it('throws when receipt insert fails', async () => {
const { supabase, setResult } = createMockSupabase()
setResult(null, { message: 'insert failed' })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await expect(processReceiptFromDocument(supabase as any, 'user-1', 'base64data', 'image/jpeg', {
documentId: null,
source: 'upload',
storageUrl: 'https://storage.example.com/file.jpg',
})).rejects.toThrow('Failed to create receipt')
})
it('sets email_from to null when not provided', async () => {
const { supabase, chain, setResult } = createMockSupabase()
setResult({ id: 'receipt-1', user_id: 'user-1' })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await processReceiptFromDocument(supabase as any, 'user-1', 'base64data', 'image/jpeg', {
documentId: null,
source: 'upload',
storageUrl: 'https://storage.example.com/file.jpg',
})
expect(chain.insert).toHaveBeenCalledWith(
expect.objectContaining({
source: 'upload',
email_from: null,
})
)
})
})
@@ -14,6 +14,7 @@ import { ChatAnthropic } from '@langchain/anthropic'
import { HumanMessage, SystemMessage } from '@langchain/core/messages'
import { z } from 'zod'
import type { ReceiptExtractionResult, ExtractedLineItem } from '@/types'
import { buildTemplatePromptSection } from '@/lib/bookkeeping/template-prompt'
import {
SYSTEMBOLAGET_PATTERNS,
RESTAURANT_PATTERNS,
@@ -54,6 +55,8 @@ VIKTIGT:
- Belopp ska vara numeriska värden utan valutasymboler
- Ange konfidenstal (0.0-1.0) för hela extraheringen baserat på bildkvalitet`
const templateSection = buildTemplatePromptSection()
const userPrompt = `Analysera detta kvitto och extrahera strukturerad data.
Returnera ett JSON-objekt med följande struktur:
@@ -77,7 +80,8 @@ Returnera ett JSON-objekt med följande struktur:
"unitPrice": 100.00,
"lineTotal": 100.00,
"vatRate": 25,
"suggestedCategory": "equipment|software|travel|office|marketing|professional_services|education|other"
"suggestedCategory": "equipment|software|travel|office|marketing|professional_services|education|other",
"suggestedTemplateId": "mall-id eller null"
}
],
"totals": {
@@ -90,10 +94,13 @@ Returnera ett JSON-objekt med följande struktur:
"isSystembolaget": false,
"isForeignMerchant": false
},
"confidence": 0.95
"confidence": 0.95,
"suggestedTemplateId": "mall-id för hela kvittot eller null"
}
KATEGORIER för suggestedCategory:
${templateSection}
KATEGORIER för suggestedCategory (backup om ingen mall matchar):
- equipment: Datorer, telefoner, kameror, teknikprylar
- software: Program, appar, molntjänster, prenumerationer
- travel: Flyg, tåg, hotell, taxi
@@ -224,6 +231,7 @@ function validateAndEnhanceResult(raw: unknown): ReceiptExtractionResult {
isForeignMerchant: isForeign,
},
confidence: validateNumber(data.confidence) || 0.5,
suggestedTemplateId: validateString(data.suggestedTemplateId) || undefined,
}
return result
@@ -245,6 +253,7 @@ function validateLineItems(data: any): ExtractedLineItem[] {
lineTotal: validateNumber(item.lineTotal) || 0,
vatRate: validateNumber(item.vatRate),
suggestedCategory: validateCategory(item.suggestedCategory),
suggestedTemplateId: validateString(item.suggestedTemplateId) || undefined,
confidence: validateNumber(item.confidence) || undefined,
}))
.filter((item) => item.lineTotal > 0 || item.description.length > 0)
@@ -6,6 +6,7 @@
*/
import type { TransactionCategory, ReceiptLineItem, ExtractedLineItem } from '@/types'
import { getTemplateById } from '@/lib/bookkeeping/booking-templates'
// Category mappings from suggested category to TransactionCategory
const CATEGORY_MAPPING: Record<string, TransactionCategory> = {
@@ -108,6 +109,16 @@ export function mapSuggestedCategory(suggestedCategory: string | null): Transact
return CATEGORY_MAPPING[suggestedCategory] || null
}
/**
* Map a booking template ID to a TransactionCategory.
* Falls back to the template's `fallback_category` field.
*/
export function mapTemplateIdToCategory(templateId: string | null | undefined): TransactionCategory | null {
if (!templateId) return null
const template = getTemplateById(templateId)
return template?.fallback_category ?? null
}
/**
* Get BAS account for a category
*/
@@ -148,9 +159,15 @@ export function processLineItems(
lineItems: ExtractedLineItem[]
): Array<ExtractedLineItem & { category: TransactionCategory | null; basAccount: string | null }> {
return lineItems.map((item) => {
// First try the AI-suggested category
let category = mapSuggestedCategory(item.suggestedCategory)
let confidence = item.confidence || 0.8
// First try the AI-suggested template ID
let category = mapTemplateIdToCategory(item.suggestedTemplateId)
let confidence = category ? (item.confidence || 0.85) : 0
// Then try the AI-suggested category
if (!category) {
category = mapSuggestedCategory(item.suggestedCategory)
confidence = item.confidence || 0.8
}
// If no AI suggestion, try pattern matching
if (!category) {
@@ -3,14 +3,24 @@
*
* Uses date variance, amount tolerance, and merchant name similarity
* to find potential transaction matches for receipts.
*
* Core matching utilities (levenshtein, merchant similarity) are imported
* from @/lib/documents/core-receipt-matcher and re-exported for backward compat.
*/
import type { Transaction, Receipt, ReceiptMatchCandidate } from '@/types'
import {
calculateMerchantSimilarity,
calculateMatchConfidence,
levenshteinDistance,
normalizeMerchantName,
DATE_TOLERANCE_DAYS,
AMOUNT_TOLERANCE_PERCENT,
MIN_MATCH_CONFIDENCE,
} from '@/lib/documents/core-receipt-matcher'
// Matching configuration
const DATE_TOLERANCE_DAYS = 3 // Allow ±3 days variance
const AMOUNT_TOLERANCE_PERCENT = 0.05 // Allow ±5% variance (for currency conversion)
const MIN_MATCH_CONFIDENCE = 0.4 // Minimum confidence to consider a match
// Re-export core functions for backward compatibility
export { calculateMerchantSimilarity, levenshteinDistance, normalizeMerchantName }
/**
* Find potential transaction matches for a receipt
@@ -59,7 +69,7 @@ export function findTransactionMatches(
const merchantSimilarity = calculateMerchantSimilarity(merchantName, transactionMerchant)
// Calculate overall confidence
const { confidence, matchReasons } = calculateMatchConfidence(
const { confidence, matchReasons } = calculateReceiptMatchConfidence(
dateVariance,
amountVariance,
merchantSimilarity,
@@ -83,140 +93,28 @@ export function findTransactionMatches(
}
/**
* Calculate merchant name similarity using Levenshtein distance
* Wrapper around core calculateMatchConfidence that adds receipt-specific
* MCC bonus logic for backward compatibility.
*/
function calculateMerchantSimilarity(name1: string, name2: string): number {
if (!name1 || !name2) return 0
// Normalize names
const n1 = normalizeMerchantName(name1)
const n2 = normalizeMerchantName(name2)
// Check for exact match
if (n1 === n2) return 1
// Check if one contains the other
if (n1.includes(n2) || n2.includes(n1)) return 0.9
// Check for word overlap
const words1 = n1.split(/\s+/)
const words2 = n2.split(/\s+/)
const commonWords = words1.filter((w) => words2.includes(w))
if (commonWords.length > 0) {
const overlapScore = commonWords.length / Math.max(words1.length, words2.length)
if (overlapScore >= 0.5) return 0.7 + overlapScore * 0.2
}
// Calculate Levenshtein similarity
const distance = levenshteinDistance(n1, n2)
const maxLength = Math.max(n1.length, n2.length)
const similarity = 1 - distance / maxLength
return similarity
}
/**
* Normalize merchant name for comparison
*/
function normalizeMerchantName(name: string): string {
return name
.toLowerCase()
.replace(/[^\w\såäöé]/g, '') // Remove special chars except Swedish letters
.replace(/\b(ab|hb|kb|ek|för|stiftelse)\b/g, '') // Remove company suffixes
.replace(/\s+/g, ' ')
.trim()
}
/**
* Calculate Levenshtein distance between two strings
*/
function levenshteinDistance(str1: string, str2: string): number {
const m = str1.length
const n = str2.length
// Create matrix
const dp: number[][] = Array(m + 1)
.fill(null)
.map(() => Array(n + 1).fill(0))
// Initialize first row and column
for (let i = 0; i <= m; i++) dp[i][0] = i
for (let j = 0; j <= n; j++) dp[0][j] = j
// Fill the matrix
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
const cost = str1[i - 1] === str2[j - 1] ? 0 : 1
dp[i][j] = Math.min(
dp[i - 1][j] + 1, // deletion
dp[i][j - 1] + 1, // insertion
dp[i - 1][j - 1] + cost // substitution
)
}
}
return dp[m][n]
}
/**
* Calculate overall match confidence and reasons
*/
function calculateMatchConfidence(
function calculateReceiptMatchConfidence(
dateVariance: number,
amountVariance: number,
merchantSimilarity: number,
receipt: Receipt,
transaction: Transaction
): { confidence: number; matchReasons: string[] } {
const matchReasons: string[] = []
let totalWeight = 0
let weightedScore = 0
const result = calculateMatchConfidence(dateVariance, amountVariance, merchantSimilarity)
// Date score (weight: 25%)
const dateScore = Math.max(0, 1 - dateVariance / DATE_TOLERANCE_DAYS)
if (dateScore >= 0.8) {
matchReasons.push(dateVariance === 0 ? 'Exakt datum' : `Datum ±${Math.round(dateVariance)} dagar`)
}
weightedScore += dateScore * 0.25
totalWeight += 0.25
// Amount score (weight: 40%)
const amountScore = Math.max(0, 1 - amountVariance / AMOUNT_TOLERANCE_PERCENT)
if (amountVariance < 0.01) {
matchReasons.push('Exakt belopp')
} else if (amountVariance < AMOUNT_TOLERANCE_PERCENT) {
matchReasons.push(`Belopp ±${Math.round(amountVariance * 100)}%`)
}
weightedScore += amountScore * 0.4
totalWeight += 0.4
// Merchant score (weight: 35%)
if (merchantSimilarity > 0) {
if (merchantSimilarity >= 0.9) {
matchReasons.push('Handlare matchar')
} else if (merchantSimilarity >= 0.6) {
matchReasons.push('Trolig handlarmatch')
}
weightedScore += merchantSimilarity * 0.35
totalWeight += 0.35
}
// Bonus for MCC match (if transaction has MCC and receipt is flagged)
// Bonus for MCC match (receipt-specific: restaurant MCC codes)
if (receipt.is_restaurant && transaction.mcc_code) {
const restaurantMCCs = [5812, 5813, 5814]
if (restaurantMCCs.includes(transaction.mcc_code)) {
matchReasons.push('Restaurang MCC matchar')
weightedScore += 0.1
result.matchReasons.push('Restaurang MCC matchar')
result.confidence = Math.round((result.confidence + 0.1) * 100) / 100
}
}
const confidence = totalWeight > 0 ? weightedScore / totalWeight : 0
return {
confidence: Math.round(confidence * 100) / 100,
matchReasons,
}
return result
}
/**
@@ -0,0 +1,164 @@
/**
* Receipt Pipeline — shared, reusable receipt processing function.
*
* SERVER-ONLY: uses receipt-analyzer (Anthropic SDK).
*
* Extracts receipt data, categorizes line items, inserts records,
* and attempts auto-matching against bank transactions.
*/
import 'server-only'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { Receipt, ReceiptMatchCandidate } from '@/types'
import { analyzeReceipt } from './receipt-analyzer'
import { processLineItems } from './receipt-categorizer'
import { autoMatchReceipts } from './receipt-matcher'
import { eventBus } from '@/lib/events/bus'
export interface ReceiptPipelineOptions {
documentId: string | null
source: 'upload' | 'camera' | 'email'
emailFrom?: string
storageUrl: string
}
export interface ProcessedReceipt {
receipt: Receipt
lineItems: unknown[]
matchedTransaction?: { transactionId: string; confidence: number }
}
/**
* Process a document through the receipt pipeline:
* 1. Analyze with Claude Vision
* 2. Categorize line items
* 3. Insert receipt + line items
* 4. Auto-match against unmatched transactions
* 5. Emit receipt.extracted event
*/
export async function processReceiptFromDocument(
supabase: SupabaseClient,
userId: string,
base64: string,
mimeType: string,
opts: ReceiptPipelineOptions
): Promise<ProcessedReceipt> {
// 1. Analyze receipt with Claude Vision
const validImageType = mimeType as 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif'
const extraction = await analyzeReceipt(base64, validImageType)
// 2. Categorize line items
const processedLineItems = processLineItems(extraction.lineItems)
// 3. Insert receipt record
const { data: receipt, error: insertError } = await supabase
.from('receipts')
.insert({
user_id: userId,
image_url: opts.storageUrl,
status: 'extracted',
extraction_confidence: extraction.confidence,
merchant_name: extraction.merchant.name,
merchant_org_number: extraction.merchant.orgNumber,
merchant_vat_number: extraction.merchant.vatNumber,
receipt_date: extraction.receipt.date,
receipt_time: extraction.receipt.time,
total_amount: extraction.totals.total,
currency: extraction.receipt.currency,
vat_amount: extraction.totals.vatAmount,
is_restaurant: extraction.flags.isRestaurant,
is_systembolaget: extraction.flags.isSystembolaget,
is_foreign_merchant: extraction.flags.isForeignMerchant,
raw_extraction: extraction,
document_id: opts.documentId,
source: opts.source,
email_from: opts.emailFrom ?? null,
})
.select()
.single()
if (insertError || !receipt) {
throw new Error(`Failed to create receipt: ${insertError?.message}`)
}
// 4. Insert line items
if (processedLineItems.length > 0) {
const lineItemsToInsert = processedLineItems.map((item, index) => ({
receipt_id: receipt.id,
description: item.description,
quantity: item.quantity,
unit_price: item.unitPrice,
line_total: item.lineTotal,
vat_rate: item.vatRate,
vat_amount:
item.vatRate && item.lineTotal
? Math.round((item.lineTotal * item.vatRate) / (100 + item.vatRate) * 100) / 100
: null,
extraction_confidence: item.confidence,
suggested_category: item.suggestedCategory,
category: item.category,
bas_account: item.basAccount,
sort_order: index,
}))
await supabase.from('receipt_line_items').insert(lineItemsToInsert)
}
// 5. Auto-match against unmatched expense transactions (±7 days from receipt date)
let matchedTransaction: ProcessedReceipt['matchedTransaction'] | undefined
if (extraction.receipt.date && extraction.totals.total) {
const receiptDate = new Date(extraction.receipt.date)
const dateFrom = new Date(receiptDate)
dateFrom.setDate(dateFrom.getDate() - 7)
const dateTo = new Date(receiptDate)
dateTo.setDate(dateTo.getDate() + 7)
const { data: transactions } = await supabase
.from('transactions')
.select('*')
.eq('user_id', userId)
.is('receipt_id', null)
.lt('amount', 0)
.gte('date', dateFrom.toISOString().split('T')[0])
.lte('date', dateTo.toISOString().split('T')[0])
if (transactions && transactions.length > 0) {
const matches = autoMatchReceipts([receipt], transactions, 0.8)
if (matches.length > 0) {
const best = matches[0]
matchedTransaction = {
transactionId: best.match.transaction.id,
confidence: best.match.confidence,
}
// Link receipt to transaction
await supabase
.from('receipts')
.update({
matched_transaction_id: best.match.transaction.id,
match_confidence: best.match.confidence,
})
.eq('id', receipt.id)
await supabase
.from('transactions')
.update({ receipt_id: receipt.id })
.eq('id', best.match.transaction.id)
}
}
}
// 6. Emit event
await eventBus.emit({
type: 'receipt.extracted',
payload: {
receipt,
documentId: opts.documentId,
confidence: extraction.confidence,
userId,
},
})
return { receipt, lineItems: processedLineItems, matchedTransaction }
}