diff --git a/components/reconciliation/AccountOverview.tsx b/components/reconciliation/AccountOverview.tsx index 86fc4840..d49e3739 100644 --- a/components/reconciliation/AccountOverview.tsx +++ b/components/reconciliation/AccountOverview.tsx @@ -191,8 +191,13 @@ export function AccountOverview({ account, rail, otherBankAccounts = [], window, if (!item.proposal) return setBusy(item.item_id) try { + // A set proposal (#2293) links the row to every verifikat in it (1:N, + // all or nothing, slices revalidated server-side); a 1:1 proposal to one. + const journalEntryIds = item.proposal.vouchers?.map((v) => v.journal_entry_id) ?? [ + item.proposal.journal_entry_id, + ] const data = await postJson(`${base}/links`, { - pairs: [{ external_ids: [item.item_id], journal_entry_ids: [item.proposal.journal_entry_id] }], + pairs: [{ external_ids: [item.item_id], journal_entry_ids: journalEntryIds }], }) if (data) { const skipped = data.skipped as Array<{ message: string }> @@ -918,6 +923,43 @@ function ItemRow({ {item.awaiting_external && {t('chip_awaiting', { source: sourceLabel })}} ) + } else if (item.proposal && item.proposal.vouchers && item.proposal.vouchers.length > 1) { + // An explaining set (#2293): "= A57 + A58", the legs' amounts underneath, + // one Koppla that links the row to all of them. + const p = item.proposal + const setVouchers = p.vouchers ?? [] + const sameDay = setVouchers.every((v) => v.entry_date === item.date) + voucherCell = ( + + + + = + + {setVouchers.map((v, i) => ( + + {i > 0 && ( + + + + + )} + + {voucherOf(v) ?? v.journal_entry_id.slice(0, 8)} + + + ))} + + {t('confidence', { percent: Math.round(p.confidence * 100) })} + + + + {setVouchers.map((v) => formatCurrency(v.amount, currency)).join(' + ')} + {sameDay ? ` · ${t('proposal_set_same_day')}` : ''} + + + ) } else if (item.proposal) { const p = item.proposal voucherCell = ( diff --git a/lib/invoices/__tests__/already-explained-guard.test.ts b/lib/invoices/__tests__/already-explained-guard.test.ts index a27a6ffd..68812e74 100644 --- a/lib/invoices/__tests__/already-explained-guard.test.ts +++ b/lib/invoices/__tests__/already-explained-guard.test.ts @@ -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', diff --git a/lib/invoices/__tests__/explaining-voucher-sets.test.ts b/lib/invoices/__tests__/explaining-voucher-sets.test.ts new file mode 100644 index 00000000..d37a3bf9 --- /dev/null +++ b/lib/invoices/__tests__/explaining-voucher-sets.test.ts @@ -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[]) { + 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) + }) +}) diff --git a/lib/invoices/duplicate-payment-detection.ts b/lib/invoices/duplicate-payment-detection.ts index 204ecd8b..2b11d0d1 100644 --- a/lib/invoices/duplicate-payment-detection.ts +++ b/lib/invoices/duplicate-payment-detection.ts @@ -287,9 +287,16 @@ const SET_DATE_WINDOW_DAYS = 7 /** Largest set of vouchers offered as the explanation of one bank row. */ export const EXPLAINING_SET_MAX_VOUCHERS = 4 +const DAY_MS = 24 * 3600 * 1000 + +/** Max journal_entry ids per link-anchor `.in()` filter: keeps the request URL short. */ +const LINK_LOOKUP_CHUNK = 100 + export interface ExplainingVoucher { journal_entry_id: string voucher_label: string + voucher_series: string | null + voucher_number: number | null entry_date: string description: string | null source_type: string | null @@ -318,42 +325,63 @@ export interface DetectSetArgs extends DetectArgs { bankAccountNumber?: string | null } +/** A bank leg with its parent entry, as {@link fetchBankLegs} returns it. */ +interface BankLegRow { + account_number: string + debit_amount: number | string | null + credit_amount: number | string | null + journal_entry: { + id: string + entry_date: string + description: string | null + voucher_series: string | null + voucher_number: number | null + status: string + source_type: string | null + } +} + +type SetCandidate = ExplainingVoucher & { dateDistanceDays: number; id: string } + +type PaymentLinkRow = { journal_entry_id: string | null; transaction_id: string | null } + +/** The row's SEK target, direction and ±window; null when it cannot take part. */ +interface SetTarget { + targetSek: number + /** Money in: the voucher DEBITS the bank account. Money out: it CREDITS it. */ + inbound: boolean + dateMs: number + lowDate: string + highDate: string +} + +function isoDateOffset(dateMs: number, days: number): string { + return new Date(dateMs + days * DAY_MS).toISOString().split('T')[0] +} + +/** + * Reversals, corrections and opening balances are bookkeeping scaffolding, + * never the payment itself (the reconciliation RPCs drop the same three). + */ +function isScaffoldingEntry(sourceType: string | null): boolean { + return sourceType === 'storno' || sourceType === 'correction' || sourceType === 'opening_balance' +} + /** - * Find the vouchers that already book this bank row, allowing the row to be - * explained by SEVERAL of them. - * - * The 1:1 detector above answers "is there one voucher of this amount?". A - * bank feed regularly delivers one row for several affärshändelser (a - * Bankgirot daily aggregate: two customers' invoices, one "BGGIRERING" row - * with no payer and no reference), and each of those may already be booked on - * its own: "Markera som betald" per invoice, one salary voucher per employee. - * Nothing on the account then equals the row, the 1:1 check passes, and the - * next door (a batch allocation, a fresh categorisation) books the same - * money a second time. That is exactly the double booking this catches. - * - * Deterministic on purpose: the only signal is an exact öre sum of unlinked - * bank legs in the row's direction, on the row's account, within ±7 days. - * No counterparty text is consulted: the bank rows this exists for carry - * none. A voucher counts as linked (and drops out) when a transaction points - * at it, a payment row with a bank transaction references it, or a - * transaction_voucher_links row anchors it: the same three storage - * locations isTransactionBooked reads, seen from the voucher side. A payment - * row WITHOUT a bank transaction is a manual settlement (#2019) and keeps the - * voucher in play: its bank line is precisely what has not been matched yet. - * * Returns null when the row cannot be stated in SEK (a foreign row with no * stored rate): a set cannot be summed in an unknown unit, and the 1:1 * detector's `amount_verified: false` path already surfaces that case. */ -export async function detectExplainingVoucherSet( - supabase: SupabaseClient, - args: DetectSetArgs, -): Promise { - const { companyId, transactionId, transactionDate, transactionAmount } = args - if (Math.round(Math.abs(transactionAmount) * 100) === 0) return null - +function prepareSetTarget(args: { + transactionDate: string + transactionAmount: number + transactionCurrency: string | null + transactionAmountSek?: number | null + transactionExchangeRate?: number | null +}): SetTarget | null { + if (Math.round(Math.abs(args.transactionAmount) * 100) === 0) return null const signedSek = resolveTransactionAmountSek({ - amount: transactionAmount, + amount: args.transactionAmount, currency: args.transactionCurrency, amount_sek: args.transactionAmountSek, exchange_rate: args.transactionExchangeRate, @@ -361,83 +389,73 @@ export async function detectExplainingVoucherSet( if (signedSek === null) return null const targetSek = roundOre(Math.abs(signedSek)) if (targetSek === 0) return null - - const dateMs = new Date(transactionDate).getTime() + const dateMs = new Date(args.transactionDate).getTime() if (Number.isNaN(dateMs)) return null - const lowDate = new Date(dateMs - SET_DATE_WINDOW_DAYS * 24 * 3600 * 1000) - .toISOString() - .split('T')[0] - const highDate = new Date(dateMs + SET_DATE_WINDOW_DAYS * 24 * 3600 * 1000) - .toISOString() - .split('T')[0] + return { + targetSek, + inbound: args.transactionAmount > 0, + dateMs, + lowDate: isoDateOffset(dateMs, -SET_DATE_WINDOW_DAYS), + highDate: isoDateOffset(dateMs, SET_DATE_WINDOW_DAYS), + } +} - // Money in: the voucher DEBITS the bank account. Money out: it CREDITS it. - const inbound = transactionAmount > 0 +/** + * Posted legs on the settlement account (or the whole 19xx range) dated + * within [lowDate, highDate]. `inbound` true fetches debits, false credits, + * null both (the batch path filters direction per row in memory). Throws + * when the read fails; callers fail open. + */ +async function fetchBankLegs( + supabase: SupabaseClient, + args: { + companyId: string + lowDate: string + highDate: string + bankAccountNumber: string | null + inbound: boolean | null + }, +): Promise { const account = args.bankAccountNumber?.trim() || null + return fetchEntryLines({ + supabase, + entryColumns: + 'id, entry_date, description, voucher_series, voucher_number, status, source_type, company_id', + lineColumns: 'account_number, debit_amount, credit_amount', + filterEntries: (q: EntryLinesQuery) => + q + .eq('company_id', args.companyId) + .eq('status', 'posted') + .gte('entry_date', args.lowDate) + .lte('entry_date', args.highDate), + filterLines: (q: EntryLinesQuery) => { + const scoped = account + ? q.eq('account_number', account) + : q.gte('account_number', String(BANK_ACCOUNT_LOW)).lte('account_number', String(BANK_ACCOUNT_HIGH)) + if (args.inbound === null) return scoped + return args.inbound ? scoped.gt('debit_amount', 0) : scoped.gt('credit_amount', 0) + }, + attachEntriesAs: 'journal_entry', + }) +} - type SetLineRow = { - account_number: string - debit_amount: number | string | null - credit_amount: number | string | null - journal_entry: { - id: string - entry_date: string - description: string | null - voucher_series: string | null - voucher_number: number | null - status: string - source_type: string | null - } - } - - let lines: SetLineRow[] - try { - lines = await fetchEntryLines({ - supabase, - entryColumns: - 'id, entry_date, description, voucher_series, voucher_number, status, source_type, company_id', - lineColumns: 'account_number, debit_amount, credit_amount', - filterEntries: (q: EntryLinesQuery) => - q - .eq('company_id', companyId) - .eq('status', 'posted') - .gte('entry_date', lowDate) - .lte('entry_date', highDate), - filterLines: (q: EntryLinesQuery) => { - const scoped = account - ? q.eq('account_number', account) - : q.gte('account_number', String(BANK_ACCOUNT_LOW)).lte('account_number', String(BANK_ACCOUNT_HIGH)) - return inbound ? scoped.gt('debit_amount', 0) : scoped.gt('credit_amount', 0) - }, - attachEntriesAs: 'journal_entry', - }) - } catch { - // Fail-open like the 1:1 detector: a detection failure must not block a - // booking. Callers log the miss. - return null - } - if (lines.length === 0) return null - - // Reversals, corrections and opening balances are bookkeeping scaffolding, - // never the payment itself (the reconciliation RPCs drop the same three). - const legs = lines.filter( - (l) => - l.journal_entry.source_type !== 'storno' && - l.journal_entry.source_type !== 'correction' && - l.journal_entry.source_type !== 'opening_balance', - ) - if (legs.length === 0) return null - - // One candidate per voucher and account: a voucher with two legs on the - // same account (a split payment line) is summed, a voucher touching two - // bank accounts (a transfer) keeps its largest leg so it can appear once. - type Candidate = ExplainingVoucher & { dateDistanceDays: number; id: string } - const byEntry = new Map() +/** + * Pure: the candidates for ONE bank row from legs already fetched. One + * candidate per voucher and account: a voucher with two legs on the same + * account (a split payment line) is summed, a voucher touching two bank + * accounts (a transfer) keeps its largest leg so it can appear once. Legs + * outside the row's window, in the wrong direction, or on scaffolding + * entries never take part. + */ +function collectSetCandidates(legs: BankLegRow[], target: SetTarget): Map { + const byEntry = new Map() for (const leg of legs) { - const raw = inbound ? leg.debit_amount : leg.credit_amount + const entry = leg.journal_entry + if (isScaffoldingEntry(entry.source_type)) continue + if (entry.entry_date < target.lowDate || entry.entry_date > target.highDate) continue + const raw = target.inbound ? leg.debit_amount : leg.credit_amount const amount = roundOre(Number(raw)) if (!(amount > 0)) continue - const entry = leg.journal_entry const existing = byEntry.get(entry.id) if (existing && existing.bank_account_number === leg.account_number) { existing.amount = roundOre(existing.amount + amount) @@ -449,6 +467,8 @@ export async function detectExplainingVoucherSet( id: entry.id, journal_entry_id: entry.id, voucher_label: `${entry.voucher_series ?? 'A'}${entry.voucher_number ?? ''}`, + voucher_series: entry.voucher_series, + voucher_number: entry.voucher_number, entry_date: entry.entry_date, description: entry.description, source_type: entry.source_type, @@ -456,66 +476,91 @@ export async function detectExplainingVoucherSet( bank_account_number: leg.account_number, dateDistanceDays: Number.isNaN(entryMs) ? SET_DATE_WINDOW_DAYS - : Math.round(Math.abs(entryMs - dateMs) / (24 * 3600 * 1000)), + : Math.round(Math.abs(entryMs - target.dateMs) / DAY_MS), }) } - if (byEntry.size === 0) return null - - // Drop vouchers a bank transaction already explains, through any of the - // three anchors. All four lookups are company-scoped (defense in depth). - const entryIds = Array.from(byEntry.keys()) - const [paymentLinksRes, supplierPaymentLinksRes, txLinksRes, junctionLinksRes] = - await Promise.all([ - supabase - .from('invoice_payments') - .select('journal_entry_id, transaction_id') - .eq('company_id', companyId) - .in('journal_entry_id', entryIds), - supabase - .from('supplier_invoice_payments') - .select('journal_entry_id, transaction_id') - .eq('company_id', companyId) - .in('journal_entry_id', entryIds), - supabase - .from('transactions') - .select('id, journal_entry_id') - .eq('company_id', companyId) - .in('journal_entry_id', entryIds), - supabase - .from('transaction_voucher_links') - .select('journal_entry_id') - .eq('company_id', companyId) - .in('journal_entry_id', entryIds), - ]) - // A PostgREST failure resolves with { data: null, error } rather than - // throwing. Reading that as "no links" would offer a voucher a bank row - // already settles, so a failed lookup fails open (null) like a thrown one: - // the guard stays advisory and the booking RPC keeps the last word. - if (paymentLinksRes.error || supplierPaymentLinksRes.error || txLinksRes.error || junctionLinksRes.error) { - return null - } - const paymentLinks = paymentLinksRes.data - const supplierPaymentLinks = supplierPaymentLinksRes.data - const txLinks = txLinksRes.data - const junctionLinks = junctionLinksRes.data + return byEntry +} +/** + * Voucher ids a bank transaction already explains, through any of the three + * anchors isTransactionBooked reads (transactions.journal_entry_id, a payment + * row carrying a bank transaction, a transaction_voucher_links row). A payment + * row WITHOUT a bank transaction is a manual settlement (#2019) and keeps the + * voucher in play: its bank line is precisely what has not been matched yet. + * Rows in `ownTransactionIds` never count as links: the guard runs before the + * caller's row is linked, and the batch path passes the very rows it is + * explaining. Every lookup is company-scoped (defense in depth) and chunked + * so a wide window never pushes the .in() past URL limits. + * + * Returns null when a lookup resolves with an error: a PostgREST failure + * resolves with { data: null, error } rather than throwing, and reading that + * as "no links" would offer a voucher a bank row already settles. Null fails + * open like a thrown scan: the guard stays advisory and the booking RPC + * keeps the last word. + */ +async function fetchExplainedVoucherIds( + supabase: SupabaseClient, + companyId: string, + entryIds: string[], + ownTransactionIds: ReadonlySet, +): Promise | null> { const linkedIds = new Set() - for (const row of [...((paymentLinks ?? []) as PaymentLinkRow[]), ...((supplierPaymentLinks ?? []) as PaymentLinkRow[])]) { - if (row.journal_entry_id && row.transaction_id) linkedIds.add(row.journal_entry_id) - } - for (const row of (txLinks ?? []) as { id: string; journal_entry_id: string | null }[]) { - // The caller's own row is never a link: the guard runs before it is linked. - if (row.journal_entry_id && row.id !== transactionId) linkedIds.add(row.journal_entry_id) - } - for (const row of (junctionLinks ?? []) as { journal_entry_id: string | null }[]) { - if (row.journal_entry_id) linkedIds.add(row.journal_entry_id) + for (let i = 0; i < entryIds.length; i += LINK_LOOKUP_CHUNK) { + const chunk = entryIds.slice(i, i + LINK_LOOKUP_CHUNK) + const [paymentLinksRes, supplierPaymentLinksRes, txLinksRes, junctionLinksRes] = + await Promise.all([ + supabase + .from('invoice_payments') + .select('journal_entry_id, transaction_id') + .eq('company_id', companyId) + .in('journal_entry_id', chunk), + supabase + .from('supplier_invoice_payments') + .select('journal_entry_id, transaction_id') + .eq('company_id', companyId) + .in('journal_entry_id', chunk), + supabase + .from('transactions') + .select('id, journal_entry_id') + .eq('company_id', companyId) + .in('journal_entry_id', chunk), + supabase + .from('transaction_voucher_links') + .select('journal_entry_id') + .eq('company_id', companyId) + .in('journal_entry_id', chunk), + ]) + if (paymentLinksRes.error || supplierPaymentLinksRes.error || txLinksRes.error || junctionLinksRes.error) { + return null + } + for (const row of [ + ...((paymentLinksRes.data ?? []) as PaymentLinkRow[]), + ...((supplierPaymentLinksRes.data ?? []) as PaymentLinkRow[]), + ]) { + if (row.journal_entry_id && row.transaction_id) linkedIds.add(row.journal_entry_id) + } + for (const row of (txLinksRes.data ?? []) as { id: string; journal_entry_id: string | null }[]) { + if (row.journal_entry_id && !ownTransactionIds.has(row.id)) linkedIds.add(row.journal_entry_id) + } + for (const row of (junctionLinksRes.data ?? []) as { journal_entry_id: string | null }[]) { + if (row.journal_entry_id) linkedIds.add(row.journal_entry_id) + } } + return linkedIds +} - const pool = Array.from(byEntry.values()).filter((c) => !linkedIds.has(c.journal_entry_id)) - if (pool.length === 0) return null - - // Sets never mix accounts: the link that resolves the warning is made on - // one settlement account. Search per account, closest account first. +/** + * Pure: the best set on one settlement account. Sets never mix accounts: the + * link that resolves the warning is made on one account. Closest account + * first, then findExactCoveringSet's order (smallest set, then closest in + * date). + */ +function pickExplainingSet( + pool: SetCandidate[], + targetSek: number, + transactionDate: string, +): ExplainingVoucherSet | null { const accounts = Array.from(new Set(pool.map((c) => c.bank_account_number))).sort() for (const accountNumber of accounts) { const set = findExactCoveringSet( @@ -537,7 +582,181 @@ export async function detectExplainingVoucherSet( return null } -type PaymentLinkRow = { journal_entry_id: string | null; transaction_id: string | null } +/** + * Find the vouchers that already book this bank row, allowing the row to be + * explained by SEVERAL of them. + * + * The 1:1 detector above answers "is there one voucher of this amount?". A + * bank feed regularly delivers one row for several affärshändelser (a + * Bankgirot daily aggregate: two customers' invoices, one "BGGIRERING" row + * with no payer and no reference), and each of those may already be booked on + * its own: "Markera som betald" per invoice, one salary voucher per employee. + * Nothing on the account then equals the row, the 1:1 check passes, and the + * next door (a batch allocation, a fresh categorisation) books the same + * money a second time. That is exactly the double booking this catches. + * + * Deterministic on purpose: the only signal is an exact öre sum of unlinked + * bank legs in the row's direction, on the row's account, within ±7 days. + * No counterparty text is consulted: the bank rows this exists for carry + * none. A voucher counts as linked (and drops out) when a transaction points + * at it, a payment row with a bank transaction references it, or a + * transaction_voucher_links row anchors it: the same three storage + * locations isTransactionBooked reads, seen from the voucher side + * (fetchExplainedVoucherIds). + * + * Composed of the same steps the batch form (detectExplainingVoucherSets) + * runs per row, so the booking doors and the reconciliation view share one + * definition of "explained by the ledger". + */ +export async function detectExplainingVoucherSet( + supabase: SupabaseClient, + args: DetectSetArgs, +): Promise { + const target = prepareSetTarget(args) + if (!target) return null + + let legs: BankLegRow[] + try { + legs = await fetchBankLegs(supabase, { + companyId: args.companyId, + lowDate: target.lowDate, + highDate: target.highDate, + bankAccountNumber: args.bankAccountNumber ?? null, + inbound: target.inbound, + }) + } catch { + // Fail-open like the 1:1 detector: a detection failure must not block a + // booking. Callers log the miss. + return null + } + if (legs.length === 0) return null + + const candidates = collectSetCandidates(legs, target) + if (candidates.size === 0) return null + + const linkedIds = await fetchExplainedVoucherIds( + supabase, + args.companyId, + Array.from(candidates.keys()), + new Set([args.transactionId]), + ) + if (!linkedIds) return null + + const pool = Array.from(candidates.values()).filter((c) => !linkedIds.has(c.journal_entry_id)) + if (pool.length === 0) return null + return pickExplainingSet(pool, target.targetSek, args.transactionDate) +} + +/** The transaction columns the batch detector needs. */ +export interface ExplainingSetBatchRow { + id: string + date: string + amount: number + currency: string | null + amount_sek?: number | null + exchange_rate?: number | null +} + +/** + * The set detector over MANY rows of one settlement account with one ledger + * scan: the read path's form (#2293). The bridge table asks "is any open row + * explained by the ledger?" for every row it lists; running the single + * detector per row would cost five queries a row. This fetches the account's + * legs once over the union of the rows' windows and the link anchors once, + * then evaluates each row exactly as detectExplainingVoucherSet does (same + * candidates, same anchors, same search). + * + * A voucher explains at most ONE row per call. The 1:N link that confirms a + * proposal does not refuse a voucher another row already settles (manualLink + * documents why N:1 is allowed), so two rows proposing the same voucher would + * both confirm cleanly and settle it twice. Rows are assigned greedily: + * same-date sets first, then smaller sets, then older rows; a row whose set + * was partly consumed is searched again against what is left. + * + * Rows that cannot be stated in SEK or carry no amount are skipped. Fails + * open (empty map) when the scan or a link lookup fails, like the single + * detector; callers log the miss. + */ +export async function detectExplainingVoucherSets( + supabase: SupabaseClient, + args: { companyId: string; bankAccountNumber: string | null; transactions: ExplainingSetBatchRow[] }, +): Promise> { + const result = new Map() + + const prepared: Array<{ row: ExplainingSetBatchRow; target: SetTarget }> = [] + for (const row of args.transactions) { + const target = prepareSetTarget({ + transactionDate: row.date, + transactionAmount: Number(row.amount), + transactionCurrency: row.currency, + transactionAmountSek: row.amount_sek ?? null, + transactionExchangeRate: row.exchange_rate ?? null, + }) + if (target) prepared.push({ row, target }) + } + if (prepared.length === 0) return result + + let lowDate = prepared[0].target.lowDate + let highDate = prepared[0].target.highDate + for (const { target } of prepared) { + if (target.lowDate < lowDate) lowDate = target.lowDate + if (target.highDate > highDate) highDate = target.highDate + } + + let legs: BankLegRow[] + try { + legs = await fetchBankLegs(supabase, { + companyId: args.companyId, + lowDate, + highDate, + bankAccountNumber: args.bankAccountNumber, + inbound: null, + }) + } catch { + return result + } + const entryIds = Array.from( + new Set(legs.filter((l) => !isScaffoldingEntry(l.journal_entry.source_type)).map((l) => l.journal_entry.id)), + ) + if (entryIds.length === 0) return result + + const linkedIds = await fetchExplainedVoucherIds( + supabase, + args.companyId, + entryIds, + new Set(prepared.map((p) => p.row.id)), + ) + if (!linkedIds) return result + + const consumed = new Set() + const search = ({ row, target }: { row: ExplainingSetBatchRow; target: SetTarget }) => { + const pool = Array.from(collectSetCandidates(legs, target).values()).filter( + (c) => !linkedIds.has(c.journal_entry_id) && !consumed.has(c.journal_entry_id), + ) + return pool.length === 0 ? null : pickExplainingSet(pool, target.targetSek, row.date) + } + + // Every row against the whole pool first, then the strongest claims settle first. + const claims: Array<{ row: ExplainingSetBatchRow; target: SetTarget; set: ExplainingVoucherSet }> = [] + for (const p of prepared) { + const set = search(p) + if (set) claims.push({ ...p, set }) + } + claims.sort( + (a, b) => + Number(b.set.same_date) - Number(a.set.same_date) || + a.set.vouchers.length - b.set.vouchers.length || + a.row.date.localeCompare(b.row.date) || + a.row.id.localeCompare(b.row.id), + ) + for (const claim of claims) { + const set = claim.set.vouchers.some((v) => consumed.has(v.journal_entry_id)) ? search(claim) : claim.set + if (!set) continue + result.set(claim.row.id, set) + for (const v of set.vouchers) consumed.add(v.journal_entry_id) + } + return result +} /** The transaction columns the set detector needs; a caller that already holds the row passes it. */ export interface TransactionForExplaining { diff --git a/lib/reconciliation/__tests__/covering-set-candidate.test.ts b/lib/reconciliation/__tests__/covering-set-candidate.test.ts new file mode 100644 index 00000000..808efb9b --- /dev/null +++ b/lib/reconciliation/__tests__/covering-set-candidate.test.ts @@ -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[], 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) + }) +}) diff --git a/lib/reconciliation/__tests__/items.test.ts b/lib/reconciliation/__tests__/items.test.ts index 45f08cd9..0f6b9c41 100644 --- a/lib/reconciliation/__tests__/items.test.ts +++ b/lib/reconciliation/__tests__/items.test.ts @@ -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 () => { diff --git a/lib/reconciliation/covering-set-candidate.ts b/lib/reconciliation/covering-set-candidate.ts new file mode 100644 index 00000000..054f9ba5 --- /dev/null +++ b/lib/reconciliation/covering-set-candidate.ts @@ -0,0 +1,113 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import { + detectExplainingVoucherSets, + type ExplainingVoucherSet, +} from '@/lib/invoices/duplicate-payment-detection' +import { createLogger } from '@/lib/logger' +import type { ReconciliationProposal } from './schemas' + +const log = createLogger('reconciliation.covering-set-candidate') + +/** + * Covering-set proposals for the bridge table (#2293): a bank row nothing + * matches 1:1 is searched for the unlinked verifikat on its account whose + * bank legs sum exactly to it, BEFORE the row is offered as unmatched. + * + * Why: a Bankgirot daily aggregate arrives as one row for several + * affärshändelser, each often already booked on its own ("Markera som + * betald" per invoice, one salary voucher per employee). The 1:1 matcher + * sees no voucher of the row's amount, the row reads "ej matchad", and the + * next door (Bokför) books the money a second time. The booking doors have + * refused that since #2300 and #2346 by running detectExplainingVoucherSet; + * this runs the SAME detector (its batch form) in the read path so the view + * stops steering users to the door the guard slams. One definition of + * "explained by the ledger", at the doors and in the view. + * + * Read-time only, never persisted: the pool is the account's open rows + * against its unlinked legs, and a stored hint would add a stale-pointer + * class for no saving (the utlägg pairing precedent, DECISIONS 2026-09-05). + * Never auto-applied: the unattended sweep persists and applies 1:1 matches + * only, and use_proposals reads the persisted column. A set proposal is + * confirmed per row through the ordinary 1:N link (linkTransactionToVouchers), + * which revalidates every slice at click time. + * + * SEK accounts only. The detector sums in SEK; the 1:N link that confirms + * the proposal compares in the account's own currency (ledgerLineAmountIn), + * so on a EUR account a set that sums in SEK could be refused at confirm. A + * proposal that cannot be confirmed is worse than none. + */ + +/** Exact öre sum, every voucher dated on the row's date: as strong as auto_exact. */ +export const COVERING_SET_SAME_DATE_CONFIDENCE = 0.95 +/** Exact öre sum within ±7 days: as strong as auto_date_range, below the 0.9 unattended floor. */ +export const COVERING_SET_WINDOW_CONFIDENCE = 0.85 + +/** The columns of an open bank row the search needs. */ +export interface CoveringSetRow { + id: string + date: string + amount: number + currency: string | null +} + +/** Pure: the proposal the bridge table renders for an explaining set. */ +export function coveringSetProposal(set: ExplainingVoucherSet): ReconciliationProposal { + const [first] = set.vouchers + const descriptions = set.vouchers + .map((v) => (v.description ?? '').trim()) + .filter((d) => d.length > 0) + return { + journal_entry_id: first.journal_entry_id, + voucher_number: first.voucher_number, + voucher_series: first.voucher_series, + entry_date: first.entry_date, + description: descriptions.join(' + '), + entry_status: 'posted', + confidence: set.same_date ? COVERING_SET_SAME_DATE_CONFIDENCE : COVERING_SET_WINDOW_CONFIDENCE, + reasons: [set.same_date ? 'exact_sum_same_date' : 'exact_sum_within_window'], + vouchers: set.vouchers.map((v) => ({ + journal_entry_id: v.journal_entry_id, + voucher_number: v.voucher_number, + voucher_series: v.voucher_series, + entry_date: v.entry_date, + description: v.description ?? '', + amount: v.amount, + })), + } +} + +/** + * Proposals keyed by transaction id for the open rows of one bank account. + * Rows the detector cannot explain are simply absent. Advisory: a detector + * failure logs and returns nothing rather than taking the bridge table down; + * the row stays unmatched and the booking doors keep their own guard. + */ +export async function proposeCoveringSets( + supabase: SupabaseClient, + companyId: string, + account: { ledger_account: string; currency: string | null }, + rows: CoveringSetRow[], +): Promise> { + const proposals = new Map() + if (rows.length === 0 || (account.currency ?? 'SEK') !== 'SEK') return proposals + try { + const sets = await detectExplainingVoucherSets(supabase, { + companyId, + bankAccountNumber: account.ledger_account, + transactions: rows.map((r) => ({ id: r.id, date: r.date, amount: r.amount, currency: r.currency })), + }) + for (const [transactionId, set] of sets) { + proposals.set(transactionId, coveringSetProposal(set)) + } + } catch (err) { + log.warn('covering-set proposals skipped', { + companyId, + entityType: 'cash_account', + details: { + account: account.ledger_account, + message: err instanceof Error ? err.message : String(err), + }, + }) + } + return proposals +} diff --git a/lib/reconciliation/items.ts b/lib/reconciliation/items.ts index df13216c..fe435c0c 100644 --- a/lib/reconciliation/items.ts +++ b/lib/reconciliation/items.ts @@ -2,10 +2,12 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { roundOre } from '@/lib/money' import { fetchJunctionLinkedTxIds, fetchUnlinkedGLLines, scopeTransactionsToAccount } from './bank-reconciliation' import { getSkattekontoReconciliationStatus } from './skattekonto-reconciliation' +import { proposeCoveringSets } from './covering-set-candidate' import { parseAccountKey, type ReconciliationItem, type ReconciliationItemBucket, + type ReconciliationProposal, } from './schemas' /** @@ -160,13 +162,30 @@ export async function listAccountItems( companyId, rows.filter((tx) => !tx.journal_entry_id && !tx.is_ignored).map((tx) => tx.id), ) + // Rows nothing explains 1:1 are searched for a set of unlinked verifikat + // summing exactly to them (#2293) before they are offered as unmatched: + // "Bokför" is the door only when the ledger has nothing for the row. + const coveringSets = buckets.some((b) => b === 'proposed' || b === 'unmatched_external') + ? await proposeCoveringSets( + supabase, + companyId, + account, + rows + .filter( + (tx) => + !tx.is_ignored && !tx.journal_entry_id && !junctionLinked.has(tx.id) && !tx.potential_journal_entry_id, + ) + .map((tx) => ({ id: tx.id, date: tx.date, amount: Number(tx.amount), currency: tx.currency })), + ) + : new Map() { for (const tx of rows) { + const coveringSet = coveringSets.get(tx.id) ?? null const bucket: ReconciliationItemBucket = tx.is_ignored ? 'ignored' : tx.journal_entry_id || junctionLinked.has(tx.id) ? 'matched' - : tx.potential_journal_entry_id + : tx.potential_journal_entry_id || coveringSet ? 'proposed' : 'unmatched_external' if (!buckets.includes(bucket)) continue @@ -191,7 +210,7 @@ export async function listAccountItems( confidence: Number(tx.potential_match_confidence ?? 0.75), reasons: [tx.potential_match_method ?? 'föreslagen av matcharen'], } - : null, + : coveringSet, actions: bucket === 'matched' ? ['unmatch'] diff --git a/lib/reconciliation/schemas.ts b/lib/reconciliation/schemas.ts index c6f015c6..69f99073 100644 --- a/lib/reconciliation/schemas.ts +++ b/lib/reconciliation/schemas.ts @@ -182,6 +182,16 @@ export const ReconciliationItemActionSchema = z.enum([ 'review', ]) +/** One verifikat of a set proposal: its bank leg in the row's direction, positive, in the account currency. */ +export const ReconciliationProposalVoucherSchema = z.object({ + journal_entry_id: z.string(), + voucher_number: z.number().int().nullable(), + voucher_series: z.string().nullable(), + entry_date: z.string(), + description: z.string(), + amount: z.number(), +}) + export const ReconciliationProposalSchema = z.object({ journal_entry_id: z.string(), voucher_number: z.number().int().nullable(), @@ -191,6 +201,16 @@ export const ReconciliationProposalSchema = z.object({ entry_status: z.enum(['draft', 'posted', 'reversed']), confidence: z.number().min(0).max(1), reasons: z.array(z.string()), + /** + * Present when the proposal is an explaining SET of unlinked verifikat whose + * bank legs sum exactly to the row (#2293, computed at read time): the row + * is linked to all of them (1:N) and journal_entry_id is the first. Length 1 + * is one voucher of the exact amount the 1:1 matcher missed (dated 4 to 7 + * days off). Absent on persisted 1:1 proposals. Apply with explicit pairs + * (journal_entry_ids = every voucher); use_proposals reads the persisted + * column only. + */ + vouchers: z.array(ReconciliationProposalVoucherSchema).min(1).optional(), }) export type ReconciliationProposal = z.infer diff --git a/messages/en.json b/messages/en.json index 8fbed5e7..7bc82c32 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8124,7 +8124,9 @@ "manual_spec_hint": "The specification comes from the system: {label}. The difference should be zero before you sign; otherwise sign with a note.", "signoff_external_balance": "Balance per supporting documents", "signoff_external_balance_help": "Same sign as in the books, liabilities with a minus. Booked: {amount}.", - "signoff_external_balance_optional": "Leave empty to sign with a note only." + "signoff_external_balance_optional": "Leave empty to sign with a note only.", + "proposal_set_title": "{count} vouchers that together make up the amount", + "proposal_set_same_day": "same day" }, "skattekonto": { "help_text": "The balance and events are fetched from Skatteverket and synced automatically every night. Completed events are booked against 1630 Skattekonto, usually automatically; anything that cannot be matched is flagged in the list. Pay in via bankgiro 5050-1055 with your OCR number.", diff --git a/messages/sv.json b/messages/sv.json index 125d8af4..946adc0a 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -8124,7 +8124,9 @@ "manual_spec_hint": "Specifikationen kommer från systemet: {label}. Differensen ska vara noll innan du signerar, annars signerar du med en notering.", "signoff_external_balance": "Saldo enligt underlag", "signoff_external_balance_help": "Samma tecken som i bokföringen, skulder anges med minus. Bokfört: {amount}.", - "signoff_external_balance_optional": "Lämna tomt om du bara vill signera med en notering." + "signoff_external_balance_optional": "Lämna tomt om du bara vill signera med en notering.", + "proposal_set_title": "{count} verifikat som tillsammans ger beloppet", + "proposal_set_same_day": "samma dag" }, "skattekonto": { "help_text": "Saldot och händelserna hämtas från Skatteverket och synkas automatiskt varje natt. Genomförda händelser bokförs mot 1630 Skattekonto, oftast automatiskt; det som inte kan matchas flaggas i listan. Betala in via bankgiro 5050-1055 med ditt OCR-nummer.", diff --git a/skills/accounted-api/references/banking.md b/skills/accounted-api/references/banking.md index 7198f439..4f2251b9 100644 --- a/skills/accounted-api/references/banking.md +++ b/skills/accounted-api/references/banking.md @@ -573,7 +573,7 @@ Response `200`: ```ts { data: { - items: { item_id: string, item_type: "skattekonto_transaction" | "transaction" | "journal_entry", side: "external" | "ledger", bucket: "proposed" | "unmatched_external" | "unmatched_ledger" | "matched" | "ignored" | "upcoming", date: string, description: string, amount: number, currency: string, voucher_number?: number, voucher_series?: string, entry_status?: "draft" | "posted" | "reversed", linked_journal_entry_id?: string, link_problem?: "entry_reversed" | "entry_draft" | "entry_missing", proposal?: { journal_entry_id: string, voucher_number: number, voucher_series: string, entry_date: string, description: string, entry_status: "draft" | "posted" | "reversed", confidence: number, reasons: string[] }, awaiting_external?: boolean, actions: ("match" | "unmatch" | "book" | "ignore" | "unignore" | "review")[] }[], + items: { item_id: string, item_type: "skattekonto_transaction" | "transaction" | "journal_entry", side: "external" | "ledger", bucket: "proposed" | "unmatched_external" | "unmatched_ledger" | "matched" | "ignored" | "upcoming", date: string, description: string, amount: number, currency: string, voucher_number?: number, voucher_series?: string, entry_status?: "draft" | "posted" | "reversed", linked_journal_entry_id?: string, link_problem?: "entry_reversed" | "entry_draft" | "entry_missing", proposal?: { journal_entry_id: string, voucher_number: number, voucher_series: string, entry_date: string, description: string, entry_status: "draft" | "posted" | "reversed", confidence: number, reasons: string[], vouchers?: { journal_entry_id: {...}, voucher_number: {...}, voucher_series: {...}, entry_date: {...}, description: {...}, amount: {...} }[] }, awaiting_external?: boolean, actions: ("match" | "unmatch" | "book" | "ignore" | "unignore" | "review")[] }[], count: number, total_count: number, has_more: boolean,