* 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>
108 lines
4.1 KiB
TypeScript
108 lines
4.1 KiB
TypeScript
/**
|
|
* 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
|
|
}
|