* 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>
43 lines
1.8 KiB
TypeScript
43 lines
1.8 KiB
TypeScript
/**
|
|
* Shared constants and helpers for the duplicate-payment / SI-match guards
|
|
* used by `/api/supplier-invoices/[id]/mark-paid` and
|
|
* `/api/transactions/[id]/categorize`. Both guards look for a likely-matching
|
|
* counterparty within a fuzzy amount + date window; keeping the thresholds in
|
|
* one place makes them tunable as we learn from real false-positive rates.
|
|
*/
|
|
|
|
/** Acceptable amount drift (±) when matching a bank tx to an invoice amount. */
|
|
export const DUPLICATE_AMOUNT_TOLERANCE_PCT = 0.02
|
|
|
|
/** Date window (±days) around the payment / invoice date. */
|
|
export const DUPLICATE_DATE_WINDOW_DAYS = 60
|
|
|
|
/** Cap on supplier / merchant names before they enter an ILIKE pattern, to
|
|
* bound query work and avoid pathological inputs degrading the index scan. */
|
|
const MAX_LIKE_NEEDLE_LENGTH = 200
|
|
|
|
/**
|
|
* Escape LIKE/ILIKE wildcards (`%`, `_`, `\`) and truncate to a safe length
|
|
* before embedding the value in an ILIKE pattern. SQL-injection is already
|
|
* handled by Supabase's parameterization; this purely prevents silent
|
|
* over-matching on names like "50% Off AB" and bounds DB work on long inputs.
|
|
*/
|
|
export function escapeLikePattern(value: string): string {
|
|
const truncated = value.length > MAX_LIKE_NEEDLE_LENGTH
|
|
? value.slice(0, MAX_LIKE_NEEDLE_LENGTH)
|
|
: 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, '')
|
|
}
|