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