fix(invoices): duplicate-payment guard on customer mark-paid + categorize (#502)
* fix(invoices): duplicate-payment guard on customer mark-paid + categorize
Two-pronged fix preventing duplicate verifikationer when a customer
invoice is marked paid OR a 19xx→1510 categorization is applied to an
inbound bank tx that already belongs to an open invoice.
Prong A (mark-paid): before booking, scan unlinked positive business
bank txs from the same customer within ±2% / ±60 days. If candidates
exist, return 409 INVOICE_PAID_LIKELY_DUPLICATE with per-candidate
match_reason (ocr_exact > name_amount_fuzzy > amount_only). Override
via `{ force: true }`. Applied to both legacy /api/invoices/[id]/
mark-paid and v1 /api/v1/.../invoices/[id]/mark-paid; v1 guard runs
before dry-run so previews can't mask the warning.
Prong B (categorize): when the user assigns 1930→1510 directly on a
positive business tx with a matching open customer invoice (by name
OR by OCR-normalized reference), return 409
TX_CATEGORIZE_SUGGEST_CI_MATCH routing them to /match-invoice.
Mirrors the supplier-side guard from #461. Shared helpers
(DUPLICATE_AMOUNT_TOLERANCE_PCT, escapeLikePattern) reused as-is.
New helper normalizeOcrReference() strips non-digits for Swedish OCR
equality. New shared candidate-finder
lib/invoices/duplicate-payment-candidates.ts keeps the legacy and v1
routes calling the same code.
Frontend:
- PaymentBookingDialog intercepts the 409, renders candidate list
with match_reason badges (Exakt OCR-träff / Sannolik träff /
Möjlig träff), offers "Länka transaktion" or "Bokför ändå"
(force-retry generates a fresh Idempotency-Key for v1 callers)
- transactions/page.tsx mirrors siMatchSuggestion handling as
ciMatchSuggestion with a parallel "Matcha mot kundfaktura?" dialog
v1 caveat documented in the route's pitfalls block:
INVOICE_PAID_LIKELY_DUPLICATE force-retry requires a fresh
Idempotency-Key because the original is body-hash bound; reusing it
returns 400 IDEMPOTENCY_KEY_REUSE.
Tests: 5 new mark-paid tests (legacy + v1) covering 409, force
bypass, partial-payment skip, ocr_exact match_reason, multi-candidate
ranking. 1 v1-only test verifying dry-run also surfaces the 409. 2
categorize Prong B tests (409 + confirm_no_match bypass).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(invoices): address compliance-swarm review on duplicate-payment guard
Three review-driven fixes:
1. **PostgREST .or() injection (OWASP V1.2.5).** `escapeLikePattern` neutralises
LIKE wildcards but NOT PostgREST filter-DSL chars (`,`, `.`, `(`, `)`). A
customer name like `Acme,fake.eq.true` could otherwise inject a synthetic
filter clause into the `.or('merchant_name.ilike.%X%,description.ilike.%X%')`
string. Replaced with two parameterised `.ilike()` queries dispatched in
parallel and merged by id in JS. Slight perf cost (two index hits per call),
eliminates the DSL-injection surface entirely.
2. **Date window anchored on invoice_date instead of due_date
(swedish-accounting-compliance bot).** The Prong B categorize intercept
filtered open customer invoices by `invoice_date ± 60d` relative to the
bank-tx date. For invoices with 60–90 day payment terms, the actual
payment lands well after `invoice_date`, so the legitimate match falls
outside the window and the guard silently misses it. Switched to
`due_date ± 60d` — the better proxy for "around when payment is expected."
No corresponding change for Prong A (mark-paid), which is correctly
anchored on `paymentDate` (the user-supplied or default-today date) and
scans bank-tx dates around that anchor.
3. **Force-bypass log enrichment (ISO A.8.15, OWASP V16).** Both
`duplicate-payment guard bypassed` warn entries now include `userId` and
`paymentAmount`. Attribution was previously incomplete — the bypass log
carried only `invoiceId`, which forced a join in log aggregation to
identify the acting principal.
Tests updated for the two-query pattern (legacy mark-paid suite enqueues
two transactions-table responses per guard invocation; v1 tests already
worked with the single-entry-per-table mock semantics).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(docs): redirect /docs/api and /llms-full.txt to docs.gnubok.se
Canonical docs host is now docs.gnubok.se. Every `docs_url` field on the
v1 error envelope still points at /docs/api/* on this app; the 308
permanent redirect forwards humans and agent crawlers to the docs
subdomain without us needing to mass-update structured-errors.ts.
/llms-full.txt also routes through the docs host where it's served from
the docs site's own build.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
b94ed3bec2
commit
b46b572ee9
@@ -187,6 +187,11 @@ export const MarkInvoicePaidSchema = z.object({
|
||||
credit_amount: nonNegativeAmount.default(0),
|
||||
line_description: z.string().optional(),
|
||||
})).min(2).optional(),
|
||||
// Bypass the duplicate-payment guard. Set after the user reviews the
|
||||
// candidate list returned by INVOICE_PAID_LIKELY_DUPLICATE and confirms
|
||||
// none of them are this payment. v1 callers must use a fresh
|
||||
// Idempotency-Key on the retry — the original is body-hash bound.
|
||||
force: z.boolean().optional(),
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -280,6 +280,17 @@ const TRANSACTIONS: Record<string, StructuredErrorEntry> = {
|
||||
'Match the transaction via POST /api/transactions/{id}/match-supplier-invoice, or resend with confirm_no_match: true to keep the plain 244x categorization.',
|
||||
},
|
||||
},
|
||||
TX_CATEGORIZE_SUGGEST_CI_MATCH: {
|
||||
httpStatus: 409,
|
||||
message_sv:
|
||||
'Det finns en obetald kundfaktura från samma kund med samma belopp. Matcha mot fakturan istället för att bokföra direkt mot kundfordringskontot — annars skapas en dubblerad verifikation som måste stornas (BFL 5 kap 5 §).',
|
||||
message_en:
|
||||
'An unpaid customer invoice from the same customer matches this amount. Suggest matching to the invoice instead of a plain 151x categorization to avoid producing a duplicate verifikation (BFL 5 kap 5 §).',
|
||||
remediation: {
|
||||
description:
|
||||
'Match the transaction via POST /api/transactions/{id}/match-invoice, or resend with confirm_no_match: true to keep the plain 151x categorization.',
|
||||
},
|
||||
},
|
||||
TX_UNCATEGORIZE_NO_LINKED_ENTRY: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Transaktionen har ingen kopplad verifikation att stornera.',
|
||||
@@ -545,6 +556,17 @@ const INVOICE: Record<string, StructuredErrorEntry> = {
|
||||
message_sv: 'Kunde inte bokföra betalningen.',
|
||||
message_en: 'Failed to create payment journal entry.',
|
||||
},
|
||||
INVOICE_PAID_LIKELY_DUPLICATE: {
|
||||
httpStatus: 409,
|
||||
message_sv:
|
||||
'Det finns redan en obokförd inkommande banktransaktion som kan vara denna betalning. Länka den istället, eller markera som betald ändå om du är säker.',
|
||||
message_en:
|
||||
'A likely-matching unlinked inbound bank transaction was found for this customer. Suggest linking it instead of creating a new payment entry.',
|
||||
remediation: {
|
||||
description:
|
||||
'Match the candidate transaction via POST /api/transactions/{id}/match-invoice, or resend mark-paid with force: true to create the payment entry anyway. When using the v1 endpoint, the force retry requires a fresh Idempotency-Key (the original key is bound to the body hash).',
|
||||
},
|
||||
},
|
||||
INVOICE_DELETE_NOT_DRAFT: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Endast utkast kan tas bort. Bokförda fakturor måste krediteras istället.',
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import {
|
||||
DUPLICATE_AMOUNT_TOLERANCE_PCT,
|
||||
DUPLICATE_DATE_WINDOW_DAYS,
|
||||
escapeLikePattern,
|
||||
normalizeOcrReference,
|
||||
} from './duplicate-payment-guard'
|
||||
|
||||
export type DuplicatePaymentMatchReason =
|
||||
| 'ocr_exact'
|
||||
| 'name_amount_fuzzy'
|
||||
| 'amount_only'
|
||||
|
||||
export interface DuplicatePaymentCandidate {
|
||||
id: string
|
||||
date: string
|
||||
amount: number
|
||||
description: string | null
|
||||
merchant_name: string | null
|
||||
reference: string | null
|
||||
match_reason: DuplicatePaymentMatchReason
|
||||
match_confidence: number
|
||||
}
|
||||
|
||||
const MATCH_REASON_RANK: Record<DuplicatePaymentMatchReason, number> = {
|
||||
ocr_exact: 0,
|
||||
name_amount_fuzzy: 1,
|
||||
amount_only: 2,
|
||||
}
|
||||
|
||||
const MATCH_REASON_CONFIDENCE: Record<DuplicatePaymentMatchReason, number> = {
|
||||
ocr_exact: 0.99,
|
||||
name_amount_fuzzy: 0.7,
|
||||
amount_only: 0.5,
|
||||
}
|
||||
|
||||
interface CustomerInvoice {
|
||||
invoice_number: string | null
|
||||
customer_name: string | null | undefined
|
||||
}
|
||||
|
||||
type Row = {
|
||||
id: string
|
||||
date: string
|
||||
amount: number
|
||||
description: string | null
|
||||
merchant_name: string | null
|
||||
reference: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan unlinked positive (inbound) business bank transactions that could be
|
||||
* the payment for this customer invoice. Used by the mark-paid duplicate
|
||||
* guard: callers route the user to "link existing" instead of double-booking.
|
||||
*
|
||||
* Customer-side adaptations vs the supplier guard:
|
||||
* - amount > 0 (inbound) instead of < 0
|
||||
* - matches BOTH `merchant_name` AND `description` (banks often describe an
|
||||
* inbound payment by payer name without populating merchant_name)
|
||||
* - per-candidate scoring with OCR (invoice_number normalized) as the
|
||||
* strongest signal
|
||||
*
|
||||
* The merchant_name and description searches are issued as two separate
|
||||
* parameterised `.ilike()` queries and deduplicated by id. We deliberately
|
||||
* avoid `.or('merchant_name.ilike.%X%,description.ilike.%X%')` because that
|
||||
* interpolates the customer name into PostgREST's filter-DSL string, where
|
||||
* `escapeLikePattern` only neutralises the LIKE wildcards (`%_\\`) and not
|
||||
* the DSL chars (`,`, `.`, `(`, `)`). A name like `Acme,fake.eq.true` would
|
||||
* otherwise inject a synthetic filter clause.
|
||||
*/
|
||||
export async function findDuplicatePaymentCandidatesForInvoice(
|
||||
supabase: SupabaseClient,
|
||||
params: {
|
||||
companyId: string
|
||||
invoice: CustomerInvoice
|
||||
paymentAmount: number
|
||||
paymentDate: string
|
||||
},
|
||||
): Promise<DuplicatePaymentCandidate[]> {
|
||||
const { companyId, invoice, paymentAmount, paymentDate } = params
|
||||
const customerName = invoice.customer_name
|
||||
if (!customerName) return []
|
||||
|
||||
const windowLow = Math.round(paymentAmount * (1 - DUPLICATE_AMOUNT_TOLERANCE_PCT) * 100) / 100
|
||||
const windowHigh = Math.round(paymentAmount * (1 + DUPLICATE_AMOUNT_TOLERANCE_PCT) * 100) / 100
|
||||
const dateMs = new Date(paymentDate).getTime()
|
||||
const dateLow = new Date(dateMs - DUPLICATE_DATE_WINDOW_DAYS * 24 * 3600 * 1000)
|
||||
.toISOString()
|
||||
.split('T')[0]
|
||||
const dateHigh = new Date(dateMs + DUPLICATE_DATE_WINDOW_DAYS * 24 * 3600 * 1000)
|
||||
.toISOString()
|
||||
.split('T')[0]
|
||||
const pattern = `%${escapeLikePattern(customerName)}%`
|
||||
|
||||
const base = () =>
|
||||
supabase
|
||||
.from('transactions')
|
||||
.select('id, date, amount, description, merchant_name, reference')
|
||||
.eq('company_id', companyId)
|
||||
.eq('is_business', true)
|
||||
.is('invoice_id', null)
|
||||
.is('supplier_invoice_id', null)
|
||||
.gt('amount', 0)
|
||||
.gte('amount', windowLow)
|
||||
.lte('amount', windowHigh)
|
||||
.gte('date', dateLow)
|
||||
.lte('date', dateHigh)
|
||||
|
||||
const [byMerchantRes, byDescriptionRes] = await Promise.all([
|
||||
base().ilike('merchant_name', pattern).order('date', { ascending: false }).limit(5),
|
||||
base().ilike('description', pattern).order('date', { ascending: false }).limit(5),
|
||||
])
|
||||
|
||||
const merged = new Map<string, Row>()
|
||||
for (const row of (byMerchantRes.data ?? []) as Row[]) merged.set(row.id, row)
|
||||
for (const row of (byDescriptionRes.data ?? []) as Row[]) {
|
||||
if (!merged.has(row.id)) merged.set(row.id, row)
|
||||
}
|
||||
const data = Array.from(merged.values())
|
||||
.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0))
|
||||
.slice(0, 5)
|
||||
|
||||
if (data.length === 0) return []
|
||||
|
||||
const invoiceOcr = normalizeOcrReference(invoice.invoice_number)
|
||||
const searchTerms = customerName
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter((term) => term.length > 2)
|
||||
|
||||
const candidates: DuplicatePaymentCandidate[] = data.map((row) => {
|
||||
const reason = scoreCandidate({
|
||||
row,
|
||||
invoiceOcr,
|
||||
searchTerms,
|
||||
})
|
||||
return {
|
||||
id: row.id,
|
||||
date: row.date,
|
||||
amount: row.amount,
|
||||
description: row.description,
|
||||
merchant_name: row.merchant_name,
|
||||
reference: row.reference,
|
||||
match_reason: reason,
|
||||
match_confidence: MATCH_REASON_CONFIDENCE[reason],
|
||||
}
|
||||
})
|
||||
|
||||
candidates.sort((a, b) => MATCH_REASON_RANK[a.match_reason] - MATCH_REASON_RANK[b.match_reason])
|
||||
return candidates
|
||||
}
|
||||
|
||||
function scoreCandidate(args: {
|
||||
row: { reference: string | null; description: string | null; merchant_name: string | null }
|
||||
invoiceOcr: string
|
||||
searchTerms: string[]
|
||||
}): DuplicatePaymentMatchReason {
|
||||
const { row, invoiceOcr, searchTerms } = args
|
||||
if (invoiceOcr && row.reference) {
|
||||
if (normalizeOcrReference(row.reference) === invoiceOcr) {
|
||||
return 'ocr_exact'
|
||||
}
|
||||
}
|
||||
if (searchTerms.length > 0) {
|
||||
const haystack = `${row.description ?? ''} ${row.merchant_name ?? ''}`.toLowerCase()
|
||||
if (searchTerms.some((term) => haystack.includes(term))) {
|
||||
return 'name_amount_fuzzy'
|
||||
}
|
||||
}
|
||||
return 'amount_only'
|
||||
}
|
||||
@@ -28,3 +28,15 @@ export function escapeLikePattern(value: string): string {
|
||||
: value
|
||||
return truncated.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_')
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a Swedish payment reference (OCR / fakturanummer) for equality
|
||||
* comparison. Banks emit references with varying separators ("2026-0042",
|
||||
* "2026 0042", "2026/0042"); the OCR-spec equality is over the digits only.
|
||||
* Returns "" for nullish/empty so callers can short-circuit without
|
||||
* branching.
|
||||
*/
|
||||
export function normalizeOcrReference(value: string | null | undefined): string {
|
||||
if (!value) return ''
|
||||
return value.replace(/\D/g, '')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user