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>
This commit is contained in:
Jakob Wennberg
2026-03-23 13:00:37 +01:00
committed by GitHub
co-authored by Claude Opus 4.6
parent fdcc94102a
commit 7c3518a080
18 changed files with 786 additions and 54 deletions
+16 -4
View File
@@ -247,7 +247,8 @@ export async function createInvoicePaymentJournalEntry(
invoice: Invoice,
paymentDate: string,
exchangeRateDifference?: number,
customerName?: string
customerName?: string,
paymentAmount?: number
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(supabase, userId, paymentDate)
if (!fiscalPeriodId) {
@@ -255,11 +256,22 @@ export async function createInvoicePaymentJournalEntry(
return null
}
const desc = buildInvoiceDescription('Inbetalning kundfaktura', invoice.invoice_number, customerName)
const bookedSekAmount = resolveSekAmount(invoice.total, invoice.total_sek, invoice.currency, invoice.exchange_rate)
const isPartial = paymentAmount != null
const desc = buildInvoiceDescription(
isPartial ? 'Delbetalning kundfaktura' : 'Inbetalning kundfaktura',
invoice.invoice_number,
customerName
)
// When paymentAmount is provided, use it for the 1930/1510 line amounts.
// Otherwise use the full invoice total (backward compatible).
const bookedSekAmount = isPartial
? resolveSekAmount(paymentAmount, null, invoice.currency, invoice.exchange_rate)
: resolveSekAmount(invoice.total, invoice.total_sek, invoice.currency, invoice.exchange_rate)
const lines: CreateJournalEntryLineInput[] = []
if (exchangeRateDifference && exchangeRateDifference !== 0) {
if (!isPartial && exchangeRateDifference && exchangeRateDifference !== 0) {
// Foreign currency with exchange rate difference
// For receivables: positive diff = gain (received more), negative = loss (received less)
const actualSekReceived = bookedSekAmount + exchangeRateDifference
+3
View File
@@ -61,6 +61,9 @@ export type CoreEvent =
| { type: 'supplier_invoice.approved'; payload: { supplierInvoice: SupplierInvoice; userId: string } }
| { type: 'supplier_invoice.paid'; payload: { supplierInvoice: SupplierInvoice; paymentAmount: number; userId: string } }
| { type: 'supplier_invoice.credited'; payload: { supplierInvoice: SupplierInvoice; creditNote: SupplierInvoice; userId: string } }
// Payment Matching
| { type: 'invoice.match_confirmed'; payload: { invoice: Invoice; transaction: Transaction; userId: string } }
| { type: 'supplier_invoice.match_confirmed'; payload: { supplierInvoice: SupplierInvoice; transaction: Transaction; userId: string } }
// Supplier Invoice Inbox
| { type: 'supplier_invoice.received'; payload: { inboxItem: InvoiceInboxItem; userId: string } }
| { type: 'supplier_invoice.extracted'; payload: { inboxItem: InvoiceInboxItem; confidence: number; userId: string } }
+13 -4
View File
@@ -40,7 +40,8 @@ function amountsMatchExact(transactionAmount: number, invoiceTotal: number): boo
function amountsMatchFuzzy(transactionAmount: number, invoiceTotal: number): boolean {
if (invoiceTotal === 0) return false
const diff = Math.abs(transactionAmount - invoiceTotal)
const tolerance = invoiceTotal * FUZZY_TOLERANCE
// Cap fuzzy tolerance at 500 SEK to prevent false positives on large invoices
const tolerance = Math.min(invoiceTotal * FUZZY_TOLERANCE, 500)
return diff <= tolerance
}
@@ -134,7 +135,7 @@ export async function findMatchingInvoices(
customer:customers(*)
`)
.eq('user_id', userId)
.in('status', ['sent', 'overdue'])
.in('status', ['sent', 'overdue', 'partially_paid'])
.order('due_date', { ascending: true })
if (error || !invoices) {
@@ -175,11 +176,19 @@ export async function findMatchingInvoices(
if (!currencyMatch) continue
// Use remaining_amount for partially paid invoices, otherwise total
const invoiceAmount = invoice.remaining_amount ?? invoice.total
// Use SEK amount for comparison if currencies differ
const compareAmount =
invoice.currency === transaction.currency
? invoice.total
: invoice.total_sek || invoice.total
? invoiceAmount
: (() => {
if (invoice.total_sek && invoice.total) {
return Math.round((invoiceAmount / invoice.total) * invoice.total_sek * 100) / 100
}
return invoiceAmount
})()
const transactionAmount = transaction.amount
+43
View File
@@ -0,0 +1,43 @@
import type { SupabaseClient } from '@supabase/supabase-js'
type MatchAction =
| 'matched'
| 'unmatched'
| 'auto_suggested'
| 'suggestion_cleared'
| 'storno_conflict_resolved'
/**
* Log a payment match event to the append-only audit trail.
* Fire-and-forget — never throws, never blocks the caller.
*/
export async function logMatchEvent(
supabase: SupabaseClient,
userId: string,
transactionId: string,
action: MatchAction,
opts?: {
invoiceId?: string
supplierInvoiceId?: string
matchConfidence?: number
matchMethod?: string
previousState?: Record<string, unknown>
newState?: Record<string, unknown>
}
): Promise<void> {
try {
await supabase.from('payment_match_log').insert({
user_id: userId,
transaction_id: transactionId,
invoice_id: opts?.invoiceId ?? null,
supplier_invoice_id: opts?.supplierInvoiceId ?? null,
action,
match_confidence: opts?.matchConfidence ?? null,
match_method: opts?.matchMethod ?? null,
previous_state: opts?.previousState ?? null,
new_state: opts?.newState ?? null,
})
} catch {
// Non-critical — audit log insert must never break the main flow
}
}
+3 -2
View File
@@ -99,8 +99,9 @@ export function findSupplierInvoiceMatch(
}
}
// Pass 4: Fuzzy amount (±0.01) + supplier name in description → 0.70
const fuzzyAmountMatch = Math.abs(txAmount - remaining) <= 0.01
// 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()
@@ -1,6 +1,7 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { Transaction, ReconciliationMethod } from '@/types'
import { eventBus } from '@/lib/events/bus'
import { logMatchEvent } from '@/lib/invoices/match-log'
// ============================================================
// Types
@@ -416,6 +417,13 @@ export async function unlinkReconciliation(
return { success: false, error: 'Failed to unlink transaction' }
}
logMatchEvent(supabase, userId, transactionId, 'unmatched', {
previousState: {
journal_entry_id: tx.journal_entry_id,
reconciliation_method: tx.reconciliation_method,
},
})
return { success: true }
}
+3 -2
View File
@@ -434,9 +434,10 @@ describe('ingestTransactions', () => {
enqueue({ data: insertedNew, error: null })
// Invoice match update for income transaction
enqueue({ data: null, error: null })
// Auto-categorization update
// logMatchEvent insert (fire-and-forget)
enqueue({ data: null, error: null })
// rawDup: skipped (in Set) — no queue entry needed
// NOTE: auto-categorization is skipped because invoice match triggers `continue`
// Transaction rawErr: insert fails
enqueue({ data: null, error: { message: 'Insert failed' } })
@@ -464,7 +465,7 @@ describe('ingestTransactions', () => {
expect(result.duplicates).toBe(1)
expect(result.errors).toBe(1)
expect(result.auto_matched_invoices).toBe(1)
expect(result.auto_categorized).toBe(1)
expect(result.auto_categorized).toBe(0) // Skipped: invoice match triggers continue
expect(result.transaction_ids).toEqual(['tx-new'])
})
+41 -2
View File
@@ -5,6 +5,7 @@ import { getBestInvoiceMatch } from '@/lib/invoices/invoice-matching'
import { findSupplierInvoiceMatch } from '@/lib/invoices/supplier-invoice-matching'
import { tryReconcileTransaction, fetchUnlinkedGLLines } from '@/lib/reconciliation/bank-reconciliation'
import { fetchMultipleRates } from '@/lib/currency/riksbanken'
import { logMatchEvent } from '@/lib/invoices/match-log'
import type { UnlinkedGLLine } from '@/lib/reconciliation/bank-reconciliation'
import type { Transaction, RawTransaction, IngestResult, SupplierInvoice, Currency, ExchangeRate } from '@/types'
@@ -137,6 +138,11 @@ export async function ingestTransactions(
data?.forEach(r => existingExternalIds.add(r.external_id))
}
// Track already-matched invoice IDs within this ingestion batch
// to prevent suggesting the same invoice for multiple transactions
const matchedInvoiceIds = new Set<string>()
const matchedSupplierInvoiceIds = new Set<string>()
for (const raw of rawTransactions) {
// 1. Check for duplicates via external_id (batch pre-fetched)
if (existingExternalIds.has(raw.external_id)) {
@@ -229,13 +235,24 @@ export async function ingestTransactions(
0.50
)
if (bestMatch) {
if (bestMatch && !matchedInvoiceIds.has(bestMatch.invoice.id)) {
await supabase
.from('transactions')
.update({ potential_invoice_id: bestMatch.invoice.id })
.eq('id', newTransaction.id)
logMatchEvent(supabase, userId, newTransaction.id, 'auto_suggested', {
invoiceId: bestMatch.invoice.id,
matchConfidence: bestMatch.confidence,
matchMethod: bestMatch.matchReason,
})
matchedInvoiceIds.add(bestMatch.invoice.id)
result.auto_matched_invoices++
// Skip mapping engine — transaction has an invoice match.
// Auto-categorization would create an orphaned journal entry
// that conflicts with the eventual invoice payment entry.
continue
}
} catch {
// Non-critical — continue processing
@@ -250,7 +267,7 @@ export async function ingestTransactions(
unpaidSupplierInvoices
)
if (match) {
if (match && !matchedSupplierInvoiceIds.has(match.supplierInvoice.id)) {
if (match.confidence >= 0.85) {
// Auto-link at high confidence
await supabase
@@ -258,13 +275,35 @@ export async function ingestTransactions(
.update({ supplier_invoice_id: match.supplierInvoice.id })
.eq('id', newTransaction.id)
// Log the match THEN drain the pool (captures which invoice was matched)
logMatchEvent(supabase, userId, newTransaction.id, 'auto_suggested', {
supplierInvoiceId: match.supplierInvoice.id,
matchConfidence: match.confidence,
matchMethod: match.matchMethod,
})
// Drain the pool — prevents next transaction from matching same invoice
unpaidSupplierInvoices = unpaidSupplierInvoices.filter(
inv => inv.id !== match.supplierInvoice.id
)
matchedSupplierInvoiceIds.add(match.supplierInvoice.id)
result.auto_matched_invoices++
// Skip mapping engine — transaction has a supplier invoice match
continue
} else {
// Store as suggestion at lower confidence (0.70–0.85)
// Do NOT drain pool for suggestions — they are tentative
await supabase
.from('transactions')
.update({ potential_supplier_invoice_id: match.supplierInvoice.id })
.eq('id', newTransaction.id)
logMatchEvent(supabase, userId, newTransaction.id, 'auto_suggested', {
supplierInvoiceId: match.supplierInvoice.id,
matchConfidence: match.confidence,
matchMethod: match.matchMethod,
})
}
}
} catch {