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
This commit is contained in:
@@ -1,352 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { matchDocumentToTransactions } from '../document-matcher'
|
||||
import { makeInvoiceInboxItem, makeTransaction } from '@/tests/helpers'
|
||||
import type { InvoiceExtractionResult, ReceiptExtractionResult } from '@/types'
|
||||
|
||||
describe('matchDocumentToTransactions', () => {
|
||||
const mockSupabase = {} as never // Not used when candidateTransactions is provided
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('supplier_invoice matching', () => {
|
||||
const baseExtraction: InvoiceExtractionResult = {
|
||||
supplier: {
|
||||
name: 'Telia AB',
|
||||
orgNumber: '556103-4249',
|
||||
vatNumber: 'SE556103424901',
|
||||
address: 'Stockholm',
|
||||
bankgiro: '5820-5093',
|
||||
plusgiro: null,
|
||||
},
|
||||
invoice: {
|
||||
invoiceNumber: 'INV-2024-001',
|
||||
invoiceDate: '2024-06-10',
|
||||
dueDate: '2024-06-20',
|
||||
paymentReference: '73401284756',
|
||||
currency: 'SEK',
|
||||
},
|
||||
lineItems: [],
|
||||
totals: { subtotal: 800, vatAmount: 200, total: 1000 },
|
||||
vatBreakdown: [],
|
||||
confidence: 0.95,
|
||||
}
|
||||
|
||||
it('returns null for government_letter type', async () => {
|
||||
const item = makeInvoiceInboxItem({
|
||||
document_type: 'government_letter',
|
||||
extracted_data: baseExtraction as unknown as Record<string, unknown>,
|
||||
})
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [])
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when no extracted_data', async () => {
|
||||
const item = makeInvoiceInboxItem({ extracted_data: null })
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [])
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when no candidate transactions', async () => {
|
||||
const item = makeInvoiceInboxItem({
|
||||
status: 'ready',
|
||||
extracted_data: baseExtraction as unknown as Record<string, unknown>,
|
||||
})
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [])
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('pass 1: matches by payment reference with 0.98 confidence', async () => {
|
||||
const item = makeInvoiceInboxItem({
|
||||
status: 'ready',
|
||||
extracted_data: baseExtraction as unknown as Record<string, unknown>,
|
||||
})
|
||||
const tx = makeTransaction({
|
||||
amount: -1000,
|
||||
reference: '73401284756',
|
||||
date: '2024-06-20',
|
||||
})
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.confidence).toBe(0.98)
|
||||
expect(result!.method).toBe('payment_reference')
|
||||
expect(result!.transactionId).toBe(tx.id)
|
||||
})
|
||||
|
||||
it('pass 1: matches with whitespace/dash-normalized references', async () => {
|
||||
const item = makeInvoiceInboxItem({
|
||||
status: 'ready',
|
||||
extracted_data: baseExtraction as unknown as Record<string, unknown>,
|
||||
})
|
||||
const tx = makeTransaction({
|
||||
amount: -1000,
|
||||
reference: '734 012 847 56',
|
||||
date: '2024-06-20',
|
||||
})
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.confidence).toBe(0.98)
|
||||
expect(result!.method).toBe('payment_reference')
|
||||
})
|
||||
|
||||
it('pass 2: matches by exact amount + bankgiro with 0.92 confidence', async () => {
|
||||
const extractionNoRef = {
|
||||
...baseExtraction,
|
||||
invoice: { ...baseExtraction.invoice, paymentReference: null },
|
||||
}
|
||||
const item = makeInvoiceInboxItem({
|
||||
status: 'ready',
|
||||
extracted_data: extractionNoRef as unknown as Record<string, unknown>,
|
||||
})
|
||||
const tx = makeTransaction({
|
||||
amount: -1000,
|
||||
reference: null,
|
||||
description: 'BETALNING 58205093',
|
||||
date: '2024-06-20',
|
||||
})
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.confidence).toBe(0.92)
|
||||
expect(result!.method).toBe('payment_reference')
|
||||
})
|
||||
|
||||
it('pass 3: matches by exact amount + date proximity with 0.85 confidence', async () => {
|
||||
const extractionNoBg = {
|
||||
...baseExtraction,
|
||||
invoice: { ...baseExtraction.invoice, paymentReference: null },
|
||||
supplier: { ...baseExtraction.supplier, bankgiro: null, plusgiro: null },
|
||||
}
|
||||
const item = makeInvoiceInboxItem({
|
||||
status: 'ready',
|
||||
extracted_data: extractionNoBg as unknown as Record<string, unknown>,
|
||||
})
|
||||
const tx = makeTransaction({
|
||||
amount: -1000,
|
||||
reference: null,
|
||||
description: 'PAYMENT',
|
||||
date: '2024-06-22',
|
||||
merchant_name: null,
|
||||
})
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.confidence).toBe(0.85)
|
||||
expect(result!.method).toBe('amount_date')
|
||||
})
|
||||
|
||||
it('pass 3: matches with lower confidence at 6–14 days', async () => {
|
||||
const extractionNoBg = {
|
||||
...baseExtraction,
|
||||
invoice: { ...baseExtraction.invoice, paymentReference: null },
|
||||
supplier: { ...baseExtraction.supplier, bankgiro: null, plusgiro: null },
|
||||
}
|
||||
const item = makeInvoiceInboxItem({
|
||||
status: 'ready',
|
||||
extracted_data: extractionNoBg as unknown as Record<string, unknown>,
|
||||
})
|
||||
const tx = makeTransaction({
|
||||
amount: -1000,
|
||||
reference: null,
|
||||
description: 'PAYMENT',
|
||||
date: '2024-06-28', // 8 days after due date
|
||||
merchant_name: null,
|
||||
})
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.confidence).toBe(0.75)
|
||||
expect(result!.method).toBe('amount_date')
|
||||
})
|
||||
|
||||
it('pass 3: does not match if date is >14 days away', async () => {
|
||||
const extractionNoBg = {
|
||||
...baseExtraction,
|
||||
invoice: { ...baseExtraction.invoice, paymentReference: null },
|
||||
supplier: { ...baseExtraction.supplier, bankgiro: null, plusgiro: null },
|
||||
}
|
||||
const item = makeInvoiceInboxItem({
|
||||
status: 'ready',
|
||||
extracted_data: extractionNoBg as unknown as Record<string, unknown>,
|
||||
})
|
||||
const tx = makeTransaction({
|
||||
amount: -1000,
|
||||
reference: null,
|
||||
description: 'PAYMENT',
|
||||
date: '2024-07-06', // 16 days after due date
|
||||
merchant_name: null,
|
||||
})
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('pass 4: matches by fuzzy amount + supplier name with 0.70 confidence', async () => {
|
||||
const extractionMinimal = {
|
||||
...baseExtraction,
|
||||
invoice: {
|
||||
...baseExtraction.invoice,
|
||||
paymentReference: null,
|
||||
dueDate: null,
|
||||
invoiceDate: null,
|
||||
},
|
||||
supplier: {
|
||||
...baseExtraction.supplier,
|
||||
bankgiro: null,
|
||||
plusgiro: null,
|
||||
name: 'Telia Sverige',
|
||||
},
|
||||
}
|
||||
const item = makeInvoiceInboxItem({
|
||||
status: 'ready',
|
||||
extracted_data: extractionMinimal as unknown as Record<string, unknown>,
|
||||
})
|
||||
const tx = makeTransaction({
|
||||
amount: -1000,
|
||||
reference: null,
|
||||
description: 'telia faktura april',
|
||||
date: '2024-06-15',
|
||||
merchant_name: null,
|
||||
})
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.confidence).toBe(0.70)
|
||||
expect(result!.method).toBe('amount_merchant')
|
||||
})
|
||||
|
||||
it('prefers higher confidence matches', async () => {
|
||||
const item = makeInvoiceInboxItem({
|
||||
status: 'ready',
|
||||
extracted_data: baseExtraction as unknown as Record<string, unknown>,
|
||||
})
|
||||
|
||||
const txWithRef = makeTransaction({
|
||||
amount: -1000,
|
||||
reference: '73401284756',
|
||||
date: '2024-06-20',
|
||||
})
|
||||
const txWithAmount = makeTransaction({
|
||||
amount: -1000,
|
||||
reference: null,
|
||||
description: 'BETALNING',
|
||||
date: '2024-06-20',
|
||||
})
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [txWithAmount, txWithRef])
|
||||
expect(result!.confidence).toBe(0.98)
|
||||
expect(result!.transactionId).toBe(txWithRef.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('receipt matching', () => {
|
||||
const receiptExtraction: ReceiptExtractionResult = {
|
||||
merchant: {
|
||||
name: 'ICA Maxi',
|
||||
orgNumber: null,
|
||||
vatNumber: null,
|
||||
isForeign: false,
|
||||
},
|
||||
receipt: {
|
||||
date: '2024-06-15',
|
||||
time: '14:30',
|
||||
currency: 'SEK',
|
||||
},
|
||||
lineItems: [],
|
||||
totals: { subtotal: 239.2, vatAmount: 59.8, total: 299 },
|
||||
flags: {
|
||||
isRestaurant: false,
|
||||
isSystembolaget: false,
|
||||
isForeignMerchant: false,
|
||||
},
|
||||
confidence: 0.92,
|
||||
}
|
||||
|
||||
it('matches receipt to transaction with high confidence', async () => {
|
||||
const item = makeInvoiceInboxItem({
|
||||
document_type: 'receipt',
|
||||
status: 'ready',
|
||||
extracted_data: receiptExtraction as unknown as Record<string, unknown>,
|
||||
})
|
||||
const tx = makeTransaction({
|
||||
amount: -299,
|
||||
date: '2024-06-15',
|
||||
merchant_name: 'ICA Maxi',
|
||||
description: 'ICA MAXI STOCKHOLM',
|
||||
})
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.method).toBe('receipt_match')
|
||||
expect(result!.confidence).toBeGreaterThanOrEqual(0.60)
|
||||
})
|
||||
|
||||
it('returns null when amount is too different', async () => {
|
||||
const item = makeInvoiceInboxItem({
|
||||
document_type: 'receipt',
|
||||
status: 'ready',
|
||||
extracted_data: receiptExtraction as unknown as Record<string, unknown>,
|
||||
})
|
||||
const tx = makeTransaction({
|
||||
amount: -500,
|
||||
date: '2024-06-15',
|
||||
merchant_name: 'ICA Maxi',
|
||||
})
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when date is too far away', async () => {
|
||||
const item = makeInvoiceInboxItem({
|
||||
document_type: 'receipt',
|
||||
status: 'ready',
|
||||
extracted_data: receiptExtraction as unknown as Record<string, unknown>,
|
||||
})
|
||||
const tx = makeTransaction({
|
||||
amount: -299,
|
||||
date: '2024-06-25', // 10 days after
|
||||
merchant_name: 'ICA Maxi',
|
||||
})
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('skips transactions with existing receipt_id', async () => {
|
||||
const item = makeInvoiceInboxItem({
|
||||
document_type: 'receipt',
|
||||
status: 'ready',
|
||||
extracted_data: receiptExtraction as unknown as Record<string, unknown>,
|
||||
})
|
||||
const tx = makeTransaction({
|
||||
amount: -299,
|
||||
date: '2024-06-15',
|
||||
merchant_name: 'ICA Maxi',
|
||||
receipt_id: 'existing-receipt',
|
||||
})
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when total is 0 or null', async () => {
|
||||
const zeroExtraction = {
|
||||
...receiptExtraction,
|
||||
totals: { ...receiptExtraction.totals, total: 0 },
|
||||
}
|
||||
const item = makeInvoiceInboxItem({
|
||||
document_type: 'receipt',
|
||||
status: 'ready',
|
||||
extracted_data: zeroExtraction as unknown as Record<string, unknown>,
|
||||
})
|
||||
const tx = makeTransaction({ amount: -299, date: '2024-06-15' })
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,127 +0,0 @@
|
||||
/**
|
||||
* Batch Document Matching
|
||||
*
|
||||
* Orchestrates matching multiple inbox items to transactions in a single sweep.
|
||||
* Fetches all unbooked transactions once, then runs per-item matching with
|
||||
* greedy assignment to prevent double-matching.
|
||||
*/
|
||||
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { InvoiceInboxItem, Transaction } from '@/types'
|
||||
import { matchDocumentToTransactions, type DocumentMatchResult } from './document-matcher'
|
||||
|
||||
export interface BatchMatchResult {
|
||||
matched: number
|
||||
total: number
|
||||
matches: Array<{ inboxItemId: string; result: DocumentMatchResult }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a matching sweep for all ready unmatched inbox items.
|
||||
*
|
||||
* 1. Fetches all ready/processing inbox items without a matched_transaction_id
|
||||
* 2. Fetches all unbooked expense transactions
|
||||
* 3. Runs matching per item, greedily assigning (highest confidence first)
|
||||
* 4. Persists matches back to inbox items
|
||||
*/
|
||||
export async function runDocumentMatchingSweep(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
inboxItemIds?: string[]
|
||||
): Promise<BatchMatchResult> {
|
||||
// 1. Fetch unmatched inbox items
|
||||
let query = supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.is('matched_transaction_id', null)
|
||||
.in('status', ['ready', 'processing'])
|
||||
|
||||
if (inboxItemIds && inboxItemIds.length > 0) {
|
||||
query = query.in('id', inboxItemIds)
|
||||
}
|
||||
|
||||
const { data: inboxItems, error: itemsError } = await query
|
||||
|
||||
if (itemsError || !inboxItems || inboxItems.length === 0) {
|
||||
console.log(`[batch-match] No unmatched inbox items found`)
|
||||
return { matched: 0, total: 0, matches: [] }
|
||||
}
|
||||
|
||||
console.log(`[batch-match] Starting sweep: ${inboxItems.length} unmatched inbox items`)
|
||||
|
||||
// 2. Fetch all unbooked expense transactions (broad window: last 90 days)
|
||||
const ninetyDaysAgo = new Date()
|
||||
ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90)
|
||||
|
||||
const { data: transactions, error: txError } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.is('journal_entry_id', null)
|
||||
.is('is_business', null)
|
||||
.lt('amount', 0)
|
||||
.gte('date', ninetyDaysAgo.toISOString().split('T')[0])
|
||||
.order('date', { ascending: false })
|
||||
|
||||
if (txError || !transactions || transactions.length === 0) {
|
||||
console.log(`[batch-match] No candidate transactions found (last 90 days)`)
|
||||
return { matched: 0, total: inboxItems.length, matches: [] }
|
||||
}
|
||||
|
||||
console.log(`[batch-match] ${transactions.length} candidate transactions (last 90 days)`)
|
||||
|
||||
// 3. Run matching for each item and collect results
|
||||
const pendingMatches: Array<{
|
||||
inboxItemId: string
|
||||
result: DocumentMatchResult
|
||||
}> = []
|
||||
|
||||
for (const item of inboxItems as InvoiceInboxItem[]) {
|
||||
const result = await matchDocumentToTransactions(
|
||||
supabase,
|
||||
companyId,
|
||||
item,
|
||||
transactions as Transaction[]
|
||||
)
|
||||
if (result) {
|
||||
pendingMatches.push({ inboxItemId: item.id, result })
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Greedy assignment: sort by confidence desc, assign each transaction at most once
|
||||
pendingMatches.sort((a, b) => b.result.confidence - a.result.confidence)
|
||||
|
||||
const assignedTransactionIds = new Set<string>()
|
||||
const finalMatches: typeof pendingMatches = []
|
||||
|
||||
for (const match of pendingMatches) {
|
||||
if (assignedTransactionIds.has(match.result.transactionId)) {
|
||||
console.log(`[batch-match] Skipped item=${match.inboxItemId} → tx=${match.result.transactionId} (already assigned to higher-confidence match)`)
|
||||
continue // Transaction already assigned to a higher-confidence match
|
||||
}
|
||||
assignedTransactionIds.add(match.result.transactionId)
|
||||
finalMatches.push(match)
|
||||
}
|
||||
|
||||
console.log(`[batch-match] Sweep complete: ${finalMatches.length}/${inboxItems.length} items matched, ${pendingMatches.length - finalMatches.length} skipped (greedy dedup)`)
|
||||
|
||||
// 5. Persist matches
|
||||
for (const { inboxItemId, result } of finalMatches) {
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({
|
||||
matched_transaction_id: result.transactionId,
|
||||
match_confidence: result.confidence,
|
||||
match_method: result.method,
|
||||
})
|
||||
.eq('id', inboxItemId)
|
||||
.eq('company_id', companyId)
|
||||
}
|
||||
|
||||
return {
|
||||
matched: finalMatches.length,
|
||||
total: inboxItems.length,
|
||||
matches: finalMatches,
|
||||
}
|
||||
}
|
||||
@@ -1,322 +0,0 @@
|
||||
/**
|
||||
* Document-to-Transaction Matcher
|
||||
*
|
||||
* Pure matching logic that works from extracted data already stored on inbox items.
|
||||
* Zero AI or extension dependencies — works entirely from structured data.
|
||||
*
|
||||
* Matching passes by document type:
|
||||
*
|
||||
* Supplier invoices:
|
||||
* 1. Payment reference exact match → 0.98
|
||||
* 2. Exact amount + bankgiro → 0.92
|
||||
* 3. Exact amount + date ±5 days → 0.85
|
||||
* 4. Fuzzy amount + supplier name → 0.70
|
||||
*
|
||||
* Receipts:
|
||||
* Weighted scoring (amount 40%, date 25%, merchant 35%), min confidence 0.60
|
||||
*/
|
||||
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { InvoiceInboxItem, Transaction, InvoiceExtractionResult, ReceiptExtractionResult } from '@/types'
|
||||
import {
|
||||
calculateMerchantSimilarity,
|
||||
calculateMatchConfidence,
|
||||
} from './core-receipt-matcher'
|
||||
|
||||
export type DocumentMatchMethod =
|
||||
| 'payment_reference'
|
||||
| 'amount_date'
|
||||
| 'amount_merchant'
|
||||
| 'receipt_match'
|
||||
|
||||
export interface DocumentMatchResult {
|
||||
transactionId: string
|
||||
confidence: number
|
||||
method: DocumentMatchMethod
|
||||
matchReasons: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a single inbox item to the best candidate transaction.
|
||||
*
|
||||
* If `candidateTransactions` is not provided, fetches unbooked expense
|
||||
* transactions within ±7 days of the document date.
|
||||
*/
|
||||
export async function matchDocumentToTransactions(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
inboxItem: InvoiceInboxItem,
|
||||
candidateTransactions?: Transaction[]
|
||||
): Promise<DocumentMatchResult | null> {
|
||||
const tag = `[document-matcher] item=${inboxItem.id} type=${inboxItem.document_type}`
|
||||
|
||||
// Only match supplier invoices and receipts
|
||||
if (inboxItem.document_type === 'government_letter' || inboxItem.document_type === 'unknown') {
|
||||
console.log(`${tag} — skipped (unsupported document type)`)
|
||||
return null
|
||||
}
|
||||
|
||||
if (!inboxItem.extracted_data) {
|
||||
console.log(`${tag} — skipped (no extracted_data)`)
|
||||
return null
|
||||
}
|
||||
|
||||
const transactions = candidateTransactions ?? (await fetchCandidateTransactions(supabase, companyId, inboxItem))
|
||||
|
||||
console.log(`${tag} — ${transactions.length} candidate transactions`)
|
||||
|
||||
if (transactions.length === 0) {
|
||||
console.log(`${tag} — no candidates, aborting`)
|
||||
return null
|
||||
}
|
||||
|
||||
let result: DocumentMatchResult | null = null
|
||||
|
||||
if (inboxItem.document_type === 'supplier_invoice') {
|
||||
result = matchSupplierInvoiceDocument(inboxItem, transactions)
|
||||
} else if (inboxItem.document_type === 'receipt') {
|
||||
result = matchReceiptDocument(inboxItem, transactions)
|
||||
}
|
||||
|
||||
if (result) {
|
||||
console.log(`${tag} — MATCHED tx=${result.transactionId} confidence=${result.confidence} method=${result.method} reasons=[${result.matchReasons.join(', ')}]`)
|
||||
} else {
|
||||
console.log(`${tag} — no match found`)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch unbooked expense transactions within ±7 days of the document date.
|
||||
*/
|
||||
async function fetchCandidateTransactions(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
inboxItem: InvoiceInboxItem
|
||||
): Promise<Transaction[]> {
|
||||
const docDate = getDocumentDate(inboxItem)
|
||||
if (!docDate) return []
|
||||
|
||||
const startDate = new Date(docDate)
|
||||
startDate.setDate(startDate.getDate() - 7)
|
||||
const endDate = new Date(docDate)
|
||||
endDate.setDate(endDate.getDate() + 7)
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.is('journal_entry_id', null)
|
||||
.is('is_business', null)
|
||||
.lt('amount', 0)
|
||||
.gte('date', startDate.toISOString().split('T')[0])
|
||||
.lte('date', endDate.toISOString().split('T')[0])
|
||||
.order('date', { ascending: false })
|
||||
|
||||
if (error || !data) return []
|
||||
return data as Transaction[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the most relevant date from an inbox item's extracted data.
|
||||
*/
|
||||
function getDocumentDate(inboxItem: InvoiceInboxItem): string | null {
|
||||
const data = inboxItem.extracted_data as Record<string, unknown> | null
|
||||
if (!data) return null
|
||||
|
||||
if (inboxItem.document_type === 'supplier_invoice') {
|
||||
const extraction = data as unknown as InvoiceExtractionResult
|
||||
return extraction.invoice?.dueDate ?? extraction.invoice?.invoiceDate ?? null
|
||||
}
|
||||
|
||||
if (inboxItem.document_type === 'receipt') {
|
||||
const extraction = data as unknown as ReceiptExtractionResult
|
||||
return extraction.receipt?.date ?? null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a supplier invoice inbox item to transactions using a 4-pass algorithm.
|
||||
*/
|
||||
function matchSupplierInvoiceDocument(
|
||||
inboxItem: InvoiceInboxItem,
|
||||
transactions: Transaction[]
|
||||
): DocumentMatchResult | null {
|
||||
const tag = `[document-matcher:supplier] item=${inboxItem.id}`
|
||||
const extraction = inboxItem.extracted_data as unknown as InvoiceExtractionResult
|
||||
if (!extraction) return null
|
||||
|
||||
const invoiceTotal = extraction.totals?.total
|
||||
if (invoiceTotal == null || invoiceTotal === 0) {
|
||||
console.log(`${tag} — no invoice total in extracted data`)
|
||||
return null
|
||||
}
|
||||
|
||||
const paymentRef = extraction.invoice?.paymentReference
|
||||
const bankgiro = extraction.supplier?.bankgiro
|
||||
const plusgiro = extraction.supplier?.plusgiro
|
||||
const supplierName = extraction.supplier?.name
|
||||
const dueDate = extraction.invoice?.dueDate ?? extraction.invoice?.invoiceDate
|
||||
|
||||
console.log(`${tag} — extracted: total=${invoiceTotal}, supplier=${supplierName || '?'}, dueDate=${dueDate || '?'}, paymentRef=${paymentRef || '?'}, bankgiro=${bankgiro || '?'}, templateId=${extraction.suggestedTemplateId || '?'}`)
|
||||
|
||||
let bestMatch: DocumentMatchResult | null = null
|
||||
|
||||
for (const tx of transactions) {
|
||||
const txAmount = Math.abs(tx.amount)
|
||||
const txDesc = (tx.description || '').toLowerCase()
|
||||
const txRef = tx.reference || ''
|
||||
|
||||
// Pass 1: Payment reference exact match → 0.98
|
||||
if (paymentRef && txRef) {
|
||||
const normTxRef = txRef.replace(/\D/g, '')
|
||||
const normPayRef = paymentRef.replace(/\D/g, '')
|
||||
if (normTxRef && normPayRef && normTxRef === normPayRef) {
|
||||
console.log(`${tag} — Pass 1 HIT: tx=${tx.id} ref=${normPayRef}`)
|
||||
return {
|
||||
transactionId: tx.id,
|
||||
confidence: 0.98,
|
||||
method: 'payment_reference',
|
||||
matchReasons: ['Betalningsreferens matchar'],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: Exact amount + bankgiro/plusgiro → 0.92
|
||||
const amountMatch = Math.abs(txAmount - invoiceTotal) < 0.005
|
||||
if (amountMatch) {
|
||||
const bgNorm = bankgiro?.replace(/\D/g, '')
|
||||
const pgNorm = plusgiro?.replace(/\D/g, '')
|
||||
const hasBgMatch = bgNorm && txDesc.includes(bgNorm)
|
||||
const hasPgMatch = pgNorm && txDesc.includes(pgNorm)
|
||||
|
||||
if (hasBgMatch || hasPgMatch) {
|
||||
console.log(`${tag} — Pass 2 HIT: tx=${tx.id} amount=${txAmount} bg/pg match`)
|
||||
return {
|
||||
transactionId: tx.id,
|
||||
confidence: 0.92,
|
||||
method: 'payment_reference',
|
||||
matchReasons: ['Exakt belopp', hasBgMatch ? 'Bankgiro matchar' : 'Plusgiro matchar'],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 3: Exact amount + date ±14 days → 0.85 (close) / 0.75 (wider)
|
||||
// Invoices are often paid early or a few days late, so we use a 14-day window.
|
||||
if (amountMatch && dueDate) {
|
||||
const txDate = new Date(tx.date)
|
||||
const docDate = new Date(dueDate)
|
||||
const diffDays = Math.abs((txDate.getTime() - docDate.getTime()) / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (diffDays <= 14) {
|
||||
// Higher confidence for close dates, lower for wider window
|
||||
const confidence = diffDays <= 5 ? 0.85 : 0.75
|
||||
console.log(`${tag} — Pass 3 HIT: tx=${tx.id} amount=${txAmount} date_diff=${diffDays.toFixed(1)}d → confidence=${confidence}`)
|
||||
const candidate: DocumentMatchResult = {
|
||||
transactionId: tx.id,
|
||||
confidence,
|
||||
method: 'amount_date',
|
||||
matchReasons: ['Exakt belopp', diffDays === 0 ? 'Exakt datum' : `Datum ±${Math.round(diffDays)} dagar`],
|
||||
}
|
||||
if (!bestMatch || candidate.confidence > bestMatch.confidence) {
|
||||
bestMatch = candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 4: Fuzzy amount (±1%) + supplier name in description → 0.70
|
||||
const fuzzyAmountMatch = Math.abs(txAmount - invoiceTotal) / invoiceTotal <= 0.01
|
||||
if (fuzzyAmountMatch && supplierName) {
|
||||
const normalizedName = supplierName.toLowerCase().replace(/[^\w\såäöé]/g, '')
|
||||
const nameWords = normalizedName.split(/\s+/).filter((w) => w.length >= 3)
|
||||
const nameInDesc = nameWords.some((word) => txDesc.includes(word))
|
||||
|
||||
if (nameInDesc) {
|
||||
console.log(`${tag} — Pass 4 HIT: tx=${tx.id} amount=${txAmount} (~${((Math.abs(txAmount - invoiceTotal) / invoiceTotal) * 100).toFixed(1)}%) name words=[${nameWords.join(',')}]`)
|
||||
const candidate: DocumentMatchResult = {
|
||||
transactionId: tx.id,
|
||||
confidence: 0.70,
|
||||
method: 'amount_merchant',
|
||||
matchReasons: ['Belopp matchar (±1%)', 'Leverantörsnamn i beskrivning'],
|
||||
}
|
||||
if (!bestMatch || candidate.confidence > bestMatch.confidence) {
|
||||
bestMatch = candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a receipt inbox item to transactions using weighted scoring.
|
||||
* Weights: amount 40%, date 25%, merchant 35%. Min confidence: 0.60.
|
||||
*/
|
||||
function matchReceiptDocument(
|
||||
inboxItem: InvoiceInboxItem,
|
||||
transactions: Transaction[]
|
||||
): DocumentMatchResult | null {
|
||||
const tag = `[document-matcher:receipt] item=${inboxItem.id}`
|
||||
const extraction = inboxItem.extracted_data as unknown as ReceiptExtractionResult
|
||||
if (!extraction) return null
|
||||
|
||||
const receiptTotal = extraction.totals?.total
|
||||
const receiptDate = extraction.receipt?.date
|
||||
const merchantName = extraction.merchant?.name
|
||||
|
||||
if (receiptTotal == null || receiptTotal === 0) {
|
||||
console.log(`${tag} — no receipt total in extracted data`)
|
||||
return null
|
||||
}
|
||||
|
||||
console.log(`${tag} — extracted: total=${receiptTotal}, date=${receiptDate || '?'}, merchant=${merchantName || '?'}, templateId=${extraction.suggestedTemplateId || '?'}`)
|
||||
|
||||
let bestMatch: DocumentMatchResult | null = null
|
||||
|
||||
for (const tx of transactions) {
|
||||
if (tx.receipt_id) continue // Skip already matched
|
||||
|
||||
const txAmount = Math.abs(tx.amount)
|
||||
const txDate = new Date(tx.date)
|
||||
|
||||
// Calculate date variance
|
||||
const dateVariance = receiptDate
|
||||
? Math.abs((new Date(receiptDate).getTime() - txDate.getTime()) / (1000 * 60 * 60 * 24))
|
||||
: 3 // Default to tolerance boundary if no date
|
||||
|
||||
if (dateVariance > 3) continue
|
||||
|
||||
// Calculate amount variance
|
||||
const amountVariance = Math.abs(receiptTotal - txAmount) / receiptTotal
|
||||
if (amountVariance > 0.05) continue // Skip if >5% off
|
||||
|
||||
// Calculate merchant similarity
|
||||
const txMerchant = tx.merchant_name || tx.description || ''
|
||||
const merchantSimilarity = merchantName
|
||||
? calculateMerchantSimilarity(merchantName, txMerchant)
|
||||
: 0
|
||||
|
||||
const { confidence, matchReasons } = calculateMatchConfidence(
|
||||
dateVariance,
|
||||
amountVariance,
|
||||
merchantSimilarity
|
||||
)
|
||||
|
||||
console.log(`${tag} — scoring tx=${tx.id} "${tx.description}": date_var=${dateVariance.toFixed(1)}d amount_var=${(amountVariance * 100).toFixed(1)}% merchant_sim=${merchantSimilarity.toFixed(2)} → confidence=${confidence}`)
|
||||
|
||||
if (confidence >= 0.60 && (!bestMatch || confidence > bestMatch.confidence)) {
|
||||
bestMatch = {
|
||||
transactionId: tx.id,
|
||||
confidence,
|
||||
method: 'receipt_match',
|
||||
matchReasons,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch
|
||||
}
|
||||
Reference in New Issue
Block a user