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
@@ -30,7 +30,7 @@ import type { EntityType } from '@/types'
|
||||
import type { InvoiceWithRelations } from '@/components/invoices/types'
|
||||
import { loadBasCatalog, type CatalogAccount } from '@/lib/bookkeeping/bas-catalog-client'
|
||||
|
||||
type DuplicateMatchReason = 'ocr_exact' | 'name_amount_fuzzy' | 'amount_only'
|
||||
type DuplicateMatchReason = 'ocr_exact' | 'name_amount_fuzzy' | 'amount_only' | 'aggregate_exact'
|
||||
|
||||
interface DuplicateCandidate {
|
||||
id: string
|
||||
@@ -41,6 +41,8 @@ interface DuplicateCandidate {
|
||||
reference: string | null
|
||||
match_reason: DuplicateMatchReason
|
||||
match_confidence: number
|
||||
/** aggregate_exact: the other open invoices the bank row also covers. */
|
||||
aggregate_invoice_numbers?: string[]
|
||||
}
|
||||
|
||||
interface PaymentBookingDialogProps {
|
||||
@@ -67,6 +69,7 @@ export default function PaymentBookingDialog({
|
||||
ocr_exact: t('match_reason_ocr_exact'),
|
||||
name_amount_fuzzy: t('match_reason_name_amount_fuzzy'),
|
||||
amount_only: t('match_reason_amount_only'),
|
||||
aggregate_exact: t('match_reason_aggregate_exact'),
|
||||
}
|
||||
|
||||
// Session-cached reference data (lib/reference-data), seeded by the
|
||||
@@ -363,11 +366,13 @@ export default function PaymentBookingDialog({
|
||||
<ul className="space-y-2">
|
||||
{duplicateCandidates.map((c) => {
|
||||
const reasonVariant: 'success' | 'secondary' | 'outline' =
|
||||
c.match_reason === 'ocr_exact'
|
||||
c.match_reason === 'ocr_exact' || c.match_reason === 'aggregate_exact'
|
||||
? 'success'
|
||||
: c.match_reason === 'name_amount_fuzzy'
|
||||
? 'secondary'
|
||||
: 'outline'
|
||||
const isAggregate =
|
||||
c.match_reason === 'aggregate_exact' && (c.aggregate_invoice_numbers?.length ?? 0) > 0
|
||||
return (
|
||||
<li
|
||||
key={c.id}
|
||||
@@ -386,6 +391,18 @@ export default function PaymentBookingDialog({
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{c.merchant_name || c.description || '-'}
|
||||
</p>
|
||||
{/* A Bankgirot aggregate: the row also settles other
|
||||
invoices, so the remedy is the split under
|
||||
Transaktioner (one samlingsverifikation, row linked),
|
||||
never marking the invoices paid one by one. */}
|
||||
{isAggregate && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('aggregate_covers', {
|
||||
count: c.aggregate_invoice_numbers!.length,
|
||||
numbers: c.aggregate_invoice_numbers!.join(', '),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -394,7 +411,7 @@ export default function PaymentBookingDialog({
|
||||
onClick={() => handleLinkExisting(c.id)}
|
||||
className="shrink-0"
|
||||
>
|
||||
{t('link_transaction')}
|
||||
{isAggregate ? t('allocate_transaction') : t('link_transaction')}
|
||||
</Button>
|
||||
</li>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user