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
@@ -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 & 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 & bokför
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
Reference in New Issue
Block a user