fix(matching): bring the MCP match-invoice commit path to parity with the dashboard route (#1646)

commitMatchTransactionInvoice fed the raw bank amount into
planInvoicePayment with no currency conversion and no absorbOreRounding,
both of which the dashboard and v1 routes do. Consequences: an exact
whole-krona settlement of an ore-carrying invoice was rejected as
MATCH_AMOUNT_EXCEEDS_REMAINING, and a cross-currency match would have
recorded SEK figures in invoice-currency columns.

Parity changes, mirroring the v1 route block:
- FX resolution: refuse foreign rows with no SEK value
  (MATCH_INVOICE_TX_FX_RATE_MISSING), convert via the Riksbanken spot
  rate on the payment date, refuse when no rate exists
  (MATCH_INVOICE_FX_RATE_UNAVAILABLE).
- planInvoicePayment gets absorbOreRounding on pure-SEK settlements.
- The accrual clearing entry is now built by the shared
  buildInvoicePaymentClearingLines helper (same as dashboard/v1), so the
  3740 oresavrundning line and 3960/7960 FX-diff lines exist and 1510 is
  credited at the invoice's booking rate. Dimension re-propagation
  included. Failure semantics preserved (no fiscal period still
  soft-fails like the old builder).
- invoice_payments.exchange_rate records the payment-date rate on
  cross-currency matches.

Reported via gnubok_feedback 2026-07-24 (codex/hermes: 14 875 SEK against
exactly 14 875 outstanding rejected as exceeding remaining).

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-17 22:21:18 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent e70718eea3
commit 15a1c96292
3 changed files with 349 additions and 26 deletions
@@ -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<typeof import('@/lib/bookkeeping/engine')>(
'@/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)
@@ -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<typeof import('@/lib/bookkeeping/engine')>(
'@/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<typeof import('@/lib/currency/riksbanken')>(
'@/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()
})
})
+124 -10
View File
@@ -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,