* 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>
714 lines
23 KiB
TypeScript
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()
|
|
})
|
|
})
|