Files
accounted/app/api/transactions/[id]/duplicate-payment-check/route.ts
T
287828a850 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>
2026-09-05 11:59:59 +02:00

95 lines
4.1 KiB
TypeScript

/**
* GET /api/transactions/[id]/duplicate-payment-check
*
* Proactive check used by the InvoiceMatchDialog and MatchAllocationDialog:
* returns the candidate verifikation that already books this bank
* transaction (`candidate`, 1:1, or null), and the set of one or more posted
* unlinked vouchers whose bank legs add up exactly to the row
* (`candidate_set`, or null). Lets the UI display the warning panel without
* needing to first submit a doomed match.
*
* Same detectors as the match-invoice and match-batch pre-flights, so what
* you see here matches what the POSTs would refuse.
*/
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import {
detectDuplicatePaymentVoucher,
detectExplainingVoucherSetForTransaction,
type ExplainingVoucherSet,
} from '@/lib/invoices/duplicate-payment-detection'
export const GET = withRouteContext(
'transaction.duplicate_payment_check',
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id: transactionId } = await params
const { supabase, companyId, log, requestId } = ctx
// Membership is enforced by withRouteContext (see its docstring): the
// resolved companyId is always a company the caller is a member of, so
// intra-company multi-user visibility of transaction metadata here is
// the intended tenancy model. The selected column set is intentionally
// narrow (id, date, amount, journal_entry_id) so this endpoint cannot
// leak description / counterparty fields that aren't required to
// surface a duplicate-payment candidate. GDPR Art.5(1)(c)/(f).
//
// currency / amount_sek / exchange_rate are part of that minimum, not an
// expansion of it: they carry no personal data, and without them the
// detector cannot state a non-SEK bank line in SEK and would compare a
// foreign amount against an always-SEK ledger leg. A narrow column list is
// exactly how this guard would go dead on FX rows.
const { data: transaction, error } = await supabase
.from('transactions')
.select('id, date, amount, currency, amount_sek, exchange_rate, journal_entry_id, cash_account_id')
.eq('id', transactionId)
.eq('company_id', companyId)
.single()
if (error || !transaction) {
return errorResponseFromCode('TX_CATEGORIZE_TX_NOT_FOUND', log, { requestId })
}
// Already linked → no possible duplicate to surface.
if (transaction.journal_entry_id) {
return NextResponse.json({ candidate: null, candidate_set: null })
}
// Both detectors fail open: returning null preserves current UX. The
// POSTs still run their own checks, so a missed pre-flight doesn't allow
// a duplicate booking.
let candidate: Awaited<ReturnType<typeof detectDuplicatePaymentVoucher>> = null
try {
candidate = await detectDuplicatePaymentVoucher(supabase, {
companyId: companyId!,
transactionId,
transactionDate: transaction.date,
transactionAmount: transaction.amount,
transactionCurrency: transaction.currency ?? null,
transactionAmountSek: transaction.amount_sek ?? null,
transactionExchangeRate: transaction.exchange_rate ?? null,
})
} catch (err) {
log.warn('duplicate-payment-voucher detection failed', err as Error)
}
let candidateSet: ExplainingVoucherSet | null = null
try {
candidateSet = await detectExplainingVoucherSetForTransaction(supabase, companyId!, {
id: transaction.id,
date: transaction.date,
amount: transaction.amount,
currency: transaction.currency ?? null,
amount_sek: transaction.amount_sek ?? null,
exchange_rate: transaction.exchange_rate ?? null,
cash_account_id: transaction.cash_account_id ?? null,
journal_entry_id: null,
})
} catch (err) {
log.warn('explaining-voucher-set detection failed', err as Error)
}
return NextResponse.json({ candidate, candidate_set: candidateSet })
},
)