diff --git a/lib/pending-operations/__tests__/failed-partial.test.ts b/lib/pending-operations/__tests__/failed-partial.test.ts index 688cc274..bbc3d27d 100644 --- a/lib/pending-operations/__tests__/failed-partial.test.ts +++ b/lib/pending-operations/__tests__/failed-partial.test.ts @@ -30,6 +30,8 @@ vi.mock('@/lib/bookkeeping/invoice-entries', async () => { }) const mockReverseEntry = vi.fn() +const mockFindFiscalPeriod = vi.fn() +const mockCreateJournalEntry = vi.fn() vi.mock('@/lib/bookkeeping/engine', async () => { const actual = await vi.importActual( '@/lib/bookkeeping/engine', @@ -37,6 +39,8 @@ vi.mock('@/lib/bookkeeping/engine', async () => { return { ...actual, reverseEntry: (...args: unknown[]) => mockReverseEntry(...args), + findFiscalPeriod: (...args: unknown[]) => mockFindFiscalPeriod(...args), + createJournalEntry: (...args: unknown[]) => mockCreateJournalEntry(...args), } }) @@ -125,6 +129,10 @@ beforeEach(() => { mockCreateCashEntry.mockResolvedValue({ id: 'je-pay' }) mockCreateCreditNoteEntry.mockResolvedValue({ id: 'je-credit' }) mockReverseEntry.mockResolvedValue({ id: 'je-storno' }) + // The accrual match path builds its clearing entry via findFiscalPeriod + + // createJournalEntry (shared-builder parity with the dashboard/v1 routes). + mockFindFiscalPeriod.mockResolvedValue('fp-1') + mockCreateJournalEntry.mockResolvedValue({ id: 'je-pay' }) }) describe('match_transaction_invoice: partial commit after the storno', () => { @@ -138,7 +146,7 @@ describe('match_transaction_invoice: partial commit after the storno', () => { enqueue({ data: null, error: null }) // transactions unlink after storno enqueue({ data: null, error: null }) // dispatcher pending_operations update - mockCreatePaymentEntry.mockRejectedValue(new JournalEntryNotBalancedError(500, 400)) + mockCreateJournalEntry.mockRejectedValue(new JournalEntryNotBalancedError(500, 400)) const op = makePendingOp({ params: { transaction_id: 'tx-1', invoice_id: 'inv-1' } }) const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) @@ -235,7 +243,7 @@ describe('match_transaction_invoice: partial commit after the storno', () => { // JE creation "succeeded" with null (e.g. no open fiscal period): // nothing was posted, so today's auto-reject semantics must survive. - mockCreatePaymentEntry.mockResolvedValue(null) + mockCreateJournalEntry.mockResolvedValue(null) const op = makePendingOp({ params: { transaction_id: 'tx-1', invoice_id: 'inv-1' } }) const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) diff --git a/lib/pending-operations/__tests__/match-transaction-invoice-settlement-account.test.ts b/lib/pending-operations/__tests__/match-transaction-invoice-settlement-account.test.ts index dc220d7f..cc733e4b 100644 --- a/lib/pending-operations/__tests__/match-transaction-invoice-settlement-account.test.ts +++ b/lib/pending-operations/__tests__/match-transaction-invoice-settlement-account.test.ts @@ -27,6 +27,36 @@ vi.mock('@/lib/bookkeeping/invoice-entries', async () => { } }) +// The accrual branch books via findFiscalPeriod + createJournalEntry with +// lines from the shared buildInvoicePaymentClearingLines helper (parity with +// the dashboard/v1 routes); the settlement account shows up as the bank leg's +// account_number. +const mockFindFiscalPeriod = vi.fn() +const mockCreateJournalEntry = vi.fn() +vi.mock('@/lib/bookkeeping/engine', async () => { + const actual = await vi.importActual( + '@/lib/bookkeeping/engine', + ) + return { + ...actual, + findFiscalPeriod: (...args: unknown[]) => mockFindFiscalPeriod(...args), + createJournalEntry: (...args: unknown[]) => mockCreateJournalEntry(...args), + } +}) + +// Riksbanken lookup for cross-currency parity tests: mocked so no network or +// DB call happens; per-test mockResolvedValue supplies the rate. +const mockFetchExchangeRate = vi.fn() +vi.mock('@/lib/currency/riksbanken', async () => { + const actual = await vi.importActual( + '@/lib/currency/riksbanken', + ) + return { + ...actual, + fetchExchangeRate: (...args: unknown[]) => mockFetchExchangeRate(...args), + } +}) + // Issue #1259: settling the invoice retires the suggestion pointers at it. // Mocked so it consumes no slot in the queued Supabase mock; the helper's own // query shape is pinned by lib/invoices/__tests__/clear-settled-invoice-suggestions.test.ts. @@ -64,6 +94,8 @@ beforeEach(() => { eventBus.clear() mockCreatePaymentEntry.mockResolvedValue({ id: 'je-1' }) mockCreateCashEntry.mockResolvedValue({ id: 'je-1' }) + mockFindFiscalPeriod.mockResolvedValue('fp-1') + mockCreateJournalEntry.mockResolvedValue({ id: 'je-1' }) }) describe('commitPendingOperation: match_transaction_invoice settlement account resolution', () => { @@ -147,16 +179,19 @@ describe('commitPendingOperation: match_transaction_invoice settlement account r const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) expect(result.status).toBe('committed') - expect(mockCreatePaymentEntry).toHaveBeenCalledWith( + expect(mockCreateJournalEntry).toHaveBeenCalledWith( expect.anything(), 'company-1', 'user-1', - expect.objectContaining({ id: 'inv-1' }), - '2026-05-12', - undefined, - 'Test AB', - 12500, - '1940', + expect.objectContaining({ + source_type: 'invoice_paid', + source_id: 'inv-1', + entry_date: '2026-05-12', + lines: expect.arrayContaining([ + expect.objectContaining({ account_number: '1940', debit_amount: 12500 }), + expect.objectContaining({ account_number: '1510', credit_amount: 12500 }), + ]), + }), ) expect(mockCreateCashEntry).not.toHaveBeenCalled() const invoiceUpdate = findCalls('invoices', 'update').at(-1)?.[0] @@ -272,16 +307,16 @@ describe('commitPendingOperation: match_transaction_invoice settlement account r const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) expect(result.status).toBe('committed') - expect(mockCreatePaymentEntry).toHaveBeenCalledWith( + expect(mockCreateJournalEntry).toHaveBeenCalledWith( expect.anything(), 'company-1', 'user-1', - expect.objectContaining({ id: 'inv-1' }), - '2026-05-12', - undefined, - 'Test AB', - 12500, - '1930', + expect.objectContaining({ + lines: expect.arrayContaining([ + expect.objectContaining({ account_number: '1930', debit_amount: 12500 }), + expect.objectContaining({ account_number: '1510', credit_amount: 12500 }), + ]), + }), ) }) @@ -331,4 +366,170 @@ describe('commitPendingOperation: match_transaction_invoice settlement account r expect(mockCreatePaymentEntry).not.toHaveBeenCalled() expect(mockCreateCashEntry).not.toHaveBeenCalled() }) + + it('settles a whole-krona payment of an öre-carrying invoice in full (3740 absorbs the residual)', async () => { + // gnubok_feedback 2026-07-24: a bank tx exactly matching the invoice's + // rounded "Att betala" was rejected as MATCH_AMOUNT_EXCEEDS_REMAINING + // because this path called planInvoicePayment without absorbOreRounding. + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ + data: { + id: 'tx-1', + company_id: 'company-1', + amount: 12500, + currency: 'SEK', + date: '2026-05-12', + invoice_id: null, + journal_entry_id: null, + cash_account_id: null, + }, + error: null, + }) // transaction fetch + enqueue({ + data: { + id: 'inv-1', + invoice_number: 'F-2026001', + status: 'sent', + total: 12499.63, + remaining_amount: 12499.63, + paid_amount: 0, + currency: 'SEK', + exchange_rate: null, + journal_entry_id: null, + customer: { name: 'Test AB' }, + }, + error: null, + }) // invoice fetch + enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' }, error: null }) // settings + enqueue({ data: [{ id: 'inv-1' }], error: null }) // invoice CAS update + enqueue({ data: null, error: null }) // invoice_payments insert + enqueue({ data: null, error: null }) // transactions update (link) + enqueue({ data: null, error: null }) // dispatcher pending_operations update + + const op = makePendingOp({ params: { transaction_id: 'tx-1', invoice_id: 'inv-1' } }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('committed') + expect(mockCreateJournalEntry).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + 'user-1', + expect.objectContaining({ + lines: expect.arrayContaining([ + expect.objectContaining({ account_number: '1930', debit_amount: 12500 }), + expect.objectContaining({ account_number: '1510', credit_amount: 12499.63 }), + expect.objectContaining({ account_number: '3740' }), + ]), + }), + ) + const invoiceUpdate = findCalls('invoices', 'update').at(-1)?.[0] + expect(invoiceUpdate).toMatchObject({ + status: 'paid', + paid_amount: 12499.63, + remaining_amount: 0, + }) + }) + + it('converts a cross-currency payment into the invoice currency before recording it', async () => { + // Parity with the dashboard/v1 routes: feeding the raw SEK amount into a + // USD invoice corrupts the units of paid_amount / remaining_amount and + // the invoice_payments row. + mockFetchExchangeRate.mockResolvedValue({ currency: 'USD', rate: 10, date: '2026-05-12' }) + const { supabase, enqueue, findCalls } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ + data: { + id: 'tx-1', + company_id: 'company-1', + amount: 12500, + amount_sek: 12500, + currency: 'SEK', + date: '2026-05-12', + invoice_id: null, + journal_entry_id: null, + cash_account_id: null, + }, + error: null, + }) // transaction fetch + enqueue({ + data: { + id: 'inv-1', + invoice_number: 'F-2026001', + status: 'sent', + total: 1250, + remaining_amount: 1250, + paid_amount: 0, + currency: 'USD', + exchange_rate: 10, + journal_entry_id: null, + customer: { name: 'US Inc' }, + }, + error: null, + }) // invoice fetch + enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' }, error: null }) // settings + enqueue({ data: [{ id: 'inv-1' }], error: null }) // invoice CAS update + enqueue({ data: null, error: null }) // invoice_payments insert + enqueue({ data: null, error: null }) // transactions update (link) + enqueue({ data: null, error: null }) // dispatcher pending_operations update + + const op = makePendingOp({ params: { transaction_id: 'tx-1', invoice_id: 'inv-1' } }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('committed') + // paid/remaining accumulate in INVOICE currency (1250 USD), not 12500 SEK. + const invoiceUpdate = findCalls('invoices', 'update').at(-1)?.[0] + expect(invoiceUpdate).toMatchObject({ + status: 'paid', + paid_amount: 1250, + remaining_amount: 0, + }) + const paymentInsert = findCalls('invoice_payments', 'insert').at(-1)?.[0] + expect(paymentInsert).toMatchObject({ amount: 1250, currency: 'USD', exchange_rate: 10 }) + }) + + it('rejects a cross-currency match when no exchange rate is available', async () => { + mockFetchExchangeRate.mockResolvedValue(null) + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ + data: { + id: 'tx-1', + company_id: 'company-1', + amount: 12500, + amount_sek: 12500, + currency: 'SEK', + date: '2026-05-12', + invoice_id: null, + journal_entry_id: null, + cash_account_id: null, + }, + error: null, + }) // transaction fetch + enqueue({ + data: { + id: 'inv-1', + invoice_number: 'F-2026001', + status: 'sent', + total: 1250, + remaining_amount: 1250, + paid_amount: 0, + currency: 'USD', + exchange_rate: 10, + journal_entry_id: null, + customer: { name: 'US Inc' }, + }, + error: null, + }) // invoice fetch + enqueue({ data: null, error: null }) // dispatcher pending_operations update + + const op = makePendingOp({ params: { transaction_id: 'tx-1', invoice_id: 'inv-1' } }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + // 400 (unlike 404/409 auto-rejects) surfaces as a plain failure; the op + // row itself is released as 'rejected' with the error payload. + expect(result.status).toBe('failed') + expect(result.http_status).toBe(400) + expect(mockCreateJournalEntry).not.toHaveBeenCalled() + }) }) diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index aae6784e..ce57fdaf 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -36,6 +36,8 @@ import { createCreditNoteJournalEntry, } from '@/lib/bookkeeping/invoice-entries' import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account' +import { buildInvoicePaymentClearingLines } from '@/lib/bookkeeping/invoice-payment-lines' +import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils' import { cashPartialBlockReason, supplierCreditNoteNeedsJournalEntry } from '@/lib/bookkeeping/booking-mode' import { ensureManualCashAccount } from '@/lib/cash-accounts/service' import { createJournalEntry, findFiscalPeriod, getSwedishLocalDate, reverseEntry, validateBalance } from '@/lib/bookkeeping/engine' @@ -2615,13 +2617,76 @@ async function commitMatchTransactionInvoice( return { error: 'Invoice is not in a matchable state', status: 409 } } + // FX resolution: parity with the dashboard and v1 match routes. paidAmount + // MUST be denominated in the INVOICE's currency (the unit of + // invoices.paid_amount / remaining_amount and invoice_payments.amount). + // This path previously fed the raw bank amount straight in, which (a) + // rejected exact whole-krona settlements of öre-carrying invoices and (b) + // would corrupt the column units on a cross-currency match. + const txIsForeign = !!transaction.currency && transaction.currency !== 'SEK' + if ( + txIsForeign && + transaction.amount_sek == null && + !(transaction.exchange_rate != null && transaction.exchange_rate > 0) + ) { + return { + error: + getErrorEntry('MATCH_INVOICE_TX_FX_RATE_MISSING')?.message_sv ?? + 'Transaktionen saknar valutakurs och SEK-belopp.', + status: 400, + } + } + const txAbsSek = + Math.round( + resolveSekAmount( + Math.abs(transaction.amount), + transaction.amount_sek != null ? Math.abs(transaction.amount_sek) : null, + transaction.currency, + transaction.exchange_rate, + ) * 100, + ) / 100 + + let fx: { required: false } | { required: true; rate: number; paidInInvoiceCurrency: number } = { + required: false, + } + if (transaction.currency !== invoice.currency) { + let rate: number | null = null + try { + const rateInfo = await fetchExchangeRate( + invoice.currency as Currency, + new Date(transaction.date), + supabase, + ) + if (rateInfo && rateInfo.rate > 0) rate = rateInfo.rate + } catch { + rate = null + } + if (rate == null) { + return { + error: + getErrorEntry('MATCH_INVOICE_FX_RATE_UNAVAILABLE')?.message_sv ?? + 'Ingen valutakurs tillgänglig för betalningsdatumet.', + status: 400, + } + } + fx = { + required: true, + rate, + paidInInvoiceCurrency: Math.round((txAbsSek / rate) * 10000) / 10000, + } + } + const paidAmount = fx.required ? fx.paidInInvoiceCurrency : transaction.amount + // Overshoot guard + paid/remaining math: shared with the dashboard and v1 // routes via planInvoicePayment. This agent/MCP path previously had NO guard, // so a 1500 payment on a 1000 invoice was silently accepted (paid_amount > - // total, AR over-credited). Runs BEFORE the storno + JE below, so a rejected - // match leaves the transaction untouched and never burns a voucher number. - const paidAmount = transaction.amount - const payment = planInvoicePayment(invoice, paidAmount) + // total, AR over-credited). Pure-SEK settlements absorb sub-krona + // öresavrundning (booked to 3740 by buildInvoicePaymentClearingLines) so a + // whole-krona payment settles in full, exactly as on the other two routes. + // Runs BEFORE the storno + JE below, so a rejected match leaves the + // transaction untouched and never burns a voucher number. + const pureSek = transaction.currency === 'SEK' && invoice.currency === 'SEK' + const payment = planInvoicePayment(invoice, paidAmount, { absorbOreRounding: pureSek }) if (!payment.ok) { return { error: @@ -2696,11 +2761,60 @@ async function commitMatchTransactionInvoice( ) journalEntryId = je?.id ?? null } else { - const je = await createInvoicePaymentJournalEntry( - supabase, companyId, userId, invoice as Invoice, transaction.date, undefined, invoice.customer?.name, paidAmount, - paymentAccount, - ) - journalEntryId = je?.id ?? null + // Clearing entry against 1510, built by the SAME shared helper the + // dashboard and v1 routes use, so all three produce byte-identical + // lines: bank leg = the actual SEK that hit the account, 1510 credited + // at the invoice's booking rate, and a 3960/7960 FX-diff line (or a + // 3740 öresavrundning line on pure SEK) making the verifikat balance. + // The old createInvoicePaymentJournalEntry(paidAmount) shape could not + // carry either residual, so öre-settled and cross-currency matches + // left 1510 unclean. Failure semantics preserved: no fiscal period + // still soft-fails to journalEntryId = null like the old builder did. + const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, transaction.date) + if (!fiscalPeriodId) { + log.warn('No open fiscal period found for payment date:', transaction.date) + } else { + 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, + fx.required ? fx.paidInInvoiceCurrency : undefined, + paymentAccount, + ) + // Re-propagate the invoice's default dimension bag onto every leg, + // including the FX result lines, so a project's kursvinst/kursförlust + // stays inside the project P&L: the shared line-builder is + // dimension-agnostic. + const defaultDimensions = coerceDimensionsBag( + (invoice as { default_dimensions?: unknown }).default_dimensions, + ) + if (defaultDimensions) { + for (const line of clearingLines) line.dimensions = { ...defaultDimensions } + } + const je = await createJournalEntry(supabase, companyId, userId, { + fiscal_period_id: fiscalPeriodId, + entry_date: transaction.date, + description: desc, + source_type: 'invoice_paid', + source_id: invoice.id, + lines: clearingLines, + }) + journalEntryId = je?.id ?? null + } } } catch (err) { // Recoverable: the dispatcher releases the op back to 'pending' and this @@ -2761,7 +2875,7 @@ async function commitMatchTransactionInvoice( payment_date: transaction.date, amount: paidAmount, currency: invoice.currency, - exchange_rate: invoice.exchange_rate, + exchange_rate: fx.required ? fx.rate : invoice.exchange_rate, journal_entry_id: journalEntryId, transaction_id: transactionId, notes: null,