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
@@ -165,6 +165,8 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
enqueue({ data: [], error: null })
// Duplicate-payment guard: description ILIKE, no candidates
enqueue({ data: [], error: null })
// Duplicate-payment guard: aggregate sweep (larger unbooked kronor rows), none
enqueue({ data: [], error: null })
// Fetch company settings (now before update due to journal-first ordering)
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
// Update invoice status (CAS guard: returns matched row)
@@ -235,6 +237,8 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
// Duplicate-payment guard: two ILIKE probes, no candidates
enqueue({ data: [], error: null })
enqueue({ data: [], error: null })
// Duplicate-payment guard: aggregate sweep (larger unbooked kronor rows), none
enqueue({ data: [], error: null })
// Company settings
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
// Deliberately NO status-update enqueued: the route must fail closed BEFORE
@@ -266,6 +270,8 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
enqueue({ data: [], error: null })
// Duplicate-payment guard: description ILIKE, no candidates
enqueue({ data: [], error: null })
// Duplicate-payment guard: aggregate sweep (larger unbooked kronor rows), none
enqueue({ data: [], error: null })
enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null })
// Update invoice status (CAS guard: returns matched row)
enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert
@@ -875,6 +881,8 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
enqueue({ data: [], error: null })
// Duplicate-payment guard: description ILIKE, no candidates
enqueue({ data: [], error: null })
// Duplicate-payment guard: aggregate sweep (larger unbooked kronor rows), none
enqueue({ data: [], error: null })
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
// Update invoice status (CAS guard: returns matched row)
enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert
@@ -1153,6 +1161,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
enqueue({ data: invoice, error: null })
enqueue({ data: [], error: null }) // duplicate guard: merchant_name
enqueue({ data: [], error: null }) // duplicate guard: description
enqueue({ data: [], error: null }) // duplicate guard: aggregate sweep
enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null })
enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert
enqueue({ data: [{ id: 'inv-1' }], error: null }) // CAS update matched