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>
This commit is contained in:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
fcda4a75dd
commit
6776cb4fc6
@@ -39,8 +39,8 @@ const TX_ID = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
const set = {
|
||||
vouchers: [
|
||||
{ journal_entry_id: JE_A, voucher_label: 'A57', entry_date: '2026-07-31', description: 'Inbetalning kundfaktura 063', source_type: 'invoice_paid', amount: 62500, bank_account_number: '1930' },
|
||||
{ journal_entry_id: JE_B, voucher_label: 'A58', entry_date: '2026-07-31', description: 'Inbetalning kundfaktura 064', source_type: 'invoice_paid', amount: 25750, bank_account_number: '1930' },
|
||||
{ journal_entry_id: JE_A, voucher_label: 'A57', voucher_series: 'A', voucher_number: 57, entry_date: '2026-07-31', description: 'Inbetalning kundfaktura 063', source_type: 'invoice_paid', amount: 62500, bank_account_number: '1930' },
|
||||
{ journal_entry_id: JE_B, voucher_label: 'A58', voucher_series: 'A', voucher_number: 58, entry_date: '2026-07-31', description: 'Inbetalning kundfaktura 064', source_type: 'invoice_paid', amount: 25750, bank_account_number: '1930' },
|
||||
],
|
||||
total: 88250,
|
||||
bank_account_number: '1930',
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user