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:
Jakob Wennberg
2026-09-06 21:03:42 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent fcda4a75dd
commit 6776cb4fc6
12 changed files with 1055 additions and 163 deletions
@@ -0,0 +1,141 @@
/**
* Tests for the covering-set candidate source of the bridge table (#2293):
* the adapter between the batch set detector and the proposal shape the
* reconciliation surface renders and confirms.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { ExplainingVoucherSet } from '@/lib/invoices/duplicate-payment-detection'
const detectMock = vi.fn()
vi.mock('@/lib/invoices/duplicate-payment-detection', () => ({
detectExplainingVoucherSets: (...args: unknown[]) => detectMock(...args),
}))
import {
coveringSetProposal,
proposeCoveringSets,
COVERING_SET_SAME_DATE_CONFIDENCE,
COVERING_SET_WINDOW_CONFIDENCE,
} from '../covering-set-candidate'
const supabase = {} as never
const COMPANY = 'company-1'
const SEK_ACCOUNT = { ledger_account: '1930', currency: 'SEK' }
function voucher(id: string, amount: number, date: string, description: string | null = `Voucher ${id}`) {
return {
journal_entry_id: id,
voucher_label: id,
voucher_series: id[0],
voucher_number: parseInt(id.slice(1), 10),
entry_date: date,
description,
source_type: 'invoice_paid',
amount,
bank_account_number: '1930',
}
}
function set(vouchers: ReturnType<typeof voucher>[], sameDate: boolean): ExplainingVoucherSet {
return {
vouchers,
total: vouchers.reduce((s, v) => s + v.amount, 0),
bank_account_number: '1930',
same_date: sameDate,
}
}
describe('coveringSetProposal', () => {
it('renders a same-date set as strong as an exact 1:1 match, with every voucher listed', () => {
const proposal = coveringSetProposal(
set([voucher('A57', 62500, '2026-07-31', 'Inbetalning 063'), voucher('A58', 25750, '2026-07-31', 'Inbetalning 064')], true),
)
expect(proposal).toEqual({
journal_entry_id: 'A57',
voucher_number: 57,
voucher_series: 'A',
entry_date: '2026-07-31',
description: 'Inbetalning 063 + Inbetalning 064',
entry_status: 'posted',
confidence: COVERING_SET_SAME_DATE_CONFIDENCE,
reasons: ['exact_sum_same_date'],
vouchers: [
{ journal_entry_id: 'A57', voucher_number: 57, voucher_series: 'A', entry_date: '2026-07-31', description: 'Inbetalning 063', amount: 62500 },
{ journal_entry_id: 'A58', voucher_number: 58, voucher_series: 'A', entry_date: '2026-07-31', description: 'Inbetalning 064', amount: 25750 },
],
})
expect(COVERING_SET_SAME_DATE_CONFIDENCE).toBe(0.95)
})
it('keeps a within-window set below the unattended auto-apply floor', () => {
const proposal = coveringSetProposal(set([voucher('A3', 1000, '2026-07-28', null)], false))
expect(proposal.confidence).toBe(COVERING_SET_WINDOW_CONFIDENCE)
expect(COVERING_SET_WINDOW_CONFIDENCE).toBeLessThan(0.9)
expect(proposal.reasons).toEqual(['exact_sum_within_window'])
expect(proposal.description).toBe('')
expect(proposal.vouchers).toEqual([
{ journal_entry_id: 'A3', voucher_number: 3, voucher_series: 'A', entry_date: '2026-07-28', description: '', amount: 1000 },
])
})
})
describe('proposeCoveringSets', () => {
beforeEach(() => {
vi.clearAllMocks()
detectMock.mockResolvedValue(new Map())
})
it('runs the batch detector on the account and maps every set to a proposal', async () => {
detectMock.mockResolvedValue(
new Map([['tx-bg', set([voucher('A57', 62500, '2026-07-31'), voucher('A58', 25750, '2026-07-31')], true)]]),
)
const proposals = await proposeCoveringSets(supabase, COMPANY, SEK_ACCOUNT, [
{ id: 'tx-bg', date: '2026-07-31', amount: 88250, currency: 'SEK' },
{ id: 'tx-other', date: '2026-07-30', amount: -120, currency: 'SEK' },
])
expect(detectMock).toHaveBeenCalledWith(supabase, {
companyId: COMPANY,
bankAccountNumber: '1930',
transactions: [
{ id: 'tx-bg', date: '2026-07-31', amount: 88250, currency: 'SEK' },
{ id: 'tx-other', date: '2026-07-30', amount: -120, currency: 'SEK' },
],
})
expect([...proposals.keys()]).toEqual(['tx-bg'])
expect(proposals.get('tx-bg')).toMatchObject({
journal_entry_id: 'A57',
confidence: 0.95,
vouchers: [{ journal_entry_id: 'A57' }, { journal_entry_id: 'A58' }],
})
})
it('never searches a non-SEK account: the 1:N link compares in the account currency', async () => {
const proposals = await proposeCoveringSets(supabase, COMPANY, { ledger_account: '1932', currency: 'EUR' }, [
{ id: 'tx-eur', date: '2026-07-31', amount: 100, currency: 'EUR' },
])
expect(proposals.size).toBe(0)
expect(detectMock).not.toHaveBeenCalled()
})
it('does nothing for an empty row list', async () => {
const proposals = await proposeCoveringSets(supabase, COMPANY, SEK_ACCOUNT, [])
expect(proposals.size).toBe(0)
expect(detectMock).not.toHaveBeenCalled()
})
it('fails open when the detector throws: the row stays unmatched, the table stays up', async () => {
detectMock.mockRejectedValue(new Error('db down'))
const proposals = await proposeCoveringSets(supabase, COMPANY, SEK_ACCOUNT, [
{ id: 'tx', date: '2026-07-31', amount: 100, currency: 'SEK' },
])
expect(proposals.size).toBe(0)
})
})
@@ -13,6 +13,10 @@ vi.mock('../bank-reconciliation', () => ({
fetchJunctionLinkedTxIds: (...args: unknown[]) => junctionMock(...args),
scopeTransactionsToAccount: (q: unknown) => q,
}))
const coveringSetsMock = vi.fn()
vi.mock('../covering-set-candidate', () => ({
proposeCoveringSets: (...args: unknown[]) => coveringSetsMock(...args),
}))
import { listAccountItems } from '../items'
@@ -30,6 +34,8 @@ describe('listAccountItems', () => {
fetchUnlinkedMock.mockReset()
junctionMock.mockReset()
junctionMock.mockResolvedValue(new Set())
coveringSetsMock.mockReset()
coveringSetsMock.mockResolvedValue(new Map())
})
it('returns null for an invalid key', async () => {
@@ -95,6 +101,67 @@ describe('listAccountItems', () => {
// Work order: proposed, unmatched external, unmatched ledger, ignored, upcoming, matched
expect(result?.items.map((i) => i.bucket)).toEqual(['proposed', 'unmatched_external', 'unmatched_ledger', 'ignored', 'matched', 'matched'])
expect(result?.total_count).toBe(6)
// Only the rows nothing explains yet are searched for a covering set: not
// the ignored, linked, junction-anchored or already-proposed ones.
expect(coveringSetsMock).toHaveBeenCalledTimes(1)
expect(coveringSetsMock.mock.calls[0][2]).toMatchObject({ ledger_account: '1930', currency: 'SEK' })
expect((coveringSetsMock.mock.calls[0][3] as Array<{ id: string }>).map((r) => r.id)).toEqual(['t-open'])
})
it('moves a bank row a covering set explains into proposed, with every voucher on the proposal (#2293)', async () => {
const openRow = (id: string, date: string, amount: number) => ({
id, date, description: 'BGGIRERING 03447786', merchant_name: null, amount, currency: 'SEK', journal_entry_id: null,
potential_journal_entry_id: null, potential_match_method: null, potential_match_confidence: null, is_ignored: false, reconciliation_method: null,
})
const setProposal = {
journal_entry_id: 'e-57',
voucher_number: 57,
voucher_series: 'A',
entry_date: '2026-07-31',
description: 'Inbetalning 063 + Inbetalning 064',
entry_status: 'posted' as const,
confidence: 0.95,
reasons: ['exact_sum_same_date'],
vouchers: [
{ journal_entry_id: 'e-57', voucher_number: 57, voucher_series: 'A', entry_date: '2026-07-31', description: 'Inbetalning 063', amount: 62500 },
{ journal_entry_id: 'e-58', voucher_number: 58, voucher_series: 'A', entry_date: '2026-07-31', description: 'Inbetalning 064', amount: 25750 },
],
}
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: CASH, ledger_account: '1930', currency: 'SEK', is_primary: true } })
enqueue({ data: [openRow('t-bg', '2026-07-31', 88250), openRow('t-lonely', '2026-07-30', -999)] })
fetchUnlinkedMock.mockResolvedValue([])
coveringSetsMock.mockResolvedValue(new Map([['t-bg', setProposal]]))
const result = await listAccountItems(supabase as never, COMPANY, `bank:${CASH}`, { limit: 50 })
const byId = Object.fromEntries((result?.items ?? []).map((i) => [i.item_id, i]))
expect(byId['t-bg']).toMatchObject({ bucket: 'proposed', proposal: setProposal, actions: ['match', 'book', 'ignore'] })
expect(byId['t-lonely']).toMatchObject({ bucket: 'unmatched_external', proposal: null })
expect(result?.items.map((i) => i.item_id)).toEqual(['t-bg', 't-lonely'])
})
it('keeps a set-explained row out of unmatched_external and skips the search when neither open bucket is wanted', async () => {
const openRow = {
id: 't-bg', date: '2026-07-31', description: 'BGGIRERING', merchant_name: null, amount: 88250, currency: 'SEK', journal_entry_id: null,
potential_journal_entry_id: null, potential_match_method: null, potential_match_confidence: null, is_ignored: false, reconciliation_method: null,
}
const proposal = { journal_entry_id: 'e-57', voucher_number: 57, voucher_series: 'A', entry_date: '2026-07-31', description: '', entry_status: 'posted' as const, confidence: 0.95, reasons: ['exact_sum_same_date'], vouchers: [] }
coveringSetsMock.mockResolvedValue(new Map([['t-bg', proposal]]))
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: CASH, ledger_account: '1930', currency: 'SEK', is_primary: true } })
enqueue({ data: [openRow] })
const unmatched = await listAccountItems(supabase as never, COMPANY, `bank:${CASH}`, { bucket: 'unmatched_external' })
expect(unmatched?.items).toEqual([])
expect(coveringSetsMock).toHaveBeenCalledTimes(1)
const other = createQueuedMockSupabase()
other.enqueue({ data: { id: CASH, ledger_account: '1930', currency: 'SEK', is_primary: true } })
other.enqueue({ data: [openRow] })
const matched = await listAccountItems(other.supabase as never, COMPANY, `bank:${CASH}`, { bucket: 'matched' })
expect(matched?.items).toEqual([])
expect(coveringSetsMock).toHaveBeenCalledTimes(1)
})
it('returns null for an unknown cash account', async () => {