fix(payments): refuse to book a bank row that unlinked vouchers already explain (#2300)

* fix(payments): refuse to book a bank row that unlinked vouchers already explain

A bank feed can deliver several affarshandelser as one row (a Bankgirot
daily aggregate: two customers' invoices, one "BGGIRERING" row with no
payer). When each invoice was already marked paid by hand, nothing on the
account equals the row, the 1:1 duplicate check passes, and "Dela
betalning" books the money a second time against whatever open invoices
the user picks (the next period's identical ones, in the reported case).

- lib/reconciliation/covering-set.ts: exact ore subset sum over a capped
  candidate list, smallest set first, closest in date second.
- detectExplainingVoucherSet(+ForTransaction): the vouchers whose bank legs
  on the row's settlement account, in the row's direction, within 7 days,
  add up exactly to the row; linked through any of the three anchors drops
  a voucher, a payment row without a bank transaction keeps it.
- POST match-batch refuses with BATCH_TX_POSSIBLE_DUPLICATE and returns the
  set; force=true must echo expected_journal_entry_ids (same binding as the
  single door). Fails open on a detection error.
- GET duplicate-payment-check returns candidate_set next to candidate.
- MatchAllocationDialog: pre-flight panel with the vouchers, one click
  links the row to them through the existing 1:1 or 1:N bank link (no new
  voucher), "Bokfor anda" acknowledges the set; confirm is disabled until
  then. Invoices dated after the bank row get a hint badge.
- Mark-paid guard: aggregate sweep (row = this invoice + an exact subset of
  other open invoices, 7 days, kronor) when the name sweeps found nothing;
  PaymentBookingDialog shows the covered invoice numbers and points to the
  split under Transaktioner.

Follow-ups: #2293 (1:N proposals in the auto-matcher), #2294 (MCP staging
guard), #2299 (supplier-side text guard).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyjeEi1U8vnuPT4QXgayXu

* test(invoices): account for the aggregate sweep in the mark-paid route queue

The sweep issues one more transactions query whenever the name probes come
back empty, so every queued-mock sequence that reaches it gains a slot. The
sweep itself now fails open on odd client shapes (a single object for a
list query) and on errors: an advisory guard must never block "Markera som
betald".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyjeEi1U8vnuPT4QXgayXu

* fix(payments): fail open on resolved query errors; aggregate sweep without a payer name

Review follow-ups on #2300. A PostgREST failure resolves with { data: null,
error } instead of throwing, so the set detector read a failed link lookup
as "no links" and a failed cash-account lookup as "scan every 19xx
account"; both now return null (the booking RPC keeps the last word). The
aggregate sweep never needed a customer name (a Bankgirot row names
nobody), so a nameless invoice goes straight to it instead of skipping the
guard. The already-booked panel is announced as a live region, and the
"also covers" string is plural-aware.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyjeEi1U8vnuPT4QXgayXu

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-05 11:59:59 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent 9418de585f
commit 287828a850
18 changed files with 1781 additions and 31 deletions
+19
View File
@@ -2096,8 +2096,27 @@ export const MatchBatchSchema = z
// (PR #603 compliance review, OWASP V4.2). Domain-appropriate ceiling:
// a real samlingsverifikat rarely covers more than a few dozen invoices.
.max(100, 'At most 100 allocations per batch'),
// Bypass the already-explained guard (BATCH_TX_POSSIBLE_DUPLICATE): the
// bank row is fully covered by one or more posted, unlinked vouchers on
// its settlement account (an invoice marked paid by hand, a salary
// voucher per employee). Set only after the user has seen those vouchers
// and decided the row is a separate event.
force: z.boolean().optional(),
// Required whenever force=true: the journal_entry_ids of the set the
// user reviewed. The route re-detects the set and refuses force unless
// the ids match, so an automation cannot sweep through force=true
// without ever consulting the vouchers (same binding as
// MatchInvoiceSchema.expected_journal_entry_id).
expected_journal_entry_ids: z.array(uuid).max(10).optional(),
})
.superRefine((data, ctx) => {
if (data.force && !(data.expected_journal_entry_ids?.length)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['expected_journal_entry_ids'],
message: 'expected_journal_entry_ids is required when force=true',
})
}
// Reject mixed customer + supplier in a single batch: semantically a
// single bank transfer settles invoices on one side. The RPC also guards
// this with BATCH_MIXED_KINDS_UNSUPPORTED, but rejecting at the schema
+7
View File
@@ -3644,6 +3644,13 @@ const MATCH_BATCH: Record<string, StructuredErrorEntry> = {
message_en:
'Transaction is already booked. Reverse the existing journal entry before re-allocating.',
},
BATCH_TX_POSSIBLE_DUPLICATE: {
httpStatus: 409,
message_sv:
'Transaktionen ser redan ut att vara bokförd: en eller flera verifikationer utan bankkoppling summerar exakt till beloppet. Koppla transaktionen till dem i stället, eller bokför ändå om de inte hör ihop.',
message_en:
'The transaction already looks booked: one or more posted vouchers with no bank link add up exactly to its amount. Link the transaction to them instead, or pass force=true with expected_journal_entry_ids to book anyway.',
},
BATCH_TX_ZERO_AMOUNT: {
httpStatus: 400,
message_sv: 'Transaktioner med beloppet 0 kan inte bokföras.',
@@ -234,8 +234,25 @@ describe('findDuplicatePaymentCandidatesForInvoice', () => {
expect(warn).not.toHaveBeenCalled()
})
it('returns nothing when the invoice has no customer name', async () => {
const { supabase, queries } = createRecordingSupabase([])
it('runs the aggregate sweep for a nameless SEK invoice: a Bankgirot row names nobody anyway', async () => {
const { supabase, queries } = createRecordingSupabase([
[{ id: 'tx-bg', date: '2026-07-31', amount: 88250, description: 'BGGIRERING 03447786', merchant_name: null, reference: null }],
[{ id: 'inv-064', invoice_number: '064', remaining_amount: 25750, total: 25750, due_date: '2026-07-31' }],
])
const candidates = await findDuplicatePaymentCandidatesForInvoice(supabase, {
companyId: 'company-1',
invoice: { ...sekInvoice, invoice_number: '063', customer_name: null, total: 62500, total_sek: 62500 },
paymentAmount: 62500,
paymentDate: '2026-07-31',
})
// No name sweeps at all: straight to the two aggregate queries.
expect(queries).toHaveLength(2)
expect(queries[0].gt).toContainEqual(['amount', 62500])
expect(candidates.map((c) => c.match_reason)).toEqual(['aggregate_exact'])
})
it('skips the name sweeps when the invoice has no customer name; only the aggregate row sweep runs', async () => {
const { supabase, queries } = createRecordingSupabase([[]])
const candidates = await findDuplicatePaymentCandidatesForInvoice(supabase, {
companyId: 'company-1',
invoice: { ...sekInvoice, customer_name: null },
@@ -243,6 +260,120 @@ describe('findDuplicatePaymentCandidatesForInvoice', () => {
paymentDate: '2026-05-10',
})
expect(candidates).toEqual([])
expect(queries).toHaveLength(0)
// No ILIKE probe without a name; the aggregate row sweep found nothing and stopped.
expect(queries).toHaveLength(1)
expect(queries[0].ilike).toBeUndefined()
})
})
describe('findDuplicatePaymentCandidatesForInvoice: Bankgirot aggregate rows', () => {
const invoice063 = { ...sekInvoice, invoice_number: '063', customer_name: 'Twelve Football AB', total: 62500, total_sek: 62500 }
function aggregateRow(over: Partial<Record<string, unknown>> = {}) {
return {
id: 'tx-bg',
date: '2026-07-31',
amount: 88250,
description: 'BGGIRERING 03447786',
merchant_name: null,
reference: null,
...over,
}
}
it('offers the aggregate row whose excess is exactly another open invoice', async () => {
// Name sweeps find nothing ("BGGIRERING" carries no payer), then the
// aggregate sweep: 88 250 - 62 500 = 25 750 = invoice 064's remaining.
const { supabase, queries } = createRecordingSupabase([
[],
[],
[aggregateRow()],
[
{ id: 'inv-064', invoice_number: '064', remaining_amount: 25750, total: 25750, due_date: '2026-07-31' },
{ id: 'inv-065', invoice_number: '065', remaining_amount: 25750, total: 25750, due_date: '2026-09-30' },
{ id: 'inv-070', invoice_number: '070', remaining_amount: 999, total: 999, due_date: '2026-08-15' },
],
])
const candidates = await findDuplicatePaymentCandidatesForInvoice(supabase, {
companyId: 'company-1',
invoice: invoice063,
paymentAmount: 62500,
paymentDate: '2026-07-31',
})
expect(queries).toHaveLength(4)
// Rows larger than the payment, unbooked, kronor, on the payment day ± 7.
expect(queries[2].gt).toContainEqual(['amount', 62500])
expect(queries[2].is).toContainEqual(['journal_entry_id', null])
expect(queries[2].or).toEqual([['currency.is.null,currency.eq.SEK']])
expect(queries[2].gte).toContainEqual(['date', '2026-07-24'])
expect(queries[2].lte).toContainEqual(['date', '2026-08-07'])
// Other open invoices only: this one is excluded by number.
expect(queries[3].neq).toContainEqual(['invoice_number', '063'])
expect(queries[3].in).toContainEqual(['status', ['sent', 'overdue', 'partially_paid']])
expect(candidates).toHaveLength(1)
expect(candidates[0]).toMatchObject({
id: 'tx-bg',
amount: 88250,
match_reason: 'aggregate_exact',
match_confidence: 0.9,
})
// The invoice due on the row's date wins over the identical one due later.
expect(candidates[0].aggregate_invoice_numbers).toEqual(['064'])
})
it('does not run the aggregate sweep when a 1:1 candidate already exists', async () => {
const { supabase, queries } = createRecordingSupabase([[bankRow()], []])
const candidates = await findDuplicatePaymentCandidatesForInvoice(supabase, {
companyId: 'company-1',
invoice: sekInvoice,
paymentAmount: 12500,
paymentDate: '2026-05-10',
})
expect(queries).toHaveLength(2)
expect(candidates[0].match_reason).not.toBe('aggregate_exact')
})
it('stays silent when the excess is not an exact sum of other open invoices', async () => {
const { supabase } = createRecordingSupabase([
[],
[],
[aggregateRow()],
[{ id: 'inv-x', invoice_number: '099', remaining_amount: 25000, total: 25000, due_date: '2026-07-31' }],
])
const candidates = await findDuplicatePaymentCandidatesForInvoice(supabase, {
companyId: 'company-1',
invoice: invoice063,
paymentAmount: 62500,
paymentDate: '2026-07-31',
})
expect(candidates).toEqual([])
})
it('stops after the row sweep when no larger unbooked row exists', async () => {
const { supabase, queries } = createRecordingSupabase([[], [], []])
const candidates = await findDuplicatePaymentCandidatesForInvoice(supabase, {
companyId: 'company-1',
invoice: invoice063,
paymentAmount: 62500,
paymentDate: '2026-07-31',
})
expect(queries).toHaveLength(3)
expect(candidates).toEqual([])
})
it('never runs for a foreign-currency invoice', async () => {
const { supabase, queries } = createRecordingSupabase([[], [], [], []])
const candidates = await findDuplicatePaymentCandidatesForInvoice(supabase, {
companyId: 'company-1',
invoice: eurInvoiceWithRate,
paymentAmount: 1000,
paymentDate: '2026-05-10',
})
// The four name sweeps (two currencies x two patterns) and nothing more.
expect(queries).toHaveLength(4)
expect(candidates).toEqual([])
})
})
@@ -1,5 +1,9 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { detectDuplicatePaymentVoucher } from '../duplicate-payment-detection'
import {
detectDuplicatePaymentVoucher,
detectExplainingVoucherSet,
detectExplainingVoucherSetForTransaction,
} from '../duplicate-payment-detection'
import { createQueuedMockSupabase } from '@/tests/helpers'
const { supabase, enqueue, reset } = createQueuedMockSupabase()
@@ -581,3 +585,272 @@ describe('detectDuplicatePaymentVoucher', () => {
expect(result!.amount_verified).toBe(true)
})
})
describe('detectExplainingVoucherSet', () => {
beforeEach(() => {
reset()
})
type SetLine = {
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: {
je_id: string
date: string
debit?: number
credit?: number
account?: string
label?: string
source_type?: string | null
description?: string
}): SetLine {
const label = opts.label ?? 'A1'
return {
account_number: opts.account ?? '1930',
debit_amount: opts.debit ?? 0,
credit_amount: opts.credit ?? 0,
journal_entry: {
id: opts.je_id,
entry_date: opts.date,
description: opts.description ?? `Voucher ${opts.je_id}`,
voucher_series: label[0],
voucher_number: parseInt(label.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 (all empty unless given). */
function enqueueScan(
rows: SetLine[],
links: {
invoicePayments?: unknown[]
supplierPayments?: unknown[]
transactions?: unknown[]
junction?: unknown[]
} = {},
) {
const entries = [...new Map(rows.map((r) => [r.journal_entry.id, r.journal_entry])).values()]
enqueue({ data: entries, error: null })
if (entries.length === 0) return
enqueue({
data: rows.map((r, i) => ({
id: `line-${i}`,
journal_entry_id: r.journal_entry.id,
account_number: r.account_number,
debit_amount: r.debit_amount,
credit_amount: r.credit_amount,
})),
error: null,
})
enqueue({ data: links.invoicePayments ?? [], error: null })
enqueue({ data: links.supplierPayments ?? [], error: null })
enqueue({ data: links.transactions ?? [], error: null })
enqueue({ data: links.junction ?? [], error: null })
}
const baseArgs = {
companyId: 'company-1',
transactionId: 'tx-bg',
transactionDate: '2026-07-31',
transactionCurrency: 'SEK',
}
it('explains a Bankgirot aggregate with the two mark-paid vouchers that sum to it', async () => {
// The reported case: 063 and 064 marked paid by hand (A57 + A58), then one
// 88 250 "BGGIRERING" row. Their payment rows carry no bank transaction.
enqueueScan(
[
leg({ je_id: 'A57', date: '2026-07-31', debit: 62500, label: 'A57', description: 'Inbetalning kundfaktura 063' }),
leg({ je_id: 'A58', date: '2026-07-31', debit: 25750, label: 'A58', description: 'Inbetalning kundfaktura 064' }),
leg({ je_id: 'A56', date: '2026-07-20', debit: 150, label: 'A56', source_type: 'bank_transaction' }),
],
{
invoicePayments: [
{ journal_entry_id: 'A57', transaction_id: null },
{ journal_entry_id: 'A58', transaction_id: null },
],
},
)
const set = await detectExplainingVoucherSet(supabase as never, {
...baseArgs,
transactionAmount: 88250,
bankAccountNumber: '1930',
})
expect(set).not.toBeNull()
expect(set!.vouchers.map((v) => v.journal_entry_id).sort()).toEqual(['A57', 'A58'])
expect(set!.total).toBe(88250)
expect(set!.bank_account_number).toBe('1930')
expect(set!.same_date).toBe(true)
expect(set!.vouchers[0].voucher_label).toBe('A57')
})
it('scopes the scan to the row settlement account and the row direction', async () => {
const callsBefore = supabase.from.mock.calls.length
enqueueScan([leg({ je_id: 'je-1', date: '2026-07-31', credit: 1185 })])
const set = await detectExplainingVoucherSet(supabase as never, {
...baseArgs,
transactionAmount: -1185,
bankAccountNumber: '1930',
})
const tables = supabase.from.mock.calls.slice(callsBefore).map((c) => c[0])
expect(tables.slice(0, 2)).toEqual(['journal_entries', 'journal_entry_lines'])
// Money out: the credit leg is the voucher's bank line.
expect(set?.vouchers.map((v) => v.amount)).toEqual([1185])
})
it('returns null when no set of at most four vouchers sums exactly to the row', async () => {
enqueueScan([
leg({ je_id: 'a', date: '2026-07-31', debit: 62500 }),
leg({ je_id: 'b', date: '2026-07-31', debit: 25000 }),
])
const set = await detectExplainingVoucherSet(supabase as never, {
...baseArgs,
transactionAmount: 88250,
})
expect(set).toBeNull()
})
it('drops vouchers a bank transaction already explains through any anchor', async () => {
enqueueScan(
[
leg({ je_id: 'via-tx', date: '2026-07-31', debit: 100 }),
leg({ je_id: 'via-payment', date: '2026-07-31', debit: 100 }),
leg({ je_id: 'via-supplier-payment', date: '2026-07-31', debit: 100 }),
leg({ je_id: 'via-junction', date: '2026-07-31', debit: 100 }),
leg({ je_id: 'free', date: '2026-07-31', debit: 100 }),
],
{
invoicePayments: [{ journal_entry_id: 'via-payment', transaction_id: 'tx-other' }],
supplierPayments: [{ journal_entry_id: 'via-supplier-payment', transaction_id: 'tx-other' }],
transactions: [{ id: 'tx-other', journal_entry_id: 'via-tx' }],
junction: [{ journal_entry_id: 'via-junction' }],
},
)
const set = await detectExplainingVoucherSet(supabase as never, {
...baseArgs,
transactionAmount: 100,
})
expect(set?.vouchers.map((v) => v.journal_entry_id)).toEqual(['free'])
})
it('never sums storno, correction or opening-balance entries', async () => {
enqueueScan([
leg({ je_id: 'storno', date: '2026-07-31', debit: 500, source_type: 'storno' }),
leg({ je_id: 'corr', date: '2026-07-31', debit: 300, source_type: 'correction' }),
leg({ je_id: 'ib', date: '2026-07-31', debit: 200, source_type: 'opening_balance' }),
])
const set = await detectExplainingVoucherSet(supabase as never, {
...baseArgs,
transactionAmount: 1000,
})
expect(set).toBeNull()
})
it('returns null without scanning when the row cannot be stated in SEK', async () => {
const callsBefore = supabase.from.mock.calls.length
const set = await detectExplainingVoucherSet(supabase as never, {
...baseArgs,
transactionAmount: 1000,
transactionCurrency: 'EUR',
transactionAmountSek: null,
transactionExchangeRate: null,
})
expect(set).toBeNull()
expect(supabase.from.mock.calls.length).toBe(callsBefore)
})
it('states a foreign row in SEK before summing', async () => {
enqueueScan([leg({ je_id: 'je-eur', date: '2026-07-31', debit: 11500 })])
const set = await detectExplainingVoucherSet(supabase as never, {
...baseArgs,
transactionAmount: 1000,
transactionCurrency: 'EUR',
transactionAmountSek: 11500,
})
expect(set?.vouchers.map((v) => v.journal_entry_id)).toEqual(['je-eur'])
expect(set?.total).toBe(11500)
})
it('fails open (null) when a link lookup resolves with an error instead of throwing', async () => {
const entries = [leg({ je_id: 'je-1', date: '2026-07-31', debit: 1000 })]
enqueue({ data: entries.map((r) => r.journal_entry), error: null })
enqueue({
data: entries.map((r, i) => ({ id: `line-${i}`, journal_entry_id: r.journal_entry.id, account_number: '1930', debit_amount: 1000, credit_amount: 0 })),
error: null,
})
enqueue({ data: [], error: null })
enqueue({ data: [], error: null })
// transactions lookup fails: PostgREST resolves, it does not throw.
enqueue({ data: null, error: { message: 'permission denied' } })
enqueue({ data: [], error: null })
const set = await detectExplainingVoucherSet(supabase as never, {
...baseArgs,
transactionAmount: 1000,
})
expect(set).toBeNull()
})
it('fails open (null) when the ledger scan throws', async () => {
enqueue({ data: null, error: { message: 'boom' } })
const set = await detectExplainingVoucherSet(supabase as never, {
...baseArgs,
transactionAmount: 1000,
})
expect(set).toBeNull()
})
})
describe('detectExplainingVoucherSetForTransaction', () => {
beforeEach(() => {
reset()
})
it('returns null for a row that already carries a pointer, without scanning', async () => {
enqueue({ data: { id: 'tx-1', date: '2026-07-31', amount: 100, currency: 'SEK', journal_entry_id: 'je-live' }, error: null })
const callsBefore = supabase.from.mock.calls.length
const set = await detectExplainingVoucherSetForTransaction(supabase as never, 'company-1', 'tx-1')
expect(set).toBeNull()
expect(supabase.from.mock.calls.length - callsBefore).toBe(1)
})
it('fails open when the cash-account lookup errors instead of widening the scan', async () => {
const callsBefore = supabase.from.mock.calls.length
enqueue({ data: { id: 'tx-1', date: '2026-07-31', amount: 100, currency: 'SEK', cash_account_id: 'ca-1', journal_entry_id: null }, error: null })
enqueue({ data: null, error: { message: 'boom' } })
const set = await detectExplainingVoucherSetForTransaction(supabase as never, 'company-1', 'tx-1')
expect(set).toBeNull()
expect(supabase.from.mock.calls.length - callsBefore).toBe(2)
})
it('resolves the settlement account from the cash account before scanning', async () => {
const callsBefore = supabase.from.mock.calls.length
enqueue({ data: { id: 'tx-1', date: '2026-07-31', amount: 100, currency: 'SEK', cash_account_id: 'ca-1', journal_entry_id: null }, error: null })
enqueue({ data: { ledger_account: '1940' }, error: null })
// entries page: nothing on the account, scan ends.
enqueue({ data: [], error: null })
const set = await detectExplainingVoucherSetForTransaction(supabase as never, 'company-1', 'tx-1')
expect(set).toBeNull()
const tables = supabase.from.mock.calls.slice(callsBefore).map((c) => c[0])
expect(tables).toEqual(['transactions', 'cash_accounts', 'journal_entries'])
})
})
+179 -5
View File
@@ -13,6 +13,8 @@ import {
type ComparableAmount,
} from './duplicate-guard-currency'
import { resolveTransactionAmountSek } from '@/lib/transactions/booking-duplicate-detection'
import { findExactCoveringSet } from '@/lib/reconciliation/covering-set'
import { roundOre } from '@/lib/money'
import { createLogger } from '@/lib/logger'
const log = createLogger('invoices/duplicate-payment-candidates')
@@ -21,6 +23,13 @@ export type DuplicatePaymentMatchReason =
| 'ocr_exact'
| 'name_amount_fuzzy'
| 'amount_only'
/**
* The bank row is larger than this payment and the difference is exactly
* (to the öre) the remaining amount of one to three OTHER open invoices:
* a Bankgirot daily aggregate that settled this invoice together with
* them. No counterparty text is consulted; such rows carry none.
*/
| 'aggregate_exact'
export interface DuplicatePaymentCandidate {
id: string
@@ -31,20 +40,33 @@ export interface DuplicatePaymentCandidate {
reference: string | null
match_reason: DuplicatePaymentMatchReason
match_confidence: number
/** For aggregate_exact: the other open invoices the row also covers. */
aggregate_invoice_numbers?: string[]
}
const MATCH_REASON_RANK: Record<DuplicatePaymentMatchReason, number> = {
ocr_exact: 0,
name_amount_fuzzy: 1,
amount_only: 2,
aggregate_exact: 1,
name_amount_fuzzy: 2,
amount_only: 3,
}
const MATCH_REASON_CONFIDENCE: Record<DuplicatePaymentMatchReason, number> = {
ocr_exact: 0.99,
aggregate_exact: 0.9,
name_amount_fuzzy: 0.7,
amount_only: 0.5,
}
/** ± days around the payment date an aggregate row is looked for: a Bankgirot
* aggregate lands on the payment day, so the wide name-sweep window would only
* add coincidental sums. */
const AGGREGATE_DATE_WINDOW_DAYS = 7
/** Other open invoices an aggregate row may cover besides this one. */
const AGGREGATE_MAX_OTHER_INVOICES = 3
const AGGREGATE_MAX_ROWS = 40
const AGGREGATE_MAX_OPEN_INVOICES = 200
interface CustomerInvoice {
invoice_number: string | null
customer_name: string | null | undefined
@@ -116,9 +138,14 @@ export async function findDuplicatePaymentCandidatesForInvoice(
): Promise<DuplicatePaymentCandidate[]> {
const { companyId, invoice, paymentAmount, paymentDate } = params
const customerName = invoice.customer_name
if (!customerName) return []
const paymentCurrency = normalizeCurrencyCode(invoice.currency)
// The name sweeps need a payer to look for; the aggregate sweep does not
// (a Bankgirot row names nobody), so a nameless invoice skips straight to it.
if (!customerName) {
if (paymentCurrency !== 'SEK') return []
return runAggregateSweep(supabase, { companyId, invoice, paymentAmount, paymentDate })
}
const reference: ComparableAmount = {
amount: paymentAmount,
currency: paymentCurrency,
@@ -200,7 +227,14 @@ export async function findDuplicatePaymentCandidatesForInvoice(
.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0))
.slice(0, 5)
if (data.length === 0) return []
// Nothing of this invoice's own size: look for the row that paid it TOGETHER
// with other invoices. One warning is enough, so the sweep only runs when
// the name sweeps came back empty. Kronor only: the sum is taken over
// remaining amounts stored in invoice currency.
if (data.length === 0) {
if (paymentCurrency !== 'SEK') return []
return runAggregateSweep(supabase, { companyId, invoice, paymentAmount, paymentDate })
}
const invoiceOcr = normalizeOcrReference(invoice.invoice_number)
const searchTerms = customerName
@@ -230,6 +264,146 @@ export async function findDuplicatePaymentCandidatesForInvoice(
return candidates
}
async function runAggregateSweep(
supabase: SupabaseClient,
params: {
companyId: string
invoice: Pick<CustomerInvoice, 'invoice_number'>
paymentAmount: number
paymentDate: string
},
): Promise<DuplicatePaymentCandidate[]> {
const { companyId, invoice, paymentAmount, paymentDate } = params
try {
return await findAggregateCandidates(supabase, {
companyId,
invoiceNumber: invoice.invoice_number,
paymentAmount,
paymentDate,
})
} catch (err) {
// Advisory guard: a failed sweep must never block "Markera som betald".
// Logged so the blind spot is visible rather than passing silently.
log.warn('duplicate-payment guard: aggregate sweep failed', {
companyId,
invoiceNumber: invoice.invoice_number,
error: err instanceof Error ? err.message : String(err),
})
return []
}
}
type OpenInvoiceRow = {
id: string
invoice_number: string | null
remaining_amount: number | string | null
total: number | string | null
due_date: string | null
}
type AggregateRow = Pick<Row, 'id' | 'date' | 'amount' | 'description' | 'merchant_name' | 'reference'>
/**
* Unlinked inbound kronor rows around the payment date that are LARGER than
* the payment, where the excess is exactly the remaining amount of one to
* three other open invoices. That is what a Bankgirot daily aggregate looks
* like from the invoice side: "BGGIRERING", no payer, one sum for two
* customers' invoices. Same exact-sum search the bank-side guard uses
* (lib/reconciliation/covering-set.ts), so the two doors agree on what
* "already paid" means. The remedy is the split under Transaktioner, which
* books ONE samlingsverifikation and links the row; marking the invoices
* paid one by one is what books the money twice.
*/
async function findAggregateCandidates(
supabase: SupabaseClient,
params: {
companyId: string
invoiceNumber: string | null
paymentAmount: number
paymentDate: string
},
): Promise<DuplicatePaymentCandidate[]> {
const { companyId, invoiceNumber, paymentAmount, paymentDate } = params
const payment = roundOre(paymentAmount)
if (!(payment > 0)) return []
const dateMs = new Date(paymentDate).getTime()
if (Number.isNaN(dateMs)) return []
const dayMs = 24 * 3600 * 1000
const dateLow = new Date(dateMs - AGGREGATE_DATE_WINDOW_DAYS * dayMs).toISOString().split('T')[0]
const dateHigh = new Date(dateMs + AGGREGATE_DATE_WINDOW_DAYS * dayMs).toISOString().split('T')[0]
const { data: rowsData } = await supabase
.from('transactions')
.select('id, date, amount, description, merchant_name, reference')
.eq('company_id', companyId)
.eq('is_business', true)
.is('invoice_id', null)
.is('supplier_invoice_id', null)
.is('journal_entry_id', null)
.or('currency.is.null,currency.eq.SEK')
.gt('amount', payment)
.gte('date', dateLow)
.lte('date', dateHigh)
.order('date', { ascending: false })
.limit(AGGREGATE_MAX_ROWS)
// Defensive shape check: a client that answers a list query with a single
// object (older test doubles do) must read as "no rows", not throw.
const rows = (Array.isArray(rowsData) ? rowsData : []) as AggregateRow[]
if (rows.length === 0) return []
let othersQuery = supabase
.from('invoices')
.select('id, invoice_number, remaining_amount, total, due_date')
.eq('company_id', companyId)
.eq('document_type', 'invoice')
.is('credited_invoice_id', null)
.in('status', ['sent', 'overdue', 'partially_paid'])
.gt('remaining_amount', 0)
.or('currency.is.null,currency.eq.SEK')
if (invoiceNumber) othersQuery = othersQuery.neq('invoice_number', invoiceNumber)
const { data: othersData } = await othersQuery
.order('due_date', { ascending: true })
.limit(AGGREGATE_MAX_OPEN_INVOICES)
const others = ((Array.isArray(othersData) ? othersData : []) as OpenInvoiceRow[]).filter(
(inv) => inv.invoice_number && Number(inv.remaining_amount ?? inv.total ?? 0) > 0,
)
if (others.length === 0) return []
const candidates: DuplicatePaymentCandidate[] = []
for (const row of rows) {
const residual = roundOre(Number(row.amount) - payment)
if (!(residual > 0)) continue
const rowMs = new Date(row.date).getTime()
const set = findExactCoveringSet(
residual,
others.map((inv) => ({
id: inv.id,
amount: Number(inv.remaining_amount ?? inv.total ?? 0),
dateDistanceDays:
inv.due_date && !Number.isNaN(rowMs)
? Math.round(Math.abs(new Date(inv.due_date).getTime() - rowMs) / dayMs)
: AGGREGATE_DATE_WINDOW_DAYS,
invoiceNumber: inv.invoice_number as string,
})),
{ maxSize: AGGREGATE_MAX_OTHER_INVOICES },
)
if (!set) continue
candidates.push({
id: row.id,
date: row.date,
amount: Number(row.amount),
description: row.description,
merchant_name: row.merchant_name,
reference: row.reference,
match_reason: 'aggregate_exact',
match_confidence: MATCH_REASON_CONFIDENCE.aggregate_exact,
aggregate_invoice_numbers: set.map((s) => s.invoiceNumber),
})
if (candidates.length >= 5) break
}
return candidates
}
/**
* A bank row as a comparable amount. `resolveTransactionAmountSek` is the one
* definition of "this bank line in kronor" (shared with the booking-time
+328
View File
@@ -26,6 +26,8 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
import { resolveTransactionAmountSek } from '@/lib/transactions/booking-duplicate-detection'
import { findExactCoveringSet } from '@/lib/reconciliation/covering-set'
import { roundOre } from '@/lib/money'
/** ± days around the transaction date considered "the same payment". */
const DATE_WINDOW_DAYS = 7
@@ -274,3 +276,329 @@ export async function detectDuplicatePaymentVoucher(
unverified_reason: targetSek === null ? 'transaction_missing_sek_value' : null,
}
}
// ============================================================
// Explaining voucher SET: one bank row, one or several vouchers
// ============================================================
/** ± days around the bank row considered "the same payment" for a set. */
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
export interface ExplainingVoucher {
journal_entry_id: string
voucher_label: string
entry_date: string
description: string | null
source_type: string | null
/** The voucher's bank leg in SEK, positive, in the bank row's direction. */
amount: number
bank_account_number: string
}
export interface ExplainingVoucherSet {
/** One to EXPLAINING_SET_MAX_VOUCHERS vouchers, closest in date first. */
vouchers: ExplainingVoucher[]
/** SEK sum of the legs: equals the bank row stated in SEK, to the öre. */
total: number
bank_account_number: string
/** True when every voucher is dated on the bank row's date. */
same_date: boolean
}
export interface DetectSetArgs extends DetectArgs {
/**
* The settlement account the bank row belongs to (cash_accounts.ledger_account)
* when known. Narrows the scan to that account, so a 1940 leg can never be
* summed into a 1930 row. Null or omitted scans the whole 19xx range, the
* legacy shape for rows with no resolvable cash account.
*/
bankAccountNumber?: 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. 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<ExplainingVoucherSet | null> {
const { companyId, transactionId, transactionDate, transactionAmount } = args
if (Math.round(Math.abs(transactionAmount) * 100) === 0) return null
const signedSek = resolveTransactionAmountSek({
amount: transactionAmount,
currency: args.transactionCurrency,
amount_sek: args.transactionAmountSek,
exchange_rate: args.transactionExchangeRate,
})
if (signedSek === null) return null
const targetSek = roundOre(Math.abs(signedSek))
if (targetSek === 0) return null
const dateMs = new Date(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]
// Money in: the voucher DEBITS the bank account. Money out: it CREDITS it.
const inbound = transactionAmount > 0
const account = args.bankAccountNumber?.trim() || null
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<SetLineRow>({
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<string, Candidate>()
for (const leg of legs) {
const raw = 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)
continue
}
if (existing && existing.amount >= amount) continue
const entryMs = new Date(entry.entry_date).getTime()
byEntry.set(entry.id, {
id: entry.id,
journal_entry_id: entry.id,
voucher_label: `${entry.voucher_series ?? 'A'}${entry.voucher_number ?? ''}`,
entry_date: entry.entry_date,
description: entry.description,
source_type: entry.source_type,
amount,
bank_account_number: leg.account_number,
dateDistanceDays: Number.isNaN(entryMs)
? SET_DATE_WINDOW_DAYS
: Math.round(Math.abs(entryMs - dateMs) / (24 * 3600 * 1000)),
})
}
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
const linkedIds = new Set<string>()
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)
}
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.
const accounts = Array.from(new Set(pool.map((c) => c.bank_account_number))).sort()
for (const accountNumber of accounts) {
const set = findExactCoveringSet(
targetSek,
pool.filter((c) => c.bank_account_number === accountNumber),
{ maxSize: EXPLAINING_SET_MAX_VOUCHERS },
)
if (!set) continue
const vouchers = [...set]
.sort((a, b) => a.dateDistanceDays - b.dateDistanceDays || a.entry_date.localeCompare(b.entry_date))
.map(({ id: _id, dateDistanceDays: _distance, ...voucher }) => voucher)
return {
vouchers,
total: targetSek,
bank_account_number: accountNumber,
same_date: vouchers.every((v) => v.entry_date === transactionDate),
}
}
return null
}
type PaymentLinkRow = { journal_entry_id: string | null; transaction_id: string | null }
/** The transaction columns the set detector needs; a caller that already holds the row passes it. */
export interface TransactionForExplaining {
id: string
date: string
amount: number
currency: string | null
amount_sek?: number | null
exchange_rate?: number | null
cash_account_id?: string | null
journal_entry_id?: string | null
}
/**
* Convenience for the routes: resolve the row's settlement account from its
* cash account and run the set detector. Accepts the transaction id (one
* fetch) or a row a caller already holds. A row that already carries a live
* pointer returns null: the booking RPCs refuse it on their own terms.
*/
export async function detectExplainingVoucherSetForTransaction(
supabase: SupabaseClient,
companyId: string,
transaction: string | TransactionForExplaining,
): Promise<ExplainingVoucherSet | null> {
let row: TransactionForExplaining | null
if (typeof transaction === 'string') {
const { data, error } = await supabase
.from('transactions')
.select('id, date, amount, currency, amount_sek, exchange_rate, cash_account_id, journal_entry_id')
.eq('id', transaction)
.eq('company_id', companyId)
.maybeSingle()
if (error) return null
row = (data as TransactionForExplaining | null) ?? null
} else {
row = transaction
}
if (!row || row.journal_entry_id) return null
let bankAccountNumber: string | null = null
if (row.cash_account_id) {
const { data: cashAccount, error } = await supabase
.from('cash_accounts')
.select('ledger_account')
.eq('id', row.cash_account_id)
.eq('company_id', companyId)
.maybeSingle()
// Without the account the scan would widen to every 19xx account: an
// unverified answer, so a failed lookup is a pass, not a wider guess.
if (error) return null
bankAccountNumber = (cashAccount?.ledger_account as string | null) ?? null
}
return detectExplainingVoucherSet(supabase, {
companyId,
transactionId: row.id,
transactionDate: row.date,
transactionAmount: Number(row.amount),
transactionCurrency: row.currency ?? null,
transactionAmountSek: row.amount_sek ?? null,
transactionExchangeRate: row.exchange_rate ?? null,
bankAccountNumber,
})
}
@@ -0,0 +1,61 @@
import { describe, it, expect } from 'vitest'
import { findExactCoveringSet } from '../covering-set'
function c(id: string, amount: number, dateDistanceDays = 0) {
return { id, amount, dateDistanceDays }
}
describe('findExactCoveringSet', () => {
it('returns null for an empty list or a non-positive target', () => {
expect(findExactCoveringSet(100, [])).toBeNull()
expect(findExactCoveringSet(0, [c('a', 100)])).toBeNull()
expect(findExactCoveringSet(-100, [c('a', 100)])).toBeNull()
})
it('finds the single voucher of the same amount', () => {
const set = findExactCoveringSet(1000, [c('a', 999), c('b', 1000), c('c', 1)])
expect(set?.map((s) => s.id)).toEqual(['b'])
})
it('finds the Bankgirot aggregate: two vouchers that sum to the row', () => {
// gecko's case: 62 500 + 25 750 booked by hand, one 88 250 bank row.
const set = findExactCoveringSet(88250, [c('A57', 62500), c('A58', 25750), c('A56', 150)])
expect(set?.map((s) => s.id).sort()).toEqual(['A57', 'A58'])
})
it('prefers the smallest set, then the one closest in date', () => {
const one = findExactCoveringSet(1000, [c('pair-1', 400, 0), c('pair-2', 600, 0), c('single', 1000, 3)])
expect(one?.map((s) => s.id)).toEqual(['single'])
const near = findExactCoveringSet(1000, [c('far', 1000, 6), c('near', 1000, 1)])
expect(near?.map((s) => s.id)).toEqual(['near'])
})
it('is exact to the öre and never reads a near miss as a match', () => {
expect(findExactCoveringSet(1000, [c('a', 999.99)])).toBeNull()
expect(findExactCoveringSet(1000.01, [c('a', 600), c('b', 400.01)])?.map((s) => s.id)).toEqual(['a', 'b'])
expect(findExactCoveringSet(1000, [c('a', 600), c('b', 400.01)])).toBeNull()
})
it('ignores candidates larger than the target or with no amount', () => {
const set = findExactCoveringSet(500, [c('big', 5000), c('zero', 0), c('neg', -500), c('ok', 500)])
expect(set?.map((s) => s.id)).toEqual(['ok'])
})
it('stops at maxSize and caps the candidate pool', () => {
const parts = [c('a', 100), c('b', 200), c('c', 300), c('d', 400)]
expect(findExactCoveringSet(1000, parts, { maxSize: 3 })).toBeNull()
expect(findExactCoveringSet(1000, parts, { maxSize: 4 })?.length).toBe(4)
// Pool cap keeps only the two closest rows, so the pair cannot be formed.
const far = [c('near-1', 100, 0), c('near-2', 200, 0), c('far-1', 700, 5)]
expect(findExactCoveringSet(1000, far, { maxCandidates: 2 })).toBeNull()
})
it('handles a busy account without blowing up', () => {
const many = Array.from({ length: 200 }, (_, i) => c(`v${i}`, 100 + (i % 37) * 13, i % 8))
const start = Date.now()
const set = findExactCoveringSet(100 + 113 + 126 + 139, many)
expect(Date.now() - start).toBeLessThan(500)
expect(set).not.toBeNull()
})
})
+107
View File
@@ -0,0 +1,107 @@
/**
* Exact subset sum over a short candidate list: which posted bank legs, taken
* together, add up to one bank row to the öre.
*
* Why this exists: a bank feed can deliver several affärshändelser as ONE row
* (a Bankgirot daily aggregate, a lump payout), and each of them may already
* be booked on its own (an invoice marked paid by hand, a salary voucher per
* employee). The 1:1 duplicate check then sees no voucher of the row's amount
* and stays silent, while the row is fully explained by two or three vouchers
* that carry no bank link. The exact sum is a deterministic signal that needs
* no counterparty text, which is what bank rows like "BGGIRERING 03447786"
* never carry.
*
* Pure and client-safe on purpose: the same search can rank a suggestion in
* the reconciliation view or guard a booking route without dragging server
* dependencies into a component.
*
* Search order is smallest set first (one voucher beats two), and within one
* size the set closest in date to the bank row. The candidate list is capped
* before the search so a busy account cannot make the combinatorics
* unbounded: with 40 candidates and sets of at most 4 the worst case is under
* a hundred thousand partial sums, which is well below a millisecond of work.
*/
export interface CoveringCandidate {
id: string
/** Positive amount in the unit the target is stated in (SEK for bank legs). */
amount: number
/** |candidate date - bank row date| in whole days. Ranks equal-size sets. */
dateDistanceDays: number
}
export interface CoveringSetOptions {
/** Largest set considered. Default 4. */
maxSize?: number
/** Candidates kept (closest in date first) before the search. Default 40. */
maxCandidates?: number
}
const DEFAULT_MAX_SIZE = 4
const DEFAULT_MAX_CANDIDATES = 40
function toOre(amount: number): number {
return Math.round(amount * 100)
}
/**
* Returns the best set of candidates whose amounts sum exactly to `target`
* (to the öre), or null when no set of at most `maxSize` candidates does.
* Candidates with a non-positive amount never take part; the target must be
* positive (callers pass the absolute value of the bank row).
*/
export function findExactCoveringSet<T extends CoveringCandidate>(
target: number,
candidates: T[],
options: CoveringSetOptions = {},
): T[] | null {
const maxSize = Math.max(1, options.maxSize ?? DEFAULT_MAX_SIZE)
const maxCandidates = Math.max(1, options.maxCandidates ?? DEFAULT_MAX_CANDIDATES)
const targetOre = toOre(target)
if (targetOre <= 0) return null
const pool = candidates
.map((c) => ({ candidate: c, ore: toOre(c.amount) }))
.filter((c) => c.ore > 0 && c.ore <= targetOre)
.sort((a, b) => {
if (a.candidate.dateDistanceDays !== b.candidate.dateDistanceDays) {
return a.candidate.dateDistanceDays - b.candidate.dateDistanceDays
}
if (a.ore !== b.ore) return b.ore - a.ore
return a.candidate.id < b.candidate.id ? -1 : a.candidate.id > b.candidate.id ? 1 : 0
})
.slice(0, maxCandidates)
for (let size = 1; size <= Math.min(maxSize, pool.length); size++) {
let best: { indices: number[]; distance: number } | null = null
const chosen: number[] = []
const walk = (start: number, remaining: number, distance: number) => {
if (chosen.length === size) {
if (remaining === 0 && (best === null || distance < best.distance)) {
best = { indices: [...chosen], distance }
}
return
}
const slotsLeft = size - chosen.length
for (let i = start; i <= pool.length - slotsLeft; i++) {
const entry = pool[i]
if (entry.ore > remaining) continue
// Nothing smaller than what is left can complete the set once the
// last slot is being filled: skip instead of descending.
if (slotsLeft === 1 && entry.ore !== remaining) continue
chosen.push(i)
walk(i + 1, remaining - entry.ore, distance + entry.candidate.dateDistanceDays)
chosen.pop()
}
}
walk(0, targetOre, 0)
if (best !== null) {
const found = best as { indices: number[]; distance: number }
return found.indices.map((i) => pool[i].candidate)
}
}
return null
}