Files
accounted/lib/invoices/__tests__/voucher-matching.test.ts
T
Jakob WennbergandClaude Opus 5 27ae59040e fix(transactions): retire stale invoice match pointers when an invoice settles (#1313)
* fix(transactions): retire stale invoice match pointers when an invoice settles

potential_invoice_id / potential_supplier_invoice_id are write-once import
suggestions: nothing revisited them once written. With recurring same-amount
invoices, an earlier suggestion pointed transaction A at invoice X, X was then
paid off by transaction B, and A kept pointing at a fully paid invoice. The
match dialog computed its amount diff against that invoice's 0 kr
remaining_amount and reported a bogus partial payment, and the dead pointer
also blocked a fresh suggestion: both re-suggestion scans require the column
to be NULL.

Add one shared helper, clearSettledInvoiceSuggestions(), that nulls a settled
invoice's own suggestion column on every other transaction of the same
company, scoped by company_id and by that invoice id only, never widening to
the confirmed invoice_id / supplier_invoice_id links. It is best effort by
construction: every caller has already booked a payment verifikat, so a failed
cleanup logs and returns instead of failing the settle.

Wired into every path where an invoice reaches paid through a payment:
the dashboard and v1 match-invoice / match-supplier-invoice routes, the
dashboard and v1 mark-paid routes, settleInvoicePayment, the batch allocation
route (per fully settled allocation), linkInvoiceToVoucher and
linkSupplierInvoiceToVoucher, linkTransactionToJournalEntry, and the MCP
staged-operation executors for mark_invoice_paid and
match_transaction_invoice. Partial payments are deliberately left alone: a
partially paid invoice is still matchable. The v1 supplier match route also
clears its own row's hint, which it was missing next to its dashboard twin.

Read-time revalidation stays as the backstop for the paths not wired up here.
countSuggestedMatches now delegates to listSuggestedMatches, which already
revalidates candidates, so the worklist badge can no longer claim a number the
list refuses to render.

A data-only backfill migration retires the pointers already stranded in the
database. It touches no journal entry, verifikat or period-locked data, is
idempotent, and its status lists mirror lib/invoices/matchable-statuses.ts.

Fixes #1259

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(transactions): wire the MCP batch allocation into the settled-pointer cleanup

Review follow-up on the #1259 fix.

commitMatchBatchAllocate calls the same match_batch_allocate RPC as the
dashboard route, and gnubok_match_batch_allocate is a live staged MCP tool, so
an agent settling a samlingsbetalning reproduced the issue exactly: the RPC
nulls potential_invoice_id / potential_supplier_invoice_id only on the source
transaction, leaving every other transaction of the company pointing at an
invoice the batch just closed. The per-allocation loop moves into
clearSettledBatchAllocationSuggestions() so the HTTP route and the MCP executor
run the same code and cannot drift again, with a commit-path test pinning that
only the fully settled allocation is retired.

The enlarged badge scan is made safe. countSuggestedMatches now feeds up to 200
ids into listSuggestedMatches, past the 150 per .in() that countInboxDocuments
already chunks for, so the candidate lookups are chunked at IN_CLAUSE_CHUNK too
and their ids deduped. Both lookups now check .error: previously a 414, a 500 or
an RLS change produced empty maps, an empty list and a zero badge with nothing
logged. Every failure branch here logs companyId, matching the logAndZero
convention.

Also: restore the anchorSupplierInvoiceDocument doc comment above its own call
in the dashboard supplier-invoice mark-paid route (the #1259 block had been
inserted between them), and assert the transaction update payload in the v1
match-supplier-invoice test, which now covers the potential_supplier_invoice_id
null that the route was missing next to its dashboard twin.

Fixes #1259

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:52:24 +02:00

714 lines
23 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest'
import {
findMatchingVouchersForInvoice,
validateVoucherForInvoiceLink,
linkInvoiceToVoucher,
} from '../voucher-matching'
import {
makeInvoice,
createQueuedMockSupabase,
} from '@/tests/helpers'
import { eventBus } from '@/lib/events/bus'
// 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 ./clear-settled-invoice-suggestions.test.ts.
const { mockClearSuggestions } = vi.hoisted(() => ({ mockClearSuggestions: vi.fn() }))
vi.mock('@/lib/invoices/clear-settled-invoice-suggestions', () => ({
clearSettledInvoiceSuggestions: mockClearSuggestions,
}))
// ============================================================
// validateVoucherForInvoiceLink: happy path + reject codes
// ============================================================
describe('validateVoucherForInvoiceLink', () => {
beforeEach(() => {
vi.clearAllMocks()
})
function setup(invoice = makeInvoice({ remaining_amount: 1000, total: 1000, currency: 'SEK' })) {
return invoice
}
it('rejects when the invoice has nothing remaining', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = setup(
makeInvoice({ remaining_amount: 0, paid_amount: 1000, total: 1000, currency: 'SEK' }),
)
enqueue({ data: null }) // unused: we short-circuit before querying
const result = await validateVoucherForInvoiceLink(
supabase as never,
'company-1',
invoice as never,
'je-1',
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('LINK_VOUCHER_INVOICE_FULLY_PAID')
})
it('rejects when the voucher is missing', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = setup()
enqueue({ data: { accounting_method: 'accrual' } }) // resolveAccountingMethod
enqueue({ data: null, error: null }) // journal_entries.maybeSingle → null
const result = await validateVoucherForInvoiceLink(
supabase as never,
'company-1',
invoice as never,
'je-missing',
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('LINK_VOUCHER_VOUCHER_NOT_FOUND')
})
it('rejects when the voucher is not posted', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = setup()
enqueue({ data: { accounting_method: 'accrual' } }) // resolveAccountingMethod
enqueue({
data: {
id: 'je-1',
voucher_series: 'A',
voucher_number: 5,
entry_date: '2026-05-01',
description: '',
status: 'draft',
source_type: 'manual',
fiscal_period_id: 'fp-1',
company_id: 'company-1',
},
})
const result = await validateVoucherForInvoiceLink(
supabase as never,
'company-1',
invoice as never,
'je-1',
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('LINK_VOUCHER_NOT_POSTED')
})
it('rejects when the voucher has no AR credit (accrual)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = setup()
enqueue({ data: { accounting_method: 'accrual' } }) // resolveAccountingMethod
enqueue({
data: {
id: 'je-1',
voucher_series: 'A',
voucher_number: 5,
entry_date: '2026-05-01',
description: '',
status: 'posted',
source_type: 'manual',
fiscal_period_id: 'fp-1',
company_id: 'company-1',
},
})
enqueue({
data: [
{ account_number: '1930', debit_amount: 1000, credit_amount: 0, currency: 'SEK' },
{ account_number: '3001', debit_amount: 0, credit_amount: 1000, currency: 'SEK' },
],
})
const result = await validateVoucherForInvoiceLink(
supabase as never,
'company-1',
invoice as never,
'je-1',
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('LINK_VOUCHER_NO_AR_CREDIT')
})
it('cash method: accepts the same 1930-debit voucher that accrual rejects', async () => {
// Kontantmetoden books debit 19xx / credit 30xx and never touches 1510, so
// the matcher keys on the bank/cash debit instead of an AR credit.
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = setup()
enqueue({ data: { accounting_method: 'cash' } }) // resolveAccountingMethod
enqueue({
data: {
id: 'je-1',
voucher_series: 'A',
voucher_number: 5,
entry_date: '2026-05-01',
description: '',
status: 'posted',
source_type: 'manual',
fiscal_period_id: 'fp-1',
company_id: 'company-1',
},
})
enqueue({
data: [
{ account_number: '1930', debit_amount: 1000, credit_amount: 0, currency: 'SEK' },
{ account_number: '3001', debit_amount: 0, credit_amount: 1000, currency: 'SEK' },
],
})
enqueue({ data: [] }) // invoice_payments already-linked lookup
const result = await validateVoucherForInvoiceLink(
supabase as never,
'company-1',
invoice as never,
'je-1',
)
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.arCreditAmount).toBe(1000)
expect(result.paymentAmount).toBe(1000)
expect(result.isFullyPaid).toBe(true)
}
})
it('cash method: rejects when the voucher has no bank/cash debit', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = setup()
enqueue({ data: { accounting_method: 'cash' } }) // resolveAccountingMethod
enqueue({
data: {
id: 'je-1',
voucher_series: 'A',
voucher_number: 5,
entry_date: '2026-05-01',
description: '',
status: 'posted',
source_type: 'manual',
fiscal_period_id: 'fp-1',
company_id: 'company-1',
},
})
enqueue({
data: [
// An AR-clearing voucher (1510 credit): valid on accrual, but on cash
// there is no 19xx debit so it must not match.
{ account_number: '1510', debit_amount: 0, credit_amount: 1000, currency: 'SEK' },
{ account_number: '3001', debit_amount: 1000, credit_amount: 0, currency: 'SEK' },
],
})
const result = await validateVoucherForInvoiceLink(
supabase as never,
'company-1',
invoice as never,
'je-1',
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('LINK_VOUCHER_NO_AR_CREDIT')
})
it('rejects when the voucher amount exceeds the remaining', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = setup()
enqueue({ data: { accounting_method: 'accrual' } }) // resolveAccountingMethod
enqueue({
data: {
id: 'je-1',
voucher_series: 'A',
voucher_number: 5,
entry_date: '2026-05-01',
description: '',
status: 'posted',
source_type: 'manual',
fiscal_period_id: 'fp-1',
company_id: 'company-1',
},
})
enqueue({
data: [
{ account_number: '1930', debit_amount: 5000, credit_amount: 0, currency: 'SEK' },
{ account_number: '1510', debit_amount: 0, credit_amount: 5000, currency: 'SEK' },
],
})
const result = await validateVoucherForInvoiceLink(
supabase as never,
'company-1',
invoice as never,
'je-1',
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('LINK_VOUCHER_AMOUNT_EXCEEDS_REMAINING')
})
it('rejects when the line currency does not match the invoice', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = setup(makeInvoice({ remaining_amount: 1000, total: 1000, currency: 'EUR' }))
enqueue({ data: { accounting_method: 'accrual' } }) // resolveAccountingMethod
enqueue({
data: {
id: 'je-1',
voucher_series: 'A',
voucher_number: 5,
entry_date: '2026-05-01',
description: '',
status: 'posted',
source_type: 'manual',
fiscal_period_id: 'fp-1',
company_id: 'company-1',
},
})
enqueue({
data: [
{ account_number: '1510', debit_amount: 0, credit_amount: 1000, currency: 'SEK' },
],
})
const result = await validateVoucherForInvoiceLink(
supabase as never,
'company-1',
invoice as never,
'je-1',
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('LINK_VOUCHER_CURRENCY_MISMATCH')
})
it('rejects when the voucher is already linked to this invoice', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = setup()
enqueue({ data: { accounting_method: 'accrual' } }) // resolveAccountingMethod
enqueue({
data: {
id: 'je-1',
voucher_series: 'A',
voucher_number: 5,
entry_date: '2026-05-01',
description: '',
status: 'posted',
source_type: 'manual',
fiscal_period_id: 'fp-1',
company_id: 'company-1',
},
})
enqueue({
data: [
{ account_number: '1510', debit_amount: 0, credit_amount: 1000, currency: 'SEK' },
],
})
enqueue({ data: [{ id: 'pmt-1' }] })
const result = await validateVoucherForInvoiceLink(
supabase as never,
'company-1',
invoice as never,
'je-1',
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('LINK_VOUCHER_ALREADY_LINKED')
})
it('returns ok=true with full-pay flag when amount equals remaining', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = setup()
enqueue({ data: { accounting_method: 'accrual' } }) // resolveAccountingMethod
enqueue({
data: {
id: 'je-1',
voucher_series: 'A',
voucher_number: 5,
entry_date: '2026-05-01',
description: '',
status: 'posted',
source_type: 'manual',
fiscal_period_id: 'fp-1',
company_id: 'company-1',
},
})
enqueue({
data: [
{ account_number: '1930', debit_amount: 1000, credit_amount: 0, currency: 'SEK' },
{ account_number: '1510', debit_amount: 0, credit_amount: 1000, currency: 'SEK' },
],
})
enqueue({ data: [] })
const result = await validateVoucherForInvoiceLink(
supabase as never,
'company-1',
invoice as never,
'je-1',
)
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.arCreditAmount).toBe(1000)
expect(result.paymentAmount).toBe(1000)
expect(result.isFullyPaid).toBe(true)
expect(result.remainingAfter).toBe(0)
}
})
it('returns ok=true with partial-pay flag when amount is less than remaining', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = setup(makeInvoice({ remaining_amount: 1000, total: 1000, currency: 'SEK' }))
enqueue({ data: { accounting_method: 'accrual' } }) // resolveAccountingMethod
enqueue({
data: {
id: 'je-1',
voucher_series: 'A',
voucher_number: 5,
entry_date: '2026-05-01',
description: '',
status: 'posted',
source_type: 'manual',
fiscal_period_id: 'fp-1',
company_id: 'company-1',
},
})
enqueue({
data: [
{ account_number: '1510', debit_amount: 0, credit_amount: 400, currency: 'SEK' },
],
})
enqueue({ data: [] })
const result = await validateVoucherForInvoiceLink(
supabase as never,
'company-1',
invoice as never,
'je-1',
)
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.paymentAmount).toBe(400)
expect(result.isFullyPaid).toBe(false)
expect(result.remainingAfter).toBe(600)
}
})
})
// ============================================================
// findMatchingVouchersForInvoice: empty + ranking smoke test
// ============================================================
describe('findMatchingVouchersForInvoice', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns empty when the invoice has nothing remaining', async () => {
const { supabase } = createQueuedMockSupabase()
const invoice = makeInvoice({ remaining_amount: 0, paid_amount: 1000, total: 1000 })
const result = await findMatchingVouchersForInvoice(
supabase as never,
'company-1',
invoice as never,
)
expect(result).toEqual([])
})
it('returns empty when the journal lines query errors', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = makeInvoice({
remaining_amount: 1000,
total: 1000,
due_date: '2026-05-01',
})
enqueue({ data: { accounting_method: 'accrual' } }) // resolveAccountingMethod
enqueue({ data: null, error: { message: 'db error' } })
const result = await findMatchingVouchersForInvoice(
supabase as never,
'company-1',
invoice as never,
)
expect(result).toEqual([])
})
it('cash method: surfaces a verifikat that debits a bank account (19xx), no 1510 needed', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = makeInvoice({
remaining_amount: 1000,
total: 1000,
currency: 'SEK',
due_date: '2026-05-01',
invoice_number: 'F-1',
})
enqueue({ data: { accounting_method: 'cash' } }) // resolveAccountingMethod
// journal_entries query with embedded lines (kontantmetoden: 19xx debit > 0)
enqueue({
data: [
{
id: 'je-1',
voucher_series: 'A',
voucher_number: 7,
entry_date: '2026-05-01',
description: 'Betalning faktura F-1',
status: 'posted',
source_type: 'manual',
fiscal_period_id: 'fp-1',
company_id: 'company-1',
journal_entry_lines: [
{
id: 'l1',
account_number: '1930',
debit_amount: 1000,
credit_amount: 0,
currency: 'SEK',
},
],
},
],
})
enqueue({ data: [] }) // invoice_payments already-linked lookup
enqueue({ data: [] }) // fiscal_periods lock lookup
const result = await findMatchingVouchersForInvoice(
supabase as never,
'company-1',
invoice as never,
)
expect(result).toHaveLength(1)
expect(result[0].journal_entry_id).toBe('je-1')
expect(result[0].ar_credit_amount).toBe(1000)
})
})
// ============================================================
// findMatchingVouchersForInvoice: the "låst period" advisory flag
//
// fiscal_periods has NO `status` column: open/locked/closed is derived from
// is_closed + locked_at (same source the enforce_period_lock trigger uses).
// Asking for a phantom column makes PostgREST error out, the lock list comes
// back empty and every candidate is silently advertised as "open".
// ============================================================
type QueuedMockSupabase = ReturnType<typeof createQueuedMockSupabase>['supabase']
type QueuedResult = { data?: unknown; error?: unknown }
/**
* Record the exact column list handed to each `.select()`, per table. The
* queued mock resolves whatever was enqueued regardless of the columns asked
* for, so it happily swallows a column that does not exist in Postgres: a
* data-only assertion cannot catch a phantom column, the select string can.
*/
function recordSelects(supabase: QueuedMockSupabase) {
const selects: { table: string; columns: string }[] = []
const original = supabase.from.getMockImplementation() as (table: string) => object
supabase.from.mockImplementation((table: string) => {
const chain = original(table)
return new Proxy(chain, {
get(target, prop, receiver) {
const value = Reflect.get(target, prop, receiver)
if (prop !== 'select' || typeof value !== 'function') return value
return (...args: unknown[]) => {
selects.push({ table, columns: String(args[0] ?? '') })
return (value as (...a: unknown[]) => unknown)(...args)
}
},
})
})
return selects
}
/** One accrual candidate (1510 credit 1000) in fiscal period `fp-1`. */
function enqueueSingleAccrualCandidate(
enqueue: (r: QueuedResult) => void,
periodLookup: QueuedResult,
) {
enqueue({ data: { accounting_method: 'accrual' } }) // resolveAccountingMethod
enqueue({
data: [
{
id: 'je-1',
voucher_series: 'A',
voucher_number: 7,
entry_date: '2026-05-01',
description: 'Betalning faktura F-1',
status: 'posted',
source_type: 'manual',
fiscal_period_id: 'fp-1',
company_id: 'company-1',
journal_entry_lines: [
{
id: 'l1',
account_number: '1510',
debit_amount: 0,
credit_amount: 1000,
currency: 'SEK',
},
],
},
],
})
enqueue({ data: [] }) // invoice_payments already-linked lookup
enqueue(periodLookup) // fiscal_periods lock lookup
}
function lockTestInvoice() {
return makeInvoice({
remaining_amount: 1000,
total: 1000,
currency: 'SEK',
due_date: '2026-05-01',
invoice_number: 'F-1',
})
}
describe('findMatchingVouchersForInvoice: period_locked flag', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('asks fiscal_periods for is_closed + locked_at, never a `status` column', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const selects = recordSelects(supabase)
enqueueSingleAccrualCandidate(enqueue, {
data: [{ id: 'fp-1', is_closed: false, locked_at: null }],
})
await findMatchingVouchersForInvoice(
supabase as never,
'company-1',
lockTestInvoice() as never,
)
const periodSelect = selects.find((s) => s.table === 'fiscal_periods')
expect(periodSelect).toBeDefined()
expect(periodSelect?.columns).toBe('id, is_closed, locked_at')
expect(periodSelect?.columns).not.toContain('status')
})
it.each([
['open', { id: 'fp-1', is_closed: false, locked_at: null }, false],
['locked', { id: 'fp-1', is_closed: false, locked_at: '2026-06-30T10:00:00Z' }, true],
['closed', { id: 'fp-1', is_closed: true, locked_at: '2026-06-30T10:00:00Z' }, true],
])('flags a %s period as period_locked=%s', async (_label, period, expected) => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueueSingleAccrualCandidate(enqueue, { data: [period] })
const result = await findMatchingVouchersForInvoice(
supabase as never,
'company-1',
lockTestInvoice() as never,
)
expect(result).toHaveLength(1)
expect(result[0].period_locked).toBe(expected)
})
it('fails closed: a fiscal_periods lookup error does not advertise the period as open', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueueSingleAccrualCandidate(enqueue, {
data: null,
error: { message: 'column fiscal_periods.status does not exist' },
})
const result = await findMatchingVouchersForInvoice(
supabase as never,
'company-1',
lockTestInvoice() as never,
)
expect(result).toHaveLength(1)
expect(result[0].period_locked).toBe(true)
})
})
// ============================================================
// linkInvoiceToVoucher: outcome shape & event emission
// ============================================================
describe('linkInvoiceToVoucher', () => {
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
// linkInvoiceToVoucher now delegates validation + writes to the atomic
// link_invoice_to_voucher RPC (audit C2): the wrapper's job is calling it
// with the right args and mapping the jsonb result/transport errors through.
// Guard behaviour itself is covered by voucher-matching.pg.test.ts against
// the real RPC.
it('passes a guard rejection from the RPC through unchanged', async () => {
const rpc = vi.fn().mockResolvedValue({
data: { ok: false, code: 'LINK_VOUCHER_INVOICE_FULLY_PAID', details: { status: 'paid' } },
error: null,
})
const result = await linkInvoiceToVoucher(
{ rpc } as never,
'user-1',
'company-1',
{ invoiceId: 'inv-1', journalEntryId: 'je-1' },
)
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.code).toBe('LINK_VOUCHER_INVOICE_FULLY_PAID')
expect(result.details).toEqual({ status: 'paid' })
}
expect(rpc).toHaveBeenCalledWith('link_invoice_to_voucher', {
p_invoice_id: 'inv-1',
p_journal_entry_id: 'je-1',
p_user_id: 'user-1',
p_company_id: 'company-1',
p_notes: null,
})
})
it('maps an RPC transport error to LINK_VOUCHER_DB_ERROR', async () => {
const rpc = vi.fn().mockResolvedValue({ data: null, error: { message: 'connection reset' } })
const result = await linkInvoiceToVoucher(
{ rpc } as never,
'user-1',
'company-1',
{ invoiceId: 'inv-1', journalEntryId: 'je-1' },
)
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.code).toBe('LINK_VOUCHER_DB_ERROR')
expect(result.details).toEqual({ reason: 'connection reset' })
}
})
it('maps an empty RPC response to LINK_VOUCHER_DB_ERROR', async () => {
const rpc = vi.fn().mockResolvedValue({ data: null, error: null })
const result = await linkInvoiceToVoucher(
{ rpc } as never,
'user-1',
'company-1',
{ invoiceId: 'inv-1', journalEntryId: 'je-1' },
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('LINK_VOUCHER_DB_ERROR')
})
// Issue #1259: an invoice settled through this path must not leave other
// transactions pointing at it as a match suggestion.
function enqueueRpcOk(
enqueue: (r: { data?: unknown; error?: unknown }) => void,
invoiceStatus: 'paid' | 'partially_paid',
) {
enqueue({
data: {
ok: true,
payment_id: 'pay-1',
invoice_status: invoiceStatus,
paid_amount: invoiceStatus === 'paid' ? 1000 : 400,
remaining_amount: invoiceStatus === 'paid' ? 0 : 600,
payment_amount: invoiceStatus === 'paid' ? 1000 : 400,
journal_entry_id: 'je-1',
currency: 'SEK',
payment_date: '2026-06-01',
},
error: null,
})
// Post-link invoice re-fetch for the event payload.
enqueue({ data: null, error: null })
}
it('retires the settled invoice suggestions on a full payment', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueueRpcOk(enqueue, 'paid')
const result = await linkInvoiceToVoucher(supabase as never, 'user-1', 'company-1', {
invoiceId: 'inv-1',
journalEntryId: 'je-1',
})
expect(result.ok).toBe(true)
expect(mockClearSuggestions).toHaveBeenCalledTimes(1)
expect(mockClearSuggestions).toHaveBeenCalledWith(supabase, 'company-1', 'invoice', 'inv-1')
})
it('leaves the suggestions alone on a partial payment: the invoice is still matchable', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueueRpcOk(enqueue, 'partially_paid')
const result = await linkInvoiceToVoucher(supabase as never, 'user-1', 'company-1', {
invoiceId: 'inv-1',
journalEntryId: 'je-1',
})
expect(result.ok).toBe(true)
expect(mockClearSuggestions).not.toHaveBeenCalled()
})
})