* fix(payments): refuse to book a bank row that unlinked vouchers already explain A bank feed can deliver several affarshandelser as one row (a Bankgirot daily aggregate: two customers' invoices, one "BGGIRERING" row with no payer). When each invoice was already marked paid by hand, nothing on the account equals the row, the 1:1 duplicate check passes, and "Dela betalning" books the money a second time against whatever open invoices the user picks (the next period's identical ones, in the reported case). - lib/reconciliation/covering-set.ts: exact ore subset sum over a capped candidate list, smallest set first, closest in date second. - detectExplainingVoucherSet(+ForTransaction): the vouchers whose bank legs on the row's settlement account, in the row's direction, within 7 days, add up exactly to the row; linked through any of the three anchors drops a voucher, a payment row without a bank transaction keeps it. - POST match-batch refuses with BATCH_TX_POSSIBLE_DUPLICATE and returns the set; force=true must echo expected_journal_entry_ids (same binding as the single door). Fails open on a detection error. - GET duplicate-payment-check returns candidate_set next to candidate. - MatchAllocationDialog: pre-flight panel with the vouchers, one click links the row to them through the existing 1:1 or 1:N bank link (no new voucher), "Bokfor anda" acknowledges the set; confirm is disabled until then. Invoices dated after the bank row get a hint badge. - Mark-paid guard: aggregate sweep (row = this invoice + an exact subset of other open invoices, 7 days, kronor) when the name sweeps found nothing; PaymentBookingDialog shows the covered invoice numbers and points to the split under Transaktioner. Follow-ups: #2293 (1:N proposals in the auto-matcher), #2294 (MCP staging guard), #2299 (supplier-side text guard). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NyjeEi1U8vnuPT4QXgayXu * test(invoices): account for the aggregate sweep in the mark-paid route queue The sweep issues one more transactions query whenever the name probes come back empty, so every queued-mock sequence that reaches it gains a slot. The sweep itself now fails open on odd client shapes (a single object for a list query) and on errors: an advisory guard must never block "Markera som betald". Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NyjeEi1U8vnuPT4QXgayXu * fix(payments): fail open on resolved query errors; aggregate sweep without a payer name Review follow-ups on #2300. A PostgREST failure resolves with { data: null, error } instead of throwing, so the set detector read a failed link lookup as "no links" and a failed cash-account lookup as "scan every 19xx account"; both now return null (the booking RPC keeps the last word). The aggregate sweep never needed a customer name (a Bankgirot row names nobody), so a nameless invoice goes straight to it instead of skipping the guard. The already-booked panel is announced as a live region, and the "also covers" string is plural-aware. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NyjeEi1U8vnuPT4QXgayXu --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
857 lines
28 KiB
TypeScript
857 lines
28 KiB
TypeScript
import { describe, it, expect, beforeEach } from 'vitest'
|
|
import {
|
|
detectDuplicatePaymentVoucher,
|
|
detectExplainingVoucherSet,
|
|
detectExplainingVoucherSetForTransaction,
|
|
} from '../duplicate-payment-detection'
|
|
import { createQueuedMockSupabase } from '@/tests/helpers'
|
|
|
|
const { supabase, enqueue, reset } = createQueuedMockSupabase()
|
|
|
|
describe('detectDuplicatePaymentVoucher', () => {
|
|
beforeEach(() => {
|
|
reset()
|
|
})
|
|
|
|
function makeLineRow(opts: {
|
|
je_id: string
|
|
account: string
|
|
debit: number
|
|
date: string
|
|
voucher_label?: string
|
|
source_type?: string | null
|
|
description?: string | null
|
|
}) {
|
|
const [series, ...numParts] = (opts.voucher_label ?? 'A1').split('')
|
|
const num = parseInt(numParts.join(''), 10) || 1
|
|
return {
|
|
account_number: opts.account,
|
|
debit_amount: opts.debit,
|
|
journal_entry: {
|
|
id: opts.je_id,
|
|
entry_date: opts.date,
|
|
description: opts.description ?? `Voucher ${opts.je_id}`,
|
|
voucher_series: series,
|
|
voucher_number: num,
|
|
status: 'posted',
|
|
source_type: opts.source_type ?? 'manual',
|
|
company_id: 'company-1',
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Enqueue the two pages the two-step entry-lines fetch reads
|
|
* (lib/bookkeeping/entry-lines.ts): the parent entries first, then the bare
|
|
* lines keyed by journal_entry_id. Fixtures stay embed-shaped; the helper
|
|
* reattaches the parent under `journal_entry`, which is exactly what the
|
|
* old aliased `journal_entry:journal_entries!inner(...)` embed produced.
|
|
*/
|
|
function enqueueLines(rows: ReturnType<typeof makeLineRow>[]) {
|
|
const entries = [
|
|
...new Map(rows.map((r) => [r.journal_entry.id, r.journal_entry])).values(),
|
|
]
|
|
enqueue({ data: entries, error: null })
|
|
// No entries means the helper never queries the lines at all.
|
|
if (entries.length === 0) return
|
|
enqueue({
|
|
data: rows.map((r, i) => ({
|
|
id: `line-${String(i).padStart(4, '0')}`,
|
|
journal_entry_id: r.journal_entry.id,
|
|
account_number: r.account_number,
|
|
debit_amount: r.debit_amount,
|
|
})),
|
|
error: null,
|
|
})
|
|
}
|
|
|
|
it('returns null when transaction amount is 0', async () => {
|
|
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
|
companyId: 'company-1',
|
|
transactionId: 'tx-1',
|
|
transactionDate: '2026-05-15',
|
|
transactionAmount: 0,
|
|
transactionCurrency: 'SEK',
|
|
})
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
it('returns null when transaction date is invalid', async () => {
|
|
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
|
companyId: 'company-1',
|
|
transactionId: 'tx-1',
|
|
transactionDate: 'not-a-date',
|
|
transactionAmount: 1000,
|
|
transactionCurrency: 'SEK',
|
|
})
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
it('returns null when no lines are found', async () => {
|
|
enqueue({ data: [], error: null })
|
|
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
|
companyId: 'company-1',
|
|
transactionId: 'tx-1',
|
|
transactionDate: '2026-05-15',
|
|
transactionAmount: 1000,
|
|
transactionCurrency: 'SEK',
|
|
})
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
it('returns the candidate when an unlinked manual JE matches exactly on the same date', async () => {
|
|
enqueueLines([
|
|
makeLineRow({
|
|
je_id: 'je-1',
|
|
account: '1930',
|
|
debit: 1000,
|
|
date: '2026-05-15',
|
|
voucher_label: 'A12',
|
|
}),
|
|
])
|
|
// invoice_payments link check (no links)
|
|
enqueue({ data: [], error: null })
|
|
// transactions link check (no links)
|
|
enqueue({ data: [], error: null })
|
|
|
|
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
|
companyId: 'company-1',
|
|
transactionId: 'tx-1',
|
|
transactionDate: '2026-05-15',
|
|
transactionAmount: 1000,
|
|
transactionCurrency: 'SEK',
|
|
})
|
|
|
|
expect(result).not.toBeNull()
|
|
expect(result!.journal_entry_id).toBe('je-1')
|
|
expect(result!.bank_account_number).toBe('1930')
|
|
expect(result!.reason).toBe('exact_amount_same_date')
|
|
expect(result!.amount).toBe(1000)
|
|
})
|
|
|
|
it('drives the scan from journal_entries and reattaches the parent under journal_entry', async () => {
|
|
// Shape guard for the entry-lines conversion: no query starts on
|
|
// journal_entry_lines with the tenant scope buried in an embed, and the
|
|
// candidate is still built from the parent fields (voucher label, date,
|
|
// description) plus the line fields (account, debit).
|
|
// The mock client is module-level, so only this test's calls are read.
|
|
const callsBefore = supabase.from.mock.calls.length
|
|
enqueueLines([
|
|
makeLineRow({
|
|
je_id: 'je-shape',
|
|
account: '1930',
|
|
debit: 1000,
|
|
date: '2026-05-15',
|
|
voucher_label: 'A12',
|
|
description: 'Manuell inbetalning',
|
|
}),
|
|
])
|
|
enqueue({ data: [], error: null })
|
|
enqueue({ data: [], error: null })
|
|
|
|
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
|
companyId: 'company-1',
|
|
transactionId: 'tx-1',
|
|
transactionDate: '2026-05-15',
|
|
transactionAmount: 1000,
|
|
transactionCurrency: 'SEK',
|
|
})
|
|
|
|
const tables = supabase.from.mock.calls.slice(callsBefore).map((c) => c[0])
|
|
expect(tables[0]).toBe('journal_entries')
|
|
expect(tables[1]).toBe('journal_entry_lines')
|
|
expect(result).toEqual({
|
|
journal_entry_id: 'je-shape',
|
|
voucher_label: 'A12',
|
|
entry_date: '2026-05-15',
|
|
description: 'Manuell inbetalning',
|
|
amount: 1000,
|
|
bank_account_number: '1930',
|
|
reason: 'exact_amount_same_date',
|
|
amount_verified: true,
|
|
unverified_reason: null,
|
|
})
|
|
})
|
|
|
|
it('returns within_window reason when JE date is close but not equal', async () => {
|
|
enqueueLines([
|
|
makeLineRow({
|
|
je_id: 'je-2',
|
|
account: '1930',
|
|
debit: 500,
|
|
date: '2026-05-12',
|
|
voucher_label: 'A5',
|
|
}),
|
|
])
|
|
enqueue({ data: [], error: null })
|
|
enqueue({ data: [], error: null })
|
|
|
|
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
|
companyId: 'company-1',
|
|
transactionId: 'tx-1',
|
|
transactionDate: '2026-05-15',
|
|
transactionAmount: 500,
|
|
transactionCurrency: 'SEK',
|
|
})
|
|
|
|
expect(result).not.toBeNull()
|
|
expect(result!.reason).toBe('exact_amount_within_window')
|
|
})
|
|
|
|
it('excludes JEs that are already linked via invoice_payments', async () => {
|
|
enqueueLines([
|
|
makeLineRow({
|
|
je_id: 'je-3',
|
|
account: '1930',
|
|
debit: 1000,
|
|
date: '2026-05-15',
|
|
}),
|
|
])
|
|
// invoice_payments has a row linking this JE to a bank transaction
|
|
enqueue({ data: [{ journal_entry_id: 'je-3', transaction_id: 'tx-bank' }], error: null })
|
|
enqueue({ data: [], error: null })
|
|
|
|
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
|
companyId: 'company-1',
|
|
transactionId: 'tx-1',
|
|
transactionDate: '2026-05-15',
|
|
transactionAmount: 1000,
|
|
transactionCurrency: 'SEK',
|
|
})
|
|
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
// #2019: "Markera som betald" and Stripe now write a payment row WITHOUT a
|
|
// bank transaction. That row means "paid by hand", not "reconciled to a
|
|
// bank line", so the voucher must still surface when the real bank line
|
|
// arrives; otherwise a second payment voucher posts silently.
|
|
it('still flags a voucher whose payment row carries no bank transaction (manual settlement)', async () => {
|
|
enqueueLines([
|
|
makeLineRow({
|
|
je_id: 'je-manual',
|
|
account: '1930',
|
|
debit: 1000,
|
|
date: '2026-05-15',
|
|
}),
|
|
])
|
|
enqueue({ data: [{ journal_entry_id: 'je-manual', transaction_id: null }], error: null })
|
|
enqueue({ data: [], error: null })
|
|
|
|
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
|
companyId: 'company-1',
|
|
transactionId: 'tx-1',
|
|
transactionDate: '2026-05-15',
|
|
transactionAmount: 1000,
|
|
transactionCurrency: 'SEK',
|
|
})
|
|
|
|
expect(result?.journal_entry_id).toBe('je-manual')
|
|
})
|
|
|
|
it('excludes JEs already linked from another transaction', async () => {
|
|
enqueueLines([
|
|
makeLineRow({
|
|
je_id: 'je-4',
|
|
account: '1930',
|
|
debit: 1000,
|
|
date: '2026-05-15',
|
|
}),
|
|
])
|
|
enqueue({ data: [], error: null })
|
|
// another transaction already links this JE
|
|
enqueue({ data: [{ id: 'tx-other', journal_entry_id: 'je-4' }], error: null })
|
|
|
|
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
|
companyId: 'company-1',
|
|
transactionId: 'tx-1',
|
|
transactionDate: '2026-05-15',
|
|
transactionAmount: 1000,
|
|
transactionCurrency: 'SEK',
|
|
})
|
|
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
it('excludes storno entries', async () => {
|
|
enqueueLines([
|
|
makeLineRow({
|
|
je_id: 'je-storno',
|
|
account: '1930',
|
|
debit: 1000,
|
|
date: '2026-05-15',
|
|
source_type: 'storno',
|
|
}),
|
|
])
|
|
enqueue({ data: [], error: null })
|
|
enqueue({ data: [], error: null })
|
|
|
|
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
|
companyId: 'company-1',
|
|
transactionId: 'tx-1',
|
|
transactionDate: '2026-05-15',
|
|
transactionAmount: 1000,
|
|
transactionCurrency: 'SEK',
|
|
})
|
|
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
it('excludes correction entries', async () => {
|
|
enqueueLines([
|
|
makeLineRow({
|
|
je_id: 'je-corr',
|
|
account: '1930',
|
|
debit: 1000,
|
|
date: '2026-05-15',
|
|
source_type: 'correction',
|
|
}),
|
|
])
|
|
enqueue({ data: [], error: null })
|
|
enqueue({ data: [], error: null })
|
|
|
|
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
|
companyId: 'company-1',
|
|
transactionId: 'tx-1',
|
|
transactionDate: '2026-05-15',
|
|
transactionAmount: 1000,
|
|
transactionCurrency: 'SEK',
|
|
})
|
|
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
it('picks the same-date candidate over a within-window candidate', async () => {
|
|
enqueueLines([
|
|
makeLineRow({
|
|
je_id: 'je-far',
|
|
account: '1930',
|
|
debit: 1000,
|
|
date: '2026-05-12',
|
|
voucher_label: 'A1',
|
|
}),
|
|
makeLineRow({
|
|
je_id: 'je-same',
|
|
account: '1930',
|
|
debit: 1000,
|
|
date: '2026-05-15',
|
|
voucher_label: 'A2',
|
|
}),
|
|
])
|
|
enqueue({ data: [], error: null })
|
|
enqueue({ data: [], error: null })
|
|
|
|
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
|
companyId: 'company-1',
|
|
transactionId: 'tx-1',
|
|
transactionDate: '2026-05-15',
|
|
transactionAmount: 1000,
|
|
transactionCurrency: 'SEK',
|
|
})
|
|
|
|
expect(result).not.toBeNull()
|
|
expect(result!.journal_entry_id).toBe('je-same')
|
|
expect(result!.reason).toBe('exact_amount_same_date')
|
|
})
|
|
|
|
it('matches absolute value for negative transaction amounts (expense)', async () => {
|
|
enqueueLines([
|
|
makeLineRow({
|
|
je_id: 'je-x',
|
|
account: '1930',
|
|
debit: 250,
|
|
date: '2026-05-15',
|
|
}),
|
|
])
|
|
enqueue({ data: [], error: null })
|
|
enqueue({ data: [], error: null })
|
|
|
|
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
|
companyId: 'company-1',
|
|
transactionId: 'tx-1',
|
|
transactionDate: '2026-05-15',
|
|
transactionAmount: -250,
|
|
transactionCurrency: 'SEK',
|
|
})
|
|
|
|
// Note: while the match-invoice route only handles income, the
|
|
// detector itself is amount-direction agnostic: it just finds JEs
|
|
// that book the same magnitude on the bank side. Callers gate by
|
|
// direction.
|
|
expect(result).not.toBeNull()
|
|
expect(result!.amount).toBe(250)
|
|
})
|
|
|
|
it('skips lines whose amount differs by more than 0.01', async () => {
|
|
enqueueLines([
|
|
makeLineRow({
|
|
je_id: 'je-off',
|
|
account: '1930',
|
|
debit: 1001,
|
|
date: '2026-05-15',
|
|
}),
|
|
])
|
|
|
|
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
|
companyId: 'company-1',
|
|
transactionId: 'tx-1',
|
|
transactionDate: '2026-05-15',
|
|
transactionAmount: 1000,
|
|
transactionCurrency: 'SEK',
|
|
})
|
|
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
it('ignores the caller transaction even if it carries a journal_entry_id link', async () => {
|
|
enqueueLines([
|
|
makeLineRow({
|
|
je_id: 'je-caller',
|
|
account: '1930',
|
|
debit: 1000,
|
|
date: '2026-05-15',
|
|
}),
|
|
])
|
|
enqueue({ data: [], error: null })
|
|
// The caller transaction itself links the JE (defensive: shouldn't happen
|
|
// in normal flow because we call this before the link, but a retry could).
|
|
enqueue({ data: [{ id: 'tx-caller', journal_entry_id: 'je-caller' }], error: null })
|
|
|
|
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
|
companyId: 'company-1',
|
|
transactionId: 'tx-caller',
|
|
transactionDate: '2026-05-15',
|
|
transactionAmount: 1000,
|
|
transactionCurrency: 'SEK',
|
|
})
|
|
|
|
expect(result).not.toBeNull()
|
|
expect(result!.journal_entry_id).toBe('je-caller')
|
|
})
|
|
|
|
// ── FX: the bank line may be foreign, the 19xx debit is always SEK ─────────
|
|
//
|
|
// journal_entry_lines.debit_amount is written in SEK even when the line
|
|
// carries currency='EUR' + amount_in_currency as document metadata, so the
|
|
// bank line has to be converted before the two can be compared at all.
|
|
|
|
it('flags a foreign receipt against the SEK voucher its own booking produced', async () => {
|
|
// 100 EUR at 11.50 was booked as a 1150 SEK debit.
|
|
enqueueLines([
|
|
makeLineRow({ je_id: 'je-fx', account: '1930', debit: 1150, date: '2026-05-15', voucher_label: 'A9' }),
|
|
])
|
|
enqueue({ data: [], error: null })
|
|
enqueue({ data: [], error: null })
|
|
|
|
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
|
companyId: 'company-1',
|
|
transactionId: 'tx-1',
|
|
transactionDate: '2026-05-15',
|
|
transactionAmount: 100,
|
|
transactionCurrency: 'EUR',
|
|
transactionAmountSek: 1150,
|
|
transactionExchangeRate: 11.5,
|
|
})
|
|
|
|
expect(result).not.toBeNull()
|
|
expect(result!.journal_entry_id).toBe('je-fx')
|
|
expect(result!.amount).toBe(1150)
|
|
expect(result!.amount_verified).toBe(true)
|
|
expect(result!.unverified_reason).toBeNull()
|
|
})
|
|
|
|
it('converts via exchange_rate when amount_sek was never stored', async () => {
|
|
enqueueLines([
|
|
makeLineRow({ je_id: 'je-rate', account: '1930', debit: 1150, date: '2026-05-15' }),
|
|
])
|
|
enqueue({ data: [], error: null })
|
|
enqueue({ data: [], error: null })
|
|
|
|
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
|
companyId: 'company-1',
|
|
transactionId: 'tx-1',
|
|
transactionDate: '2026-05-15',
|
|
transactionAmount: 100,
|
|
transactionCurrency: 'EUR',
|
|
transactionAmountSek: null,
|
|
transactionExchangeRate: 11.5,
|
|
})
|
|
|
|
expect(result!.journal_entry_id).toBe('je-rate')
|
|
expect(result!.amount_verified).toBe(true)
|
|
})
|
|
|
|
it('does NOT flag a EUR line against an unrelated same-magnitude SEK voucher', async () => {
|
|
// 1000 EUR is ~11 500 SEK, nothing to do with a 1000 SEK voucher. The old
|
|
// comparison put the raw 1000 against the leg and matched.
|
|
enqueueLines([
|
|
makeLineRow({ je_id: 'je-coincidence', account: '1930', debit: 1000, date: '2026-05-15' }),
|
|
])
|
|
|
|
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
|
companyId: 'company-1',
|
|
transactionId: 'tx-1',
|
|
transactionDate: '2026-05-15',
|
|
transactionAmount: 1000,
|
|
transactionCurrency: 'EUR',
|
|
transactionAmountSek: 11500,
|
|
transactionExchangeRate: 11.5,
|
|
})
|
|
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
it('WARNS rather than passing when a foreign line carries no rate', async () => {
|
|
// Nothing can be compared. A null return would read as "no duplicate" and
|
|
// let the matcher post a second payment voucher for one affärshändelse.
|
|
enqueueLines([
|
|
makeLineRow({ je_id: 'je-unverifiable', account: '1930', debit: 1150, date: '2026-05-15' }),
|
|
])
|
|
enqueue({ data: [], error: null })
|
|
enqueue({ data: [], error: null })
|
|
|
|
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
|
companyId: 'company-1',
|
|
transactionId: 'tx-1',
|
|
transactionDate: '2026-05-15',
|
|
transactionAmount: 100,
|
|
transactionCurrency: 'EUR',
|
|
transactionAmountSek: null,
|
|
transactionExchangeRate: null,
|
|
})
|
|
|
|
expect(result).not.toBeNull()
|
|
expect(result!.journal_entry_id).toBe('je-unverifiable')
|
|
expect(result!.amount_verified).toBe(false)
|
|
expect(result!.unverified_reason).toBe('transaction_missing_sek_value')
|
|
// The amount test never ran, so the reason must not claim an amount match:
|
|
// 'exact_amount_*' here made the dialog render "på samma belopp" for a
|
|
// candidate whose amounts were never compared.
|
|
expect(result!.reason).toBe('date_window_only')
|
|
// The leg's SEK figure, never the bank line's foreign 100.
|
|
expect(result!.amount).toBe(1150)
|
|
})
|
|
|
|
it('returns null (a verified pass) for a rateless foreign line with no 19xx debit in the window', async () => {
|
|
enqueueLines([])
|
|
|
|
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
|
companyId: 'company-1',
|
|
transactionId: 'tx-1',
|
|
transactionDate: '2026-05-15',
|
|
transactionAmount: 100,
|
|
transactionCurrency: 'EUR',
|
|
transactionAmountSek: null,
|
|
transactionExchangeRate: null,
|
|
})
|
|
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
it('still excludes storno when the amount cannot be verified', async () => {
|
|
enqueueLines([
|
|
makeLineRow({ je_id: 'je-s', account: '1930', debit: 1150, date: '2026-05-15', source_type: 'storno' }),
|
|
])
|
|
|
|
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
|
companyId: 'company-1',
|
|
transactionId: 'tx-1',
|
|
transactionDate: '2026-05-15',
|
|
transactionAmount: 100,
|
|
transactionCurrency: 'EUR',
|
|
transactionAmountSek: null,
|
|
transactionExchangeRate: null,
|
|
})
|
|
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
it('leaves a SEK company on exactly the old path (null currency = SEK default)', async () => {
|
|
enqueueLines([
|
|
makeLineRow({ je_id: 'je-sek', account: '1930', debit: 1000, date: '2026-05-15' }),
|
|
])
|
|
enqueue({ data: [], error: null })
|
|
enqueue({ data: [], error: null })
|
|
|
|
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
|
companyId: 'company-1',
|
|
transactionId: 'tx-1',
|
|
transactionDate: '2026-05-15',
|
|
transactionAmount: 1000,
|
|
transactionCurrency: null,
|
|
})
|
|
|
|
expect(result!.journal_entry_id).toBe('je-sek')
|
|
expect(result!.amount_verified).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('detectExplainingVoucherSet', () => {
|
|
beforeEach(() => {
|
|
reset()
|
|
})
|
|
|
|
type SetLine = {
|
|
account_number: string
|
|
debit_amount: number
|
|
credit_amount: number
|
|
journal_entry: {
|
|
id: string
|
|
entry_date: string
|
|
description: string | null
|
|
voucher_series: string
|
|
voucher_number: number
|
|
status: string
|
|
source_type: string | null
|
|
company_id: string
|
|
}
|
|
}
|
|
|
|
function leg(opts: {
|
|
je_id: string
|
|
date: string
|
|
debit?: number
|
|
credit?: number
|
|
account?: string
|
|
label?: string
|
|
source_type?: string | null
|
|
description?: string
|
|
}): SetLine {
|
|
const label = opts.label ?? 'A1'
|
|
return {
|
|
account_number: opts.account ?? '1930',
|
|
debit_amount: opts.debit ?? 0,
|
|
credit_amount: opts.credit ?? 0,
|
|
journal_entry: {
|
|
id: opts.je_id,
|
|
entry_date: opts.date,
|
|
description: opts.description ?? `Voucher ${opts.je_id}`,
|
|
voucher_series: label[0],
|
|
voucher_number: parseInt(label.slice(1), 10) || 1,
|
|
status: 'posted',
|
|
source_type: opts.source_type === undefined ? 'invoice_paid' : opts.source_type,
|
|
company_id: 'company-1',
|
|
},
|
|
}
|
|
}
|
|
|
|
/** entries page, lines page, then the four link lookups (all empty unless given). */
|
|
function enqueueScan(
|
|
rows: SetLine[],
|
|
links: {
|
|
invoicePayments?: unknown[]
|
|
supplierPayments?: unknown[]
|
|
transactions?: unknown[]
|
|
junction?: unknown[]
|
|
} = {},
|
|
) {
|
|
const entries = [...new Map(rows.map((r) => [r.journal_entry.id, r.journal_entry])).values()]
|
|
enqueue({ data: entries, error: null })
|
|
if (entries.length === 0) return
|
|
enqueue({
|
|
data: rows.map((r, i) => ({
|
|
id: `line-${i}`,
|
|
journal_entry_id: r.journal_entry.id,
|
|
account_number: r.account_number,
|
|
debit_amount: r.debit_amount,
|
|
credit_amount: r.credit_amount,
|
|
})),
|
|
error: null,
|
|
})
|
|
enqueue({ data: links.invoicePayments ?? [], error: null })
|
|
enqueue({ data: links.supplierPayments ?? [], error: null })
|
|
enqueue({ data: links.transactions ?? [], error: null })
|
|
enqueue({ data: links.junction ?? [], error: null })
|
|
}
|
|
|
|
const baseArgs = {
|
|
companyId: 'company-1',
|
|
transactionId: 'tx-bg',
|
|
transactionDate: '2026-07-31',
|
|
transactionCurrency: 'SEK',
|
|
}
|
|
|
|
it('explains a Bankgirot aggregate with the two mark-paid vouchers that sum to it', async () => {
|
|
// The reported case: 063 and 064 marked paid by hand (A57 + A58), then one
|
|
// 88 250 "BGGIRERING" row. Their payment rows carry no bank transaction.
|
|
enqueueScan(
|
|
[
|
|
leg({ je_id: 'A57', date: '2026-07-31', debit: 62500, label: 'A57', description: 'Inbetalning kundfaktura 063' }),
|
|
leg({ je_id: 'A58', date: '2026-07-31', debit: 25750, label: 'A58', description: 'Inbetalning kundfaktura 064' }),
|
|
leg({ je_id: 'A56', date: '2026-07-20', debit: 150, label: 'A56', source_type: 'bank_transaction' }),
|
|
],
|
|
{
|
|
invoicePayments: [
|
|
{ journal_entry_id: 'A57', transaction_id: null },
|
|
{ journal_entry_id: 'A58', transaction_id: null },
|
|
],
|
|
},
|
|
)
|
|
|
|
const set = await detectExplainingVoucherSet(supabase as never, {
|
|
...baseArgs,
|
|
transactionAmount: 88250,
|
|
bankAccountNumber: '1930',
|
|
})
|
|
|
|
expect(set).not.toBeNull()
|
|
expect(set!.vouchers.map((v) => v.journal_entry_id).sort()).toEqual(['A57', 'A58'])
|
|
expect(set!.total).toBe(88250)
|
|
expect(set!.bank_account_number).toBe('1930')
|
|
expect(set!.same_date).toBe(true)
|
|
expect(set!.vouchers[0].voucher_label).toBe('A57')
|
|
})
|
|
|
|
it('scopes the scan to the row settlement account and the row direction', async () => {
|
|
const callsBefore = supabase.from.mock.calls.length
|
|
enqueueScan([leg({ je_id: 'je-1', date: '2026-07-31', credit: 1185 })])
|
|
|
|
const set = await detectExplainingVoucherSet(supabase as never, {
|
|
...baseArgs,
|
|
transactionAmount: -1185,
|
|
bankAccountNumber: '1930',
|
|
})
|
|
|
|
const tables = supabase.from.mock.calls.slice(callsBefore).map((c) => c[0])
|
|
expect(tables.slice(0, 2)).toEqual(['journal_entries', 'journal_entry_lines'])
|
|
// Money out: the credit leg is the voucher's bank line.
|
|
expect(set?.vouchers.map((v) => v.amount)).toEqual([1185])
|
|
})
|
|
|
|
it('returns null when no set of at most four vouchers sums exactly to the row', async () => {
|
|
enqueueScan([
|
|
leg({ je_id: 'a', date: '2026-07-31', debit: 62500 }),
|
|
leg({ je_id: 'b', date: '2026-07-31', debit: 25000 }),
|
|
])
|
|
const set = await detectExplainingVoucherSet(supabase as never, {
|
|
...baseArgs,
|
|
transactionAmount: 88250,
|
|
})
|
|
expect(set).toBeNull()
|
|
})
|
|
|
|
it('drops vouchers a bank transaction already explains through any anchor', async () => {
|
|
enqueueScan(
|
|
[
|
|
leg({ je_id: 'via-tx', date: '2026-07-31', debit: 100 }),
|
|
leg({ je_id: 'via-payment', date: '2026-07-31', debit: 100 }),
|
|
leg({ je_id: 'via-supplier-payment', date: '2026-07-31', debit: 100 }),
|
|
leg({ je_id: 'via-junction', date: '2026-07-31', debit: 100 }),
|
|
leg({ je_id: 'free', date: '2026-07-31', debit: 100 }),
|
|
],
|
|
{
|
|
invoicePayments: [{ journal_entry_id: 'via-payment', transaction_id: 'tx-other' }],
|
|
supplierPayments: [{ journal_entry_id: 'via-supplier-payment', transaction_id: 'tx-other' }],
|
|
transactions: [{ id: 'tx-other', journal_entry_id: 'via-tx' }],
|
|
junction: [{ journal_entry_id: 'via-junction' }],
|
|
},
|
|
)
|
|
const set = await detectExplainingVoucherSet(supabase as never, {
|
|
...baseArgs,
|
|
transactionAmount: 100,
|
|
})
|
|
expect(set?.vouchers.map((v) => v.journal_entry_id)).toEqual(['free'])
|
|
})
|
|
|
|
it('never sums storno, correction or opening-balance entries', async () => {
|
|
enqueueScan([
|
|
leg({ je_id: 'storno', date: '2026-07-31', debit: 500, source_type: 'storno' }),
|
|
leg({ je_id: 'corr', date: '2026-07-31', debit: 300, source_type: 'correction' }),
|
|
leg({ je_id: 'ib', date: '2026-07-31', debit: 200, source_type: 'opening_balance' }),
|
|
])
|
|
const set = await detectExplainingVoucherSet(supabase as never, {
|
|
...baseArgs,
|
|
transactionAmount: 1000,
|
|
})
|
|
expect(set).toBeNull()
|
|
})
|
|
|
|
it('returns null without scanning when the row cannot be stated in SEK', async () => {
|
|
const callsBefore = supabase.from.mock.calls.length
|
|
const set = await detectExplainingVoucherSet(supabase as never, {
|
|
...baseArgs,
|
|
transactionAmount: 1000,
|
|
transactionCurrency: 'EUR',
|
|
transactionAmountSek: null,
|
|
transactionExchangeRate: null,
|
|
})
|
|
expect(set).toBeNull()
|
|
expect(supabase.from.mock.calls.length).toBe(callsBefore)
|
|
})
|
|
|
|
it('states a foreign row in SEK before summing', async () => {
|
|
enqueueScan([leg({ je_id: 'je-eur', date: '2026-07-31', debit: 11500 })])
|
|
const set = await detectExplainingVoucherSet(supabase as never, {
|
|
...baseArgs,
|
|
transactionAmount: 1000,
|
|
transactionCurrency: 'EUR',
|
|
transactionAmountSek: 11500,
|
|
})
|
|
expect(set?.vouchers.map((v) => v.journal_entry_id)).toEqual(['je-eur'])
|
|
expect(set?.total).toBe(11500)
|
|
})
|
|
|
|
it('fails open (null) when a link lookup resolves with an error instead of throwing', async () => {
|
|
const entries = [leg({ je_id: 'je-1', date: '2026-07-31', debit: 1000 })]
|
|
enqueue({ data: entries.map((r) => r.journal_entry), error: null })
|
|
enqueue({
|
|
data: entries.map((r, i) => ({ id: `line-${i}`, journal_entry_id: r.journal_entry.id, account_number: '1930', debit_amount: 1000, credit_amount: 0 })),
|
|
error: null,
|
|
})
|
|
enqueue({ data: [], error: null })
|
|
enqueue({ data: [], error: null })
|
|
// transactions lookup fails: PostgREST resolves, it does not throw.
|
|
enqueue({ data: null, error: { message: 'permission denied' } })
|
|
enqueue({ data: [], error: null })
|
|
const set = await detectExplainingVoucherSet(supabase as never, {
|
|
...baseArgs,
|
|
transactionAmount: 1000,
|
|
})
|
|
expect(set).toBeNull()
|
|
})
|
|
|
|
it('fails open (null) when the ledger scan throws', async () => {
|
|
enqueue({ data: null, error: { message: 'boom' } })
|
|
const set = await detectExplainingVoucherSet(supabase as never, {
|
|
...baseArgs,
|
|
transactionAmount: 1000,
|
|
})
|
|
expect(set).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('detectExplainingVoucherSetForTransaction', () => {
|
|
beforeEach(() => {
|
|
reset()
|
|
})
|
|
|
|
it('returns null for a row that already carries a pointer, without scanning', async () => {
|
|
enqueue({ data: { id: 'tx-1', date: '2026-07-31', amount: 100, currency: 'SEK', journal_entry_id: 'je-live' }, error: null })
|
|
const callsBefore = supabase.from.mock.calls.length
|
|
const set = await detectExplainingVoucherSetForTransaction(supabase as never, 'company-1', 'tx-1')
|
|
expect(set).toBeNull()
|
|
expect(supabase.from.mock.calls.length - callsBefore).toBe(1)
|
|
})
|
|
|
|
it('fails open when the cash-account lookup errors instead of widening the scan', async () => {
|
|
const callsBefore = supabase.from.mock.calls.length
|
|
enqueue({ data: { id: 'tx-1', date: '2026-07-31', amount: 100, currency: 'SEK', cash_account_id: 'ca-1', journal_entry_id: null }, error: null })
|
|
enqueue({ data: null, error: { message: 'boom' } })
|
|
const set = await detectExplainingVoucherSetForTransaction(supabase as never, 'company-1', 'tx-1')
|
|
expect(set).toBeNull()
|
|
expect(supabase.from.mock.calls.length - callsBefore).toBe(2)
|
|
})
|
|
|
|
it('resolves the settlement account from the cash account before scanning', async () => {
|
|
const callsBefore = supabase.from.mock.calls.length
|
|
enqueue({ data: { id: 'tx-1', date: '2026-07-31', amount: 100, currency: 'SEK', cash_account_id: 'ca-1', journal_entry_id: null }, error: null })
|
|
enqueue({ data: { ledger_account: '1940' }, error: null })
|
|
// entries page: nothing on the account, scan ends.
|
|
enqueue({ data: [], error: null })
|
|
const set = await detectExplainingVoucherSetForTransaction(supabase as never, 'company-1', 'tx-1')
|
|
expect(set).toBeNull()
|
|
const tables = supabase.from.mock.calls.slice(callsBefore).map((c) => c[0])
|
|
expect(tables).toEqual(['transactions', 'cash_accounts', 'journal_entries'])
|
|
})
|
|
})
|