* 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>
95 lines
4.1 KiB
TypeScript
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 })
|
|
},
|
|
)
|