import { describe, it, expect, beforeEach } from 'vitest' import { detectDuplicatePaymentVoucher } 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[]) { 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 enqueue({ data: [{ journal_entry_id: 'je-3' }], 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 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) }) })