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
+1
View File
@@ -46,6 +46,7 @@ const statusConfig: Record<InvoiceStatus, { label: string; variant: 'default' |
draft: { label: 'Utkast', variant: 'secondary', icon: FileText },
sent: { label: 'Skickad', variant: 'default', icon: Send },
paid: { label: 'Betald', variant: 'success', icon: CheckCircle },
partially_paid: { label: 'Delbetalad', variant: 'warning', icon: Clock },
overdue: { label: 'Förfallen', variant: 'destructive', icon: Clock },
cancelled: { label: 'Makulerad', variant: 'secondary', icon: XCircle },
credited: { label: 'Krediterad', variant: 'secondary', icon: XCircle },
+1
View File
@@ -20,6 +20,7 @@ const statusConfig: Record<InvoiceStatus, { label: string; variant: 'default' |
draft: { label: 'Utkast', variant: 'secondary', icon: FileText, borderColor: 'border-muted-foreground/30' },
sent: { label: 'Skickad', variant: 'default', icon: Send, borderColor: 'border-warning/50' },
paid: { label: 'Betald', variant: 'success', icon: CheckCircle, borderColor: 'border-success/50' },
partially_paid: { label: 'Delbetalad', variant: 'warning', icon: Clock, borderColor: 'border-warning/50' },
overdue: { label: 'Förfallen', variant: 'destructive', icon: Clock, borderColor: 'border-destructive/50' },
cancelled: { label: 'Makulerad', variant: 'secondary', icon: XCircle, borderColor: 'border-muted-foreground/30' },
credited: { label: 'Krediterad', variant: 'secondary', icon: XCircle, borderColor: 'border-muted-foreground/30' },
@@ -23,6 +23,23 @@ vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
getOutputVatAccount: vi.fn().mockReturnValue('2611'),
}))
const mockReverseEntry = vi.fn()
vi.mock('@/lib/bookkeeping/engine', () => ({
reverseEntry: (...args: unknown[]) => mockReverseEntry(...args),
}))
vi.mock('@/lib/invoices/match-log', () => ({
logMatchEvent: vi.fn(),
}))
vi.mock('@/lib/events/bus', () => ({
eventBus: { emit: vi.fn() },
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
import { POST } from '../route'
const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000'
@@ -140,13 +157,14 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
expect(body.error).toBe('Invoice is not in an unpaid state')
})
it('matches transaction to invoice with accrual method', async () => {
it('matches transaction to invoice with accrual method (full payment)', async () => {
const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null, date: '2024-06-15' })
const customer = makeCustomer()
const invoice = makeInvoice({
id: VALID_UUID,
status: 'sent',
total: 12500,
remaining_amount: 12500,
subtotal: 10000,
vat_amount: 2500,
invoice_number: 'F-2024001',
@@ -162,10 +180,14 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-1' })
// Update invoice to paid
// Update invoice (optimistic lock returns updated row)
enqueue({ data: [{ id: VALID_UUID }], error: null })
// Insert invoice_payments
enqueue({ data: null, error: null })
// Update transaction
enqueue({ data: null, error: null })
// logMatchEvent insert (fire-and-forget)
enqueue({ data: null, error: null })
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
method: 'POST',
@@ -176,6 +198,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
success: boolean
invoice_status: string
paid_amount: number
remaining_amount: number
journal_entry_id: string
}>(response)
@@ -183,22 +206,243 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
expect(body.success).toBe(true)
expect(body.invoice_status).toBe('paid')
expect(body.paid_amount).toBe(12500)
expect(body.remaining_amount).toBe(0)
expect(body.journal_entry_id).toBe('je-1')
// Verify accrual payment entry was called
// Verify accrual payment entry was called with paymentAmount
expect(mockCreateInvoicePaymentJournalEntry).toHaveBeenCalledWith(
expect.anything(),
'user-1',
expect.objectContaining({ id: VALID_UUID }),
'2024-06-15',
undefined,
expect.anything()
expect.anything(),
12500
)
})
it('stornos conflicting journal entry before matching', async () => {
const tx = makeTransaction({
id: 'tx-1',
amount: 12500,
invoice_id: null,
journal_entry_id: 'je-conflict',
date: '2024-06-15',
})
const invoice = makeInvoice({
id: VALID_UUID,
status: 'sent',
total: 12500,
remaining_amount: 12500,
})
// Fetch transaction
enqueue({ data: tx, error: null })
// Fetch invoice
enqueue({ data: invoice, error: null })
mockReverseEntry.mockResolvedValue({ id: 'je-storno' })
// Clear journal_entry_id on transaction
enqueue({ data: null, error: null })
// logMatchEvent for storno
enqueue({ data: null, error: null })
// Fetch company settings
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-payment' })
// Update invoice (optimistic lock)
enqueue({ data: [{ id: VALID_UUID }], error: null })
// Insert invoice_payments
enqueue({ data: null, error: null })
// Update transaction
enqueue({ data: null, error: null })
// logMatchEvent for match
enqueue({ data: null, error: null })
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
method: 'POST',
body: { invoice_id: VALID_UUID },
})
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{ success: boolean; journal_entry_id: string }>(response)
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(body.journal_entry_id).toBe('je-payment')
expect(mockReverseEntry).toHaveBeenCalledWith(expect.anything(), 'user-1', 'je-conflict')
})
it('returns 500 when storno fails — no partial state change', async () => {
const tx = makeTransaction({
id: 'tx-1',
amount: 12500,
invoice_id: null,
journal_entry_id: 'je-conflict',
})
const invoice = makeInvoice({ id: VALID_UUID, status: 'sent', remaining_amount: 12500 })
enqueue({ data: tx, error: null })
enqueue({ data: invoice, error: null })
mockReverseEntry.mockRejectedValue(new Error('Period locked'))
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
method: 'POST',
body: { invoice_id: VALID_UUID },
})
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(500)
expect(body.error).toBe('Failed to reverse conflicting journal entry')
// Invoice should NOT have been updated — no further DB calls after storno failure
expect(mockCreateInvoicePaymentJournalEntry).not.toHaveBeenCalled()
})
it('supports partial payment (partially_paid status)', async () => {
const tx = makeTransaction({ id: 'tx-1', amount: 5000, invoice_id: null, date: '2024-06-15' })
const invoice = makeInvoice({
id: VALID_UUID,
status: 'sent',
total: 12500,
remaining_amount: 12500,
paid_amount: 0,
})
enqueue({ data: tx, error: null })
enqueue({ data: invoice, error: null })
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-partial' })
// Update invoice (optimistic lock)
enqueue({ data: [{ id: VALID_UUID }], error: null })
// Insert invoice_payments
enqueue({ data: null, error: null })
// Update transaction
enqueue({ data: null, error: null })
// logMatchEvent
enqueue({ data: null, error: null })
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
method: 'POST',
body: { invoice_id: VALID_UUID },
})
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{
success: boolean
invoice_status: string
paid_amount: number
remaining_amount: number
}>(response)
expect(status).toBe(200)
expect(body.invoice_status).toBe('partially_paid')
expect(body.paid_amount).toBe(5000)
expect(body.remaining_amount).toBe(7500)
})
it('cash method partial payment uses clearing entry with note', async () => {
const tx = makeTransaction({ id: 'tx-1', amount: 5000, invoice_id: null, date: '2024-06-15' })
const invoice = makeInvoice({
id: VALID_UUID,
status: 'sent',
total: 12500,
remaining_amount: 12500,
paid_amount: 0,
})
enqueue({ data: tx, error: null })
enqueue({ data: invoice, error: null })
enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null })
mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-clearing' })
// Update invoice
enqueue({ data: [{ id: VALID_UUID }], error: null })
// Insert invoice_payments
enqueue({ data: null, error: null })
// Update transaction
enqueue({ data: null, error: null })
// logMatchEvent
enqueue({ data: null, error: null })
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
method: 'POST',
body: { invoice_id: VALID_UUID },
})
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{ invoice_status: string }>(response)
expect(status).toBe(200)
expect(body.invoice_status).toBe('partially_paid')
// Cash partial uses accrual-style clearing entry, NOT createInvoiceCashEntry
expect(mockCreateInvoicePaymentJournalEntry).toHaveBeenCalled()
expect(mockCreateInvoiceCashEntry).not.toHaveBeenCalled()
})
it('returns 409 when invoice is fully paid (optimistic lock)', async () => {
const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null })
const invoice = makeInvoice({
id: VALID_UUID,
status: 'sent',
total: 12500,
remaining_amount: 12500,
})
enqueue({ data: tx, error: null })
enqueue({ data: invoice, error: null })
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-1' })
// Optimistic lock returns 0 rows (another request fully paid it)
enqueue({ data: [], error: null })
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
method: 'POST',
body: { invoice_id: VALID_UUID },
})
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(409)
expect(body.error).toContain('already been fully paid')
})
it('returns 409 on duplicate invoice_payment (unique constraint)', async () => {
const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null })
const invoice = makeInvoice({
id: VALID_UUID,
status: 'sent',
total: 12500,
remaining_amount: 12500,
})
enqueue({ data: tx, error: null })
enqueue({ data: invoice, error: null })
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-1' })
// Optimistic lock succeeds
enqueue({ data: [{ id: VALID_UUID }], error: null })
// invoice_payments insert fails with unique constraint violation
enqueue({ data: null, error: { code: '23505', message: 'duplicate' } })
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
method: 'POST',
body: { invoice_id: VALID_UUID },
})
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(409)
expect(body.error).toContain('already matched')
})
it('returns success with journal_entry_error when journal entry fails (non-blocking)', async () => {
const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null, date: '2024-06-15' })
const invoice = makeInvoice({ id: VALID_UUID, status: 'sent', total: 12500 })
const invoice = makeInvoice({ id: VALID_UUID, status: 'sent', total: 12500, remaining_amount: 12500 })
enqueue({ data: tx, error: null })
enqueue({ data: invoice, error: null })
@@ -206,10 +450,14 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
mockCreateInvoicePaymentJournalEntry.mockRejectedValue(new Error('Period locked'))
// Update invoice
// Update invoice (optimistic lock)
enqueue({ data: [{ id: VALID_UUID }], error: null })
// Insert invoice_payments
enqueue({ data: null, error: null })
// Update transaction
enqueue({ data: null, error: null })
// logMatchEvent
enqueue({ data: null, error: null })
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
method: 'POST',
+149 -20
View File
@@ -4,18 +4,25 @@ import {
createInvoicePaymentJournalEntry,
createInvoiceCashEntry,
} from '@/lib/bookkeeping/invoice-entries'
import { reverseEntry } from '@/lib/bookkeeping/engine'
import { validateBody } from '@/lib/api/validate'
import { MatchInvoiceSchema } from '@/lib/api/schemas'
import type { EntityType, Invoice } from '@/types'
import { logMatchEvent } from '@/lib/invoices/match-log'
import { eventBus } from '@/lib/events/bus'
import { ensureInitialized } from '@/lib/init'
import type { EntityType, Invoice, Transaction } from '@/types'
ensureInitialized()
/**
* POST /api/transactions/[id]/match-invoice
*
* Confirms an invoice match for a transaction:
* 1. Links transaction to invoice (sets invoice_id)
* 2. Updates invoice status to 'paid'
* 3. Sets paid_at and paid_amount on invoice
* 4. Creates journal entry for payment receipt
* Confirms an invoice match for a transaction. Supports partial payments:
* 1. If transaction has an auto-categorization journal entry, storno it first
* 2. Links transaction to invoice (sets invoice_id)
* 3. Updates invoice status to 'paid' or 'partially_paid'
* 4. Records payment in invoice_payments table
* 5. Creates journal entry for payment receipt
* - Debit 1930 Företagskonto (Bank)
* - Credit 1510 Kundfordringar (Accounts Receivable)
*/
@@ -77,17 +84,54 @@ export async function POST(
return NextResponse.json({ error: 'Invoice not found' }, { status: 404 })
}
// Verify invoice is unpaid
if (invoice.status !== 'sent' && invoice.status !== 'overdue') {
// Verify invoice is in a matchable state (sent, overdue, or partially_paid)
if (invoice.status !== 'sent' && invoice.status !== 'overdue' && invoice.status !== 'partially_paid') {
return NextResponse.json(
{ error: 'Invoice is not in an unpaid state' },
{ status: 400 }
)
}
// --- Commit 1: Storno conflicting auto-categorization journal entry ---
// Order: storno MUST complete before any other state changes.
// If storno fails, return 500 immediately — nothing else has been modified.
if (transaction.journal_entry_id) {
try {
await reverseEntry(supabase, user.id, transaction.journal_entry_id)
// Clear the journal_entry_id on the transaction
const { error: clearJeError } = await supabase
.from('transactions')
.update({ journal_entry_id: null })
.eq('id', transactionId)
if (clearJeError) {
console.error('Failed to clear journal_entry_id after storno:', clearJeError)
}
logMatchEvent(supabase, user.id, transactionId, 'storno_conflict_resolved', {
invoiceId: invoice_id,
previousState: { journal_entry_id: transaction.journal_entry_id },
newState: { journal_entry_id: null },
})
} catch (err) {
console.error('Failed to storno conflicting journal entry:', err)
return NextResponse.json(
{ error: 'Failed to reverse conflicting journal entry' },
{ status: 500 }
)
}
}
const now = new Date().toISOString()
const paidAmount = transaction.amount
// Calculate partial payment amounts
const newPaidAmount = Math.round(((invoice.paid_amount || 0) + paidAmount) * 100) / 100
const currentRemaining = invoice.remaining_amount ?? (invoice.total - (invoice.paid_amount || 0))
const newRemaining = Math.max(0, Math.round((currentRemaining - paidAmount) * 100) / 100)
const isFullyPaid = newRemaining <= 0
const newStatus = isFullyPaid ? 'paid' : 'partially_paid'
// Fetch accounting method
const { data: settings } = await supabase
.from('company_settings')
@@ -103,8 +147,8 @@ export async function POST(
let journalEntryError: string | null = null
try {
if (accountingMethod === 'cash') {
// Kontantmetoden: combined revenue entry with per-line VAT rates
if (accountingMethod === 'cash' && isFullyPaid) {
// Kontantmetoden, full payment: combined revenue entry with per-line VAT rates
const journalEntry = await createInvoiceCashEntry(
supabase,
user.id,
@@ -114,6 +158,24 @@ export async function POST(
invoice.customer?.name
)
journalEntryId = journalEntry?.id ?? null
} else if (accountingMethod === 'cash' && !isFullyPaid) {
// Kontantmetoden, partial payment: use accrual-style clearing entry.
// Under kontantmetoden, invoice creation produces no journal entry,
// so 1510 has no prior balance. The debit 1930 / credit 1510 creates
// a credit on 1510 with no offsetting debit — this is intentional.
// 1510 is used as a temporary clearing account under cash method.
// The full revenue + VAT recognition (with 1510 reversal) happens at
// final payment when createInvoiceCashEntry is called.
const journalEntry = await createInvoicePaymentJournalEntry(
supabase,
user.id,
invoice as Invoice,
transaction.date,
undefined,
invoice.customer?.name,
paidAmount
)
journalEntryId = journalEntry?.id ?? null
} else {
// Faktureringsmetoden: clear receivable (Debit 1930, Credit 1510)
const journalEntry = await createInvoicePaymentJournalEntry(
@@ -122,7 +184,8 @@ export async function POST(
invoice as Invoice,
transaction.date,
undefined,
invoice.customer?.name
invoice.customer?.name,
paidAmount
)
journalEntryId = journalEntry?.id ?? null
}
@@ -132,15 +195,21 @@ export async function POST(
// Continue - we still want to update the invoice and transaction
}
// Update invoice to paid status
const { error: updateInvError } = await supabase
// --- Commit 4: Optimistic lock on invoice status ---
// Only update if invoice is still in a matchable state.
// Prevents TOCTOU race where another request fully pays the invoice
// between our fetch and this update.
const { data: updatedRows, error: updateInvError } = await supabase
.from('invoices')
.update({
status: 'paid',
paid_at: now,
paid_amount: paidAmount,
status: newStatus,
paid_at: isFullyPaid ? now : null,
paid_amount: newPaidAmount,
remaining_amount: newRemaining,
})
.eq('id', invoice_id)
.in('status', ['sent', 'overdue', 'partially_paid'])
.select('id')
if (updateInvError) {
console.error('Failed to update invoice:', updateInvError)
@@ -150,6 +219,44 @@ export async function POST(
)
}
if (!updatedRows || updatedRows.length === 0) {
return NextResponse.json(
{ error: 'Invoice has already been fully paid or is no longer matchable' },
{ status: 409 }
)
}
// Record payment in invoice_payments table
const paymentNotes = (accountingMethod === 'cash' && !isFullyPaid)
? 'Kontantmetoden: intäkt bokförs vid slutbetalning'
: null
const { error: paymentInsertError } = await supabase
.from('invoice_payments')
.insert({
user_id: user.id,
invoice_id,
payment_date: transaction.date,
amount: paidAmount,
currency: invoice.currency,
exchange_rate: invoice.exchange_rate,
journal_entry_id: journalEntryId,
transaction_id: transactionId,
notes: paymentNotes,
})
if (paymentInsertError) {
// Catch unique constraint violation (same transaction matched to same invoice twice)
if (paymentInsertError.code === '23505') {
return NextResponse.json(
{ error: 'This transaction is already matched to this invoice' },
{ status: 409 }
)
}
console.error('Failed to record invoice payment:', paymentInsertError)
return NextResponse.json({ error: 'Failed to record invoice payment' }, { status: 500 })
}
// Update transaction to link to invoice and clear potential match
const { error: updateTxError } = await supabase
.from('transactions')
@@ -158,7 +265,7 @@ export async function POST(
potential_invoice_id: null,
journal_entry_id: journalEntryId,
is_business: true,
category: 'income_services', // Default to services income
category: 'income_services',
})
.eq('id', transactionId)
@@ -170,11 +277,33 @@ export async function POST(
)
}
// Log the match event and emit event
logMatchEvent(supabase, user.id, transactionId, 'matched', {
invoiceId: invoice_id,
matchConfidence: 1.0,
matchMethod: 'manual_confirm',
newState: { status: newStatus, paid_amount: newPaidAmount, remaining_amount: newRemaining },
})
try {
eventBus.emit({
type: 'invoice.match_confirmed',
payload: {
invoice: invoice as Invoice,
transaction: transaction as Transaction,
userId: user.id,
},
})
} catch {
// Event emission is non-critical
}
return NextResponse.json({
success: true,
invoice_status: 'paid',
paid_at: now,
paid_amount: paidAmount,
invoice_status: newStatus,
paid_at: isFullyPaid ? now : null,
paid_amount: newPaidAmount,
remaining_amount: newRemaining,
journal_entry_id: journalEntryId,
journal_entry_error: journalEntryError,
})
@@ -6,7 +6,12 @@ import {
} from '@/lib/bookkeeping/supplier-invoice-entries'
import { validateBody } from '@/lib/api/validate'
import { MatchSupplierInvoiceSchema } from '@/lib/api/schemas'
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
import { logMatchEvent } from '@/lib/invoices/match-log'
import { eventBus } from '@/lib/events/bus'
import { ensureInitialized } from '@/lib/init'
import type { SupplierInvoice, SupplierInvoiceItem, Transaction } from '@/types'
ensureInitialized()
/**
* POST /api/transactions/[id]/match-supplier-invoice
@@ -116,13 +121,13 @@ export async function POST(
console.error('Failed to create payment journal entry:', err)
}
// Update supplier invoice
// Optimistic lock: only update if invoice is still in a matchable state
const newRemaining = Math.max(0, Math.round((invoice.remaining_amount - paymentAmount) * 100) / 100)
const newPaidAmount = Math.round((invoice.paid_amount + paymentAmount) * 100) / 100
const isFullyPaid = newRemaining <= 0
const newStatus = isFullyPaid ? 'paid' : 'partially_paid'
const { error: updateInvError } = await supabase
const { data: updatedRows, error: updateInvError } = await supabase
.from('supplier_invoices')
.update({
status: newStatus,
@@ -133,20 +138,43 @@ export async function POST(
transaction_id: transactionId,
})
.eq('id', supplier_invoice_id)
.in('status', ['registered', 'approved', 'partially_paid'])
.select('id')
if (updateInvError) {
return NextResponse.json({ error: 'Failed to update supplier invoice' }, { status: 500 })
}
// Record payment
await supabase.from('supplier_invoice_payments').insert({
supplier_invoice_id,
payment_date: transaction.date,
amount: paymentAmount,
currency: invoice.currency,
journal_entry_id: journalEntryId,
transaction_id: transactionId,
})
if (!updatedRows || updatedRows.length === 0) {
return NextResponse.json(
{ error: 'Supplier invoice has already been fully paid or is no longer matchable' },
{ status: 409 }
)
}
// Record payment — catch unique constraint violation
const { error: paymentInsertError } = await supabase
.from('supplier_invoice_payments')
.insert({
user_id: user.id,
supplier_invoice_id,
payment_date: transaction.date,
amount: paymentAmount,
currency: invoice.currency,
journal_entry_id: journalEntryId,
transaction_id: transactionId,
})
if (paymentInsertError) {
if (paymentInsertError.code === '23505') {
return NextResponse.json(
{ error: 'This transaction is already matched to this supplier invoice' },
{ status: 409 }
)
}
console.error('Failed to record supplier invoice payment:', paymentInsertError)
return NextResponse.json({ error: 'Failed to record invoice payment' }, { status: 500 })
}
// Update transaction
const { error: updateTxError } = await supabase
@@ -162,6 +190,27 @@ export async function POST(
return NextResponse.json({ error: 'Failed to link transaction' }, { status: 500 })
}
// Log the match event and emit event
logMatchEvent(supabase, user.id, transactionId, 'matched', {
supplierInvoiceId: supplier_invoice_id,
matchConfidence: 1.0,
matchMethod: 'manual_confirm',
newState: { status: newStatus, paid_amount: newPaidAmount, remaining_amount: newRemaining },
})
try {
eventBus.emit({
type: 'supplier_invoice.match_confirmed',
payload: {
supplierInvoice: invoice as SupplierInvoice,
transaction: transaction as Transaction,
userId: user.id,
},
})
} catch {
// Event emission is non-critical
}
return NextResponse.json({
success: true,
invoice_status: newStatus,
@@ -33,6 +33,7 @@ export async function POST() {
}
let matched = 0
const matchedInvoiceIds = new Set<string>()
for (const tx of transactions) {
try {
@@ -43,12 +44,13 @@ export async function POST() {
0.50
)
if (bestMatch) {
if (bestMatch && !matchedInvoiceIds.has(bestMatch.invoice.id)) {
await supabase
.from('transactions')
.update({ potential_invoice_id: bestMatch.invoice.id })
.eq('id', tx.id)
matchedInvoiceIds.add(bestMatch.invoice.id)
matched++
}
} catch {
+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 {
@@ -0,0 +1,54 @@
-- Migration: payment_match_log
-- Append-only audit trail for all payment matching state transitions.
-- Required by BFL 7:1 — all match/unmatch events are räkenskapsinformation.
-- Do NOT add cleanup/DELETE jobs — 7-year retention is legally required.
-- Partition by created_at (yearly) when volume exceeds ~100k rows.
CREATE TABLE public.payment_match_log (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id uuid NOT NULL REFERENCES auth.users ON DELETE CASCADE,
transaction_id uuid NOT NULL REFERENCES public.transactions ON DELETE CASCADE,
invoice_id uuid REFERENCES public.invoices ON DELETE SET NULL,
supplier_invoice_id uuid REFERENCES public.supplier_invoices ON DELETE SET NULL,
action text NOT NULL CHECK (action IN (
'matched',
'unmatched',
'auto_suggested',
'suggestion_cleared',
'storno_conflict_resolved'
)),
match_confidence numeric,
match_method text,
previous_state jsonb,
new_state jsonb,
created_at timestamptz NOT NULL DEFAULT now()
-- Intentionally NO updated_at: append-only
);
ALTER TABLE public.payment_match_log ENABLE ROW LEVEL SECURITY;
-- Users can read their own match log entries
CREATE POLICY "payment_match_log_select" ON public.payment_match_log
FOR SELECT USING (auth.uid() = user_id);
-- Users can insert their own match log entries
CREATE POLICY "payment_match_log_insert" ON public.payment_match_log
FOR INSERT WITH CHECK (auth.uid() = user_id);
-- NO UPDATE or DELETE policies — immutability enforced by triggers below
-- Indexes
CREATE INDEX idx_payment_match_log_user_id ON public.payment_match_log (user_id);
CREATE INDEX idx_payment_match_log_transaction_id ON public.payment_match_log (transaction_id);
CREATE INDEX idx_payment_match_log_invoice_id ON public.payment_match_log (invoice_id);
CREATE INDEX idx_payment_match_log_supplier_invoice_id ON public.payment_match_log (supplier_invoice_id);
CREATE INDEX idx_payment_match_log_created_at ON public.payment_match_log (created_at);
-- Immutability triggers: reuse audit_log_immutable() from migration 014
CREATE TRIGGER payment_match_log_no_update
BEFORE UPDATE ON public.payment_match_log
FOR EACH ROW EXECUTE FUNCTION public.audit_log_immutable();
CREATE TRIGGER payment_match_log_no_delete
BEFORE DELETE ON public.payment_match_log
FOR EACH ROW EXECUTE FUNCTION public.audit_log_immutable();
@@ -0,0 +1,89 @@
-- Migration: invoice_partial_payments
-- Adds remaining_amount to invoices, 'partially_paid' status, and invoice_payments table.
-- Mirrors the existing supplier_invoice_payments pattern for customer invoices.
-- 1. Add remaining_amount column to invoices
ALTER TABLE public.invoices
ADD COLUMN IF NOT EXISTS remaining_amount numeric;
-- 2. Drop and re-add status CHECK to include 'partially_paid'
ALTER TABLE public.invoices
DROP CONSTRAINT IF EXISTS invoices_status_check;
ALTER TABLE public.invoices
ADD CONSTRAINT invoices_status_check
CHECK (status IN ('draft', 'sent', 'paid', 'partially_paid', 'overdue', 'cancelled', 'credited'));
-- 3. Backfill remaining_amount from existing data
UPDATE public.invoices
SET remaining_amount = GREATEST(0, total - COALESCE(paid_amount, 0));
-- 4. Make remaining_amount NOT NULL with default
ALTER TABLE public.invoices
ALTER COLUMN remaining_amount SET NOT NULL,
ALTER COLUMN remaining_amount SET DEFAULT 0;
-- 5. Create invoice_payments table (mirrors supplier_invoice_payments)
CREATE TABLE public.invoice_payments (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id uuid NOT NULL REFERENCES auth.users ON DELETE CASCADE,
invoice_id uuid NOT NULL REFERENCES public.invoices ON DELETE CASCADE,
payment_date date NOT NULL,
amount numeric NOT NULL,
currency text DEFAULT 'SEK',
exchange_rate numeric,
exchange_rate_difference numeric DEFAULT 0,
journal_entry_id uuid REFERENCES public.journal_entries ON DELETE SET NULL,
transaction_id uuid REFERENCES public.transactions ON DELETE SET NULL,
notes text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.invoice_payments ENABLE ROW LEVEL SECURITY;
CREATE TRIGGER update_invoice_payments_updated_at
BEFORE UPDATE ON public.invoice_payments
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
CREATE POLICY "invoice_payments_select" ON public.invoice_payments
FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "invoice_payments_insert" ON public.invoice_payments
FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE INDEX idx_invoice_payments_user_id ON public.invoice_payments (user_id);
CREATE INDEX idx_invoice_payments_invoice_id ON public.invoice_payments (invoice_id);
CREATE INDEX idx_invoice_payments_transaction_id ON public.invoice_payments (transaction_id);
-- Prevent same transaction matched to same invoice twice
CREATE UNIQUE INDEX idx_invoice_payments_tx_inv_unique
ON public.invoice_payments (transaction_id, invoice_id);
-- 6. Add unique constraint to supplier_invoice_payments if not present
CREATE UNIQUE INDEX IF NOT EXISTS idx_supplier_invoice_payments_tx_inv_unique
ON public.supplier_invoice_payments (transaction_id, supplier_invoice_id);
-- 7. Add user_id to supplier_invoice_payments if not present (denormalized for RLS perf)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'supplier_invoice_payments'
AND column_name = 'user_id'
) THEN
ALTER TABLE public.supplier_invoice_payments
ADD COLUMN user_id uuid REFERENCES auth.users ON DELETE CASCADE;
-- Backfill user_id from parent supplier_invoices
UPDATE public.supplier_invoice_payments sip
SET user_id = si.user_id
FROM public.supplier_invoices si
WHERE sip.supplier_invoice_id = si.id;
END IF;
END
$$;
ALTER TABLE public.supplier_invoice_payments
ALTER COLUMN user_id SET NOT NULL;
+22
View File
@@ -11,6 +11,7 @@ import type {
DocumentAttachment,
TaxCode,
Invoice,
InvoicePayment,
Customer,
Supplier,
SupplierInvoice,
@@ -309,12 +310,33 @@ export function makeInvoice(overrides: Partial<Invoice> = {}): Invoice {
converted_from_id: null,
paid_at: null,
paid_amount: null,
remaining_amount: 12500,
created_at: '2024-06-15T14:30:00Z',
updated_at: '2024-06-15T14:30:00Z',
...overrides,
}
}
export function makeInvoicePayment(
overrides: Partial<InvoicePayment> = {}
): InvoicePayment {
return {
id: nextId(),
user_id: 'user-1',
invoice_id: 'invoice-1',
payment_date: '2024-07-01',
amount: 12500,
currency: 'SEK',
exchange_rate: null,
exchange_rate_difference: 0,
journal_entry_id: null,
transaction_id: null,
notes: null,
created_at: '2024-07-01T00:00:00Z',
...overrides,
}
}
export function makeCustomer(overrides: Partial<Customer> = {}): Customer {
return {
id: nextId(),
+22 -1
View File
@@ -32,7 +32,7 @@ export type CustomerType =
| 'non_eu_business' // Non-EU company
// Invoice status
export type InvoiceStatus = 'draft' | 'sent' | 'paid' | 'overdue' | 'cancelled' | 'credited'
export type InvoiceStatus = 'draft' | 'sent' | 'paid' | 'partially_paid' | 'overdue' | 'cancelled' | 'credited'
// Invoice document type
export type InvoiceDocumentType = 'invoice' | 'proforma' | 'delivery_note'
@@ -438,6 +438,25 @@ export interface SupplierInvoicePayment {
created_at: string
}
// Invoice Payment (partial payments)
export interface InvoicePayment {
id: string
user_id: string
invoice_id: string
payment_date: string
amount: number
currency: string
exchange_rate: number | null
exchange_rate_difference: number
journal_entry_id: string | null
transaction_id: string | null
notes: string | null
created_at: string
}
// Invoice
export interface Invoice {
id: string
@@ -498,6 +517,7 @@ export interface Invoice {
// Payment tracking
paid_at: string | null
paid_amount: number | null
remaining_amount: number
created_at: string
updated_at: string
@@ -505,6 +525,7 @@ export interface Invoice {
// Relations (populated when fetched)
customer?: Customer
items?: InvoiceItem[]
payments?: InvoicePayment[]
}
// Invoice Item