* feat(reconciliation): propose the explaining voucher set for a bank row before offering Bokför (#2293) The bridge table said "ej matchad" and steered to Bokför when a Bankgirot aggregate was already booked as two or three unlinked vouchers. The booking doors have refused that double booking since #2300 and #2346 with detectExplainingVoucherSet; the view never ran it. - duplicate-payment-detection: split the set detector into fetch and pure steps and add detectExplainingVoucherSets, the batch form (one ledger scan, one anchor lookup, per-row verdict identical to the single detector; a voucher explains at most one row per call). ExplainingVoucher now also carries voucher_series and voucher_number. - reconciliation/covering-set-candidate (new): maps sets to proposals (0.95 same date, 0.85 within seven days), SEK accounts only, fails open. - items: open bank rows nothing explains 1:1 are searched before they land in unmatched_external; a hit lands in proposed with proposal.vouchers. - schemas: ReconciliationProposal.vouchers (optional, set proposals only). - AccountOverview: "= A57 + A58" with the legs' amounts, one Koppla that posts every voucher as a 1:N pair to the existing links route. - i18n: reconciliation.proposal_set_title and proposal_set_same_day (sv, en). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 * chore(skill): regenerate accounted-api for ReconciliationProposal.vouchers (#2293) The set proposal field added to the reconciliation items response shape flows into the generated agent skill; regenerated with `npm run apiskill:generate`, which changes one line of references/banking.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
268 lines
9.8 KiB
TypeScript
268 lines
9.8 KiB
TypeScript
/**
|
|
* Tests for detectExplainingVoucherSets, the batch form of the set detector
|
|
* that the reconciliation view runs (#2293).
|
|
*
|
|
* The contract: one ledger scan over the union of the rows' windows, one
|
|
* link-anchor lookup, and per row exactly the single detector's verdict
|
|
* (exact öre sum of unlinked legs in the row's direction on the account,
|
|
* within ±7 days, at most four vouchers); a voucher explains at most one row
|
|
* per call; anything that cannot be judged fails open (no proposal).
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { detectExplainingVoucherSets } from '../duplicate-payment-detection'
|
|
import { createQueuedMockSupabase } from '@/tests/helpers'
|
|
|
|
const { supabase, enqueue, reset, findCalls } = createQueuedMockSupabase()
|
|
|
|
type Leg = {
|
|
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: {
|
|
id: string
|
|
date: string
|
|
debit?: number
|
|
credit?: number
|
|
account?: string
|
|
source_type?: string | null
|
|
}): Leg {
|
|
return {
|
|
account_number: opts.account ?? '1930',
|
|
debit_amount: opts.debit ?? 0,
|
|
credit_amount: opts.credit ?? 0,
|
|
journal_entry: {
|
|
id: opts.id,
|
|
entry_date: opts.date,
|
|
description: `Voucher ${opts.id}`,
|
|
voucher_series: opts.id[0],
|
|
voucher_number: parseInt(opts.id.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 (empty unless given). */
|
|
function enqueueScan(
|
|
legs: Leg[],
|
|
links: { transactions?: unknown[]; junction?: unknown[]; invoicePayments?: unknown[] } = {},
|
|
) {
|
|
const entries = [...new Map(legs.map((l) => [l.journal_entry.id, l.journal_entry])).values()]
|
|
enqueue({ data: entries })
|
|
if (entries.length === 0) return
|
|
enqueue({
|
|
data: legs.map((l, i) => ({
|
|
id: `line-${i}`,
|
|
journal_entry_id: l.journal_entry.id,
|
|
account_number: l.account_number,
|
|
debit_amount: l.debit_amount,
|
|
credit_amount: l.credit_amount,
|
|
})),
|
|
})
|
|
enqueue({ data: links.invoicePayments ?? [] })
|
|
enqueue({ data: [] })
|
|
enqueue({ data: links.transactions ?? [] })
|
|
enqueue({ data: links.junction ?? [] })
|
|
}
|
|
|
|
const COMPANY = 'company-1'
|
|
const row = (id: string, date: string, amount: number) => ({ id, date, amount, currency: 'SEK' })
|
|
|
|
async function run(transactions: ReturnType<typeof row>[]) {
|
|
return detectExplainingVoucherSets(supabase as never, {
|
|
companyId: COMPANY,
|
|
bankAccountNumber: '1930',
|
|
transactions,
|
|
})
|
|
}
|
|
|
|
describe('detectExplainingVoucherSets', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
reset()
|
|
})
|
|
|
|
it('explains the Bankgirot aggregate with the two mark-paid vouchers in ONE scan', async () => {
|
|
enqueueScan([
|
|
leg({ id: 'A57', date: '2026-07-31', debit: 62500 }),
|
|
leg({ id: 'A58', date: '2026-07-31', debit: 25750 }),
|
|
leg({ id: 'A56', date: '2026-07-20', debit: 150 }),
|
|
])
|
|
|
|
const sets = await run([row('tx-bg', '2026-07-31', 88250), row('tx-other', '2026-07-30', -999)])
|
|
|
|
const set = sets.get('tx-bg')
|
|
expect(set).toBeDefined()
|
|
expect(set!.vouchers.map((v) => v.journal_entry_id).sort()).toEqual(['A57', 'A58'])
|
|
expect(set!.vouchers[0]).toMatchObject({ voucher_series: 'A', voucher_number: 57, voucher_label: 'A57' })
|
|
expect(set!.same_date).toBe(true)
|
|
expect(set!.total).toBe(88250)
|
|
expect(sets.has('tx-other')).toBe(false)
|
|
// One entries query, one lines query, four anchor lookups: never per row.
|
|
expect(supabase.from).toHaveBeenCalledTimes(6)
|
|
// The scan covers the union of the rows' ±7-day windows and the account.
|
|
expect(findCalls('journal_entries', 'gte')).toEqual([['entry_date', '2026-07-23']])
|
|
expect(findCalls('journal_entries', 'lte')).toEqual([['entry_date', '2026-08-07']])
|
|
expect(findCalls('journal_entry_lines', 'eq')).toEqual([['account_number', '1930']])
|
|
})
|
|
|
|
it('returns nothing when no set of vouchers sums exactly to a row', async () => {
|
|
enqueueScan([leg({ id: 'A1', date: '2026-07-31', debit: 62500 }), leg({ id: 'A2', date: '2026-07-31', debit: 25000 })])
|
|
|
|
const sets = await run([row('tx-bg', '2026-07-31', 88250)])
|
|
|
|
expect(sets.size).toBe(0)
|
|
})
|
|
|
|
it('sums only legs in the row direction and inside the row window', async () => {
|
|
enqueueScan([
|
|
// Right amount, wrong direction (a credit cannot explain money in).
|
|
leg({ id: 'A1', date: '2026-07-31', credit: 62500 }),
|
|
leg({ id: 'A2', date: '2026-07-31', debit: 25750 }),
|
|
// Right amount, eight days off: outside this row's ±7-day window even
|
|
// though the batch scan fetched it for another row.
|
|
leg({ id: 'A3', date: '2026-08-08', debit: 62500 }),
|
|
// Within the window: explains the row together with A2.
|
|
leg({ id: 'A4', date: '2026-08-06', debit: 62500 }),
|
|
])
|
|
|
|
const sets = await run([row('tx-bg', '2026-07-31', 88250), row('tx-late', '2026-08-10', 1)])
|
|
|
|
const set = sets.get('tx-bg')
|
|
expect(set!.vouchers.map((v) => v.journal_entry_id).sort()).toEqual(['A2', 'A4'])
|
|
expect(set!.same_date).toBe(false)
|
|
})
|
|
|
|
it('stops at four vouchers per set', async () => {
|
|
enqueueScan([
|
|
leg({ id: 'A1', date: '2026-07-31', debit: 100 }),
|
|
leg({ id: 'A2', date: '2026-07-31', debit: 200 }),
|
|
leg({ id: 'A3', date: '2026-07-31', debit: 300 }),
|
|
leg({ id: 'A4', date: '2026-07-31', debit: 400 }),
|
|
leg({ id: 'A5', date: '2026-07-31', debit: 5000 }),
|
|
])
|
|
|
|
// 6000 needs all five legs; 1000 is exactly the four small ones.
|
|
const sets = await run([row('tx-five', '2026-07-31', 6000), row('tx-four', '2026-07-31', 1000)])
|
|
|
|
expect(sets.has('tx-five')).toBe(false)
|
|
expect(sets.get('tx-four')!.vouchers.map((v) => v.journal_entry_id).sort()).toEqual(['A1', 'A2', 'A3', 'A4'])
|
|
})
|
|
|
|
it('lets a voucher explain at most one row, same-date claims first', async () => {
|
|
enqueueScan([leg({ id: 'A1', date: '2026-07-31', debit: 1000 }), leg({ id: 'A2', date: '2026-07-28', debit: 1000 })])
|
|
|
|
// Both rows could take A1; the same-date row gets it, the other re-searches and takes A2.
|
|
const sets = await run([row('tx-early', '2026-07-29', 1000), row('tx-same', '2026-07-31', 1000)])
|
|
|
|
expect(sets.get('tx-same')!.vouchers.map((v) => v.journal_entry_id)).toEqual(['A1'])
|
|
expect(sets.get('tx-early')!.vouchers.map((v) => v.journal_entry_id)).toEqual(['A2'])
|
|
})
|
|
|
|
it('drops a voucher when nothing is left for the second claim', async () => {
|
|
enqueueScan([leg({ id: 'A1', date: '2026-07-31', debit: 1000 })])
|
|
|
|
const sets = await run([row('tx-a', '2026-07-31', 1000), row('tx-b', '2026-07-31', 1000)])
|
|
|
|
expect(sets.size).toBe(1)
|
|
expect(sets.get('tx-a')!.vouchers.map((v) => v.journal_entry_id)).toEqual(['A1'])
|
|
})
|
|
|
|
it('drops vouchers a bank transaction already explains, but never the rows being explained', async () => {
|
|
enqueueScan(
|
|
[
|
|
leg({ id: 'A57', date: '2026-07-31', debit: 62500 }),
|
|
leg({ id: 'A58', date: '2026-07-31', debit: 25750 }),
|
|
leg({ id: 'A59', date: '2026-07-31', debit: 25750 }),
|
|
],
|
|
{
|
|
// A58 is settled by another row; A59 is "linked" only from a row in
|
|
// this batch (a stale pointer the batch is explaining), which does not count.
|
|
transactions: [
|
|
{ id: 'tx-elsewhere', journal_entry_id: 'A58' },
|
|
{ id: 'tx-bg', journal_entry_id: 'A59' },
|
|
],
|
|
},
|
|
)
|
|
|
|
const sets = await run([row('tx-bg', '2026-07-31', 88250)])
|
|
|
|
expect(sets.get('tx-bg')!.vouchers.map((v) => v.journal_entry_id).sort()).toEqual(['A57', 'A59'])
|
|
})
|
|
|
|
it('never sums storno, correction or opening-balance entries', async () => {
|
|
enqueueScan([
|
|
leg({ id: 'A1', date: '2026-07-31', debit: 500, source_type: 'storno' }),
|
|
leg({ id: 'A2', date: '2026-07-31', debit: 500, source_type: 'correction' }),
|
|
leg({ id: 'A3', date: '2026-07-31', debit: 1000, source_type: 'opening_balance' }),
|
|
])
|
|
|
|
const sets = await run([row('tx', '2026-07-31', 1000)])
|
|
|
|
expect(sets.size).toBe(0)
|
|
// Scaffolding never reaches the anchor lookups either.
|
|
expect(supabase.from).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('skips rows that cannot be stated in SEK or carry no amount, without scanning for them', async () => {
|
|
const sets = await detectExplainingVoucherSets(supabase as never, {
|
|
companyId: COMPANY,
|
|
bankAccountNumber: '1930',
|
|
transactions: [
|
|
{ id: 'tx-eur', date: '2026-07-31', amount: 100, currency: 'EUR' },
|
|
{ id: 'tx-zero', date: '2026-07-31', amount: 0, currency: 'SEK' },
|
|
{ id: 'tx-bad-date', date: 'not-a-date', amount: 100, currency: 'SEK' },
|
|
],
|
|
})
|
|
|
|
expect(sets.size).toBe(0)
|
|
expect(supabase.from).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('fails open (nothing) when a link lookup resolves with an error', async () => {
|
|
const legs = [leg({ id: 'A1', date: '2026-07-31', debit: 1000 })]
|
|
const entries = legs.map((l) => l.journal_entry)
|
|
enqueue({ data: entries })
|
|
enqueue({
|
|
data: legs.map((l, i) => ({
|
|
id: `line-${i}`,
|
|
journal_entry_id: l.journal_entry.id,
|
|
account_number: l.account_number,
|
|
debit_amount: l.debit_amount,
|
|
credit_amount: l.credit_amount,
|
|
})),
|
|
})
|
|
enqueue({ data: [] })
|
|
enqueue({ data: [] })
|
|
enqueue({ data: null, error: { message: 'boom' } })
|
|
enqueue({ data: [] })
|
|
|
|
const sets = await run([row('tx', '2026-07-31', 1000)])
|
|
|
|
expect(sets.size).toBe(0)
|
|
})
|
|
|
|
it('fails open (nothing) when the ledger scan throws', async () => {
|
|
supabase.from.mockImplementationOnce(() => {
|
|
throw new Error('db down')
|
|
})
|
|
|
|
const sets = await run([row('tx', '2026-07-31', 1000)])
|
|
|
|
expect(sets.size).toBe(0)
|
|
})
|
|
})
|