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:
Jakob Wennberg
2026-05-15 22:46:02 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent b94ed3bec2
commit b46b572ee9
13 changed files with 1132 additions and 7 deletions
+113 -7
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import {
Dialog,
DialogContent,
@@ -17,13 +18,32 @@ import { useToast } from '@/components/ui/use-toast'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import { proposePaymentLines } from '@/lib/bookkeeping/propose-payment-lines'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { formatCurrency } from '@/lib/utils'
import { formatCurrency, formatDate } from '@/lib/utils'
import { createClient } from '@/lib/supabase/client'
import { useCompany } from '@/contexts/CompanyContext'
import { Plus, Trash2, Loader2 } from 'lucide-react'
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
import type { Invoice, InvoiceItem, Customer, BASAccount, EntityType } from '@/types'
type DuplicateMatchReason = 'ocr_exact' | 'name_amount_fuzzy' | 'amount_only'
interface DuplicateCandidate {
id: string
date: string
amount: number
description: string | null
merchant_name: string | null
reference: string | null
match_reason: DuplicateMatchReason
match_confidence: number
}
const MATCH_REASON_LABEL: Record<DuplicateMatchReason, string> = {
ocr_exact: 'Exakt OCR-träff',
name_amount_fuzzy: 'Sannolik träff',
amount_only: 'Möjlig träff',
}
interface InvoiceWithRelations extends Invoice {
customer: Customer
items: InvoiceItem[]
@@ -45,6 +65,7 @@ export default function PaymentBookingDialog({
onSuccess,
}: PaymentBookingDialogProps) {
const { toast } = useToast()
const router = useRouter()
const supabase = createClient()
const { company } = useCompany()
@@ -53,11 +74,13 @@ export default function PaymentBookingDialog({
const [paymentDate, setPaymentDate] = useState(() => new Date().toISOString().split('T')[0])
const [isSubmitting, setIsSubmitting] = useState(false)
const [isInitialized, setIsInitialized] = useState(false)
const [duplicateCandidates, setDuplicateCandidates] = useState<DuplicateCandidate[] | null>(null)
// Load accounts and settings when dialog opens
useEffect(() => {
if (!open) {
setIsInitialized(false)
setDuplicateCandidates(null)
return
}
@@ -162,7 +185,7 @@ export default function PaymentBookingDialog({
setLines((prev) => prev.filter((_, i) => i !== index))
}
const handleSubmit = async () => {
const submit = async (force: boolean) => {
if (!isBalanced) return
setIsSubmitting(true)
@@ -183,11 +206,20 @@ export default function PaymentBookingDialog({
body: JSON.stringify({
payment_date: paymentDate,
lines: apiLines,
...(force ? { force: true } : {}),
}),
})
if (!response.ok) {
const data = await response.json()
const code = (data as { error?: { code?: string } })?.error?.code
if (code === 'INVOICE_PAID_LIKELY_DUPLICATE') {
const details = (data as { error?: { details?: { candidates?: DuplicateCandidate[] } } })
?.error?.details
setDuplicateCandidates(details?.candidates ?? [])
setIsSubmitting(false)
return
}
const error = new Error('Kunde inte markera som betald') as Error & { body?: unknown; status?: number }
error.body = data
error.status = response.status
@@ -208,6 +240,14 @@ export default function PaymentBookingDialog({
setIsSubmitting(false)
}
const handleSubmit = () => submit(false)
const handleForceSubmit = () => submit(true)
const handleLinkExisting = (transactionId: string) => {
onOpenChange(false)
router.push(`/transactions?highlight=${encodeURIComponent(transactionId)}`)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[680px]">
@@ -221,7 +261,58 @@ export default function PaymentBookingDialog({
</DialogDescription>
</DialogHeader>
{!isInitialized ? (
{duplicateCandidates && duplicateCandidates.length > 0 ? (
<div className="space-y-4">
<div className="space-y-1">
<p className="text-sm font-medium">Möjlig dubblettbetalning</p>
<p className="text-sm text-muted-foreground">
{duplicateCandidates.length === 1
? 'En inkommande banktransaktion ser ut att vara denna betalning. Länka den istället för att skapa en ny verifikation, eller bokför ändå om du är säker.'
: `${duplicateCandidates.length} inkommande banktransaktioner ser ut att kunna vara denna betalning. Länka rätt transaktion istället för att skapa en ny verifikation, eller bokför ändå om du är säker.`}
</p>
</div>
<ul className="space-y-2">
{duplicateCandidates.map((c) => {
const reasonVariant: 'success' | 'secondary' | 'outline' =
c.match_reason === 'ocr_exact'
? 'success'
: c.match_reason === 'name_amount_fuzzy'
? 'secondary'
: 'outline'
return (
<li
key={c.id}
className="flex flex-col gap-2 rounded-lg border bg-card p-3 sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={reasonVariant}>{MATCH_REASON_LABEL[c.match_reason]}</Badge>
<span className="text-sm tabular-nums text-muted-foreground">
{formatDate(c.date)}
</span>
<span className="text-sm font-medium tabular-nums">
{formatCurrency(c.amount, invoice.currency)}
</span>
</div>
<p className="truncate text-xs text-muted-foreground">
{c.merchant_name || c.description || '—'}
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleLinkExisting(c.id)}
className="shrink-0"
>
Länka transaktion
</Button>
</li>
)
})}
</ul>
</div>
) : !isInitialized ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
@@ -386,10 +477,25 @@ export default function PaymentBookingDialog({
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting} className="w-full sm:w-auto min-h-11">
Avbryt
</Button>
<Button onClick={handleSubmit} disabled={!isBalanced || isSubmitting || !isInitialized} className="w-full sm:w-auto min-h-11">
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Bekräfta &amp; bokför
</Button>
{duplicateCandidates && duplicateCandidates.length > 0 ? (
<Button
onClick={handleForceSubmit}
disabled={!isBalanced || isSubmitting}
className="w-full sm:w-auto min-h-11"
>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Bokför ändå
</Button>
) : (
<Button
onClick={handleSubmit}
disabled={!isBalanced || isSubmitting || !isInitialized}
className="w-full sm:w-auto min-h-11"
>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Bekräfta &amp; bokför
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>