Files
accounted/lib/invoices/__tests__/explaining-voucher-sets.test.ts
T
6776cb4fc6 feat(reconciliation): propose the explaining voucher set for a bank row before offering Bokför (#2359)
* 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>
2026-09-06 21:03:42 +02:00

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)
})
})