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:
co-authored by
Claude Opus 4.6
parent
fdcc94102a
commit
7c3518a080
@@ -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
|
||||
|
||||
@@ -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 } }
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user