diff --git a/app/(dashboard)/invoices/[id]/page.tsx b/app/(dashboard)/invoices/[id]/page.tsx index 12732b22..7db0074d 100644 --- a/app/(dashboard)/invoices/[id]/page.tsx +++ b/app/(dashboard)/invoices/[id]/page.tsx @@ -46,6 +46,7 @@ const statusConfig: Record ({ 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', diff --git a/app/api/transactions/[id]/match-invoice/route.ts b/app/api/transactions/[id]/match-invoice/route.ts index 06dbcfa0..594f2678 100644 --- a/app/api/transactions/[id]/match-invoice/route.ts +++ b/app/api/transactions/[id]/match-invoice/route.ts @@ -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, }) diff --git a/app/api/transactions/[id]/match-supplier-invoice/route.ts b/app/api/transactions/[id]/match-supplier-invoice/route.ts index 232fb749..5673f7e7 100644 --- a/app/api/transactions/[id]/match-supplier-invoice/route.ts +++ b/app/api/transactions/[id]/match-supplier-invoice/route.ts @@ -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, diff --git a/app/api/transactions/batch-match-invoices/route.ts b/app/api/transactions/batch-match-invoices/route.ts index a33046de..f5e2050f 100644 --- a/app/api/transactions/batch-match-invoices/route.ts +++ b/app/api/transactions/batch-match-invoices/route.ts @@ -33,6 +33,7 @@ export async function POST() { } let matched = 0 + const matchedInvoiceIds = new Set() 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 { diff --git a/lib/bookkeeping/invoice-entries.ts b/lib/bookkeeping/invoice-entries.ts index 5de2e17f..9b21723a 100644 --- a/lib/bookkeeping/invoice-entries.ts +++ b/lib/bookkeeping/invoice-entries.ts @@ -247,7 +247,8 @@ export async function createInvoicePaymentJournalEntry( invoice: Invoice, paymentDate: string, exchangeRateDifference?: number, - customerName?: string + customerName?: string, + paymentAmount?: number ): Promise { 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 diff --git a/lib/events/types.ts b/lib/events/types.ts index 7ffe2308..0934cf9f 100644 --- a/lib/events/types.ts +++ b/lib/events/types.ts @@ -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 } } diff --git a/lib/invoices/invoice-matching.ts b/lib/invoices/invoice-matching.ts index 13680122..a50d1738 100644 --- a/lib/invoices/invoice-matching.ts +++ b/lib/invoices/invoice-matching.ts @@ -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 diff --git a/lib/invoices/match-log.ts b/lib/invoices/match-log.ts new file mode 100644 index 00000000..f01e27bf --- /dev/null +++ b/lib/invoices/match-log.ts @@ -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 + newState?: Record + } +): Promise { + 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 + } +} diff --git a/lib/invoices/supplier-invoice-matching.ts b/lib/invoices/supplier-invoice-matching.ts index a022182b..51589d36 100644 --- a/lib/invoices/supplier-invoice-matching.ts +++ b/lib/invoices/supplier-invoice-matching.ts @@ -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() diff --git a/lib/reconciliation/bank-reconciliation.ts b/lib/reconciliation/bank-reconciliation.ts index 2a193a34..5fec6c5f 100644 --- a/lib/reconciliation/bank-reconciliation.ts +++ b/lib/reconciliation/bank-reconciliation.ts @@ -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 } } diff --git a/lib/transactions/__tests__/ingest.test.ts b/lib/transactions/__tests__/ingest.test.ts index c0244f26..585d51e9 100644 --- a/lib/transactions/__tests__/ingest.test.ts +++ b/lib/transactions/__tests__/ingest.test.ts @@ -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']) }) diff --git a/lib/transactions/ingest.ts b/lib/transactions/ingest.ts index 5f2eac90..4b5918b5 100644 --- a/lib/transactions/ingest.ts +++ b/lib/transactions/ingest.ts @@ -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() + const matchedSupplierInvoiceIds = new Set() + 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 { diff --git a/supabase/migrations/20260323120000_payment_match_log.sql b/supabase/migrations/20260323120000_payment_match_log.sql new file mode 100644 index 00000000..e1812179 --- /dev/null +++ b/supabase/migrations/20260323120000_payment_match_log.sql @@ -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(); diff --git a/supabase/migrations/20260323120001_invoice_partial_payments.sql b/supabase/migrations/20260323120001_invoice_partial_payments.sql new file mode 100644 index 00000000..9a1b2a1d --- /dev/null +++ b/supabase/migrations/20260323120001_invoice_partial_payments.sql @@ -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; diff --git a/tests/helpers.ts b/tests/helpers.ts index 4dc98fff..7c4d7b84 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -11,6 +11,7 @@ import type { DocumentAttachment, TaxCode, Invoice, + InvoicePayment, Customer, Supplier, SupplierInvoice, @@ -309,12 +310,33 @@ export function makeInvoice(overrides: Partial = {}): 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 { + 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 { return { id: nextId(), diff --git a/types/index.ts b/types/index.ts index 5060423b..03cb570a 100644 --- a/types/index.ts +++ b/types/index.ts @@ -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