* 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>
62 lines
2.8 KiB
TypeScript
62 lines
2.8 KiB
TypeScript
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()
|
|
})
|
|
})
|