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:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
9418de585f
commit
287828a850
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user