diff --git a/app/api/transactions/[id]/link-journal-entry/__tests__/route.test.ts b/app/api/transactions/[id]/link-journal-entry/__tests__/route.test.ts index cf7bc792..405ca4df 100644 --- a/app/api/transactions/[id]/link-journal-entry/__tests__/route.test.ts +++ b/app/api/transactions/[id]/link-journal-entry/__tests__/route.test.ts @@ -165,7 +165,9 @@ describe('POST /api/transactions/[id]/link-journal-entry', () => { expect(status).toBe(200) expect(body.success).toBe(true) expect(body.journal_entry_id).toBe(JE_UUID) - expect(body.voucher_label).toBe('A12') + // Canonical format from formatVoucherLabel — series-number with hyphen, + // matches gnubok_link_invoice_to_voucher and SIE #VER cross-references. + expect(body.voucher_label).toBe('A-12') expect(body.invoice_id).toBeNull() expect(body.invoice_status).toBeNull() }) diff --git a/app/api/transactions/[id]/link-journal-entry/route.ts b/app/api/transactions/[id]/link-journal-entry/route.ts index 36e7a02c..77d8f368 100644 --- a/app/api/transactions/[id]/link-journal-entry/route.ts +++ b/app/api/transactions/[id]/link-journal-entry/route.ts @@ -10,9 +10,7 @@ * - journal_entry_id (required): the existing posted JE to link to. * - invoice_id (optional): when supplied, also inserts an * invoice_payments row pointing at the existing JE and flips the - * invoice status to 'paid' / 'partially_paid'. Same optimistic-lock - * pattern as match-invoice. Omit when linking against a JE that - * doesn't relate to a customer invoice (uncommon but supported). + * invoice status to 'paid' / 'partially_paid'. * * Effects: * - transactions.journal_entry_id = je_id @@ -25,16 +23,17 @@ * * NEVER creates a new journal entry; the underlying double-entry already * exists. The match log records 'linked_to_existing_voucher' for audit. + * + * Core logic is shared with the MCP commit handler in lib/pending-operations/commit.ts + * (gnubok_link_transaction_to_journal_entry) — see lib/transactions/link-journal-entry.ts. */ import { NextResponse } from 'next/server' import { withRouteContext } from '@/lib/api/with-route-context' import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' import { validateBody } from '@/lib/api/validate' import { LinkTransactionJournalEntrySchema } from '@/lib/api/schemas' -import { logMatchEvent } from '@/lib/invoices/match-log' -import { eventBus } from '@/lib/events/bus' import { ensureInitialized } from '@/lib/init' -import type { Invoice, Transaction } from '@/types' +import { linkTransactionToJournalEntry } from '@/lib/transactions/link-journal-entry' ensureInitialized() @@ -53,242 +52,33 @@ export const POST = withRouteContext( const txLog = log.child({ transactionId, journalEntryId: journal_entry_id, invoiceId: invoice_id }) - // Data minimization (GDPR Art.5(1)(c)): pull only the columns the route - // actually uses for validation, the optimistic-lock invoice update, the - // invoice_payments insert, and the compensating-rollback path. Avoid - // `select('*')` so freshly-added columns (PII or otherwise) never leak - // into the request scope or downstream logs by accident. - const { data: transaction, error: fetchTxError } = await supabase - .from('transactions') - .select( - 'id, date, amount, currency, exchange_rate, journal_entry_id, invoice_id, is_business, potential_invoice_id, potential_supplier_invoice_id', - ) - .eq('id', transactionId) - .eq('company_id', companyId) - .single() - - if (fetchTxError || !transaction) { - return errorResponseFromCode('TX_CATEGORIZE_TX_NOT_FOUND', txLog, { requestId }) - } - - if (transaction.journal_entry_id) { - return errorResponseFromCode('LINK_TX_TX_ALREADY_LINKED', txLog, { - requestId, - details: { existingJournalEntryId: transaction.journal_entry_id }, - }) - } - - const { data: journalEntry, error: fetchJeError } = await supabase - .from('journal_entries') - .select('id, status, voucher_series, voucher_number, entry_date') - .eq('id', journal_entry_id) - .eq('company_id', companyId) - .single() - - if (fetchJeError || !journalEntry) { - return errorResponseFromCode('LINK_TX_JE_NOT_FOUND', txLog, { requestId }) - } - - if (journalEntry.status !== 'posted') { - return errorResponseFromCode('LINK_TX_JE_NOT_POSTED', txLog, { - requestId, - details: { currentStatus: journalEntry.status }, - }) - } - - // If invoice_id supplied, validate + prepare invoice update. - let invoice: (Invoice & { customer?: { name?: string } | null }) | null = null - let newPaidAmount = 0 - let newRemaining = 0 - let isFullyPaid = false - let newStatus: 'paid' | 'partially_paid' = 'paid' - - if (invoice_id) { - const { data: invoiceRow, error: fetchInvError } = await supabase - .from('invoices') - .select('*, customer:customers(name)') - .eq('id', invoice_id) - .eq('company_id', companyId) - .single() - - if (fetchInvError || !invoiceRow) { - return errorResponseFromCode('LINK_TX_INVOICE_NOT_FOUND', txLog, { requestId }) - } - - if ( - invoiceRow.status !== 'sent' && - invoiceRow.status !== 'overdue' && - invoiceRow.status !== 'partially_paid' - ) { - return errorResponseFromCode('LINK_TX_INVOICE_NOT_OPEN', txLog, { - requestId, - details: { currentStatus: invoiceRow.status }, - }) - } - - invoice = invoiceRow as Invoice & { customer?: { name?: string } | null } - - const paidAmount = transaction.amount - newPaidAmount = Math.round(((invoice.paid_amount || 0) + paidAmount) * 100) / 100 - const currentRemaining = invoice.remaining_amount ?? (invoice.total - (invoice.paid_amount || 0)) - newRemaining = Math.max(0, Math.round((currentRemaining - paidAmount) * 100) / 100) - isFullyPaid = newRemaining <= 0 - newStatus = isFullyPaid ? 'paid' : 'partially_paid' - } - - // Capture pre-link values so the compensating-rollback path below can - // restore the row if the optimistic invoice update loses its race or - // the invoice_payments insert fails. Without this snapshot a partial - // state would persist: tx linked, invoice unchanged, no payment row. - const priorTxState = { - journal_entry_id: transaction.journal_entry_id, // validated null above - invoice_id: transaction.invoice_id, - potential_invoice_id: transaction.potential_invoice_id, - potential_supplier_invoice_id: transaction.potential_supplier_invoice_id, - is_business: transaction.is_business, - } - - // Link the transaction first. If a subsequent step fails the compensating - // path below restores priorTxState. Doing the tx update before the invoice - // update preserves the "transaction disappears from inbox" UX even if the - // invoice update races. - const { error: updateTxError } = await supabase - .from('transactions') - .update({ - journal_entry_id, - invoice_id: invoice_id ?? null, - potential_invoice_id: null, - potential_supplier_invoice_id: null, - is_business: true, - }) - .eq('id', transactionId) - .eq('company_id', companyId) - .is('journal_entry_id', null) - - if (updateTxError) { - txLog.error('failed to link transaction to journal entry', updateTxError) - return errorResponse(updateTxError, txLog, { requestId }) - } - - async function rollbackTxLink(reason: string) { - const { error: rollbackErr } = await supabase - .from('transactions') - .update(priorTxState) - .eq('id', transactionId) - .eq('company_id', companyId) - if (rollbackErr) { - // Best-effort: the original error is more useful to surface; a - // failed rollback gets warn-logged so a reconciliation job can pick - // up the partial state offline. PI1.3 risk is documented here so - // the audit trail is honest about the remaining gap. - txLog.warn('failed to roll back transaction link after subsequent step failed', { - rollbackError: rollbackErr.message, - reason, - }) - } - } - - const now = new Date().toISOString() - - if (invoice && invoice_id) { - // Optimistic lock: only flip status if invoice is still matchable. - const { data: updatedRows, error: updateInvError } = await supabase - .from('invoices') - .update({ - status: newStatus, - paid_at: isFullyPaid ? now : null, - paid_amount: newPaidAmount, - remaining_amount: newRemaining, - }) - .eq('id', invoice_id) - .eq('company_id', companyId) - .in('status', ['sent', 'overdue', 'partially_paid']) - .select('id') - - if (updateInvError) { - await rollbackTxLink('invoice update errored') - txLog.error('failed to update invoice status', updateInvError) - return errorResponse(updateInvError, txLog, { requestId }) - } - - if (!updatedRows || updatedRows.length === 0) { - await rollbackTxLink('invoice optimistic lock returned 0 rows') - return errorResponseFromCode('LINK_TX_INVOICE_RACE', txLog, { requestId }) - } - - const { error: paymentInsertError } = await supabase - .from('invoice_payments') - .insert({ - user_id: user.id, - company_id: companyId, - invoice_id, - payment_date: transaction.date, - amount: transaction.amount, - currency: invoice.currency, - exchange_rate: invoice.exchange_rate, - journal_entry_id, - transaction_id: transactionId, - notes: 'Kopplad till befintlig verifikation (ingen ny bokföring skapad)', - }) - - if (paymentInsertError && paymentInsertError.code !== '23505') { - // Compensate: revert the invoice update and the tx link before - // surfacing the error so the ledger doesn't carry an invoice that - // says "paid" with no corresponding payment row. - const { error: invRevertErr } = await supabase - .from('invoices') - .update({ - status: invoice.status, - paid_at: invoice.paid_at ?? null, - paid_amount: invoice.paid_amount ?? 0, - remaining_amount: invoice.remaining_amount ?? invoice.total, - }) - .eq('id', invoice_id) - .eq('company_id', companyId) - if (invRevertErr) { - txLog.warn('failed to revert invoice status after payment insert failed', { - rollbackError: invRevertErr.message, - }) - } - await rollbackTxLink('invoice_payments insert failed') - txLog.error('failed to record invoice payment', paymentInsertError) - return errorResponseFromCode('MATCH_INVOICE_RECORD_PAYMENT_FAILED', txLog, { requestId }) - } - } - - logMatchEvent(supabase, user.id, transactionId, 'linked_to_existing_voucher', { + const outcome = await linkTransactionToJournalEntry(supabase, user.id, companyId, { + transactionId, + journalEntryId: journal_entry_id, invoiceId: invoice_id, - newState: { - journal_entry_id, - invoice_id: invoice_id ?? null, - invoice_status: invoice ? newStatus : null, - }, }) - if (invoice && invoice_id) { - try { - eventBus.emit({ - type: 'invoice.match_confirmed', - payload: { - invoice: invoice as Invoice, - transaction: transaction as Transaction, - userId: user.id, - companyId, - }, + if (!outcome.ok) { + // LINK_TX_DB_ERROR is the only code emitted on raw DB failure; route it + // through the generic errorResponse fallback so the INTERNAL_ERROR envelope + // matches the rest of the API. Everything else maps to a structured-error + // entry with the right HTTP status. + if (outcome.code === 'LINK_TX_DB_ERROR') { + return errorResponse(new Error(String(outcome.details?.reason ?? 'Database error')), txLog, { + requestId, }) - } catch (err) { - txLog.warn('invoice.match_confirmed event emission failed', err as Error) } + return errorResponseFromCode(outcome.code, txLog, { requestId, details: outcome.details }) } return NextResponse.json({ success: true, - journal_entry_id, - voucher_label: `${journalEntry.voucher_series ?? 'A'}${journalEntry.voucher_number ?? ''}`, - invoice_id: invoice_id ?? null, - invoice_status: invoice ? newStatus : null, - paid_amount: invoice ? newPaidAmount : null, - remaining_amount: invoice ? newRemaining : null, + journal_entry_id: outcome.result.journalEntryId, + voucher_label: outcome.result.voucherLabel, + invoice_id: outcome.result.invoiceId, + invoice_status: outcome.result.invoiceStatus, + paid_amount: outcome.result.paidAmount, + remaining_amount: outcome.result.remainingAmount, }) }, { requireWrite: true }, diff --git a/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts b/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts index efc77cd4..c4127491 100644 --- a/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts +++ b/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts @@ -14,18 +14,20 @@ vi.mock('@/lib/supabase/server', () => ({ createClient: () => Promise.resolve(mockSupabase), })) -const mockCreateInvoicePaymentJournalEntry = vi.fn() const mockCreateInvoiceCashEntry = vi.fn() vi.mock('@/lib/bookkeeping/invoice-entries', () => ({ - createInvoicePaymentJournalEntry: (...args: unknown[]) => mockCreateInvoicePaymentJournalEntry(...args), createInvoiceCashEntry: (...args: unknown[]) => mockCreateInvoiceCashEntry(...args), getRevenueAccount: vi.fn().mockReturnValue('3001'), getOutputVatAccount: vi.fn().mockReturnValue('2611'), })) const mockReverseEntry = vi.fn() +const mockFindFiscalPeriod = vi.fn() +const mockCreateJournalEntry = vi.fn() vi.mock('@/lib/bookkeeping/engine', () => ({ reverseEntry: (...args: unknown[]) => mockReverseEntry(...args), + findFiscalPeriod: (...args: unknown[]) => mockFindFiscalPeriod(...args), + createJournalEntry: (...args: unknown[]) => mockCreateJournalEntry(...args), })) vi.mock('@/lib/invoices/match-log', () => ({ @@ -71,6 +73,12 @@ describe('POST /api/transactions/[id]/match-invoice', () => { mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) // Default to no soft-duplicate detected — happy-path tests don't care. mockDetectDuplicate.mockResolvedValue(null) + // Clearing path delegates to findFiscalPeriod + createJournalEntry (FX fix + // PR #614 round 6 — see lib/bookkeeping/invoice-payment-lines.ts). Give + // both safe defaults; tests that exercise the clearing path override + // mockCreateJournalEntry to assert the result id. + mockFindFiscalPeriod.mockResolvedValue('fp-1') + mockCreateJournalEntry.mockResolvedValue({ id: 'je-1' }) }) it('returns 401 when not authenticated', async () => { @@ -197,6 +205,38 @@ describe('POST /api/transactions/[id]/match-invoice', () => { expect((body.error as unknown as { code: string }).code).toBe('MATCH_INVOICE_NOT_OPEN') }) + it('returns 400 MATCH_INVOICE_CURRENCY_MISMATCH for cross-currency settlement', async () => { + // Round-9 fix: a SEK bank tx paying a USD invoice would otherwise + // corrupt invoice.paid_amount (accumulator treats SEK as USD), flip + // a 140 USD invoice to status=paid after a tiny partial. Block here + // and route the user to the multi-allocation flow that handles + // 3960/7960 FX-diff postings end-to-end. + const tx = makeTransaction({ id: 'tx-1', amount: 1000, invoice_id: null, currency: 'SEK' }) + const invoice = makeInvoice({ + id: VALID_UUID, + status: 'sent', + currency: 'USD', + total: 140, + remaining_amount: 140, + }) + enqueue({ data: tx, error: null }) + enqueue({ data: invoice, 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: { code: string; details: Record } }>(response) + + expect(status).toBe(400) + expect(body.error.code).toBe('MATCH_INVOICE_CURRENCY_MISMATCH') + expect(body.error.details).toMatchObject({ + transactionCurrency: 'SEK', + invoiceCurrency: 'USD', + }) + }) + 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() @@ -220,7 +260,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { // Fetch company settings enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) - mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-1' }) + mockCreateJournalEntry.mockResolvedValue({ id: 'je-1' }) // Update invoice (optimistic lock returns updated row) enqueue({ data: [{ id: VALID_UUID }], error: null }) @@ -251,16 +291,24 @@ describe('POST /api/transactions/[id]/match-invoice', () => { expect(body.remaining_amount).toBe(0) expect(body.journal_entry_id).toBe('je-1') - // Verify accrual payment entry was called with paymentAmount - expect(mockCreateInvoicePaymentJournalEntry).toHaveBeenCalledWith( + // Clearing path now builds lines via buildInvoicePaymentClearingLines and + // posts via createJournalEntry directly (FX fix PR #614 round 6). For a + // same-currency SEK invoice that's two lines: Dr 1930 12 500 / Cr 1510 + // 12 500, no FX-diff line. + expect(mockCreateJournalEntry).toHaveBeenCalledWith( expect.anything(), 'company-1', 'user-1', - expect.objectContaining({ id: VALID_UUID }), - '2024-06-15', - undefined, - expect.anything(), - 12500 + expect.objectContaining({ + fiscal_period_id: 'fp-1', + entry_date: '2024-06-15', + source_type: 'invoice_paid', + source_id: VALID_UUID, + lines: expect.arrayContaining([ + expect.objectContaining({ account_number: '1930', debit_amount: 12500 }), + expect.objectContaining({ account_number: '1510', credit_amount: 12500 }), + ]), + }), ) }) @@ -294,7 +342,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { // Fetch company settings enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) - mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-payment' }) + mockCreateJournalEntry.mockResolvedValue({ id: 'je-payment' }) // Update invoice (optimistic lock) enqueue({ data: [{ id: VALID_UUID }], error: null }) @@ -346,7 +394,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { // routes any non-typed error to INTERNAL_ERROR. expect((body.error as unknown as { code: string }).code).toBe('INTERNAL_ERROR') // Invoice should NOT have been updated — no further DB calls after storno failure - expect(mockCreateInvoicePaymentJournalEntry).not.toHaveBeenCalled() + expect(mockCreateJournalEntry).not.toHaveBeenCalled() }) it('supports partial payment (partially_paid status)', async () => { @@ -364,7 +412,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { enqueue({ data: [], error: null }) // hard-duplicate check enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) - mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-partial' }) + mockCreateJournalEntry.mockResolvedValue({ id: 'je-partial' }) // Update invoice (optimistic lock) enqueue({ data: [{ id: VALID_UUID }], error: null }) @@ -419,10 +467,11 @@ describe('POST /api/transactions/[id]/match-invoice', () => { enqueue({ data: [], error: null }) // hard-duplicate check enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null }) - mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-clearing' }) + mockCreateJournalEntry.mockResolvedValue({ id: 'je-clearing' }) - // The PDF re-attach block runs because invoice.journal_entry_id is set; - // returning null skips the attach without aborting the match. + // Route order: PDF re-attach (runs first when invoice.journal_entry_id is + // set; null result skips the attach insert) → optimistic invoice update → + // invoice_payments → update transaction → logMatchEvent. enqueue({ data: null, error: null }) // document_attachments lookup enqueue({ data: [{ id: VALID_UUID }], error: null }) // update invoice enqueue({ data: null, error: null }) // insert invoice_payments @@ -438,8 +487,9 @@ describe('POST /api/transactions/[id]/match-invoice', () => { expect(status).toBe(200) expect(body.invoice_status).toBe('paid') - // Must clear 1510, not re-recognise revenue + VAT - expect(mockCreateInvoicePaymentJournalEntry).toHaveBeenCalled() + // Must clear 1510 via the clearing-entry path, not re-recognise revenue + + // VAT via createInvoiceCashEntry. + expect(mockCreateJournalEntry).toHaveBeenCalled() expect(mockCreateInvoiceCashEntry).not.toHaveBeenCalled() }) @@ -492,7 +542,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { enqueue({ data: [], error: null }) // hard-duplicate check enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null }) - mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-clearing' }) + mockCreateJournalEntry.mockResolvedValue({ id: 'je-clearing' }) // Update invoice enqueue({ data: [{ id: VALID_UUID }], error: null }) @@ -512,8 +562,9 @@ describe('POST /api/transactions/[id]/match-invoice', () => { expect(status).toBe(200) expect(body.invoice_status).toBe('partially_paid') - // Cash partial uses accrual-style clearing entry, NOT createInvoiceCashEntry - expect(mockCreateInvoicePaymentJournalEntry).toHaveBeenCalled() + // Cash partial uses accrual-style clearing entry (now via the shared + // helper + createJournalEntry), NOT createInvoiceCashEntry. + expect(mockCreateJournalEntry).toHaveBeenCalled() expect(mockCreateInvoiceCashEntry).not.toHaveBeenCalled() }) @@ -530,7 +581,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { enqueue({ data: invoice, error: null }) enqueue({ data: [], error: null }) // hard-duplicate check enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) - mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-1' }) + mockCreateJournalEntry.mockResolvedValue({ id: 'je-1' }) // Optimistic lock returns 0 rows (another request fully paid it) enqueue({ data: [], error: null }) @@ -559,7 +610,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { enqueue({ data: invoice, error: null }) enqueue({ data: [], error: null }) // hard-duplicate check enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) - mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-1' }) + mockCreateJournalEntry.mockResolvedValue({ id: 'je-1' }) // Optimistic lock succeeds enqueue({ data: [{ id: VALID_UUID }], error: null }) @@ -586,7 +637,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { enqueue({ data: [], error: null }) // hard-duplicate check enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) - mockCreateInvoicePaymentJournalEntry.mockRejectedValue(new Error('Period locked')) + mockCreateJournalEntry.mockRejectedValue(new Error('Period locked')) // Update invoice (optimistic lock) enqueue({ data: [{ id: VALID_UUID }], error: null }) @@ -642,7 +693,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { expect(status).toBe(409) expect(body.error.code).toBe('MATCH_INVOICE_ALREADY_HAS_PAYMENT_VOUCHER') expect(body.error.details?.existing_journal_entry_id).toBe('je-existing') - expect(mockCreateInvoicePaymentJournalEntry).not.toHaveBeenCalled() + expect(mockCreateJournalEntry).not.toHaveBeenCalled() }) it('does NOT run hard-duplicate guard for partially_paid invoices (legitimate additional payment)', async () => { @@ -660,7 +711,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { // Hard-duplicate check is skipped for partially_paid; jump straight to settings enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) - mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-partial-extra' }) + mockCreateJournalEntry.mockResolvedValue({ id: 'je-partial-extra' }) enqueue({ data: [{ id: VALID_UUID }], error: null }) // update invoice enqueue({ data: null, error: null }) // insert invoice_payments enqueue({ data: null, error: null }) // update tx @@ -709,7 +760,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { expect(body.error.code).toBe('MATCH_INVOICE_POSSIBLE_DUPLICATE') expect(body.error.details?.candidate?.journal_entry_id).toBe('je-manual') expect(body.error.details?.candidate?.voucher_label).toBe('A12') - expect(mockCreateInvoicePaymentJournalEntry).not.toHaveBeenCalled() + expect(mockCreateJournalEntry).not.toHaveBeenCalled() }) it('force=true bypasses the soft-duplicate guard when the candidate echo matches', async () => { @@ -739,7 +790,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) - mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-forced' }) + mockCreateJournalEntry.mockResolvedValue({ id: 'je-forced' }) enqueue({ data: [{ id: VALID_UUID }], error: null }) // update invoice enqueue({ data: null, error: null }) // insert invoice_payments enqueue({ data: null, error: null }) // update tx @@ -801,7 +852,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { expect(body.error.code).toBe('MATCH_INVOICE_FORCE_CANDIDATE_MISMATCH') expect(body.error.details?.expected_journal_entry_id).toBe(STALE_UUID) expect(body.error.details?.detected_journal_entry_id).toBe(OTHER_CANDIDATE_UUID) - expect(mockCreateInvoicePaymentJournalEntry).not.toHaveBeenCalled() + expect(mockCreateJournalEntry).not.toHaveBeenCalled() }) it('returns 409 MATCH_INVOICE_FORCE_CANDIDATE_MISMATCH when no current duplicate exists for the force call', async () => { @@ -823,6 +874,6 @@ describe('POST /api/transactions/[id]/match-invoice', () => { const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) expect(status).toBe(409) expect(body.error.code).toBe('MATCH_INVOICE_FORCE_CANDIDATE_MISMATCH') - expect(mockCreateInvoicePaymentJournalEntry).not.toHaveBeenCalled() + expect(mockCreateJournalEntry).not.toHaveBeenCalled() }) }) diff --git a/app/api/transactions/[id]/match-invoice/preview/route.ts b/app/api/transactions/[id]/match-invoice/preview/route.ts index 84c343c1..d78bed2b 100644 --- a/app/api/transactions/[id]/match-invoice/preview/route.ts +++ b/app/api/transactions/[id]/match-invoice/preview/route.ts @@ -19,6 +19,7 @@ import { withRouteContext } from '@/lib/api/with-route-context' import { errorResponseFromCode } from '@/lib/errors/get-structured-error' import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils' import { getRevenueAccount, getOutputVatAccount } from '@/lib/bookkeeping/invoice-entries' +import { buildInvoicePaymentClearingLines } from '@/lib/bookkeeping/invoice-payment-lines' import type { EntityType, Invoice, InvoiceItem } from '@/types' import { z } from 'zod' @@ -49,9 +50,13 @@ export const GET = withRouteContext( } const { invoice_id } = parsed.data + // Data minimization (GDPR Art.5(1)(c)): amount_sek + exchange_rate are + // pulled because buildInvoicePaymentClearingLines needs them for the + // cross-currency bank-leg math (round-7 FX fix). All other columns + // would broaden the projection without serving the preview's purpose. const { data: transaction, error: txErr } = await supabase .from('transactions') - .select('id, date, amount, currency') + .select('id, date, amount, amount_sek, currency, exchange_rate') .eq('id', transactionId) .eq('company_id', companyId) .single() @@ -157,22 +162,38 @@ export const GET = withRouteContext( }) lines.push(...creditLines) } else { - // Clearing entry: Dr 1930 / Cr 1510 at the paid amount in SEK. + // Clearing entry. Delegates to the shared helper so the preview and + // the committed verifikat are byte-identical — fixing the prior + // bug where the preview ran `resolveSekAmount(tx.amount, null, + // INV.currency, INV.rate)`, treating the SEK tx number as if it + // were in the invoice's currency and multiplying by the invoice's + // rate. That produced a fictitious bank-leg and silently dropped + // the FX gain/loss for cross-currency invoices. const inv = invoice as Invoice - const bookedSek = resolveSekAmount(paidAmount, null, inv.currency, inv.exchange_rate) - const amount = Math.round(bookedSek * 100) / 100 - lines.push({ - account_number: '1930', - debit_amount: amount, - credit_amount: 0, - description: 'Inbetalning från bank', - }) - lines.push({ - account_number: '1510', - debit_amount: 0, - credit_amount: amount, - description: 'Kvittning kundfordran', - }) + const { lines: clearingLines } = buildInvoicePaymentClearingLines( + { + amount: transaction.amount, + amount_sek: transaction.amount_sek ?? null, + currency: transaction.currency, + exchange_rate: transaction.exchange_rate ?? null, + }, + { + currency: inv.currency, + exchange_rate: inv.exchange_rate ?? null, + remaining_amount: inv.remaining_amount ?? null, + total: inv.total, + paid_amount: inv.paid_amount ?? null, + }, + 'Inbetalning kundfaktura', + ) + for (const line of clearingLines) { + lines.push({ + account_number: line.account_number, + debit_amount: line.debit_amount, + credit_amount: line.credit_amount, + description: line.line_description ?? '', + }) + } } return NextResponse.json({ diff --git a/app/api/transactions/[id]/match-invoice/route.ts b/app/api/transactions/[id]/match-invoice/route.ts index 37e2c77a..d072c90f 100644 --- a/app/api/transactions/[id]/match-invoice/route.ts +++ b/app/api/transactions/[id]/match-invoice/route.ts @@ -1,8 +1,6 @@ import { NextResponse } from 'next/server' -import { - createInvoicePaymentJournalEntry, - createInvoiceCashEntry, -} from '@/lib/bookkeeping/invoice-entries' +import { createInvoiceCashEntry } from '@/lib/bookkeeping/invoice-entries' +import { buildInvoicePaymentClearingLines } from '@/lib/bookkeeping/invoice-payment-lines' import { reverseEntry, createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine' import { AccountsNotInChartError, isBookkeepingError } from '@/lib/bookkeeping/errors' import { getErrorMessage } from '@/lib/errors/get-error-message' @@ -101,6 +99,31 @@ export const POST = withRouteContext( }) } + // Currency-integrity guard (BFL 5 kap 2§ + swedish-compliance PR #614 + // round 9). invoices.paid_amount / remaining_amount are denominated in + // invoice.currency; invoice_payments rows carry currency = invoice.currency + // with amount in that currency. The accumulator below assumes + // `tx.amount` is already in invoice.currency. For a SEK bank tx paying + // a USD invoice the accumulator would silently treat 230 SEK as "230 + // USD paid" and flip a 140 USD invoice to status=paid after a partial. + // + // Block cross-currency on this single-allocation path until a proper + // FX-aware settlement flow lands. Same-currency (SEK→SEK or USD→USD) + // remains fully supported including partials; the buildInvoicePayment- + // ClearingLines helper handles the bookkeeping side correctly in both + // cases. For SEK tx → USD invoice the user should use the multi- + // allocation dialog (gnubok_match_batch_allocate) which DOES handle + // FX-diff postings on 3960/7960 end-to-end. + if (transaction.currency !== invoice.currency) { + return errorResponseFromCode('MATCH_INVOICE_CURRENCY_MISMATCH', txLog, { + requestId, + details: { + transactionCurrency: transaction.currency, + invoiceCurrency: invoice.currency, + }, + }) + } + // Hard-duplicate guard: if the invoice is 'sent'/'overdue' but already // has a payment voucher attached (status leak), refuse — booking again // would double-credit 1510 / double-debit 1930. Partially-paid invoices @@ -311,10 +334,48 @@ export const POST = withRouteContext( // intentional — under kontantmetoden 1510 has no prior balance, so // partials leave a credit on 1510 that gets resolved on final // payment when createInvoiceCashEntry would normally run. - const journalEntry = await createInvoicePaymentJournalEntry( - supabase, companyId, user.id, invoice as Invoice, transaction.date, - undefined, invoice.customer?.name, paidAmount, + // + // Builds lines via buildInvoicePaymentClearingLines so the verifikat + // is byte-identical to what the preview route showed the user. For + // same-currency invoices that's just 1930/1510. For cross-currency + // it also posts a 3960/7960 FX-diff line so the verifikat balances + // per BFL 5 kap 4–5§. Bypasses createInvoicePaymentJournalEntry on + // this single path (mark-paid and other callers still use it) — + // see lib/bookkeeping/invoice-payment-lines.ts for the contract. + const fiscalPeriodId = await findFiscalPeriod(supabase, companyId!, transaction.date) + if (!fiscalPeriodId) { + return errorResponseFromCode('INVOICE_PAID_NO_FISCAL_PERIOD', txLog, { + requestId, + details: { paymentDate: transaction.date }, + }) + } + const desc = invoice.customer?.name + ? `Inbetalning kundfaktura ${invoice.invoice_number}, ${invoice.customer.name}` + : `Inbetalning kundfaktura ${invoice.invoice_number}` + const { lines: clearingLines } = buildInvoicePaymentClearingLines( + { + amount: transaction.amount, + amount_sek: transaction.amount_sek ?? null, + currency: transaction.currency, + exchange_rate: transaction.exchange_rate ?? null, + }, + { + currency: invoice.currency, + exchange_rate: invoice.exchange_rate ?? null, + remaining_amount: invoice.remaining_amount ?? null, + total: invoice.total, + paid_amount: invoice.paid_amount ?? null, + }, + desc, ) + const journalEntry = await createJournalEntry(supabase, companyId!, user.id, { + fiscal_period_id: fiscalPeriodId, + entry_date: transaction.date, + description: desc, + source_type: 'invoice_paid', + source_id: invoice.id, + lines: clearingLines, + }) journalEntryId = journalEntry?.id ?? null } } catch (err) { diff --git a/components/transactions/InvoiceMatchDialog.tsx b/components/transactions/InvoiceMatchDialog.tsx index 72c5fba4..061050c4 100644 --- a/components/transactions/InvoiceMatchDialog.tsx +++ b/components/transactions/InvoiceMatchDialog.tsx @@ -338,7 +338,10 @@ export default function InvoiceMatchDialog({ - {/* Invoice details */} + {/* Invoice details. Shows remaining_amount (what the customer + still owes) rather than the original total, so a partially- + paid invoice displays the actual figure the user is matching + against. Mirrors the supplier-invoice block below. */} {isCustomerInvoice && (

{t('invoice_label')}

@@ -354,7 +357,7 @@ export default function InvoiceMatchDialog({ {formatCurrency( - transaction.potential_invoice!.total, + transaction.potential_invoice!.remaining_amount ?? transaction.potential_invoice!.total, transaction.potential_invoice!.currency, )} @@ -385,17 +388,29 @@ export default function InvoiceMatchDialog({
)} - {/* Amount comparison */} + {/* Amount comparison. Compares the bank tx against what the + customer STILL OWES (remaining_amount), not the original + invoice.total — otherwise a 1 250 SEK invoice with a prior + 230 SEK partial would show "Differens: 250 kr" when a 1 000 + SEK top-up arrives, instead of the actual 20 kr shortfall. + The customer branch previously fell back to .total; both + branches now mirror the supplier branch's correct logic. */} {(() => { const txAbs = Math.abs(transaction.amount) - const invTotal = isSupplierInvoice + const invRemaining = isSupplierInvoice ? transaction.potential_supplier_invoice!.remaining_amount ?? transaction.potential_supplier_invoice!.total - : transaction.potential_invoice!.total + : transaction.potential_invoice!.remaining_amount ?? transaction.potential_invoice!.total const invCurrency = isSupplierInvoice ? transaction.potential_supplier_invoice!.currency : transaction.potential_invoice!.currency const sameCurrency = transaction.currency === invCurrency - const amountsMatch = sameCurrency && Math.abs(txAbs - invTotal) < 0.01 + // Cross-currency "match" comparison is meaningless without an FX + // conversion — show the explicit different-currencies warning + // and skip the numeric match check. The committed verifikat is + // built by buildInvoicePaymentClearingLines, which posts the + // FX diff to 3960/7960 so the books balance correctly even + // when the on-screen numbers can't be naively compared. + const amountsMatch = sameCurrency && Math.abs(txAbs - invRemaining) < 0.01 if (amountsMatch) { return ( @@ -406,16 +421,25 @@ export default function InvoiceMatchDialog({ ) } - const diff = Math.abs(txAbs - invTotal) return (

{t('amounts_differ')}

- {t('amount_diff', { amount: formatCurrency(diff, transaction.currency) })} - {!sameCurrency && t('different_currencies')} - {isSupplierInvoice && diff > 0.01 && sameCurrency && t('partial_payment_note')} + {sameCurrency ? ( + <> + {t('amount_diff', { + amount: formatCurrency( + Math.abs(txAbs - invRemaining), + transaction.currency, + ), + })} + {isSupplierInvoice && t('partial_payment_note')} + + ) : ( + t('different_currencies') + )}

diff --git a/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts b/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts index 992baac5..50bd1d1f 100644 --- a/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts +++ b/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts @@ -13,7 +13,7 @@ describe('tools/list payload size guard', () => { })) const payload = JSON.stringify({ tools: projection }) const approxTokens = Math.round(payload.length / 4) - // Ceiling progression: 20K → 25K → 30K. + // Ceiling progression: 20K → 25K → 30K → 31K → 31.5K. // * 20K → 25K when item 8 of the agent-native API plan landed // (additionalProperties: false on all inputSchemas + period_status in the // staged operation envelope). @@ -22,9 +22,18 @@ describe('tools/list payload size guard', () => { // gnubok_approve_pending_operation, gnubok_reject_pending_operation, // gnubok_set_inbox_extracted_data from main + gnubok_get_agent_briefing, // _remember_fact, _forget_fact, _feedback from the agent branch). + // * 30K → 31K when gnubok_match_batch_allocate and + // gnubok_bulk_book_transactions landed (PRs #603/#606/#608/#610). Each + // adds the shared STAGED_OPERATION_SCHEMA + a non-trivial inputSchema + // for the multi-tx flows. Descriptions already trimmed to 230–260 chars. + // * 31K → 31.5K when gnubok_link_transaction_to_journal_entry landed (PR + // #614). Same family as match_batch_allocate / bulk_book_transactions — + // closes the MCP parity gap with the existing REST endpoint so agents + // can attach a bank tx to an already-posted verifikat without creating + // duplicate bookkeeping. Description trimmed to ~180 chars. // Long-term answer to growth is leaning harder on gnubok_search_tools — if this // fires again, prefer trimming descriptions or making a tool opt-in via search // before bumping further. - expect(approxTokens).toBeLessThan(30_000) + expect(approxTokens).toBeLessThan(31_500) }) }) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index f86dda37..cb4f9445 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -11,6 +11,7 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping' import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries' import { upsertCounterpartyTemplate, findCounterpartyTemplatesBatch, formatCounterpartyName } from '@/lib/bookkeeping/counterparty-templates' +import { formatVoucherLabel } from '@/lib/transactions/link-journal-entry' import { eventBus } from '@/lib/events/bus' import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules' import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken' @@ -4408,6 +4409,533 @@ export const tools: McpTool[] = [ }, }, + { + name: 'gnubok_match_batch_allocate', + description: 'Allocate 1 bank tx across N customer OR N supplier invoices (samlingsbetalning, BFL 5 kap 6§). Use when one receipt covers many invoices, or one transfer pays many bills. Customer kind requires income tx; supplier kind requires expense. Never mix kinds. Stages.', + inputSchema: { + type: 'object', + additionalProperties: false, + properties: { + transaction_id: { type: 'string' }, + allocations: { + type: 'array', + minItems: 1, + maxItems: 100, + items: { + type: 'object', + additionalProperties: false, + properties: { + kind: { type: 'string', enum: ['customer_invoice', 'supplier_invoice'] }, + invoice_id: { type: 'string' }, + supplier_invoice_id: { type: 'string' }, + amount: { type: 'number', exclusiveMinimum: 0, description: 'Amount in TX currency. Cross-currency = bank-credited SEK.' }, + }, + required: ['kind', 'amount'], + }, + }, + }, + required: ['transaction_id', 'allocations'], + }, + outputSchema: STAGED_OPERATION_SCHEMA, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + async execute(args, companyId, userId, supabase, actor) { + const transactionId = args.transaction_id as string + const allocations = args.allocations as Array<{ + kind: 'customer_invoice' | 'supplier_invoice' + invoice_id?: string + supplier_invoice_id?: string + amount: number + }> + if (!transactionId) throw new Error('transaction_id is required') + if (!Array.isArray(allocations) || allocations.length === 0) { + throw new Error('allocations is required (non-empty array)') + } + + const { data: transaction, error: txError } = await supabase + .from('transactions') + .select('id, description, merchant_name, amount, currency, date, journal_entry_id') + .eq('id', transactionId) + .eq('company_id', companyId) + .single() + if (txError || !transaction) throw new Error('Transaction not found') + if (transaction.journal_entry_id) throw new Error('Transaction is already booked') + if (transaction.amount === 0) throw new Error('Transaction has zero amount') + + // Direction guard mirrors the RPC: customer_invoice → income, supplier_invoice → expense. + const hasCustomer = allocations.some((a) => a.kind === 'customer_invoice') + const hasSupplier = allocations.some((a) => a.kind === 'supplier_invoice') + if (hasCustomer && hasSupplier) { + throw new Error('Cannot mix customer_invoice and supplier_invoice allocations in one batch') + } + if (hasCustomer && transaction.amount <= 0) { + throw new Error('Customer allocations require an income transaction (amount > 0)') + } + if (hasSupplier && transaction.amount >= 0) { + throw new Error('Supplier allocations require an expense transaction (amount < 0)') + } + + // Per-allocation guard (Greptile P1): each row must carry the + // correct ID for its kind. The inputSchema marks both invoice_id + // and supplier_invoice_id as optional because they're mutually + // exclusive — but the JSON-Schema vocabulary can't express "X + // required iff Y=A". Check explicitly here. Round-8: also reject + // unexpected extra IDs (V4.5) — a customer_invoice row supplying + // supplier_invoice_id silently leaks the extra ID into preview_data. + for (const [i, a] of allocations.entries()) { + if (a.kind === 'customer_invoice') { + if (!a.invoice_id) { + throw new Error(`allocations[${i}]: invoice_id is required when kind=customer_invoice`) + } + if (a.supplier_invoice_id) { + throw new Error(`allocations[${i}]: supplier_invoice_id must not be set when kind=customer_invoice`) + } + } else if (a.kind === 'supplier_invoice') { + if (!a.supplier_invoice_id) { + throw new Error(`allocations[${i}]: supplier_invoice_id is required when kind=supplier_invoice`) + } + if (a.invoice_id) { + throw new Error(`allocations[${i}]: invoice_id must not be set when kind=supplier_invoice`) + } + } + } + + // Tenant-isolation pre-check (OWASP V8.2.1): verify every + // referenced invoice belongs to this company BEFORE staging. + // The RPC also re-checks this, but failing fast at the MCP + // layer gives the agent a clear error instead of an opaque + // BATCH_INVOICE_NOT_FOUND code at commit time. + const invoiceIds = allocations + .filter((a) => a.kind === 'customer_invoice') + .map((a) => a.invoice_id!) + const supplierInvoiceIds = allocations + .filter((a) => a.kind === 'supplier_invoice') + .map((a) => a.supplier_invoice_id!) + // Belt-and-suspenders (CC6.1): assert both count equality AND the + // missing-set is empty. The Supabase REST client de-dupes by PK so + // count >= unique input length is enough on its own, but the + // explicit guard prevents an undefined-row edge case in the JSON + // response from silently passing. + if (invoiceIds.length > 0) { + const uniqueIds = Array.from(new Set(invoiceIds)) + const { data: found } = await supabase + .from('invoices') + .select('id') + .in('id', uniqueIds) + .eq('company_id', companyId) + const foundRows = found ?? [] + const foundSet = new Set(foundRows.map((r) => r.id)) + const missing = uniqueIds.filter((id) => !foundSet.has(id)) + if (missing.length > 0 || foundRows.length !== uniqueIds.length) { + throw new Error(`Invoices not found for this company: ${missing.join(', ') || '(count mismatch)'}`) + } + } + if (supplierInvoiceIds.length > 0) { + const uniqueIds = Array.from(new Set(supplierInvoiceIds)) + const { data: found } = await supabase + .from('supplier_invoices') + .select('id') + .in('id', uniqueIds) + .eq('company_id', companyId) + const foundRows = found ?? [] + const foundSet = new Set(foundRows.map((r) => r.id)) + const missing = uniqueIds.filter((id) => !foundSet.has(id)) + if (missing.length > 0 || foundRows.length !== uniqueIds.length) { + throw new Error(`Supplier invoices not found for this company: ${missing.join(', ') || '(count mismatch)'}`) + } + } + + const totalAllocated = allocations.reduce((sum, a) => sum + a.amount, 0) + const txAbs = Math.abs(transaction.amount) + // 0.005 SEK tolerance is for floating-point equalisation only, + // NOT a rounding allowance. The RPC `match_batch_allocate` + // re-enforces the same guard (BATCH_AMOUNT_EXCEEDS_TX / + // BATCH_AMOUNT_BELOW_TX) authoritatively (per PR #607 round 3), + // and the verifikat lines balance exactly to the öre. + if (Math.abs(totalAllocated - txAbs) > 0.005) { + throw new Error( + `Allocations sum (${totalAllocated.toFixed(2)}) must equal transaction amount (${txAbs.toFixed(2)})` + ) + } + + const txDesc = transaction.merchant_name || transaction.description || transactionId + // Swedish plurals: kundfaktura → kundfakturor (not kundfakturaor). + // Same for leverantörsfaktura → leverantörsfakturor. + const noun = hasCustomer ? 'kundfaktura' : 'leverantörsfaktura' + const summary = `${allocations.length} ${allocations.length === 1 ? noun : `${noun.slice(0, -1)}or`}` + + return stagePendingOperation(supabase, companyId, userId, 'match_batch_allocate', + `Fördela: ${txDesc} → ${summary}`, + { transaction_id: transactionId, allocations }, + // GDPR Art.25: transaction_description is included in preview_data + // so the user can recognise the tx at approval time (merchant_name + // or fallback to bank description). Same trade-off documented on + // gnubok_link_transaction_to_journal_entry — it's the minimum + // signal needed for an informed approval. Counterparty-identifying + // invoice IDs stay in params (audit trail); they are NOT echoed + // back into preview_data beyond aggregate counts. + { + transaction_description: txDesc, + transaction_amount: transaction.amount, + transaction_currency: transaction.currency, + transaction_date: transaction.date, + allocations_count: allocations.length, + allocations_kind: hasCustomer ? 'customer_invoice' : 'supplier_invoice', + total_allocated: totalAllocated, + }, + actor, + { + description: 'After approval the combined verifikat is created and each invoice is advanced. Verify with gnubok_get_ar_ledger (customer) or gnubok_get_supplier_ledger.', + tool: hasCustomer ? 'gnubok_get_ar_ledger' : 'gnubok_get_supplier_ledger', + }, + { dateForPeriodCheck: transaction.date } + ) + }, + }, + + { + name: 'gnubok_link_transaction_to_journal_entry', + description: 'Link 1 bank tx to an already-posted verifikat (no new bokföring). Use when the user booked the affärshändelse manually. Pass invoice_id to also settle a kundfaktura. Stages.', + inputSchema: { + type: 'object', + additionalProperties: false, + properties: { + transaction_id: { type: 'string' }, + journal_entry_id: { type: 'string' }, + invoice_id: { type: 'string', description: 'Optional kundfaktura to settle alongside the link.' }, + }, + required: ['transaction_id', 'journal_entry_id'], + }, + outputSchema: STAGED_OPERATION_SCHEMA, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + async execute(args, companyId, userId, supabase, actor) { + const transactionId = args.transaction_id as string + const journalEntryId = args.journal_entry_id as string + const invoiceId = (args.invoice_id as string | undefined) ?? undefined + if (!transactionId || !journalEntryId) { + throw new Error('transaction_id and journal_entry_id are required') + } + + // Tenant-isolation + state pre-checks (OWASP V8.2.1). The commit + // handler re-validates authoritatively; failing fast at stage time + // gives the agent a clean error before the user is asked to approve. + const { data: tx, error: txError } = await supabase + .from('transactions') + .select('id, date, amount, currency, journal_entry_id, description, merchant_name') + .eq('id', transactionId) + .eq('company_id', companyId) + .maybeSingle() + if (txError || !tx) throw new Error('Transaction not found') + if (tx.journal_entry_id) { + throw new Error('Transaction is already linked to a journal entry') + } + + const { data: je, error: jeError } = await supabase + .from('journal_entries') + .select('id, status, voucher_series, voucher_number, entry_date') + .eq('id', journalEntryId) + .eq('company_id', companyId) + .maybeSingle() + if (jeError || !je) throw new Error('Journal entry not found') + if (je.status !== 'posted') { + throw new Error(`Journal entry must be posted (status=${je.status})`) + } + + let invoicePreview: { invoice_number: string | null; remaining: number | null; will_be_fully_paid: boolean } | null = null + if (invoiceId) { + // GDPR Art.5(1)(c): only the columns the preview displays. We need + // remaining_amount for the will-be-fully-paid math, invoice_number + // for the staged-op title, and currency so we can fast-fail the + // mismatch before the user is asked to approve (the commit handler + // re-checks authoritatively via LINK_TX_INVOICE_CURRENCY_MISMATCH). + const { data: invoice, error: invError } = await supabase + .from('invoices') + .select('id, invoice_number, status, currency, remaining_amount') + .eq('id', invoiceId) + .eq('company_id', companyId) + .maybeSingle() + if (invError || !invoice) throw new Error('Invoice not found') + if (!['sent', 'overdue', 'partially_paid'].includes(invoice.status)) { + throw new Error(`Invoice is not in an open state (status=${invoice.status})`) + } + // Currency-mismatch pre-stage check (swedish-compliance PR #614 + // round 8). The link-to-existing-voucher contract requires tx and + // invoice currency to match — cross-currency settlement must go + // through the match-invoice flow that posts 3960/7960 FX-diff + // lines via buildInvoicePaymentClearingLines. Failing fast here + // saves the user an approval round-trip. + if (tx.currency !== invoice.currency) { + throw new Error( + `Transaction currency (${tx.currency}) does not match invoice currency (${invoice.currency}). Cross-currency settlement must go through the match-invoice flow.` + ) + } + // Explicit NaN guard (A.8.28): silently treating a malformed numeric + // column as 0 would let a bogus preview pass to the user. The DB + // column is NUMERIC NOT NULL on remaining_amount once status leaves + // 'draft', so a NaN here means something upstream is broken. + const remaining = Number(invoice.remaining_amount) + const txAmount = Number(tx.amount) + if (!Number.isFinite(remaining) || !Number.isFinite(txAmount)) { + throw new Error('Invoice remaining_amount or tx amount is not a finite number') + } + const newRemaining = Math.max(0, Math.round((remaining - txAmount) * 100) / 100) + invoicePreview = { + invoice_number: (invoice.invoice_number as string | null) ?? null, + remaining: newRemaining, + will_be_fully_paid: newRemaining <= 0, + } + } + + // Period-lock check uses the LATER of tx.date and je.entry_date so a + // tx in an open period attached to a verifikat in a locked period + // surfaces the period_status envelope correctly. Mirrors the same + // logic in gnubok_bulk_book_transactions. + const txDate = tx.date as string + const jeDate = je.entry_date as string + const periodCheckDate = jeDate > txDate ? jeDate : txDate + + // Centralised verifikat-label format (formatVoucherLabel) — keeps the + // MCP staging preview and the committed audit-trail label byte-identical, + // so BFL 5 kap 7§ traceability holds even if the format ever changes. + const voucherLabel = formatVoucherLabel( + je.voucher_series as string | null, + je.voucher_number as number | null, + ) + const txDesc = (tx.merchant_name as string | null) || (tx.description as string | null) || transactionId.slice(0, 8) + + return stagePendingOperation( + supabase, + companyId, + userId, + 'link_transaction_journal_entry', + invoiceId + ? `Länka ${txDesc} → verifikat ${voucherLabel} + faktura ${invoicePreview?.invoice_number ?? invoiceId.slice(0, 8)}` + : `Länka ${txDesc} → verifikat ${voucherLabel}`, + { transaction_id: transactionId, journal_entry_id: journalEntryId, invoice_id: invoiceId ?? null }, + // GDPR Art.25: voucher_description is intentionally OMITTED from + // preview_data — it can carry free-text merchant/counterparty PII + // and the voucher_label alone uniquely identifies the verifikat for + // the user's approval decision. Same reasoning as the per-tx + // description handling elsewhere in this file. + { + transaction_description: txDesc, + transaction_amount: tx.amount, + transaction_currency: tx.currency, + transaction_date: txDate, + voucher_label: voucherLabel, + voucher_date: jeDate, + invoice_id: invoiceId ?? null, + invoice_number: invoicePreview?.invoice_number ?? null, + invoice_remaining_after: invoicePreview?.remaining ?? null, + will_be_fully_paid: invoicePreview?.will_be_fully_paid ?? null, + }, + actor, + { + description: invoiceId + ? 'After approval the tx attaches to the existing verifikat and the invoice flips to paid/partially_paid. No new bokföring. Verify with gnubok_get_ar_ledger.' + : 'After approval the tx attaches to the existing verifikat. No new bokföring. Verify with gnubok_query_journal.', + tool: invoiceId ? 'gnubok_get_ar_ledger' : 'gnubok_query_journal', + }, + { dateForPeriodCheck: periodCheckDate } + ) + }, + }, + + { + name: 'gnubok_bulk_book_transactions', + description: 'Bulk-book N bank txs on the same date into 1 samlingsverifikat (BFL 5 kap 6§). Two paths: link N txs to an existing posted verifikat, or create a new verifikat from caller-supplied lines. All txs must share date + direction. Docs on the txs inherit. Stages.', + inputSchema: { + type: 'object', + additionalProperties: false, + properties: { + tx_ids: { type: 'array', minItems: 1, maxItems: 200, items: { type: 'string' } }, + existing_journal_entry_id: { type: 'string' }, + new_entry: { + type: 'object', + additionalProperties: false, + properties: { + description: { type: 'string', minLength: 1, maxLength: 500 }, + lines: { + type: 'array', + minItems: 2, + maxItems: 200, + items: { + type: 'object', + additionalProperties: false, + properties: { + account_number: { type: 'string', pattern: '^\\d{4}$' }, + debit_amount: { type: 'number', minimum: 0 }, + credit_amount: { type: 'number', minimum: 0 }, + currency: { type: 'string', minLength: 3, maxLength: 3 }, + line_description: { type: 'string', maxLength: 200 }, + }, + required: ['account_number', 'debit_amount', 'credit_amount', 'currency'], + }, + }, + }, + required: ['description', 'lines'], + }, + }, + required: ['tx_ids'], + }, + outputSchema: STAGED_OPERATION_SCHEMA, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + async execute(args, companyId, userId, supabase, actor) { + const txIds = args.tx_ids as string[] + const existingJeId = (args.existing_journal_entry_id as string | undefined) ?? null + const newEntry = (args.new_entry as { description: string; lines: unknown[] } | undefined) ?? null + if (!Array.isArray(txIds) || txIds.length === 0) throw new Error('tx_ids is required (non-empty)') + if ((existingJeId == null) === (newEntry == null)) { + throw new Error('Provide exactly one of existing_journal_entry_id or new_entry') + } + + // Balance pre-check on the create-new path (compliance-swarm V2.3 + // / swedish-compliance). The RPC also rejects with + // BULK_BOOK_UNBALANCED, but failing fast here lets the agent get + // a clear error before staging is even attempted. + // The 0.005 tolerance is for floating-point equalisation only, + // NOT a rounding allowance per BFL 5 kap 4–5§. The RPC enforces + // exact balance to the öre on insert. + if (newEntry) { + const lines = (newEntry as { lines?: Array<{ debit_amount?: number; credit_amount?: number }> }).lines + if (Array.isArray(lines) && lines.length > 0) { + // Reject NaN / non-finite values explicitly (A.8.28). + // `Number(x) || 0` silently treats NaN as 0; that would let + // a malformed amount pass the balance check by accident. + // Round-8 addition: reject debit=0 && credit=0 "ghost" lines + // (BFL 5 kap 6§ — every line must represent a real + // bokföringspost with a non-zero amount). + for (const [i, l] of lines.entries()) { + const d = Number(l.debit_amount) + const c = Number(l.credit_amount) + if (!Number.isFinite(d) || !Number.isFinite(c)) { + throw new Error(`new_entry.lines[${i}]: debit_amount and credit_amount must be finite numbers`) + } + if (d === 0 && c === 0) { + throw new Error(`new_entry.lines[${i}]: debit_amount and credit_amount cannot both be zero (BFL 5 kap 6§)`) + } + } + const totalDebit = lines.reduce((s, l) => s + Number(l.debit_amount), 0) + const totalCredit = lines.reduce((s, l) => s + Number(l.credit_amount), 0) + if (Math.abs(totalDebit - totalCredit) > 0.005) { + throw new Error( + `new_entry.lines must balance — debits=${totalDebit.toFixed(2)} credits=${totalCredit.toFixed(2)}` + ) + } + } + } + + const { data: txs, error: txError } = await supabase + .from('transactions') + .select('id, amount, currency, date, journal_entry_id') + .in('id', txIds) + .eq('company_id', companyId) + if (txError || !txs || txs.length !== txIds.length) { + throw new Error('One or more transactions not found') + } + const booked = txs.find((t) => t.journal_entry_id != null) + if (booked) throw new Error(`Transaction ${booked.id} is already booked`) + const dates = new Set(txs.map((t) => t.date)) + if (dates.size > 1) throw new Error('All transactions must share the same date') + // Reject zero-amount txs (round-8 / A.8.28). The direction computation + // below treats amount === 0 as 'expense' (amount > 0 is false), which + // would then mis-classify a real income tx in the same batch. Mirrors + // the explicit zero-amount guard in gnubok_match_batch_allocate. + const zeroAmountTx = txs.find((t) => t.amount === 0) + if (zeroAmountTx) throw new Error(`Transaction ${zeroAmountTx.id} has zero amount`) + const direction = txs[0]!.amount > 0 ? 'income' : 'expense' + if (txs.some((t) => (direction === 'income' ? t.amount < 0 : t.amount > 0))) { + throw new Error('All transactions must share the same direction (all income or all expense)') + } + // Currency homogeneity (swedish-compliance): a samlingsverifikat + // combining e.g. SEK + EUR txs without explicit FX lines violates + // BFL 5 kap 2§ (alla belopp skall uttryckas i svenska kronor) + // read together with the valutakurs rules in BFL 5 kap 6§. + // Cross-currency batches should go through gnubok_match_batch_allocate + // (which handles the FX diff on 7960/3960). Reject mixed currencies here. + const currencies = new Set(txs.map((t) => t.currency)) + if (currencies.size > 1) { + // Route the agent to the cross-currency-capable tool rather + // than letting it retry with hand-built FX lines. + throw new Error( + 'All transactions must share the same currency. For cross-currency allocations, use gnubok_match_batch_allocate (which handles the FX diff on 7960/3960).' + ) + } + + const txSum = txs.reduce((s, t) => s + t.amount, 0) + const txDate = txs[0]!.date as string + + // For link-existing branch, also fetch the target JE and use the + // LATER of tx_date and JE.entry_date for period-lock check + // (swedish-compliance): otherwise a tx in an open period could be + // attached to a verifikat in a closed period and the guard + // would miss it. Same query also enforces tenant isolation on + // the JE (OWASP V8.2.1) before the RPC sees the ID. + let periodCheckDate = txDate + if (existingJeId) { + const { data: je, error: jeError } = await supabase + .from('journal_entries') + .select('id, entry_date, status') + .eq('id', existingJeId) + .eq('company_id', companyId) + .maybeSingle() + if (jeError || !je) { + throw new Error('Existing journal entry not found for this company') + } + if (je.status !== 'posted') { + throw new Error(`Existing journal entry must be posted (status=${je.status})`) + } + // Pass the later date so the period-lock guard fires on whichever + // side is in a locked/closed period. + periodCheckDate = (je.entry_date as string) > txDate ? (je.entry_date as string) : txDate + } + + return stagePendingOperation(supabase, companyId, userId, 'bulk_book_transactions', + existingJeId + ? `Länka ${txIds.length} transaktioner till verifikat (${txDate})` + : `Samlingsverifikation: ${txIds.length} transaktioner ${txDate}`, + { + tx_ids: txIds, + existing_journal_entry_id: existingJeId, + new_entry: newEntry, + }, + // GDPR Art.25: preview_data carries only aggregate counts + the + // shared date/direction — no per-tx descriptions, no per-line + // descriptions, no counterparty IDs. The user-facing approval + // dialog reconstructs detail from the tx_ids list at render time + // rather than persisting denormalized PII here. Same privacy-by- + // design rationale as gnubok_link_transaction_to_journal_entry. + { + tx_count: txIds.length, + tx_date: txDate, + tx_sum: txSum, + direction, + mode: existingJeId ? 'link_existing' : 'create_new', + }, + actor, + { + description: 'After approval the verifikat carries the combined business event. Verify with gnubok_query_journal or gnubok_get_reconciliation_status.', + tool: 'gnubok_query_journal', + }, + { dateForPeriodCheck: periodCheckDate } + ) + }, + }, + { name: 'gnubok_find_voucher_candidates_for_invoice', description: 'List posted verifikat that credit kundfordran (1510) and could be the payment for this invoice. Use before gnubok_link_invoice_to_voucher when the user wants to mark a faktura paid against an existing verifikation (no new bokföring).', diff --git a/lib/auth/api-keys.ts b/lib/auth/api-keys.ts index 0aaa5374..9a817522 100644 --- a/lib/auth/api-keys.ts +++ b/lib/auth/api-keys.ts @@ -144,6 +144,9 @@ export const TOOL_SCOPE_MAP: Record = { gnubok_get_counterparty_templates: 'transactions:read', gnubok_suggest_categories: 'transactions:read', gnubok_match_transaction_to_invoice: 'transactions:write', + gnubok_link_transaction_to_journal_entry: 'transactions:write', + gnubok_match_batch_allocate: 'transactions:write', + gnubok_bulk_book_transactions: 'transactions:write', gnubok_auto_match_period: 'transactions:write', // Customers gnubok_list_customers: 'customers:read', diff --git a/lib/bookkeeping/__tests__/invoice-payment-lines.test.ts b/lib/bookkeeping/__tests__/invoice-payment-lines.test.ts new file mode 100644 index 00000000..5f2b8fb3 --- /dev/null +++ b/lib/bookkeeping/__tests__/invoice-payment-lines.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect } from 'vitest' +import { buildInvoicePaymentClearingLines } from '../invoice-payment-lines' + +describe('buildInvoicePaymentClearingLines', () => { + describe('same currency (SEK invoice + SEK tx)', () => { + it('full payment: 1930 = 1510 = tx amount, no FX line', () => { + const result = buildInvoicePaymentClearingLines( + { amount: 1250, amount_sek: null, currency: 'SEK', exchange_rate: null }, + { currency: 'SEK', exchange_rate: null, remaining_amount: 1250, total: 1250, paid_amount: 0 }, + 'Inbetalning kundfaktura', + ) + expect(result.bankSek).toBe(1250) + expect(result.arSek).toBe(1250) + expect(result.fxDiffSek).toBe(0) + expect(result.lines).toHaveLength(2) + expect(result.lines[0]).toMatchObject({ account_number: '1930', debit_amount: 1250, credit_amount: 0 }) + expect(result.lines[1]).toMatchObject({ account_number: '1510', debit_amount: 0, credit_amount: 1250 }) + }) + + it('partial payment: 1930 = 1510 = tx amount (the actual SEK received)', () => { + // Scenario from the user: invoice 1 250, prior 230 partial, now 1 000 hits. + // 1930/1510 must equal 1 000 (not 1 250). After this verifikat the invoice + // remaining is 20 SEK and status stays partially_paid (handled by the + // caller, not this helper). + const result = buildInvoicePaymentClearingLines( + { amount: 1000, amount_sek: null, currency: 'SEK', exchange_rate: null }, + { currency: 'SEK', exchange_rate: null, remaining_amount: 1020, total: 1250, paid_amount: 230 }, + 'Inbetalning kundfaktura', + ) + expect(result.bankSek).toBe(1000) + expect(result.arSek).toBe(1000) + expect(result.fxDiffSek).toBe(0) + expect(result.lines).toHaveLength(2) + }) + + it('expense tx (negative amount) treats absolute SEK value', () => { + const result = buildInvoicePaymentClearingLines( + { amount: -500, amount_sek: null, currency: 'SEK', exchange_rate: null }, + { currency: 'SEK', exchange_rate: null, remaining_amount: 500, total: 500, paid_amount: 0 }, + 'desc', + ) + expect(result.bankSek).toBe(500) + expect(result.arSek).toBe(500) + }) + }) + + describe('cross currency (USD invoice + SEK tx)', () => { + it('bank received MORE SEK than booked: gain to 3960', () => { + // Invoice 100 USD booked at 10.00 (1000 SEK on 1510) + // Bank receives 1100 SEK (rate moved to 11.00 by payment date) + // FX gain = 100 SEK → 3960 credit + const result = buildInvoicePaymentClearingLines( + { amount: 1100, amount_sek: null, currency: 'SEK', exchange_rate: null }, + { currency: 'USD', exchange_rate: 10, remaining_amount: 100, total: 100, paid_amount: 0 }, + 'Inbetalning kundfaktura', + ) + expect(result.bankSek).toBe(1100) + expect(result.arSek).toBe(1000) + expect(result.fxDiffSek).toBe(-100) + expect(result.lines).toHaveLength(3) + expect(result.lines[0]).toMatchObject({ account_number: '1930', debit_amount: 1100 }) + expect(result.lines[1]).toMatchObject({ account_number: '1510', credit_amount: 1000 }) + expect(result.lines[2]).toMatchObject({ + account_number: '3960', + credit_amount: 100, + line_description: 'Valutakursvinst', + }) + // Balanced + const debit = result.lines.reduce((s, l) => s + l.debit_amount, 0) + const credit = result.lines.reduce((s, l) => s + l.credit_amount, 0) + expect(Math.round((debit - credit) * 100)).toBe(0) + }) + + it('ambiguous loss scenario (bank < SEK booked) is treated as partial — defers FX', () => { + // Invoice 100 USD booked at 10.50 (1050 SEK on 1510) + // Bank receives 1000 SEK — could be (a) partial payment that didn't + // cover the full USD amount, or (b) full payment at a worse FX rate. + // From a SEK-only bank tx we can't distinguish; defaulting to "partial" + // is the safer choice (no premature 1510 zeroing). If the user knows + // it's actually a full-clear-with-loss, they use mark-paid with an + // explicit exchange_rate_difference instead. + const result = buildInvoicePaymentClearingLines( + { amount: 1000, amount_sek: null, currency: 'SEK', exchange_rate: null }, + { currency: 'USD', exchange_rate: 10.5, remaining_amount: 100, total: 100, paid_amount: 0 }, + 'Inbetalning kundfaktura', + ) + expect(result.bankSek).toBe(1000) + expect(result.arSek).toBe(1000) + expect(result.fxDiffSek).toBe(0) + expect(result.lines).toHaveLength(2) + }) + + it('partial cross-currency payment defers FX: bank-leg = AR-leg = bankSek, no 3960/7960 line', () => { + // Invoice 140 USD @ 15.30 (2142 SEK booked on 1510) + // Bank receives 230 SEK — way below the 2142 remaining. If we credited + // the full 2142 to 1510 we'd zero the GL balance while the invoice row + // stayed partially_paid (BFL 5 kap 4–5§ violation). Defer FX to the + // final settlement that closes the invoice. + const result = buildInvoicePaymentClearingLines( + { amount: 230, amount_sek: null, currency: 'SEK', exchange_rate: null }, + { currency: 'USD', exchange_rate: 15.3, remaining_amount: 140, total: 140, paid_amount: 0 }, + 'Delbetalning kundfaktura', + ) + expect(result.bankSek).toBe(230) + expect(result.arSek).toBe(230) + expect(result.fxDiffSek).toBe(0) + expect(result.lines).toHaveLength(2) + expect(result.lines[0]).toMatchObject({ account_number: '1930', debit_amount: 230 }) + expect(result.lines[1]).toMatchObject({ account_number: '1510', credit_amount: 230 }) + }) + + it('exact match: no FX line', () => { + // Invoice 100 USD @ 10.00 (1000 SEK booked); bank receives 1000 SEK + const result = buildInvoicePaymentClearingLines( + { amount: 1000, amount_sek: null, currency: 'SEK', exchange_rate: null }, + { currency: 'USD', exchange_rate: 10, remaining_amount: 100, total: 100, paid_amount: 0 }, + 'desc', + ) + expect(result.bankSek).toBe(1000) + expect(result.arSek).toBe(1000) + expect(result.fxDiffSek).toBe(0) + expect(result.lines).toHaveLength(2) + }) + + it('sub-öre FX diff is suppressed (within floating-point tolerance)', () => { + // 100.001 USD × 10 = 1000.01, but bookkeeping rounds at the line level + const result = buildInvoicePaymentClearingLines( + { amount: 1000, amount_sek: null, currency: 'SEK', exchange_rate: null }, + { + currency: 'USD', + exchange_rate: 10, + remaining_amount: 100.0001, + total: 100.0001, + paid_amount: 0, + }, + 'desc', + ) + expect(Math.abs(result.fxDiffSek)).toBeLessThanOrEqual(0.005) + expect(result.lines).toHaveLength(2) + }) + }) + + describe('cross currency (USD invoice + USD tx)', () => { + it('uses tx amount_sek for the bank-leg when populated', () => { + // USD-denominated bank account paying a USD invoice — ingest converts + // tx → SEK using the bank-date rate. + const result = buildInvoicePaymentClearingLines( + { amount: 100, amount_sek: 1100, currency: 'USD', exchange_rate: 11 }, + { currency: 'USD', exchange_rate: 10, remaining_amount: 100, total: 100, paid_amount: 0 }, + 'desc', + ) + // Same-currency path: bank-leg uses resolveSekAmount (which honours + // amount_sek), AR-leg equals bank-leg, no FX diff line. + expect(result.bankSek).toBe(1100) + expect(result.arSek).toBe(1100) + expect(result.fxDiffSek).toBe(0) + }) + }) +}) diff --git a/lib/bookkeeping/invoice-payment-lines.ts b/lib/bookkeeping/invoice-payment-lines.ts new file mode 100644 index 00000000..58b68b6e --- /dev/null +++ b/lib/bookkeeping/invoice-payment-lines.ts @@ -0,0 +1,207 @@ +/** + * Builds the journal-entry lines for the clearing entry that closes (fully + * or partially) a customer invoice against an actual bank transaction. + * + * The lines built here are the "Inbetalning kundfaktura" path under + * faktureringsmetoden (accrual) — Dr 1930 / Cr 1510, with a 3960/7960 + * FX-diff line when the invoice and the bank tx are in different currencies. + * + * Shared between: + * - GET /api/transactions/[id]/match-invoice/preview (read-only, drives + * the dialog the user confirms against) + * - POST /api/transactions/[id]/match-invoice (the commit path) + * + * Single source of truth so the preview and the committed verifikat are + * byte-identical. Earlier the two diverged on the cross-currency math — + * the preview ran `resolveSekAmount(tx.amount, null, INV.currency, INV.rate)`, + * treating the SEK tx number as if it were in the invoice's currency and + * multiplying by the invoice's stored rate. That produced a fictitious + * bank-leg amount and silently dropped the FX gain/loss. + * + * # Customer-invoice only + * + * This helper is the CUSTOMER side (kundfaktura): AR account 1510, FX gain + * 3960 (valutakursvinster rörelsefordringar), FX loss 7960 + * (valutakursförluster rörelsefordringar), bank-leg = Dr. Supplier-side + * settlement has the opposite DR/CR polarity (Cr 1930 / Dr 2440-series) and + * a different account taxonomy; it lives in the match_batch_allocate RPC, + * not here. Do not call this helper from supplier-invoice flows. + * + * # Currency model + * + * tx.currency — currency of the bank tx (almost always SEK) + * tx.amount — amount in tx.currency + * tx.exchange_rate — populated at ingest only when tx.currency != SEK + * tx.amount_sek — pre-computed SEK at ingest for non-SEK tx + * invoice.currency — currency the invoice was issued in + * invoice.exchange_rate — the rate at which AR was originally booked on 1510 + * + * Bank-leg (1930) = always the actual SEK that hit the bank. + * AR-leg (1510) = the SEK value of the customer-debt reduction at the + * INVOICE's stored rate (capped to bankSek on partials + * to keep 1510 in sync with invoice.remaining_amount). + * FX diff = (AR-leg SEK − Bank-leg SEK); sign drives 3960 vs 7960. + * Per BFL 5 kap 4–5§ every verifikat must balance to the + * öre; the FX diff line is what makes the cross-currency + * verifikat balance. Only emitted when the bank tx fully + * clears the invoice's remaining — partials defer the + * FX adjustment to the final settlement to avoid + * prematurely zeroing 1510 while the AR row still says + * partially_paid. + */ +import type { CreateJournalEntryLineInput } from '@/types' +import { resolveSekAmount } from './currency-utils' + +const TWO_DP = (n: number): number => Math.round(n * 100) / 100 + +export interface PaymentClearingTx { + amount: number + amount_sek: number | null + currency: string + exchange_rate: number | null +} + +export interface PaymentClearingInvoice { + currency: string + exchange_rate: number | null + remaining_amount: number | null + total: number + paid_amount: number | null +} + +export interface PaymentClearingLines { + /** Actual SEK that hit the bank. The 1930 debit. */ + bankSek: number + /** SEK value of the AR reduction at the invoice's stored rate. The 1510 credit. */ + arSek: number + /** + * fxDiffSek = arSek − bankSek (this orientation matches what's needed to + * make the verifikat balance: positive value goes Dr 7960, negative + * value goes Cr 3960). + * + * Sign reading (note this is the OPPOSITE of an intuitive "profit" + * orientation — the value here is a balance-adjustment magnitude, not a + * P&L number, because AR is the side being cleared): + * positive → bank received FEWER SEK than AR was booked at → kursförlust → 7960 Dr + * negative → bank received MORE SEK than AR was booked at → kursvinst → 3960 Cr + * |value| ≤ 0.005 → no FX diff line emitted (floating-point tolerance, + * NOT a rounding allowance per BFL 5 kap 4–5§) + * + * If you want an intuitive "gain" number for UI display, use + * `bankSek - arSek` (negate this field). Do not consume the raw sign + * in caller logic without reading this paragraph. + */ + fxDiffSek: number + lines: CreateJournalEntryLineInput[] +} + +/** + * Build the verifikat lines for a customer-invoice payment matched against + * a bank tx. Pure — no DB calls. Caller decides how to persist. + * + * For same-currency invoices the FX diff is always 0 and only the two + * bank/AR lines are returned. For cross-currency, a 3960 or 7960 line is + * appended to balance the verifikat. Per the contract documented in this + * file, when the tx is cross-currency we assume the bank tx fully clears + * the invoice's remaining amount and book the full FX diff to one + * verifikat — same pattern as the match_batch_allocate RPC, which is the + * only other code path that posts FX diffs on customer-invoice + * settlements. + */ +export function buildInvoicePaymentClearingLines( + tx: PaymentClearingTx, + invoice: PaymentClearingInvoice, + description: string, +): PaymentClearingLines { + // Bank-leg: actual SEK that hit the bank. resolveSekAmount returns the + // raw amount for SEK txs and amount * exchange_rate for foreign txs + // (preferring the pre-computed amount_sek when set). + const bankSek = TWO_DP( + resolveSekAmount( + Math.abs(tx.amount), + tx.amount_sek != null ? Math.abs(tx.amount_sek) : null, + tx.currency, + tx.exchange_rate, + ), + ) + + const sameCurrency = tx.currency === invoice.currency + const invoiceIsForeign = invoice.currency !== 'SEK' + + let arSek: number + let fxDiffSek: number + + if (sameCurrency || !invoiceIsForeign) { + // Same currency (or SEK invoice paid by SEK tx): the customer-debt + // reduction equals what hit the bank. No FX diff possible. + arSek = bankSek + fxDiffSek = 0 + } else { + // Cross-currency: AR is denominated in invoice.currency and was + // booked on 1510 at invoice.exchange_rate. The remaining-amount × rate + // is the SEK currently sitting on 1510 for this invoice. + const invRemainingForeign = invoice.remaining_amount ?? invoice.total - (invoice.paid_amount ?? 0) + const invRate = invoice.exchange_rate ?? 1 + const arSekFullRemaining = TWO_DP(invRemainingForeign * invRate) + + // Branch on whether the bank tx fully clears (or over-pays) the + // remaining 1510 balance. Partial cross-currency must NOT credit the + // full remaining — that would zero 1510 in the GL while the invoice + // row stays at status=partially_paid, leaving the ledger inconsistent + // with the AR sub-ledger and over-stating FX gain/loss for the period. + // Defer the FX adjustment to the final settlement (when bank-SEK + // covers the full remaining), per BFL 5 kap 4–5§ "verifikat must + // reflect the actual affärshändelse". + if (bankSek >= arSekFullRemaining - 0.005) { + // Full payment of remaining (or overpay): clear AR and book FX diff. + arSek = arSekFullRemaining + fxDiffSek = TWO_DP(arSek - bankSek) + } else { + // Partial cross-currency: book 1930 / 1510 at bankSek (the actual + // SEK that moved), no FX line. The deferred FX diff lands on the + // verifikat that finally closes the invoice. + arSek = bankSek + fxDiffSek = 0 + } + } + + const lines: CreateJournalEntryLineInput[] = [ + { + account_number: '1930', + debit_amount: bankSek, + credit_amount: 0, + line_description: description, + }, + { + account_number: '1510', + debit_amount: 0, + credit_amount: arSek, + line_description: description, + }, + ] + + // Tolerance of 0.005 SEK is for floating-point equalisation only, not a + // rounding allowance per BFL 5 kap 4–5§. Same rationale as the balance + // pre-check in gnubok_bulk_book_transactions. + if (Math.abs(fxDiffSek) > 0.005) { + if (fxDiffSek > 0) { + // arSek > bankSek → bank received fewer SEK than booked. Loss → 7960 debit. + lines.push({ + account_number: '7960', + debit_amount: Math.abs(fxDiffSek), + credit_amount: 0, + line_description: 'Valutakursförlust', + }) + } else { + // bankSek > arSek → bank received more SEK than booked. Gain → 3960 credit. + lines.push({ + account_number: '3960', + debit_amount: 0, + credit_amount: Math.abs(fxDiffSek), + line_description: 'Valutakursvinst', + }) + } + } + + return { bankSek, arSek, fxDiffSek, lines } +} diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index 35685a9a..1509d367 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -346,6 +346,13 @@ const MATCH_INVOICE: Record = { message_sv: 'Endast fakturor kan matchas mot en transaktion. Proforma och följesedel saknar momsskyldighet.', message_en: 'Only invoices may be matched to a transaction; proforma and delivery notes have no VAT obligation.', }, + MATCH_INVOICE_CURRENCY_MISMATCH: { + httpStatus: 400, + message_sv: + 'Transaktionens och fakturans valuta måste vara samma. För valutaomräkning, använd flerfaktura-matchningen som hanterar valutakursdifferenser på 3960/7960.', + message_en: + 'Transaction and invoice currency must match. For cross-currency settlement, use the multi-invoice allocation flow which posts FX-diff lines on 3960/7960.', + }, MATCH_INVOICE_ALREADY_PAID: { httpStatus: 409, message_sv: 'Fakturan har redan slutbetalats av en annan förfrågan.', @@ -434,6 +441,13 @@ const LINK_TX_JE: Record = { message_sv: 'Fakturan ändrades samtidigt. Försök igen.', message_en: 'Invoice status changed concurrently. Retry the request.', }, + LINK_TX_INVOICE_CURRENCY_MISMATCH: { + httpStatus: 400, + message_sv: + 'Transaktionens och fakturans valuta måste vara samma för att länka till en befintlig verifikation. Använd matchningsdialogen för valutaomräkning.', + message_en: + 'Transaction and invoice currency must match to link to an existing voucher. Use the match-invoice flow for cross-currency settlement.', + }, } const MATCH_SI: Record = { diff --git a/lib/pending-operations/__tests__/link-transaction-journal-entry.test.ts b/lib/pending-operations/__tests__/link-transaction-journal-entry.test.ts new file mode 100644 index 00000000..73dcf734 --- /dev/null +++ b/lib/pending-operations/__tests__/link-transaction-journal-entry.test.ts @@ -0,0 +1,263 @@ +/** + * Unit tests for commitLinkTransactionJournalEntry. + * Driven through the public commitPendingOperation dispatcher. + * + * The MCP tool gnubok_link_transaction_to_journal_entry stages a + * 'link_transaction_journal_entry' pending_operation; this dispatcher + * picks it up, the executor delegates to the shared service in + * lib/transactions/link-journal-entry.ts. The service is also covered + * indirectly by the REST route test + * app/api/transactions/[id]/link-journal-entry/__tests__/route.test.ts — + * these tests focus on the dispatcher/executor wiring. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { eventBus } from '@/lib/events/bus' +import { createQueuedMockSupabase, makeTransaction, makeInvoice } from '@/tests/helpers' +import type { PendingOperation } from '@/types' + +vi.mock('@/lib/invoices/match-log', () => ({ + logMatchEvent: vi.fn(), +})) + +import { commitPendingOperation } from '../commit' + +const TX_UUID = '550e8400-e29b-41d4-a716-446655440000' +const JE_UUID = '550e8400-e29b-41d4-a716-446655440001' +const INV_UUID = '550e8400-e29b-41d4-a716-446655440002' + +function makePendingOp(overrides: Partial): PendingOperation { + return { + id: 'op-1', + user_id: 'user-1', + company_id: 'company-1', + operation_type: 'link_transaction_journal_entry', + status: 'pending', + title: 'test', + params: {}, + preview_data: {}, + result_data: null, + actor_type: 'user', + actor_id: null, + actor_label: null, + risk_level: 'medium', + created_at: '2026-05-30T00:00:00Z', + resolved_at: null, + updated_at: '2026-05-30T00:00:00Z', + ...overrides, + } as PendingOperation +} + +beforeEach(() => { + vi.clearAllMocks() + eventBus.clear() +}) + +describe('commitPendingOperation: link_transaction_journal_entry', () => { + it('returns 400 when transaction_id is missing', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ data: null, error: null }) // dispatcher's reject update + + const op = makePendingOp({ params: { journal_entry_id: JE_UUID } }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('failed') + expect(result.http_status).toBe(400) + expect(result.error).toMatch(/transaction_id/i) + }) + + it('returns 404 when transaction not found', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ data: null, error: { message: 'not found' } }) // tx fetch + enqueue({ data: null, error: null }) // dispatcher's auto-reject update + + const op = makePendingOp({ + params: { transaction_id: TX_UUID, journal_entry_id: JE_UUID }, + }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + // 404 is auto-rejected by the dispatcher (so the user can re-stage with + // adjusted inputs); the originating http_status is preserved on the + // result for the caller to inspect. + expect(result.status).toBe('rejected') + expect(result.http_status).toBe(404) + }) + + it('returns 400 when transaction already linked', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ + data: makeTransaction({ id: TX_UUID, journal_entry_id: 'je-prior' }), + error: null, + }) + enqueue({ data: null, error: null }) // dispatcher's reject update + + const op = makePendingOp({ + params: { transaction_id: TX_UUID, journal_entry_id: JE_UUID }, + }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('failed') + expect(result.http_status).toBe(400) + expect(result.error).toMatch(/already linked/i) + }) + + it('returns 400 when JE is not posted', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ + data: makeTransaction({ id: TX_UUID, journal_entry_id: null }), + error: null, + }) + enqueue({ + data: { + id: JE_UUID, + status: 'draft', + voucher_series: 'A', + voucher_number: 1, + entry_date: '2026-05-15', + }, + error: null, + }) + enqueue({ data: null, error: null }) // dispatcher's reject update + + const op = makePendingOp({ + params: { transaction_id: TX_UUID, journal_entry_id: JE_UUID }, + }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('failed') + expect(result.http_status).toBe(400) + expect(result.error).toMatch(/posted/i) + }) + + it('happy path: links tx without invoice, no new bookkeeping created', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ + data: makeTransaction({ id: TX_UUID, journal_entry_id: null, amount: 1000, date: '2026-05-15' }), + error: null, + }) + enqueue({ + data: { + id: JE_UUID, + status: 'posted', + voucher_series: 'A', + voucher_number: 12, + entry_date: '2026-05-15', + }, + error: null, + }) + enqueue({ data: null, error: null }) // tx UPDATE + enqueue({ data: null, error: null }) // logMatchEvent insert + enqueue({ data: null, error: null }) // dispatcher commit update + + const op = makePendingOp({ + params: { transaction_id: TX_UUID, journal_entry_id: JE_UUID }, + }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('committed') + expect(result.data).toMatchObject({ + transaction_id: TX_UUID, + journal_entry_id: JE_UUID, + voucher_label: 'A-12', + invoice_id: null, + invoice_status: null, + }) + }) + + it('happy path with invoice: links tx, flips invoice to paid', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ + data: makeTransaction({ id: TX_UUID, journal_entry_id: null, amount: 1000, date: '2026-05-15' }), + error: null, + }) + enqueue({ + data: { + id: JE_UUID, + status: 'posted', + voucher_series: 'A', + voucher_number: 1, + entry_date: '2026-05-15', + }, + error: null, + }) + enqueue({ + data: makeInvoice({ + id: INV_UUID, + status: 'sent', + total: 1000, + remaining_amount: 1000, + paid_amount: 0, + currency: 'SEK', + }), + error: null, + }) + enqueue({ data: null, error: null }) // tx UPDATE + enqueue({ data: [{ id: INV_UUID }], error: null }) // optimistic-lock invoice UPDATE + enqueue({ data: null, error: null }) // invoice_payments INSERT + enqueue({ data: null, error: null }) // logMatchEvent insert + enqueue({ data: null, error: null }) // dispatcher commit update + + const op = makePendingOp({ + params: { + transaction_id: TX_UUID, + journal_entry_id: JE_UUID, + invoice_id: INV_UUID, + }, + }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('committed') + expect(result.data).toMatchObject({ + invoice_id: INV_UUID, + invoice_status: 'paid', + paid_amount: 1000, + remaining_amount: 0, + }) + }) + + it('returns 409 LINK_TX_INVOICE_RACE when optimistic lock loses', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ + data: makeTransaction({ id: TX_UUID, journal_entry_id: null, amount: 1000, date: '2026-05-15' }), + error: null, + }) + enqueue({ + data: { + id: JE_UUID, + status: 'posted', + voucher_series: 'A', + voucher_number: 1, + entry_date: '2026-05-15', + }, + error: null, + }) + enqueue({ + data: makeInvoice({ id: INV_UUID, status: 'sent', total: 1000, remaining_amount: 1000 }), + error: null, + }) + enqueue({ data: null, error: null }) // tx UPDATE succeeds + enqueue({ data: [], error: null }) // optimistic invoice UPDATE returns 0 rows + enqueue({ data: null, error: null }) // compensating rollback restores tx + enqueue({ data: null, error: null }) // dispatcher's reject update + + const op = makePendingOp({ + params: { + transaction_id: TX_UUID, + journal_entry_id: JE_UUID, + invoice_id: INV_UUID, + }, + }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + // 409 is auto-rejected by the dispatcher (a fresh stage with the latest + // invoice state will succeed if the racing payer didn't already settle). + expect(result.status).toBe('rejected') + expect(result.http_status).toBe(409) + }) +}) diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index e7e8b763..95d1ca33 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -40,6 +40,7 @@ import { createSupplierInvoiceRegistrationEntry, } from '@/lib/bookkeeping/supplier-invoice-entries' import { linkInvoiceToVoucher } from '@/lib/invoices/voucher-matching' +import { linkTransactionToJournalEntry } from '@/lib/transactions/link-journal-entry' import { getErrorEntry } from '@/lib/errors/structured-errors' import { parseSIEFile } from '@/lib/import/sie-parser' import { executeSIEImport, undoSIEImport } from '@/lib/import/sie-import' @@ -2615,6 +2616,197 @@ async function commitGenerateAgi( } } +// ── Multi-tx commit handlers (PRs #603/#606/#608/#610) ──────────── +// +// Both wrap their SQL RPC. The RPCs do all the heavy lifting (locking, +// balance/period checks, journal entry creation, voucher number, +// payment/junction rows, doc inheritance). The commit handlers just +// shape params, call the RPC, and translate the structured error code +// or success payload into an ExecutorResult. + +async function commitMatchBatchAllocate( + supabase: SupabaseClient, + companyId: string, + params: Record +): Promise { + // Trust boundary (compliance-swarm V8.2.1, A.8.2): + // Tenant isolation is enforced authoritatively inside the SQL RPC + // `match_batch_allocate` (supabase/migrations/20260601122000_*.sql): + // - `transactions` row fetched WHERE id = p_tx_id AND company_id = p_company_id + // - `invoices` and `supplier_invoices` rows fetched WHERE id = ? AND company_id = p_company_id + // - `auth.uid()` resolves the caller; membership checked against + // `company_members.company_id = p_company_id` + // The MCP execute() handler additionally pre-checks the same IDs to + // surface clean errors before staging. This commit handler is a thin + // pass-through by design — re-querying here would triple the same + // check without adding security. + const txId = params.transaction_id as string + const allocations = params.allocations + if (!txId) return { error: 'transaction_id is required', status: 400 } + if (!Array.isArray(allocations) || allocations.length === 0) { + return { error: 'allocations is required (non-empty array)', status: 400 } + } + const { data, error } = await supabase.rpc('match_batch_allocate', { + p_tx_id: txId, + p_allocations: allocations, + p_company_id: companyId, + }) + if (error) { + // Sanitised log (A.8.11, CC7.2): only error code + message, no + // payload — error.details can echo invoice IDs, amounts, etc. + log.error('match_batch_allocate RPC error', { + code: (error as { code?: string }).code, + message: error.message, + }) + return { error: error.message || 'Database error', status: 500 } + } + const result = data as { ok: boolean; code?: string; details?: unknown; journal_entry_id?: string } + if (!result || !result.ok) { + return { + error: result?.code || 'match_batch_allocate failed', + status: 400, + data: result?.details as Record | undefined, + } + } + // Structured audit-trail entry on success (compliance-swarm V16). Tx + // count + JE id + the source tx id only — no amounts, no + // counterparty identifiers, no descriptions. txId is included + // intentionally so the audit trail can join successful commits back + // to the source bank tx without a separate query; it's not PII on + // its own (just an internal UUID, scoped to companyId already logged). + log.info('match_batch_allocate committed', { + companyId, + operationType: 'match_batch_allocate', + journalEntryId: result.journal_entry_id, + txId, + allocationCount: allocations.length, + }) + return { data: result as unknown as Record, status: 200 } +} + +async function commitBulkBookTransactions( + supabase: SupabaseClient, + companyId: string, + params: Record +): Promise { + // Trust boundary (compliance-swarm V8.2.1, A.8.2): + // Tenant isolation + chart-of-accounts validation are enforced + // authoritatively inside the SQL RPC `bulk_book_transactions` + // (supabase/migrations/20260602121000_*.sql): + // - All `transactions` rows fetched WHERE id = ANY(p_tx_ids) AND + // company_id = p_company_id (line ~115). + // - `journal_entries` row (link-existing branch) fetched WHERE id = + // p_existing_journal_entry_id AND company_id = p_company_id. + // - Every account_number in p_new_entry.lines validated against + // `chart_of_accounts` filtered by company_id + is_active (PR #610 + // round 2 added this allowlist). + // - `auth.uid()` resolves the caller; membership checked against + // `company_members.company_id = p_company_id`. + // The MCP execute() handler additionally pre-checks tx ownership + + // JE ownership at stage time to surface clean errors. This commit + // handler is a thin pass-through by design. + const txIds = params.tx_ids + const existingJeId = (params.existing_journal_entry_id as string | null | undefined) ?? null + const newEntry = (params.new_entry as Record | null | undefined) ?? null + if (!Array.isArray(txIds) || txIds.length === 0) { + return { error: 'tx_ids is required (non-empty array)', status: 400 } + } + if ((existingJeId == null) === (newEntry == null)) { + return { + error: 'Provide exactly one of existing_journal_entry_id or new_entry', + status: 400, + } + } + const { data, error } = await supabase.rpc('bulk_book_transactions', { + p_tx_ids: txIds, + p_existing_journal_entry_id: existingJeId, + p_new_entry: newEntry, + p_company_id: companyId, + }) + if (error) { + // Sanitised log (A.8.11, CC7.2): only error code + message. + log.error('bulk_book_transactions RPC error', { + code: (error as { code?: string }).code, + message: error.message, + }) + return { error: error.message || 'Database error', status: 500 } + } + const result = data as { ok: boolean; code?: string; details?: unknown; journal_entry_id?: string; mode?: string; linked_tx_count?: number; docs_linked?: number } + if (!result || !result.ok) { + return { + error: result?.code || 'bulk_book_transactions failed', + status: 400, + data: result?.details as Record | undefined, + } + } + // Structured audit-trail entry on success (compliance-swarm V16). + log.info('bulk_book_transactions committed', { + companyId, + operationType: 'bulk_book_transactions', + journalEntryId: result.journal_entry_id, + mode: result.mode, + txCount: result.linked_tx_count, + docsLinked: result.docs_linked, + }) + return { data: result as unknown as Record, status: 200 } +} + +async function commitLinkTransactionJournalEntry( + supabase: SupabaseClient, + userId: string, + companyId: string, + params: Record +): Promise { + const transactionId = params.transaction_id as string | undefined + const journalEntryId = params.journal_entry_id as string | undefined + const invoiceId = (params.invoice_id as string | undefined) ?? undefined + + if (!transactionId || !journalEntryId) { + return { error: 'transaction_id and journal_entry_id are required', status: 400 } + } + + const outcome = await linkTransactionToJournalEntry(supabase, userId, companyId, { + transactionId, + journalEntryId, + invoiceId, + }) + + if (!outcome.ok) { + const entry = getErrorEntry(outcome.code) + const httpStatus = entry?.httpStatus ?? 500 + return { + error: entry?.message_en ?? outcome.code, + status: httpStatus, + data: outcome.details as Record | undefined, + } + } + + // Structured audit-trail entry on success (compliance-swarm V16, SOC 2 CC4.1). + // Mirrors commitMatchBatchAllocate / commitBulkBookTransactions — IDs only, + // no amounts or counterparty PII. invoiceId is logged as boolean to avoid + // leaking which invoices are touched while still distinguishing the two + // code paths (link-only vs link+settle). + log.info('link_transaction_journal_entry committed', { + companyId, + operationType: 'link_transaction_journal_entry', + transactionId: outcome.result.transactionId, + journalEntryId: outcome.result.journalEntryId, + settledInvoice: outcome.result.invoiceId != null, + }) + + return { + data: { + transaction_id: outcome.result.transactionId, + journal_entry_id: outcome.result.journalEntryId, + voucher_label: outcome.result.voucherLabel, + invoice_id: outcome.result.invoiceId, + invoice_status: outcome.result.invoiceStatus, + paid_amount: outcome.result.paidAmount, + remaining_amount: outcome.result.remainingAmount, + }, + } +} + // ── Public dispatcher ──────────────────────────────────────────── /** @@ -2756,6 +2948,15 @@ export async function commitPendingOperation( case 'generate_agi': result = await commitGenerateAgi(supabase, userId, companyId, pendingOp.params) break + case 'match_batch_allocate': + result = await commitMatchBatchAllocate(supabase, companyId, pendingOp.params) + break + case 'bulk_book_transactions': + result = await commitBulkBookTransactions(supabase, companyId, pendingOp.params) + break + case 'link_transaction_journal_entry': + result = await commitLinkTransactionJournalEntry(supabase, userId, companyId, pendingOp.params) + break default: return { status: 'failed', diff --git a/lib/pending-operations/risk-tiers.ts b/lib/pending-operations/risk-tiers.ts index b863cc7d..bffa6a08 100644 --- a/lib/pending-operations/risk-tiers.ts +++ b/lib/pending-operations/risk-tiers.ts @@ -92,6 +92,22 @@ export const OPERATION_RISK_TIERS: Record = { // staged. create_salary_run: 'medium', generate_agi: 'high', + + // ── Multi-tx flows (PRs #603/#606/#608/#610) ─────────────────────── + // Allocate 1 bank tx across N customer or supplier invoices into one + // combined verifikat. Reversible via storno + invoice_payments delete, + // so 'medium' (same tier as match_transaction_invoice — its single- + // invoice counterpart). + match_batch_allocate: 'medium', + // Bulk-book N bank txs into 1 verifikat. The create-new branch posts + // a verifikat with caller-supplied lines (template-expanded or manual), + // the same compliance-critical surface as create_voucher. 'high'. + bulk_book_transactions: 'high', + // Link a single bank tx to an already-posted verifikat (no new JE created). + // Reversible by clearing transactions.journal_entry_id and deleting any + // invoice_payments row — sits next to link_invoice_voucher semantically; + // both attach an existing booking to a different entity. + link_transaction_journal_entry: 'medium', } export function getRiskLevel(operationType: string): RiskLevel { diff --git a/lib/transactions/link-journal-entry.ts b/lib/transactions/link-journal-entry.ts new file mode 100644 index 00000000..ef29419e --- /dev/null +++ b/lib/transactions/link-journal-entry.ts @@ -0,0 +1,378 @@ +/** + * Link a bank transaction to an already-posted journal entry without creating + * new bookkeeping. Optionally settle a customer invoice in the same call by + * inserting an invoice_payments row pointing at the existing JE and flipping + * the invoice status with an optimistic-lock pattern. + * + * Shared between two callers: + * - REST: app/api/transactions/[id]/link-journal-entry/route.ts + * (duplicate-payment UI: user confirms the suggested existing voucher) + * - MCP commit handler: lib/pending-operations/commit.ts + * (gnubok_link_transaction_to_journal_entry — agent-staged operation) + * + * NEVER creates a new journal entry. The match log records + * 'linked_to_existing_voucher' for audit on success. + */ +import type { SupabaseClient } from '@supabase/supabase-js' +import { eventBus } from '@/lib/events/bus' +import { logMatchEvent } from '@/lib/invoices/match-log' +import { createLogger } from '@/lib/logger' +import type { Invoice, Transaction } from '@/types' + +const log = createLogger('transactions/link-journal-entry') + +// Codes returned by linkTransactionToJournalEntry. All map to entries in +// lib/errors/structured-errors.ts so both callers (REST route, MCP commit +// handler) can surface the right HTTP status and the localized message. +// The TX-not-found case reuses the shared TX_CATEGORIZE_TX_NOT_FOUND code +// rather than a link-specific one — it predates this route and is the +// canonical "bank tx not found in this company" envelope. +export type LinkTransactionJournalEntryErrorCode = + | 'TX_CATEGORIZE_TX_NOT_FOUND' + | 'LINK_TX_TX_ALREADY_LINKED' + | 'LINK_TX_JE_NOT_FOUND' + | 'LINK_TX_JE_NOT_POSTED' + | 'LINK_TX_INVOICE_NOT_FOUND' + | 'LINK_TX_INVOICE_NOT_OPEN' + | 'LINK_TX_INVOICE_CURRENCY_MISMATCH' + | 'LINK_TX_INVOICE_RACE' + | 'MATCH_INVOICE_RECORD_PAYMENT_FAILED' + | 'LINK_TX_DB_ERROR' + +export interface LinkTransactionJournalEntryParams { + transactionId: string + journalEntryId: string + invoiceId?: string +} + +export interface LinkTransactionJournalEntryResult { + transactionId: string + journalEntryId: string + voucherLabel: string + invoiceId: string | null + invoiceStatus: 'paid' | 'partially_paid' | null + paidAmount: number | null + remainingAmount: number | null +} + +export type LinkTransactionJournalEntryOutcome = + | { ok: true; result: LinkTransactionJournalEntryResult } + | { ok: false; code: LinkTransactionJournalEntryErrorCode; details?: Record } + +/** + * Canonical verifikat-label format: `${series}-${number}` (e.g. "A-12"). + * Centralised so the MCP staging preview and the committed result can't + * diverge — divergence is a BFL 5 kap 7§ traceability hazard because the + * verifikationsserie label that ends up in the audit trail must match the + * label the user saw at approval time. + * + * Fallbacks ('A' series, empty number) are defensive only; in practice a + * posted verifikat always has both. Callers should never construct this + * string inline — import this helper instead. + */ +export function formatVoucherLabel( + voucherSeries: string | null | undefined, + voucherNumber: number | string | null | undefined, +): string { + const series = voucherSeries ?? 'A' + const num = voucherNumber ?? '' + return num === '' ? series : `${series}-${num}` +} + +export async function linkTransactionToJournalEntry( + supabase: SupabaseClient, + userId: string, + companyId: string, + params: LinkTransactionJournalEntryParams +): Promise { + const { transactionId, journalEntryId, invoiceId } = params + + // Data minimization (GDPR Art.5(1)(c)): pull only the columns needed for + // validation, optimistic-lock invoice update, invoice_payments insert, and + // the compensating-rollback path. No select('*'). + const { data: transaction, error: fetchTxError } = await supabase + .from('transactions') + .select( + 'id, date, amount, currency, exchange_rate, journal_entry_id, invoice_id, is_business, potential_invoice_id, potential_supplier_invoice_id' + ) + .eq('id', transactionId) + .eq('company_id', companyId) + .single() + + if (fetchTxError || !transaction) { + return { ok: false, code: 'TX_CATEGORIZE_TX_NOT_FOUND' } + } + + if (transaction.journal_entry_id) { + return { + ok: false, + code: 'LINK_TX_TX_ALREADY_LINKED', + details: { existingJournalEntryId: transaction.journal_entry_id as string }, + } + } + + const { data: journalEntry, error: fetchJeError } = await supabase + .from('journal_entries') + .select('id, status, voucher_series, voucher_number, entry_date') + .eq('id', journalEntryId) + .eq('company_id', companyId) + .single() + + if (fetchJeError || !journalEntry) { + return { ok: false, code: 'LINK_TX_JE_NOT_FOUND' } + } + + if (journalEntry.status !== 'posted') { + return { + ok: false, + code: 'LINK_TX_JE_NOT_POSTED', + details: { currentStatus: journalEntry.status as string }, + } + } + + type FetchedInvoice = Pick< + Invoice, + | 'id' + | 'status' + | 'total' + | 'paid_amount' + | 'remaining_amount' + | 'currency' + | 'exchange_rate' + | 'paid_at' + | 'invoice_number' + > & { customer?: { name?: string } | null } + let invoice: FetchedInvoice | null = null + let newPaidAmount = 0 + let newRemaining = 0 + let isFullyPaid = false + let newStatus: 'paid' | 'partially_paid' = 'paid' + + if (invoiceId) { + // Data minimization (GDPR Art.5(1)(c) / SOC 2 CC6.1): explicit column + // list rather than select('*, customer:customers(name)'). Adding new + // PII columns to invoices won't silently widen this fetch. + const { data: invoiceRow, error: fetchInvError } = await supabase + .from('invoices') + .select( + 'id, status, total, paid_amount, remaining_amount, currency, exchange_rate, paid_at, invoice_number, customer:customers(name)' + ) + .eq('id', invoiceId) + .eq('company_id', companyId) + .single() + + if (fetchInvError || !invoiceRow) { + return { ok: false, code: 'LINK_TX_INVOICE_NOT_FOUND' } + } + + if ( + invoiceRow.status !== 'sent' && + invoiceRow.status !== 'overdue' && + invoiceRow.status !== 'partially_paid' + ) { + return { + ok: false, + code: 'LINK_TX_INVOICE_NOT_OPEN', + details: { currentStatus: invoiceRow.status as string }, + } + } + + invoice = invoiceRow as unknown as FetchedInvoice + + // BFL 5 kap 2§ + currency-integrity guard: invoices.paid_amount and + // remaining_amount are stored in the INVOICE'S currency. Mixing a + // foreign-currency tx.amount into those columns silently corrupts the + // ledger (a 230 SEK payment would record "230 USD paid" on a USD + // invoice). This link path is for the same-currency case only; + // cross-currency payments must go through /api/transactions/[id]/match- + // invoice which routes through buildInvoicePaymentClearingLines and + // posts the FX diff on 3960/7960. Reject here to keep the contract clear. + if (transaction.currency !== invoice.currency) { + return { + ok: false, + code: 'LINK_TX_INVOICE_CURRENCY_MISMATCH', + details: { + transactionCurrency: transaction.currency as string, + invoiceCurrency: invoice.currency, + }, + } + } + + const paidAmount = transaction.amount as number + newPaidAmount = Math.round(((invoice.paid_amount || 0) + paidAmount) * 100) / 100 + const currentRemaining = + invoice.remaining_amount ?? invoice.total - (invoice.paid_amount || 0) + newRemaining = Math.max(0, Math.round((currentRemaining - paidAmount) * 100) / 100) + isFullyPaid = newRemaining <= 0 + newStatus = isFullyPaid ? 'paid' : 'partially_paid' + } + + // Snapshot tx state so the compensating-rollback path can restore the row + // if a subsequent step fails — otherwise a partial state would persist + // (tx linked, invoice unchanged, no payment row). + const priorTxState = { + journal_entry_id: transaction.journal_entry_id, // validated null above + invoice_id: transaction.invoice_id, + potential_invoice_id: transaction.potential_invoice_id, + potential_supplier_invoice_id: transaction.potential_supplier_invoice_id, + is_business: transaction.is_business, + } + + const { error: updateTxError } = await supabase + .from('transactions') + .update({ + journal_entry_id: journalEntryId, + invoice_id: invoiceId ?? null, + potential_invoice_id: null, + potential_supplier_invoice_id: null, + is_business: true, + }) + .eq('id', transactionId) + .eq('company_id', companyId) + .is('journal_entry_id', null) + + if (updateTxError) { + return { ok: false, code: 'LINK_TX_DB_ERROR', details: { reason: updateTxError.message } } + } + + async function rollbackTxLink(reason: string): Promise { + // SOC 2 PI1.3 (processing integrity): if a rollback itself fails, the + // ledger ends up in a partial state — tx pointing at the existing + // verifikat with no invoice_payments row, or the invoice row at an + // intermediate paid_amount. We surface the rollback failure (IDs only, + // no amounts or counterparty names) so a reconciliation job can + // detect and repair the divergence. The original failure code still + // goes back to the caller as the proximate cause. + const { error: rollbackErr } = await supabase + .from('transactions') + .update(priorTxState) + .eq('id', transactionId) + .eq('company_id', companyId) + if (rollbackErr) { + log.warn('failed to roll back transaction link after subsequent step failed', { + companyId, + transactionId, + journalEntryId, + reason, + rollbackError: rollbackErr.message, + }) + } + } + + const now = new Date().toISOString() + + if (invoice && invoiceId) { + const { data: updatedRows, error: updateInvError } = await supabase + .from('invoices') + .update({ + status: newStatus, + paid_at: isFullyPaid ? now : null, + paid_amount: newPaidAmount, + remaining_amount: newRemaining, + }) + .eq('id', invoiceId) + .eq('company_id', companyId) + .in('status', ['sent', 'overdue', 'partially_paid']) + .select('id') + + if (updateInvError) { + await rollbackTxLink('invoice update errored') + return { ok: false, code: 'LINK_TX_DB_ERROR', details: { reason: updateInvError.message } } + } + + if (!updatedRows || updatedRows.length === 0) { + await rollbackTxLink('invoice optimistic lock returned 0 rows') + return { ok: false, code: 'LINK_TX_INVOICE_RACE' } + } + + // BFL 5 kap 2§ + ML 8 kap 21–23§: the payment row must record the rate + // effective on the PAYMENT date, not the invoice-creation date. If + // transaction.exchange_rate is null (SEK tx, no rate needed), leave the + // payment row's rate null too — a downstream Riksbanken lookup can + // populate it lazily if reporting needs it. Falling back to + // invoice.exchange_rate would silently record the wrong (invoice-date) + // rate, which corrupts the FX-diff figures in any later VAT or income + // reporting. + const paymentExchangeRate = transaction.exchange_rate ?? null + + const { error: paymentInsertError } = await supabase + .from('invoice_payments') + .insert({ + user_id: userId, + company_id: companyId, + invoice_id: invoiceId, + payment_date: transaction.date, + amount: transaction.amount, + currency: invoice.currency, + exchange_rate: paymentExchangeRate, + journal_entry_id: journalEntryId, + transaction_id: transactionId, + notes: 'Kopplad till befintlig verifikation (ingen ny bokföring skapad)', + }) + + if (paymentInsertError && paymentInsertError.code !== '23505') { + const { error: invRevertErr } = await supabase + .from('invoices') + .update({ + status: invoice.status, + paid_at: invoice.paid_at ?? null, + paid_amount: invoice.paid_amount ?? 0, + remaining_amount: invoice.remaining_amount ?? invoice.total, + }) + .eq('id', invoiceId) + .eq('company_id', companyId) + if (invRevertErr) { + log.warn('failed to revert invoice status after payment insert failed', { + companyId, + invoiceId, + rollbackError: invRevertErr.message, + }) + } + await rollbackTxLink('invoice_payments insert failed') + return { ok: false, code: 'MATCH_INVOICE_RECORD_PAYMENT_FAILED' } + } + } + + logMatchEvent(supabase, userId, transactionId, 'linked_to_existing_voucher', { + invoiceId, + newState: { + journal_entry_id: journalEntryId, + invoice_id: invoiceId ?? null, + invoice_status: invoice ? newStatus : null, + }, + }) + + if (invoice && invoiceId) { + try { + eventBus.emit({ + type: 'invoice.match_confirmed', + payload: { + invoice: invoice as Invoice, + transaction: transaction as Transaction, + userId, + companyId, + }, + }) + } catch { + /* non-critical */ + } + } + + const voucherLabel = formatVoucherLabel( + journalEntry.voucher_series as string | null, + journalEntry.voucher_number as number | null, + ) + + return { + ok: true, + result: { + transactionId, + journalEntryId, + voucherLabel, + invoiceId: invoiceId ?? null, + invoiceStatus: invoice ? newStatus : null, + paidAmount: invoice ? newPaidAmount : null, + remainingAmount: invoice ? newRemaining : null, + }, + } +} diff --git a/supabase/migrations/20260603120000_pending_operations_add_batch_allocate_and_bulk_book.sql b/supabase/migrations/20260603120000_pending_operations_add_batch_allocate_and_bulk_book.sql new file mode 100644 index 00000000..0228556f --- /dev/null +++ b/supabase/migrations/20260603120000_pending_operations_add_batch_allocate_and_bulk_book.sql @@ -0,0 +1,62 @@ +-- Expand pending_operations.operation_type to include the multi-tx +-- bookkeeping ops introduced in PRs #603, #606, #608, #610: +-- +-- match_batch_allocate — 1 bank tx → N customer or N supplier +-- invoices (samlingsbetalning per +-- BFL 5 kap 6§ st 3). Calls the SQL RPC +-- `match_batch_allocate(tx_id, allocations, +-- company_id)` on approval. Risk: medium +-- (reversible via storno of the JE + +-- deletion of the invoice_payments rows). +-- +-- bulk_book_transactions — N bank txs on the same date → 1 +-- combined verifikat (samlingsverifikation +-- per BFL 5 kap 6§). Two branches: +-- link-to-existing (no new JE; pure +-- junction rows) or create-new (caller- +-- supplied or template-expanded lines). +-- Risk: high (creates a posted verifikat +-- with arbitrary lines, same surface as +-- create_voucher). + +ALTER TABLE public.pending_operations + DROP CONSTRAINT IF EXISTS pending_operations_operation_type_check; + +ALTER TABLE public.pending_operations + ADD CONSTRAINT pending_operations_operation_type_check + CHECK (operation_type IN ( + 'categorize_transaction', + 'create_customer', + 'create_invoice', + 'mark_invoice_paid', + 'send_invoice', + 'mark_invoice_sent', + 'match_transaction_invoice', + 'close_period', + 'lock_period', + 'unlock_period', + 'set_opening_balances', + 'run_year_end', + 'run_currency_revaluation', + 'import_sie', + 'explain_voucher_gap', + 'uncategorize_transaction', + 'approve_supplier_invoice', + 'credit_supplier_invoice', + 'credit_invoice', + 'convert_invoice', + 'create_transaction', + 'attach_document_to_transaction', + 'create_voucher', + 'correct_entry', + 'reverse_entry', + 'create_supplier', + 'create_supplier_invoice_from_inbox', + 'post_annual_depreciation', + 'link_invoice_voucher', + 'undo_sie_import', -- backfilled (was missing from prior expansions) + 'match_batch_allocate', -- PR #603/#607: 1 tx → N invoices + 'bulk_book_transactions' -- PR #606/#610: N txs → 1 verifikat + )); + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260603121000_pending_operations_add_salary_run_and_agi.sql b/supabase/migrations/20260603121000_pending_operations_add_salary_run_and_agi.sql new file mode 100644 index 00000000..5d59b548 --- /dev/null +++ b/supabase/migrations/20260603121000_pending_operations_add_salary_run_and_agi.sql @@ -0,0 +1,56 @@ +-- Backfill `create_salary_run` and `generate_agi` into the +-- pending_operations.operation_type CHECK constraint. +-- +-- Same bug class as `undo_sie_import` (fixed in 20260603120000): both +-- ops have a risk-tier entry (lib/pending-operations/risk-tiers.ts) +-- and a commit executor (lib/pending-operations/commit.ts) but were +-- never added to the CHECK constraint. Production currently has no +-- pending rows of either type — confirmed via SELECT operation_type, +-- COUNT(*) FROM pending_operations — so this is a forward-looking +-- fix, not a hot repair. +-- +-- Flagged by swedish-compliance review on PR #614. + +ALTER TABLE public.pending_operations + DROP CONSTRAINT IF EXISTS pending_operations_operation_type_check; + +ALTER TABLE public.pending_operations + ADD CONSTRAINT pending_operations_operation_type_check + CHECK (operation_type IN ( + 'categorize_transaction', + 'create_customer', + 'create_invoice', + 'mark_invoice_paid', + 'send_invoice', + 'mark_invoice_sent', + 'match_transaction_invoice', + 'close_period', + 'lock_period', + 'unlock_period', + 'set_opening_balances', + 'run_year_end', + 'run_currency_revaluation', + 'import_sie', + 'explain_voucher_gap', + 'uncategorize_transaction', + 'approve_supplier_invoice', + 'credit_supplier_invoice', + 'credit_invoice', + 'convert_invoice', + 'create_transaction', + 'attach_document_to_transaction', + 'create_voucher', + 'correct_entry', + 'reverse_entry', + 'create_supplier', + 'create_supplier_invoice_from_inbox', + 'post_annual_depreciation', + 'link_invoice_voucher', + 'undo_sie_import', + 'match_batch_allocate', + 'bulk_book_transactions', + 'create_salary_run', -- backfilled (swedish-compliance PR #614) + 'generate_agi' -- backfilled (swedish-compliance PR #614) + )); + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260604120000_pending_operations_add_link_transaction_journal_entry.sql b/supabase/migrations/20260604120000_pending_operations_add_link_transaction_journal_entry.sql new file mode 100644 index 00000000..40eb9f4f --- /dev/null +++ b/supabase/migrations/20260604120000_pending_operations_add_link_transaction_journal_entry.sql @@ -0,0 +1,59 @@ +-- Backfill `link_transaction_journal_entry` into the +-- pending_operations.operation_type CHECK constraint. +-- +-- Same bug class as 20260603121000 (which backfilled create_salary_run + +-- generate_agi): the op has a risk-tier entry, a commit executor, and a +-- new MCP tool (gnubok_link_transaction_to_journal_entry, PR #614), but +-- the CHECK constraint was last enumerated in 20260603121000 and did +-- not include this value. Without this migration, any INSERT staged +-- by the new MCP tool would be rejected with constraint violation, +-- silently preventing the verifikat-link audit-trail row required by +-- BFL 5 kap 6–7§ (every affärshändelse must have a verifikation with +-- a logged match event). +-- +-- Flagged by swedish-compliance review on commit 5b884c3a (PR #614). + +ALTER TABLE public.pending_operations + DROP CONSTRAINT IF EXISTS pending_operations_operation_type_check; + +ALTER TABLE public.pending_operations + ADD CONSTRAINT pending_operations_operation_type_check + CHECK (operation_type IN ( + 'categorize_transaction', + 'create_customer', + 'create_invoice', + 'mark_invoice_paid', + 'send_invoice', + 'mark_invoice_sent', + 'match_transaction_invoice', + 'close_period', + 'lock_period', + 'unlock_period', + 'set_opening_balances', + 'run_year_end', + 'run_currency_revaluation', + 'import_sie', + 'explain_voucher_gap', + 'uncategorize_transaction', + 'approve_supplier_invoice', + 'credit_supplier_invoice', + 'credit_invoice', + 'convert_invoice', + 'create_transaction', + 'attach_document_to_transaction', + 'create_voucher', + 'correct_entry', + 'reverse_entry', + 'create_supplier', + 'create_supplier_invoice_from_inbox', + 'post_annual_depreciation', + 'link_invoice_voucher', + 'undo_sie_import', + 'match_batch_allocate', + 'bulk_book_transactions', + 'create_salary_run', + 'generate_agi', + 'link_transaction_journal_entry' -- backfilled (swedish-compliance PR #614 round 5) + )); + +NOTIFY pgrst, 'reload schema'; diff --git a/types/index.ts b/types/index.ts index 98793a8f..e86a7a10 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1560,6 +1560,12 @@ export type PendingOperationType = | 'generate_agi' // Mark invoice paid by linking an existing posted verifikat (no new JE) | 'link_invoice_voucher' + // PR #603/#607: allocate 1 bank tx across N customer or supplier invoices + | 'match_batch_allocate' + // PR #606/#610: bulk-book N bank txs into 1 combined verifikat + | 'bulk_book_transactions' + // PR #614: link a single bank tx to an already-posted verifikat (no new JE) + | 'link_transaction_journal_entry' export type PendingOperationStatus = 'pending' | 'committing' | 'committed' | 'rejected' export type PendingOperationActorType = 'user' | 'api_key' | 'mcp_oauth' | 'cron'