Files
accounted/app/api/transactions/[id]/match-supplier-invoice/__tests__/route.test.ts
T
17d48ebb84 fix(invoices): guard cash-method partial payments across every payment path (#1413)
* fix(invoices): guard cash-method partial payments across every payment path

A never-booked kontantmetoden invoice can only be settled by the generated
cash entry (createInvoiceCashEntry / createSupplierInvoiceCashEntry), and
that entry always books the FULL invoice: it takes no payment amount. Three
payment surfaces still let partial payments through to it, corrupting books:

- settleInvoicePayment dropped the fully-paid term entirely, so a partial
  payment (Stripe sync, mark-paid) booked the entire invoice: over-recognized
  revenue, over-declared output VAT, and a bank debit that did not match the
  money received.
- The dashboard and agent match-transaction paths fell back to an
  accrual-style clearing entry against an EMPTY 1510: negative receivable,
  no revenue, no moms (ML 13 kap 8 § puts each installment's moms in its own
  receipt period). The comment claimed the credit "gets resolved on final
  payment", but the cash builder never touches 1510 and books the full
  total, so the final payment double-debited the bank instead.
- The supplier routes had no full-settlement term at all, so a partial
  payment booked the full expense + input VAT.

Fix: one shared predicate (cashPartialBlockReason in booking-mode.ts)
rejects generated cash entries unless the payment settles the invoice in
full from a fully unpaid state, wired into all six POST surfaces, the agent
commit paths, and the three preview routes (so dialogs cannot propose a
verifikat the POST refuses). New bilingual error codes
INVOICE_PAID_CASH_PARTIAL_UNSUPPORTED / SI_CASH_PARTIAL_UNSUPPORTED.
Invoices booked at issue are unaffected: their partial payments keep the
normal 1510/2440 clearing path.

The v1 match-invoice route already had this guard (VALIDATION_ERROR); its
behavior is unchanged. Proper per-installment recognition (proportional
revenue + moms per receipt) is the follow-up feature that would lift this
restriction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(review): drop stale ML 13 kap 8 § cites for kontantmetoden VAT timing

Compliance-review finding: the section is the old ML 1994:200 numbering; in
ML 2023:200, 13 kap covers input-VAT deduction, not redovisningstidpunkt.
The substantive rule (bokslutsmetoden reports moms at payment, per
installment, except at year-end) is unchanged and stated without a section
cite until the current-law section is verified. Comments and cookbook prose
only; no behavior change. Also fixes the two pre-existing occurrences.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 18:05:11 +02:00

805 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createQueuedMockSupabase,
createMockRouteParams,
parseJsonResponse,
} from '@/tests/helpers'
import { AccountsNotInChartError } from '@/lib/bookkeeping/errors'
const { mockLoggerWarn } = vi.hoisted(() => ({ mockLoggerWarn: vi.fn() }))
vi.mock('@/lib/logger', () => ({
createLogger: () => ({
info: vi.fn(),
warn: mockLoggerWarn,
error: vi.fn(),
child: vi.fn().mockReturnThis(),
}),
}))
const { supabase: mockSupabase, enqueue, reset, findCalls } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
vi.mock('@/lib/invoices/match-log', () => ({
logMatchEvent: vi.fn(),
}))
// 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.
const mockClearSuggestions = vi.fn()
vi.mock('@/lib/invoices/clear-settled-invoice-suggestions', () => ({
clearSettledInvoiceSuggestions: (...args: unknown[]) => mockClearSuggestions(...args),
}))
vi.mock('@/lib/events/bus', () => ({
eventBus: { emit: vi.fn() },
}))
const mockCreatePaymentEntry = vi.fn()
const mockCreateCashEntry = vi.fn()
vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({
createSupplierInvoicePaymentEntry: (...args: unknown[]) => mockCreatePaymentEntry(...args),
createSupplierInvoiceCashEntry: (...args: unknown[]) => mockCreateCashEntry(...args),
}))
// Pure-SEK clearing now posts via the shared builder + createJournalEntry
// (not createSupplierInvoicePaymentEntry). Mock the engine so that path doesn't
// hit the queued Supabase mock.
const mockCreateJournalEntry = vi.fn()
const mockFindFiscalPeriod = vi.fn()
vi.mock('@/lib/bookkeeping/engine', () => ({
createJournalEntry: (...args: unknown[]) => mockCreateJournalEntry(...args),
findFiscalPeriod: (...args: unknown[]) => mockFindFiscalPeriod(...args),
}))
import { POST } from '../route'
import { eventBus } from '@/lib/events/bus'
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
mockCreatePaymentEntry.mockResolvedValue({ id: 'je-1' })
mockCreateCashEntry.mockResolvedValue({ id: 'je-1' })
mockCreateJournalEntry.mockResolvedValue({ id: 'je-1' })
mockFindFiscalPeriod.mockResolvedValue('fp-1')
})
const TX_UUID = '11111111-1111-4111-8111-111111111111'
const SI_UUID = '22222222-2222-4222-8222-222222222222'
function makeReq() {
return new Request(`http://localhost/api/transactions/${TX_UUID}/match-supplier-invoice`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ supplier_invoice_id: SI_UUID }),
})
}
function enqueueHappyPath(opts: {
transaction: {
amount: number
currency: string
amount_sek?: number | null
cash_account_id?: string | null
document_id?: string | null
}
invoice: {
currency: string
exchange_rate?: number | null
remaining_amount?: number
paid_amount?: number
status?: string
}
accountingMethod?: string
// ledger_account returned by the cash_accounts lookup; only enqueued when
// the transaction carries a cash_account_id.
cashAccountLedger?: string
}) {
// 1. transactions fetch
enqueue({
data: {
id: TX_UUID,
company_id: 'company-1',
amount: opts.transaction.amount,
currency: opts.transaction.currency,
amount_sek: opts.transaction.amount_sek ?? null,
supplier_invoice_id: null,
cash_account_id: opts.transaction.cash_account_id ?? null,
document_id: opts.transaction.document_id ?? null,
date: '2026-05-12',
},
error: null,
})
// 2. supplier_invoices fetch
enqueue({
data: {
id: SI_UUID,
currency: opts.invoice.currency,
exchange_rate: opts.invoice.exchange_rate ?? null,
status: opts.invoice.status ?? 'registered',
remaining_amount: opts.invoice.remaining_amount ?? 225,
paid_amount: opts.invoice.paid_amount ?? 0,
supplier: { supplier_type: 'eu_business' },
items: [],
},
error: null,
})
// 3. company_settings fetch
enqueue({ data: { accounting_method: opts.accountingMethod ?? 'accrual' }, error: null })
// 4. cash_accounts lookup (only when the transaction is linked to one)
if (opts.transaction.cash_account_id) {
enqueue({ data: { ledger_account: opts.cashAccountLedger ?? '1930' }, error: null })
}
// 5. supplier_invoices update (CAS)
enqueue({ data: [{ id: SI_UUID }], error: null })
// 6. supplier_invoice_payments insert
enqueue({ data: null, error: null })
// 7. transactions update (link)
enqueue({ data: null, error: null })
}
describe('POST /api/transactions/[id]/match-supplier-invoice: FX residual', () => {
it('books a clean SEK clearing entry (no FX) for a SEK tx paying a SEK invoice', async () => {
// SEK/SEK now routes through buildSupplierPaymentClearingLines +
// createJournalEntry, not createSupplierInvoicePaymentEntry. An exact
// payment yields just Dr 2440 / Cr 1930: no 3960/7960 FX line, no 3740.
enqueueHappyPath({
transaction: { amount: -2390, currency: 'SEK' },
invoice: { currency: 'SEK', remaining_amount: 2390 },
})
await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
expect(mockCreatePaymentEntry).not.toHaveBeenCalled()
expect(mockCreateJournalEntry).toHaveBeenCalledTimes(1)
const input = mockCreateJournalEntry.mock.calls[0][3] as {
lines: Array<{ account_number: string; debit_amount: number; credit_amount: number }>
}
expect(input.lines).toHaveLength(2)
expect(input.lines.find((l) => l.account_number === '2440')?.debit_amount).toBe(2390)
expect(input.lines.find((l) => l.account_number === '1930')?.credit_amount).toBe(2390)
expect(
input.lines.some((l) => ['3960', '7960', '3740'].includes(l.account_number)),
).toBe(false)
})
it('computes a loss when the SEK paid exceeds the AP booked SEK (EUR invoice)', async () => {
// Invoice: 225 EUR @ rate 10.6254 → AP booked at 2390.72 SEK.
// Bank: paid 2400 SEK out of a SEK account.
// → diff = 2390.72 − 2400 = −9.28 (loss, debit 7960).
enqueueHappyPath({
transaction: { amount: -2400, currency: 'SEK' },
invoice: { currency: 'EUR', exchange_rate: 10.6254, remaining_amount: 225 },
})
await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
const args = mockCreatePaymentEntry.mock.calls[0]
expect(args[4]).toBeCloseTo(2390.72, 2) // paymentAmountSek = originalBookedSek
expect(args[6]).toBeCloseTo(-9.28, 2) // exchangeRateDifference (loss)
})
it('computes a gain when the SEK paid is less than the AP booked SEK', async () => {
// Invoice: 100 EUR @ rate 11 → AP booked at 1100 SEK.
// Bank: paid 1080 SEK (rate had dipped) → diff = 1100 − 1080 = +20 (gain → credit 3960).
enqueueHappyPath({
transaction: { amount: -1080, currency: 'SEK' },
invoice: { currency: 'EUR', exchange_rate: 11, remaining_amount: 100 },
})
await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
const args = mockCreatePaymentEntry.mock.calls[0]
expect(args[4]).toBeCloseTo(1100, 2)
expect(args[6]).toBeCloseTo(20, 2)
})
it('uses transaction.amount_sek for a foreign-currency bank transaction', async () => {
// Reverse case: SEK invoice for 1000 kr, paid from a EUR card that
// showed amount_sek = 1063 (rate had moved).
// → diff = 1000 − 1063 = −63 (loss).
enqueueHappyPath({
transaction: { amount: -100, currency: 'EUR', amount_sek: -1063 },
invoice: { currency: 'SEK', remaining_amount: 1000 },
})
await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
const args = mockCreatePaymentEntry.mock.calls[0]
// SEK invoice: originalBookedSek = remaining = 1000, FX diff = 1000 - 1063 = -63
expect(args[4]).toBe(1000)
expect(args[6]).toBeCloseTo(-63, 2)
})
it('falls back to bank SEK when the invoice has no exchange_rate on file', async () => {
// Foreign-currency invoice but exchange_rate is null on the row.
// Without a rate we can't compute the AP-booked SEK precisely, so we
// pass the actual bank SEK and skip the FX diff.
enqueueHappyPath({
transaction: { amount: -239, currency: 'SEK' },
invoice: { currency: 'USD', exchange_rate: null, remaining_amount: 25 },
})
await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
const args = mockCreatePaymentEntry.mock.calls[0]
expect(args[4]).toBe(239)
expect(args[6]).toBeUndefined()
})
})
describe('POST /api/transactions/[id]/match-supplier-invoice: settlement account resolution', () => {
// Regression for a real misbooking: a company whose last few supplier
// invoices were paid privately (mark-paid sets company_settings
// .last_supplier_payment_account = '2893') later matched a genuine bank
// transaction to a supplier invoice. The route used to default
// paymentAccount from that sticky setting, so the payment credited 2893
// (shareholder loan) instead of the transaction's real 1930 bank account.
it('credits the account this transaction is linked to, even with a stale last_supplier_payment_account on file', async () => {
enqueue({
data: {
id: TX_UUID,
company_id: 'company-1',
amount: -1001,
currency: 'SEK',
amount_sek: null,
supplier_invoice_id: null,
cash_account_id: 'ca-1930',
date: '2026-02-01',
},
error: null,
})
enqueue({
data: {
id: SI_UUID,
currency: 'SEK',
exchange_rate: null,
status: 'registered',
remaining_amount: 1001,
paid_amount: 0,
supplier: { supplier_type: 'swedish_business' },
items: [],
},
error: null,
})
// Stale sticky setting from an earlier private-funds payment: must be
// ignored now that the route no longer selects it.
enqueue({ data: { accounting_method: 'accrual', last_supplier_payment_account: '2893' }, error: null })
enqueue({ data: { ledger_account: '1930' }, error: null }) // cash_accounts lookup
enqueue({ data: [{ id: SI_UUID }], error: null })
enqueue({ data: null, error: null })
enqueue({ data: null, error: null })
await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
expect(mockCreateJournalEntry).toHaveBeenCalledTimes(1)
const input = mockCreateJournalEntry.mock.calls[0][3] as {
lines: Array<{ account_number: string; debit_amount: number; credit_amount: number }>
}
expect(input.lines.find((l) => l.account_number === '1930')?.credit_amount).toBe(1001)
expect(input.lines.some((l) => l.account_number === '2893')).toBe(false)
})
it('credits the transaction\'s own linked cash account when it is not the primary 1930', async () => {
enqueue({
data: {
id: TX_UUID,
company_id: 'company-1',
amount: -500,
currency: 'SEK',
amount_sek: null,
supplier_invoice_id: null,
cash_account_id: 'ca-1940',
date: '2026-02-01',
},
error: null,
})
enqueue({
data: {
id: SI_UUID,
currency: 'SEK',
exchange_rate: null,
status: 'registered',
remaining_amount: 500,
paid_amount: 0,
supplier: { supplier_type: 'swedish_business' },
items: [],
},
error: null,
})
enqueue({ data: { accounting_method: 'accrual' }, error: null })
enqueue({ data: { ledger_account: '1940' }, error: null }) // cash_accounts lookup
enqueue({ data: [{ id: SI_UUID }], error: null })
enqueue({ data: null, error: null })
enqueue({ data: null, error: null })
await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
const input = mockCreateJournalEntry.mock.calls[0][3] as {
lines: Array<{ account_number: string; debit_amount: number; credit_amount: number }>
}
expect(input.lines.find((l) => l.account_number === '1940')?.credit_amount).toBe(500)
})
it('defaults to 1930 when the transaction has no linked cash account', async () => {
enqueueHappyPath({
transaction: { amount: -750, currency: 'SEK' },
invoice: { currency: 'SEK', remaining_amount: 750 },
})
await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
const input = mockCreateJournalEntry.mock.calls[0][3] as {
lines: Array<{ account_number: string; debit_amount: number; credit_amount: number }>
}
expect(input.lines.find((l) => l.account_number === '1930')?.credit_amount).toBe(750)
})
it('aborts with 500 BOOKKEEPING_DATABASE_ERROR (mutates nothing) when the cash_accounts lookup errors', async () => {
// Regression: an explicit cash_account_id almost certainly resolves to a
// non-1930 account, so a transient lookup failure must not silently
// degrade to 1930 (same misbooking risk this whole fix exists to close,
// just triggered by infra flakiness instead of a stale setting). The
// request should fail before any state mutation, not book to a guessed
// account.
enqueue({
data: {
id: TX_UUID,
company_id: 'company-1',
amount: -600,
currency: 'SEK',
amount_sek: null,
supplier_invoice_id: null,
cash_account_id: 'ca-broken',
date: '2026-02-01',
},
error: null,
})
enqueue({
data: {
id: SI_UUID,
currency: 'SEK',
exchange_rate: null,
status: 'registered',
remaining_amount: 600,
paid_amount: 0,
supplier: { supplier_type: 'swedish_business' },
items: [],
},
error: null,
})
enqueue({ data: { accounting_method: 'accrual' }, error: null })
enqueue({ data: null, error: { message: 'connection reset' } }) // cash_accounts lookup errors
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(res)
expect(status).toBe(500)
expect(body.error.code).toBe('BOOKKEEPING_DATABASE_ERROR')
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
})
})
describe('POST /api/transactions/[id]/match-supplier-invoice: settlement account on FX and cash-method branches', () => {
// Regression locks for #1000: the FX branch (createSupplierInvoicePaymentEntry)
// and cash-method branch (createSupplierInvoiceCashEntry) must receive the
// resolved settlement account, not fall back to their internal 1930 default
// whenever the transaction is linked to a different cash account.
it('FX branch: passes the linked non-1930 account to createSupplierInvoicePaymentEntry', async () => {
enqueueHappyPath({
transaction: { amount: -2400, currency: 'SEK', cash_account_id: 'ca-1940' },
invoice: { currency: 'EUR', exchange_rate: 10.6254, remaining_amount: 225 },
cashAccountLedger: '1940',
})
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
expect(res.status).toBe(200)
expect(mockCreatePaymentEntry).toHaveBeenCalledTimes(1)
const args = mockCreatePaymentEntry.mock.calls[0]
// Confirm the FX branch actually ran: booked 2390.72, paid 2400 -> loss 9.28.
expect(args[6]).toBeCloseTo(-9.28, 2)
expect(args[8]).toBe('1940')
})
it('FX branch: falls back to 1930 when the transaction has no linked cash account', async () => {
enqueueHappyPath({
transaction: { amount: -2400, currency: 'SEK' },
invoice: { currency: 'EUR', exchange_rate: 10.6254, remaining_amount: 225 },
})
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
expect(res.status).toBe(200)
expect(mockCreatePaymentEntry.mock.calls[0][8]).toBe('1930')
})
it('cash-method branch: passes the linked non-1930 account to createSupplierInvoiceCashEntry', async () => {
enqueueHappyPath({
transaction: { amount: -500, currency: 'SEK', cash_account_id: 'ca-1940' },
invoice: { currency: 'SEK', remaining_amount: 500 },
accountingMethod: 'cash',
cashAccountLedger: '1940',
})
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
expect(res.status).toBe(200)
expect(mockCreateCashEntry).toHaveBeenCalledTimes(1)
expect(mockCreatePaymentEntry).not.toHaveBeenCalled()
const args = mockCreateCashEntry.mock.calls[0]
expect(args[8]).toBe('1940')
// Pure SEK settlement: no settledBankSek override.
expect(args[9]).toBeUndefined()
})
it('cash-method branch: falls back to 1930 when the transaction has no linked cash account', async () => {
enqueueHappyPath({
transaction: { amount: -500, currency: 'SEK' },
invoice: { currency: 'SEK', remaining_amount: 500 },
accountingMethod: 'cash',
})
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
expect(res.status).toBe(200)
expect(mockCreateCashEntry.mock.calls[0][8]).toBe('1930')
})
})
describe('POST /api/transactions/[id]/match-supplier-invoice: non-FX paths', () => {
it('returns 200 with the expected body shape on the happy path', async () => {
enqueueHappyPath({
transaction: { amount: -1000, currency: 'SEK' },
invoice: { currency: 'SEK', remaining_amount: 1000 },
})
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
const { status, body } = await parseJsonResponse<{
success: boolean
paid_amount: number
remaining_amount: number
}>(res)
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(body.paid_amount).toBe(1000)
expect(body.remaining_amount).toBe(0)
const invoiceUpdate = findCalls('supplier_invoices', 'update').at(-1)?.[0]
expect(invoiceUpdate).toMatchObject({ paid_at: '2026-05-12T12:00:00Z' })
expect(vi.mocked(eventBus.emit)).toHaveBeenCalledWith(
expect.objectContaining({
type: 'supplier_invoice.match_confirmed',
payload: expect.objectContaining({
supplierInvoice: expect.objectContaining({
status: 'paid',
paid_at: '2026-05-12T12:00:00Z',
paid_amount: 1000,
remaining_amount: 0,
}),
transaction: expect.objectContaining({
supplier_invoice_id: SI_UUID,
journal_entry_id: 'je-1',
}),
}),
}),
)
})
// The suggestion pointer must not survive the match that consumes it: this
// request marks the invoice paid, so a surviving hint would point at a
// settled invoice. The customer-invoice route has always cleared its own
// field; this one did not.
it('clears potential_supplier_invoice_id when it links the matched transaction', async () => {
enqueueHappyPath({
transaction: { amount: -1000, currency: 'SEK' },
invoice: { currency: 'SEK', remaining_amount: 1000 },
})
await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
const txUpdate = findCalls('transactions', 'update').at(-1)?.[0]
expect(txUpdate).toMatchObject({
supplier_invoice_id: SI_UUID,
potential_supplier_invoice_id: null,
is_business: true,
})
})
// Issue #1259: the settled invoice's pointer must also be retired from every
// OTHER transaction that still carries it as an import-time suggestion.
it('retires the settled invoice suggestion on the other transactions', async () => {
enqueueHappyPath({
transaction: { amount: -1000, currency: 'SEK' },
invoice: { currency: 'SEK', remaining_amount: 1000 },
})
await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
expect(mockClearSuggestions).toHaveBeenCalledTimes(1)
expect(mockClearSuggestions).toHaveBeenCalledWith(
mockSupabase,
'company-1',
'supplier_invoice',
SI_UUID,
{ exceptTransactionId: TX_UUID },
)
})
it('leaves the suggestions alone on a partial payment: the invoice is still matchable', async () => {
enqueueHappyPath({
transaction: { amount: -400, currency: 'SEK' },
invoice: { currency: 'SEK', remaining_amount: 1000 },
})
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
const { body } = await parseJsonResponse<{ invoice_status: string }>(res)
expect(body.invoice_status).toBe('partially_paid')
expect(mockClearSuggestions).not.toHaveBeenCalled()
})
it('öresavrundning: a whole-krona Bankgiro payment settles an öre-bearing invoice in full via 3740', async () => {
// The reported bug: invoice 11 231,25, bank paid 11 231 → previously left
// 0,25 stranded as partially_paid. Now → paid, with 0,25 booked to 3740.
enqueueHappyPath({
transaction: { amount: -11231, currency: 'SEK' },
invoice: { currency: 'SEK', remaining_amount: 11231.25 },
})
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
const { status, body } = await parseJsonResponse<{
invoice_status: string
paid_amount: number
remaining_amount: number
}>(res)
expect(status).toBe(200)
expect(body.invoice_status).toBe('paid')
expect(body.remaining_amount).toBe(0)
expect(body.paid_amount).toBe(11231.25)
expect(mockCreatePaymentEntry).not.toHaveBeenCalled()
const input = mockCreateJournalEntry.mock.calls[0][3] as {
lines: Array<{ account_number: string; debit_amount: number; credit_amount: number }>
}
expect(input.lines.find((l) => l.account_number === '2440')?.debit_amount).toBe(11231.25)
expect(input.lines.find((l) => l.account_number === '1930')?.credit_amount).toBe(11231)
expect(input.lines.find((l) => l.account_number === '3740')?.credit_amount).toBe(0.25)
})
it('returns 400 MATCH_SI_AMOUNT_EXCEEDS_REMAINING when tx exceeds invoice remaining (same currency)', async () => {
// Tx pays out 6 000 SEK, invoice has 5 000 SEK remaining. Legacy code path
// would push paid_amount past invoice.total. The new guard rejects so the
// user routes the excess through the split-payment flow.
enqueue({
data: {
id: TX_UUID,
company_id: 'company-1',
amount: -6000,
currency: 'SEK',
amount_sek: null,
supplier_invoice_id: null,
date: '2026-05-12',
},
error: null,
})
enqueue({
data: {
id: SI_UUID,
currency: 'SEK',
exchange_rate: null,
status: 'registered',
remaining_amount: 5000,
paid_amount: 0,
supplier: { supplier_type: 'swedish_business' },
items: [],
},
error: null,
})
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
const { status, body } = await parseJsonResponse<{ error: unknown }>(res)
expect(status).toBe(400)
expect((body.error as { code: string }).code).toBe('MATCH_SI_AMOUNT_EXCEEDS_REMAINING')
const details = (body.error as { details: Record<string, number> }).details
expect(details.transaction_amount).toBe(6000)
expect(details.remaining_amount).toBe(5000)
expect(details.excess).toBe(1000)
})
it('succeeds for an overdue invoice (status is a valid CAS target)', async () => {
// Regression: CAS guard previously omitted 'overdue', so selecting an overdue
// invoice from SupplierInvoicePicker would commit a JE, fail the update, orphan
// the voucher, and return MATCH_SI_NOT_OPEN.
enqueueHappyPath({
transaction: { amount: -1000, currency: 'SEK' },
invoice: { currency: 'SEK', remaining_amount: 1000, status: 'overdue' },
})
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
expect(res.status).toBe(200)
})
it('does NOT trigger overshoot guard on currency mismatch (FX path clamps to remaining)', async () => {
// SEK transaction paying a EUR invoice. The currency-mismatch branch
// collapses paymentAmountInvoiceCurrency to invoice.remaining_amount and
// cannot overshoot, so the guard must not fire here.
enqueueHappyPath({
transaction: { amount: -10000, currency: 'SEK' },
invoice: { currency: 'EUR', remaining_amount: 200, exchange_rate: 11.5 },
})
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
expect(res.status).toBe(200)
})
})
describe('POST /api/transactions/[id]/match-supplier-invoice: cash method + FX', () => {
it('full cross-currency settlement books at the payment rate (no FX-unsupported error)', async () => {
// Cash method, SEK account paying a 25 USD invoice. The invoice's stored
// rate (9.20 → 230 SEK) differs from the 239 SEK that actually left the
// bank: previously this was blocked. It must now succeed and hand the
// cash builder the real bank SEK so 1930 matches the bank line.
enqueueHappyPath({
transaction: { amount: -239, currency: 'SEK' },
invoice: { currency: 'USD', exchange_rate: 9.20, remaining_amount: 25 },
accountingMethod: 'cash',
})
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
expect(res.status).toBe(200)
expect(mockCreateCashEntry).toHaveBeenCalledTimes(1)
expect(mockCreatePaymentEntry).not.toHaveBeenCalled()
// settledBankSek is the 10th positional arg (index 9).
expect(mockCreateCashEntry.mock.calls[0][9]).toBe(239)
})
it('full same-currency foreign settlement passes the actual bank SEK to the cash builder', async () => {
// 19 USD invoice paid from a USD card showing amount_sek = 175.28, while
// the invoice was captured at 9.20 (174.80). Full settlement → booked at
// the payment rate (175.28), no kursdifferens.
enqueueHappyPath({
transaction: { amount: -19, currency: 'USD', amount_sek: -175.28 },
invoice: { currency: 'USD', exchange_rate: 9.20, remaining_amount: 19 },
accountingMethod: 'cash',
})
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
expect(res.status).toBe(200)
expect(mockCreateCashEntry.mock.calls[0][9]).toBe(175.28)
})
it('foreign tx with no amount_sek books at the invoice rate, not the raw foreign amount', async () => {
// The bank line carries no stored SEK (amount_sek null). The old fallback
// treated 19 USD as 19 SEK → "19 kr". We must instead use the invoice's
// rate (≈175 kr): no settledBankSek override is passed (FX diff is 0,
// there's no independent bank figure) and the entry is NOT blocked.
enqueueHappyPath({
transaction: { amount: -19, currency: 'USD', amount_sek: null },
invoice: { currency: 'USD', exchange_rate: 9.225, remaining_amount: 19 },
accountingMethod: 'cash',
})
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
expect(res.status).toBe(200)
expect(mockCreateCashEntry).toHaveBeenCalledTimes(1)
// No bogus settledBankSek=19 override: the builder uses the invoice rate.
expect(mockCreateCashEntry.mock.calls[0][9]).toBeUndefined()
})
it('PARTIAL foreign payment under the cash method is still rejected', async () => {
// Paying only 10 of 19 USD remaining. The cash builder books the whole
// invoice, so a partial bank amount cannot pin the entry: still blocked.
enqueueHappyPath({
transaction: { amount: -10, currency: 'USD', amount_sek: -92.25 },
invoice: { currency: 'USD', exchange_rate: 9.20, remaining_amount: 19 },
accountingMethod: 'cash',
})
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(res)
expect(status).toBe(400)
expect(body.error.code).toBe('MATCH_SI_CASH_FX_UNSUPPORTED')
expect(mockCreateCashEntry).not.toHaveBeenCalled()
})
it('does NOT absorb öre under the cash method: a SEK sub-krona diff is rejected as partial', async () => {
// Kontantmetoden books the full invoice via the cash entry (not the bank
// amount), so folding the 0,25 to 3740 would hide a 1930 discrepancy. The
// öre band is accrual-only. Previously the sub-krona shortfall booked the
// FULL cash entry while leaving the invoice partially_paid (an over-book
// the invoice could never recover from); now it is rejected outright.
enqueueHappyPath({
transaction: { amount: -11231, currency: 'SEK' },
invoice: { currency: 'SEK', remaining_amount: 11231.25 },
accountingMethod: 'cash',
})
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(res)
expect(status).toBe(400)
expect(body.error.code).toBe('SI_CASH_PARTIAL_UNSUPPORTED')
expect(mockCreateCashEntry).not.toHaveBeenCalled()
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
})
})
describe('POST /api/transactions/[id]/match-supplier-invoice: transaction document propagation', () => {
const DOC_UUID = '33333333-3333-4333-8333-333333333333'
it('links a pinned transaction document to the payment JE after the match', async () => {
enqueueHappyPath({
transaction: { amount: -1000, currency: 'SEK', document_id: DOC_UUID },
invoice: { currency: 'SEK', remaining_amount: 1000 },
})
// 8. document_attachments update (the propagation)
enqueue({ data: null, error: null })
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
expect(res.status).toBe(200)
const tables = mockSupabase.from.mock.calls.map((c) => c[0])
expect(tables).toContain('document_attachments')
})
it('does not touch document_attachments when the transaction has no pinned doc', async () => {
enqueueHappyPath({
transaction: { amount: -1000, currency: 'SEK' },
invoice: { currency: 'SEK', remaining_amount: 1000 },
})
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
expect(res.status).toBe(200)
const tables = mockSupabase.from.mock.calls.map((c) => c[0])
expect(tables).not.toContain('document_attachments')
})
it('propagation failure is non-fatal: the committed match still returns 200', async () => {
enqueueHappyPath({
transaction: { amount: -1000, currency: 'SEK', document_id: DOC_UUID },
invoice: { currency: 'SEK', remaining_amount: 1000 },
})
// Propagation update errors (e.g. period locked between commit and link).
enqueue({ data: null, error: { message: 'BFL period lock' } })
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
const { status, body } = await parseJsonResponse<{ success: boolean }>(res)
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(mockLoggerWarn).toHaveBeenCalledWith(
expect.stringContaining('failed to link transaction document'),
expect.anything(),
)
})
})
describe('POST /api/transactions/[id]/match-supplier-invoice: payment JE failure aborts', () => {
// Regression: the route used to catch a JE-creation failure and proceed: // marking the invoice paid with NO payment voucher. That half-state is
// unrecoverable (mark-paid rejects 'paid', match rejects linked txs), so a
// failed voucher must now fail the whole match before any state mutation.
it('returns 500 MATCH_SI_JE_FAILED and mutates nothing when the engine throws (pure-SEK path)', async () => {
// Only the 3 reads enqueued: if the route (incorrectly) proceeded to the
// invoice update, the empty queue would surface as MATCH_SI_NOT_OPEN.
enqueueHappyPath({
transaction: { amount: -29890, currency: 'SEK' },
invoice: { currency: 'SEK', remaining_amount: 29890 },
})
mockCreateJournalEntry.mockRejectedValue(new Error('boom'))
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(res)
expect(status).toBe(500)
expect(body.error.code).toBe('MATCH_SI_JE_FAILED')
})
it('maps a bookkeeping error (missing account) to its structured code and aborts', async () => {
enqueueHappyPath({
transaction: { amount: -11231, currency: 'SEK' },
invoice: { currency: 'SEK', remaining_amount: 11231.25 },
})
mockCreateJournalEntry.mockRejectedValue(new AccountsNotInChartError(['3740']))
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
const { status, body } = await parseJsonResponse<{
error: { code: string; details?: { account_numbers?: string[] } }
}>(res)
expect(status).toBeGreaterThanOrEqual(400)
expect(body.error.code).toBe(new AccountsNotInChartError(['3740']).code)
expect(body.error.details?.account_numbers).toEqual(['3740'])
})
it('returns MATCH_SI_JE_FAILED when the engine resolves without an entry', async () => {
enqueueHappyPath({
transaction: { amount: -100, currency: 'SEK' },
invoice: { currency: 'SEK', remaining_amount: 100 },
})
mockCreateJournalEntry.mockResolvedValue(null)
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(res)
expect(status).toBe(500)
expect(body.error.code).toBe('MATCH_SI_JE_FAILED')
})
})