Files
accounted/lib/invoices/supplier-invoice-matching.ts
T
Jakob WennbergandClaude Opus 4.6 7c3518a080 feat: payment matching audit trail and partial payments (#89)
* feat: payment matching audit trail, partial payments, and match improvements

- Add payment_match_log table (append-only audit trail per BFL 7:1) with
  immutability triggers and proper RLS
- Add invoice_payments table for partial payment tracking, mirroring
  supplier_invoice_payments pattern
- Add remaining_amount column + partially_paid status to invoices
- Add match-log.ts service for recording match/unmatch state transitions
- Improve match-invoice route: support partial payments, record audit log,
  emit payment.matched events, handle storno conflicts
- Improve match-supplier-invoice route: audit logging, partial payment support
- Update invoice-entries.ts for partial payment journal entries
- Update transaction ingest to detect and auto-suggest invoice matches
- Update bank-reconciliation to handle partial payment state
- Add invoice_payments, payment_match_log types and helpers
- Extend tests for match-invoice route and transaction ingest

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address review feedback — payment matching correctness bugs

- P1: Return 500 on non-23505 payment insert failures instead of
  silently continuing with corrupted invoice state (both routes)
- P1: Fix foreign-currency partial payment matching — use proportional
  remaining SEK amount instead of full total_sek
- P2: Check error when clearing journal_entry_id after storno
- P2: Add updated_at column + trigger to invoice_payments table
- P2: Make supplier_invoice_payments.user_id NOT NULL after backfill

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 13:00:37 +01:00

133 lines
4.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Supplier Invoice Matching — auto-match expense transactions to unpaid supplier invoices.
*
* 4-pass matching algorithm (ordered by confidence):
* 1. Payment reference/OCR exact match → 0.98
* 2. Exact amount + bankgiro/plusgiro match → 0.92
* 3. Exact amount + date ±5 days → 0.85
* 4. Fuzzy amount (±0.01) + supplier name in description → 0.70
*
* Auto-match threshold: ≥0.85 → applied automatically
* Suggestion threshold: 0.70–0.85 → stored as potential_supplier_invoice_id
*/
import type { Transaction, SupplierInvoice } from '@/types'
export interface SupplierInvoiceMatch {
supplierInvoice: SupplierInvoice
confidence: number
matchMethod: 'payment_reference' | 'amount_bankgiro' | 'amount_date' | 'fuzzy_name'
}
/**
* Normalize payment reference for comparison (strip whitespace and non-digits).
*/
function normalizeReference(ref: string): string {
return ref.replace(/\D/g, '')
}
/**
* Find the best matching supplier invoice for an expense transaction.
* Expects invoices to have the `supplier` relation populated (for name/bankgiro matching).
* Only matches against invoices with status 'registered' or 'approved'
* and with remaining_amount > 0.
*/
export function findSupplierInvoiceMatch(
transaction: Transaction,
unpaidInvoices: SupplierInvoice[]
): SupplierInvoiceMatch | null {
if (unpaidInvoices.length === 0) return null
// Only match expense transactions
const txAmount = Math.abs(transaction.amount)
if (txAmount === 0) return null
let bestMatch: SupplierInvoiceMatch | null = null
for (const invoice of unpaidInvoices) {
// Only match against registered/approved invoices with remaining amount
if (!['registered', 'approved'].includes(invoice.status)) continue
const remaining = invoice.remaining_amount ?? invoice.total
if (remaining <= 0) continue
// Pass 1: Payment reference/OCR exact match → 0.98
if (transaction.reference && invoice.payment_reference) {
const txRef = normalizeReference(transaction.reference)
const invRef = normalizeReference(invoice.payment_reference)
if (txRef && invRef && txRef === invRef) {
return {
supplierInvoice: invoice,
confidence: 0.98,
matchMethod: 'payment_reference',
}
}
}
// Pass 2: Exact amount + bankgiro/plusgiro match → 0.92
const amountMatch = Math.abs(txAmount - remaining) < 0.005
if (amountMatch) {
const txDesc = (transaction.description || '').toLowerCase()
const supplierBg = invoice.supplier?.bankgiro
const supplierPg = invoice.supplier?.plusgiro
const bgMatch = supplierBg && txDesc.includes(normalizeReference(supplierBg))
const pgMatch = supplierPg && txDesc.includes(normalizeReference(supplierPg))
if (bgMatch || pgMatch) {
return {
supplierInvoice: invoice,
confidence: 0.92,
matchMethod: 'amount_bankgiro',
}
}
}
// Pass 3: Exact amount + date ±5 days → 0.85
if (amountMatch && invoice.due_date) {
const txDate = new Date(transaction.date)
const dueDate = new Date(invoice.due_date)
const diffDays = Math.abs((txDate.getTime() - dueDate.getTime()) / (1000 * 60 * 60 * 24))
if (diffDays <= 5) {
const confidence = 0.85
if (!bestMatch || confidence > bestMatch.confidence) {
bestMatch = {
supplierInvoice: invoice,
confidence,
matchMethod: 'amount_date',
}
}
}
}
// Pass 4: Fuzzy amount (±5 SEK) + supplier name in description → 0.70
// Tolerance covers öresavrundning and minor fee differences
const fuzzyAmountMatch = Math.abs(txAmount - remaining) <= 5.00
const supplierName = invoice.supplier?.name
if (fuzzyAmountMatch && supplierName) {
const txDesc = (transaction.description || '').toLowerCase()
const normalizedName = supplierName.toLowerCase()
// Check if any significant word from the supplier name appears in the description
const nameWords = normalizedName
.replace(/[^\w\såäöé]/g, '')
.split(/\s+/)
.filter((w) => w.length >= 3)
const nameInDesc = nameWords.some((word) => txDesc.includes(word))
if (nameInDesc) {
const confidence = 0.70
if (!bestMatch || confidence > bestMatch.confidence) {
bestMatch = {
supplierInvoice: invoice,
confidence,
matchMethod: 'fuzzy_name',
}
}
}
}
}
return bestMatch
}