Files
accounted/app/api/transactions/[id]/match-supplier-invoice/__tests__/route.test.ts
T
Jonas Flodén da692c2898 fix(transactions): include 'overdue' in match-supplier-invoice CAS guard (#779)
SupplierInvoicePicker shows overdue invoices as payable candidates, but
the CAS update in the match route omitted 'overdue' from its status
whitelist. This caused the update to return 0 rows for any overdue
invoice, committing a journal entry and then orphaning it before
returning MATCH_SI_NOT_OPEN — making the match appear to fail due to a
concurrent request. The v1 route already had this correct.

Signed-off-by: Jonas Flodén <jonas@floden.nu>
2026-06-29 22:13:48 +02:00

451 lines
19 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 { supabase: mockSupabase, enqueue, reset } = 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(),
}))
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'
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 }
invoice: {
currency: string
exchange_rate?: number | null
remaining_amount?: number
paid_amount?: number
status?: string
}
accountingMethod?: 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,
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. supplier_invoices update (CAS)
enqueue({ data: [{ id: SI_UUID }], error: null })
// 5. supplier_invoice_payments insert
enqueue({ data: null, error: null })
// 6. 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 — 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)
})
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 stays 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; here the invoice stays partially_paid.
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<{
invoice_status: string
remaining_amount: number
}>(res)
expect(status).toBe(200)
expect(mockCreateCashEntry).toHaveBeenCalledTimes(1)
expect(mockCreateJournalEntry).not.toHaveBeenCalled() // no 3740 clearing entry
expect(body.invoice_status).toBe('partially_paid')
expect(body.remaining_amount).toBe(0.25)
})
})
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')
})
})