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
@@ -131,6 +131,24 @@ export default function TransactionsPage() {
|
||||
} | null>(null)
|
||||
const [siMatchProcessing, setSiMatchProcessing] = useState(false)
|
||||
|
||||
// Prong B (customer side): prompt to match against an unpaid customer
|
||||
// invoice instead of categorizing direct to 1510 on an inbound bank tx.
|
||||
// Triggered by a 409 TX_CATEGORIZE_SUGGEST_CI_MATCH.
|
||||
const [ciMatchSuggestion, setCiMatchSuggestion] = useState<{
|
||||
transactionId: string
|
||||
retry: () => Promise<string | null>
|
||||
candidates: Array<{
|
||||
invoice_id: string
|
||||
invoice_number: string | null
|
||||
invoice_date: string
|
||||
remaining_amount: number
|
||||
currency: string
|
||||
customer_name: string | null
|
||||
match_reason: 'ocr_exact' | 'name_amount_fuzzy'
|
||||
}>
|
||||
} | null>(null)
|
||||
const [ciMatchProcessing, setCiMatchProcessing] = useState(false)
|
||||
|
||||
// Entity type for tooltip context
|
||||
const [entityType, setEntityType] = useState<string>('enskild_firma')
|
||||
|
||||
@@ -486,6 +504,21 @@ export default function TransactionsPage() {
|
||||
setProcessingId(null)
|
||||
return null
|
||||
}
|
||||
if (
|
||||
result?.error?.code === 'TX_CATEGORIZE_SUGGEST_CI_MATCH' &&
|
||||
Array.isArray(result.error.details?.candidates)
|
||||
) {
|
||||
// Prong B (customer side): invite the user to match the unpaid
|
||||
// customer invoice instead of booking a plain 1510 categorization
|
||||
// that would later create a duplicate.
|
||||
setCiMatchSuggestion({
|
||||
transactionId: id,
|
||||
retry: () => runCategorize({ ...args, confirmNoMatch: true }),
|
||||
candidates: result.error.details.candidates,
|
||||
})
|
||||
setProcessingId(null)
|
||||
return null
|
||||
}
|
||||
toast({
|
||||
title: 'Kategorisering misslyckades',
|
||||
description: getErrorMessage(result, { context: 'transaction', statusCode: response.status }),
|
||||
@@ -568,6 +601,55 @@ export default function TransactionsPage() {
|
||||
await handleCategorize(id, false, 'private')
|
||||
}
|
||||
|
||||
async function handleMatchSuggestedInvoice(transactionId: string, invoiceId: string) {
|
||||
setCiMatchProcessing(true)
|
||||
try {
|
||||
const response = await fetch(`/api/transactions/${transactionId}/match-invoice`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ invoice_id: invoiceId }),
|
||||
})
|
||||
const result = await response.json()
|
||||
if (!response.ok) {
|
||||
toast({
|
||||
title: 'Matchning misslyckades',
|
||||
description: getErrorMessage(result, { context: 'transaction', statusCode: response.status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
setCiMatchProcessing(false)
|
||||
return
|
||||
}
|
||||
|
||||
toast({ title: 'Kundfaktura matchad', description: 'Fakturan markerades som betald' })
|
||||
setCiMatchSuggestion(null)
|
||||
setExitingIds((prev) => new Set(prev).add(transactionId))
|
||||
setTotalUncategorizedCount((prev) => Math.max(0, (prev ?? 1) - 1))
|
||||
setTimeout(() => {
|
||||
setTransactions((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === transactionId
|
||||
? {
|
||||
...t,
|
||||
invoice_id: invoiceId,
|
||||
is_business: true,
|
||||
journal_entry_id: result.journal_entry_id ?? t.journal_entry_id,
|
||||
}
|
||||
: t
|
||||
)
|
||||
)
|
||||
setExitingIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(transactionId)
|
||||
return next
|
||||
})
|
||||
}, 350)
|
||||
} catch {
|
||||
toast({ title: 'Matchning misslyckades', description: 'Försök igen.', variant: 'destructive' })
|
||||
} finally {
|
||||
setCiMatchProcessing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMatchSuggestedSupplierInvoice(transactionId: string, supplierInvoiceId: string) {
|
||||
setSiMatchProcessing(true)
|
||||
try {
|
||||
@@ -1493,6 +1575,69 @@ export default function TransactionsPage() {
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Prong B (customer side): match-against-customer-invoice suggestion */}
|
||||
<Dialog
|
||||
open={ciMatchSuggestion !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setCiMatchSuggestion(null)
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Matcha mot kundfaktura?</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Det finns en obetald kundfaktura med samma belopp från samma kund. 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 §).
|
||||
</p>
|
||||
<div className="space-y-2 rounded-md border bg-muted/30 p-3">
|
||||
{ciMatchSuggestion?.candidates.map((c) => (
|
||||
<div key={c.invoice_id} className="flex items-center justify-between gap-3 text-sm">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">
|
||||
{c.customer_name || 'Kund'} · {c.invoice_number ?? '—'}
|
||||
</span>
|
||||
{c.match_reason === 'ocr_exact' && (
|
||||
<Badge variant="success">Exakt OCR-träff</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground tabular-nums">
|
||||
{formatDate(c.invoice_date)} · kvar {formatCurrency(c.remaining_amount, c.currency)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleMatchSuggestedInvoice(ciMatchSuggestion.transactionId, c.invoice_id)}
|
||||
disabled={ciMatchProcessing}
|
||||
>
|
||||
Matcha
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<Button variant="outline" onClick={() => setCiMatchSuggestion(null)}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
const retry = ciMatchSuggestion?.retry
|
||||
setCiMatchSuggestion(null)
|
||||
if (retry) await retry()
|
||||
}}
|
||||
disabled={ciMatchProcessing}
|
||||
>
|
||||
Bokför på kundfordringar ändå
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -123,6 +123,10 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
|
||||
// Fetch invoice
|
||||
enqueue({ data: invoice, error: null })
|
||||
// Duplicate-payment guard: merchant_name ILIKE — no candidates
|
||||
enqueue({ data: [], error: null })
|
||||
// Duplicate-payment guard: description ILIKE — no candidates
|
||||
enqueue({ data: [], error: null })
|
||||
// Fetch company settings (now before update due to journal-first ordering)
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
// Update invoice status (CAS guard: returns matched row)
|
||||
@@ -165,6 +169,10 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
})
|
||||
|
||||
enqueue({ data: invoice, error: null })
|
||||
// Duplicate-payment guard: merchant_name ILIKE — no candidates
|
||||
enqueue({ data: [], error: null })
|
||||
// Duplicate-payment guard: description ILIKE — no candidates
|
||||
enqueue({ data: [], error: null })
|
||||
enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null })
|
||||
// Update invoice status (CAS guard: returns matched row)
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
||||
@@ -193,6 +201,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
})
|
||||
|
||||
it('returns 500 when journal entry creation fails (invoice not marked paid)', async () => {
|
||||
// No customer attached → duplicate guard skips with missing_customer_name
|
||||
const invoice = makeInvoice({ id: 'inv-1', status: 'sent', total: 12500 })
|
||||
|
||||
enqueue({ data: invoice, error: null })
|
||||
@@ -208,6 +217,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
})
|
||||
|
||||
it('uses custom lines when provided instead of auto-generating', async () => {
|
||||
// No customer attached → duplicate guard skips with missing_customer_name
|
||||
const invoice = makeInvoice({ id: 'inv-1', status: 'sent', total: 12500 })
|
||||
|
||||
// Fetch invoice
|
||||
@@ -304,6 +314,198 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 409 INVOICE_PAID_LIKELY_DUPLICATE when an unlinked transaction matches', async () => {
|
||||
const customer = makeCustomer()
|
||||
const invoice = makeInvoice({
|
||||
id: 'inv-1',
|
||||
status: 'sent',
|
||||
total: 12500,
|
||||
customer,
|
||||
})
|
||||
|
||||
enqueue({ data: invoice, error: null })
|
||||
// Duplicate-payment guard: merchant_name ILIKE returns the match
|
||||
enqueue({
|
||||
data: [
|
||||
{
|
||||
id: 'tx-99',
|
||||
date: '2026-05-10',
|
||||
amount: 12500,
|
||||
description: 'Inbetalning Test AB',
|
||||
merchant_name: 'Test AB',
|
||||
reference: null,
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
// description ILIKE — no additional match (dedup keeps merchant_name result)
|
||||
enqueue({ data: [], error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: { code: string; details: { candidates: Array<{ id: string; match_reason: string }> } }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('INVOICE_PAID_LIKELY_DUPLICATE')
|
||||
expect(body.error.details.candidates).toHaveLength(1)
|
||||
expect(body.error.details.candidates[0].id).toBe('tx-99')
|
||||
expect(body.error.details.candidates[0].match_reason).toBe('name_amount_fuzzy')
|
||||
expect(mockCreateInvoicePaymentJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('proceeds when force=true even with candidates present', async () => {
|
||||
const customer = makeCustomer()
|
||||
const invoice = makeInvoice({
|
||||
id: 'inv-1',
|
||||
status: 'sent',
|
||||
total: 12500,
|
||||
customer,
|
||||
})
|
||||
|
||||
enqueue({ data: invoice, error: null })
|
||||
// Guard query is SKIPPED because force=true short-circuits the check
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
||||
|
||||
mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-force' })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-paid', {
|
||||
method: 'POST',
|
||||
body: { force: true },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ success: boolean; journal_entry_id: string }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.journal_entry_id).toBe('je-force')
|
||||
})
|
||||
|
||||
it('skips duplicate guard on partial payment (lines total < remaining)', async () => {
|
||||
const customer = makeCustomer()
|
||||
const invoice = makeInvoice({
|
||||
id: 'inv-1',
|
||||
status: 'sent',
|
||||
total: 12500,
|
||||
customer,
|
||||
})
|
||||
|
||||
// No guard query enqueued — guard is skipped for partial payments
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
||||
|
||||
mockFindFiscalPeriod.mockResolvedValue('fp-1')
|
||||
mockCreateJournalEntry.mockResolvedValue({ id: 'je-partial' })
|
||||
|
||||
const partialLines = [
|
||||
{ account_number: '1930', debit_amount: 5000, credit_amount: 0 },
|
||||
{ account_number: '1510', debit_amount: 0, credit_amount: 5000 },
|
||||
]
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-paid', {
|
||||
method: 'POST',
|
||||
body: { lines: partialLines },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ success: boolean; journal_entry_id: string }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.journal_entry_id).toBe('je-partial')
|
||||
})
|
||||
|
||||
it('surfaces ocr_exact match_reason when tx reference normalizes to invoice_number', async () => {
|
||||
const customer = makeCustomer()
|
||||
const invoice = makeInvoice({
|
||||
id: 'inv-1',
|
||||
invoice_number: '2026-0042',
|
||||
status: 'sent',
|
||||
total: 12500,
|
||||
customer,
|
||||
})
|
||||
|
||||
enqueue({ data: invoice, error: null })
|
||||
// OCR match: tx.reference '20260042' normalizes to invoice_number '20260042'
|
||||
enqueue({
|
||||
data: [
|
||||
{
|
||||
id: 'tx-ocr',
|
||||
date: '2026-05-10',
|
||||
amount: 12500,
|
||||
description: 'Insättning',
|
||||
merchant_name: 'Test AB',
|
||||
reference: '2026 0042',
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
// description ILIKE — no additional match
|
||||
enqueue({ data: [], error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: { code: string; details: { candidates: Array<{ id: string; match_reason: string }> } }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('INVOICE_PAID_LIKELY_DUPLICATE')
|
||||
expect(body.error.details.candidates[0].match_reason).toBe('ocr_exact')
|
||||
})
|
||||
|
||||
it('ranks ocr_exact ahead of name_amount_fuzzy when multiple candidates match', async () => {
|
||||
const customer = makeCustomer()
|
||||
const invoice = makeInvoice({
|
||||
id: 'inv-1',
|
||||
invoice_number: '2026-0042',
|
||||
status: 'sent',
|
||||
total: 12500,
|
||||
customer,
|
||||
})
|
||||
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({
|
||||
data: [
|
||||
// Name+amount only (no OCR)
|
||||
{
|
||||
id: 'tx-name',
|
||||
date: '2026-05-09',
|
||||
amount: 12500,
|
||||
description: 'Inbetalning Test AB',
|
||||
merchant_name: 'Test AB',
|
||||
reference: null,
|
||||
},
|
||||
// OCR exact match
|
||||
{
|
||||
id: 'tx-ocr',
|
||||
date: '2026-05-08',
|
||||
amount: 12500,
|
||||
description: 'Inbetalning Test AB',
|
||||
merchant_name: 'Test AB',
|
||||
reference: '2026-0042',
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
// description ILIKE — no additional matches
|
||||
enqueue({ data: [], error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: { code: string; details: { candidates: Array<{ id: string; match_reason: string }> } }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.details.candidates[0].id).toBe('tx-ocr')
|
||||
expect(body.error.details.candidates[0].match_reason).toBe('ocr_exact')
|
||||
expect(body.error.details.candidates[1].id).toBe('tx-name')
|
||||
expect(body.error.details.candidates[1].match_reason).toBe('name_amount_fuzzy')
|
||||
})
|
||||
|
||||
it('falls back to auto-generation when lines are not provided', async () => {
|
||||
const customer = makeCustomer()
|
||||
const invoice = makeInvoice({
|
||||
@@ -314,6 +516,10 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
})
|
||||
|
||||
enqueue({ data: invoice, error: null })
|
||||
// Duplicate-payment guard: merchant_name ILIKE — no candidates
|
||||
enqueue({ data: [], error: null })
|
||||
// Duplicate-payment guard: description ILIKE — no candidates
|
||||
enqueue({ data: [], error: null })
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
// Update invoice status (CAS guard: returns matched row)
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
||||
|
||||
@@ -9,6 +9,7 @@ import { MarkInvoicePaidSchema } from '@/lib/api/schemas'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { findDuplicatePaymentCandidatesForInvoice } from '@/lib/invoices/duplicate-payment-candidates'
|
||||
import type { CreateJournalEntryInput, EntityType, Invoice } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
@@ -50,6 +51,7 @@ export const POST = withRouteContext(
|
||||
let exchangeRateDifference: number | undefined
|
||||
let bodyPaymentDate: string | undefined
|
||||
let customLines: { account_number: string; debit_amount: number; credit_amount: number; line_description?: string }[] | undefined
|
||||
let force = false
|
||||
let rawBody: unknown
|
||||
try {
|
||||
const text = await request.text()
|
||||
@@ -72,11 +74,56 @@ export const POST = withRouteContext(
|
||||
exchangeRateDifference = parsed.data.exchange_rate_difference
|
||||
bodyPaymentDate = parsed.data.payment_date
|
||||
customLines = parsed.data.lines
|
||||
force = parsed.data.force === true
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const paymentDate = bodyPaymentDate || now.split('T')[0]
|
||||
|
||||
// Duplicate-payment guard: surface a likely-matching unlinked inbound bank
|
||||
// transaction before booking. Skipped on partial payments (explicit,
|
||||
// deliberate action), on force=true, and on invoices without a resolved
|
||||
// customer name. Mirrors the supplier-side guard at
|
||||
// /api/supplier-invoices/[id]/mark-paid. The dialog always sends custom
|
||||
// lines, so the partial-payment skip is gated on total debit vs remaining,
|
||||
// not on the mere presence of customLines.
|
||||
const remainingAmount =
|
||||
(invoice as Invoice & { remaining_amount?: number }).remaining_amount ?? invoice.total
|
||||
const paymentAmount = customLines
|
||||
? customLines.reduce((s, l) => s + l.debit_amount, 0)
|
||||
: remainingAmount
|
||||
const paidRounded = Math.round(paymentAmount * 100) / 100
|
||||
const remainingRounded = Math.round(remainingAmount * 100) / 100
|
||||
if (!force && paidRounded >= remainingRounded) {
|
||||
const customerName = (invoice as Invoice & { customer?: { name?: string } }).customer?.name
|
||||
if (!customerName) {
|
||||
opLog.warn('duplicate-payment guard skipped', {
|
||||
reason: 'missing_customer_name',
|
||||
invoiceId: id,
|
||||
})
|
||||
} else {
|
||||
const candidates = await findDuplicatePaymentCandidatesForInvoice(supabase, {
|
||||
companyId: companyId!,
|
||||
invoice: { invoice_number: invoice.invoice_number, customer_name: customerName },
|
||||
paymentAmount,
|
||||
paymentDate,
|
||||
})
|
||||
if (candidates.length > 0) {
|
||||
return errorResponseFromCode('INVOICE_PAID_LIKELY_DUPLICATE', opLog, {
|
||||
requestId,
|
||||
details: { candidates },
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if (force) {
|
||||
opLog.warn('duplicate-payment guard bypassed', {
|
||||
reason: 'force=true',
|
||||
invoiceId: id,
|
||||
userId: user.id,
|
||||
paymentAmount,
|
||||
})
|
||||
}
|
||||
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method, entity_type')
|
||||
|
||||
@@ -395,6 +395,104 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
expect(body.journal_entry_created).toBe(true)
|
||||
})
|
||||
|
||||
it('returns 409 TX_CATEGORIZE_SUGGEST_CI_MATCH when 1930/1510 mapping matches an open customer invoice', async () => {
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
amount: 12500,
|
||||
description: 'Inbetalning Acme AB',
|
||||
merchant_name: 'Acme AB',
|
||||
journal_entry_id: null,
|
||||
})
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
|
||||
mockBuildMappingResultFromCategory.mockReturnValue({
|
||||
...defaultMappingResult,
|
||||
debit_account: '1930',
|
||||
credit_account: '1510',
|
||||
})
|
||||
|
||||
// Customer lookup pass 1 (merchant_name): one match
|
||||
enqueue({ data: [{ id: 'cust-1' }], error: null })
|
||||
// Customer lookup pass 2 (description)
|
||||
enqueue({ data: [{ id: 'cust-1' }], error: null })
|
||||
// Open invoices by customer
|
||||
enqueue({
|
||||
data: [
|
||||
{
|
||||
id: 'inv-1',
|
||||
invoice_number: '2026-0042',
|
||||
invoice_date: '2026-05-01',
|
||||
remaining_amount: 12500,
|
||||
total: 12500,
|
||||
currency: 'SEK',
|
||||
customer: { name: 'Acme AB' },
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
// OCR pass: tx.reference is null so the route still runs the OCR query
|
||||
// with a no-op result. Provide an empty data set so the chain resolves.
|
||||
enqueue({ data: [], error: null })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/categorize', {
|
||||
method: 'POST',
|
||||
body: { is_business: true, category: 'income_services' },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: { code: string; details: { candidates: Array<{ invoice_id: string; match_reason: string }> } }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('TX_CATEGORIZE_SUGGEST_CI_MATCH')
|
||||
expect(body.error.details.candidates).toHaveLength(1)
|
||||
expect(body.error.details.candidates[0].invoice_id).toBe('inv-1')
|
||||
expect(body.error.details.candidates[0].match_reason).toBe('name_amount_fuzzy')
|
||||
expect(mockCreateTransactionJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('proceeds with 1930/1510 categorization when confirm_no_match=true (customer side)', async () => {
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
amount: 12500,
|
||||
description: 'Inbetalning Acme AB',
|
||||
merchant_name: 'Acme AB',
|
||||
journal_entry_id: null,
|
||||
})
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
|
||||
mockBuildMappingResultFromCategory.mockReturnValue({
|
||||
...defaultMappingResult,
|
||||
debit_account: '1930',
|
||||
credit_account: '1510',
|
||||
})
|
||||
|
||||
// No customer/invoice lookups: confirm_no_match=true skips the block.
|
||||
// ensureFiscalPeriod
|
||||
enqueue({ data: [{ id: 'period-1' }], error: null })
|
||||
mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
// Transaction update
|
||||
enqueue({ data: [{ id: 'tx-1' }], error: null })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/categorize', {
|
||||
method: 'POST',
|
||||
body: { is_business: true, category: 'income_services', confirm_no_match: true },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
success: boolean
|
||||
journal_entry_id: string
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.journal_entry_id).toBe('je-1')
|
||||
})
|
||||
|
||||
it('categorizes as private when is_business is false', async () => {
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
DUPLICATE_AMOUNT_TOLERANCE_PCT,
|
||||
DUPLICATE_DATE_WINDOW_DAYS,
|
||||
escapeLikePattern,
|
||||
normalizeOcrReference,
|
||||
} from '@/lib/invoices/duplicate-payment-guard'
|
||||
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
@@ -274,6 +275,13 @@ export const POST = withRouteContext(
|
||||
creditAccount: mappingResult.credit_account,
|
||||
})
|
||||
}
|
||||
if (body.confirm_no_match && /^151\d$/.test(mappingResult.credit_account)) {
|
||||
txLog.warn('customer-invoice match suggestion bypassed', {
|
||||
reason: 'confirm_no_match=true',
|
||||
debitAccount: mappingResult.debit_account,
|
||||
creditAccount: mappingResult.credit_account,
|
||||
})
|
||||
}
|
||||
|
||||
// Prong B: intercept plain 244x categorization of supplier payments when
|
||||
// an open supplier invoice already covers this amount. Categorizing direct
|
||||
@@ -347,6 +355,140 @@ export const POST = withRouteContext(
|
||||
}
|
||||
}
|
||||
|
||||
// Prong B (customer side): intercept plain 151x categorization of an
|
||||
// inbound payment when an unpaid customer invoice already covers this
|
||||
// amount. Symmetric with the supplier-side intercept above. The debit
|
||||
// must be a bank/cash account (^19\d{2}$, BAS class 19) — a 1xxx debit
|
||||
// outside class 19 isn't a payment receipt and the suggestion would
|
||||
// misdirect the user.
|
||||
if (
|
||||
!body.confirm_no_match &&
|
||||
is_business &&
|
||||
transaction.amount > 0 &&
|
||||
/^19\d{2}$/.test(mappingResult.debit_account) &&
|
||||
/^151\d$/.test(mappingResult.credit_account)
|
||||
) {
|
||||
const txAmount = transaction.amount
|
||||
const windowLow = Math.round(txAmount * (1 - DUPLICATE_AMOUNT_TOLERANCE_PCT) * 100) / 100
|
||||
const windowHigh = Math.round(txAmount * (1 + DUPLICATE_AMOUNT_TOLERANCE_PCT) * 100) / 100
|
||||
|
||||
// Resolve candidate customer(s) by name. Inbound bank txs are typically
|
||||
// described by payer name in EITHER merchant_name OR description, so
|
||||
// search both. OCR-direct lookup is below.
|
||||
let customerIds: string[] = []
|
||||
const searchTerms: string[] = []
|
||||
if (transaction.merchant_name) searchTerms.push(transaction.merchant_name)
|
||||
if (transaction.description) searchTerms.push(transaction.description)
|
||||
const collected = new Set<string>()
|
||||
for (const term of searchTerms) {
|
||||
const escaped = escapeLikePattern(term)
|
||||
const { data: matched } = await supabase
|
||||
.from('customers')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.ilike('name', `%${escaped}%`)
|
||||
.limit(10)
|
||||
for (const c of matched ?? []) collected.add(c.id)
|
||||
}
|
||||
customerIds = Array.from(collected)
|
||||
|
||||
// Date window anchored on `due_date`, NOT `invoice_date`. Customer
|
||||
// payments arrive close to (or after) the due date; for an invoice
|
||||
// with 60–90 day terms, anchoring on invoice_date would push the
|
||||
// expected payment outside a ±60-day window and the guard would miss
|
||||
// genuine matches. due_date is the better proxy for "around when the
|
||||
// payment is expected."
|
||||
const txDateMs = new Date(transaction.date).getTime()
|
||||
const dueDateLow = new Date(txDateMs - DUPLICATE_DATE_WINDOW_DAYS * 24 * 3600 * 1000)
|
||||
.toISOString()
|
||||
.split('T')[0]
|
||||
const dueDateHigh = new Date(txDateMs + DUPLICATE_DATE_WINDOW_DAYS * 24 * 3600 * 1000)
|
||||
.toISOString()
|
||||
.split('T')[0]
|
||||
|
||||
type CandidateRow = {
|
||||
id: string
|
||||
invoice_number: string | null
|
||||
invoice_date: string
|
||||
due_date: string | null
|
||||
remaining_amount: number | null
|
||||
total: number
|
||||
currency: string
|
||||
customer: { name?: string } | null
|
||||
}
|
||||
const openInvoiceCandidates: CandidateRow[] = []
|
||||
|
||||
if (customerIds.length > 0) {
|
||||
const { data: byCustomer } = await supabase
|
||||
.from('invoices')
|
||||
.select(
|
||||
'id, invoice_number, invoice_date, due_date, remaining_amount, total, currency, customer:customers(name)',
|
||||
)
|
||||
.eq('company_id', companyId)
|
||||
.in('customer_id', customerIds)
|
||||
.in('status', ['sent', 'overdue', 'partially_paid'])
|
||||
.gte('remaining_amount', windowLow)
|
||||
.lte('remaining_amount', windowHigh)
|
||||
.gte('due_date', dueDateLow)
|
||||
.lte('due_date', dueDateHigh)
|
||||
.order('due_date', { ascending: false })
|
||||
.limit(5)
|
||||
for (const row of (byCustomer ?? []) as unknown as CandidateRow[]) {
|
||||
openInvoiceCandidates.push(row)
|
||||
}
|
||||
}
|
||||
|
||||
// OCR pass: if the bank-tx reference matches an open invoice's
|
||||
// invoice_number, surface it regardless of customer-name match. This
|
||||
// catches the common case where the bank populated `reference` but
|
||||
// neither merchant_name nor description carried the customer name.
|
||||
const txReference = (transaction as Transaction & { reference?: string | null }).reference
|
||||
const normalizedTxRef = normalizeOcrReference(txReference ?? null)
|
||||
if (normalizedTxRef) {
|
||||
const { data: byRef } = await supabase
|
||||
.from('invoices')
|
||||
.select(
|
||||
'id, invoice_number, invoice_date, due_date, remaining_amount, total, currency, customer:customers(name)',
|
||||
)
|
||||
.eq('company_id', companyId)
|
||||
.in('status', ['sent', 'overdue', 'partially_paid'])
|
||||
.gte('remaining_amount', windowLow)
|
||||
.lte('remaining_amount', windowHigh)
|
||||
.gte('due_date', dueDateLow)
|
||||
.lte('due_date', dueDateHigh)
|
||||
.order('due_date', { ascending: false })
|
||||
.limit(20)
|
||||
for (const row of (byRef ?? []) as unknown as CandidateRow[]) {
|
||||
if (normalizeOcrReference(row.invoice_number) === normalizedTxRef) {
|
||||
if (!openInvoiceCandidates.some((existing) => existing.id === row.id)) {
|
||||
openInvoiceCandidates.unshift(row)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (openInvoiceCandidates.length > 0) {
|
||||
return errorResponseFromCode('TX_CATEGORIZE_SUGGEST_CI_MATCH', txLog, {
|
||||
requestId,
|
||||
details: {
|
||||
candidates: openInvoiceCandidates.slice(0, 5).map((inv) => {
|
||||
const reasonOcr =
|
||||
normalizedTxRef && normalizeOcrReference(inv.invoice_number) === normalizedTxRef
|
||||
return {
|
||||
invoice_id: inv.id,
|
||||
invoice_number: inv.invoice_number,
|
||||
invoice_date: inv.invoice_date,
|
||||
remaining_amount: inv.remaining_amount ?? inv.total,
|
||||
currency: inv.currency,
|
||||
customer_name: inv.customer?.name ?? null,
|
||||
match_reason: reasonOcr ? ('ocr_exact' as const) : ('name_amount_fuzzy' as const),
|
||||
}
|
||||
}),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await ensureFiscalPeriod(supabase, user.id, companyId, transaction.date, fiscalYearStartMonth, txLog)
|
||||
|
||||
let journalEntryCreated = false
|
||||
|
||||
@@ -310,6 +310,116 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => {
|
||||
expect(body.error.code).toBe('INVOICE_PAID_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('returns 409 INVOICE_PAID_LIKELY_DUPLICATE when a matching unlinked transaction exists', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
invoices: { data: SENT_INVOICE, error: null },
|
||||
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
|
||||
transactions: {
|
||||
data: [
|
||||
{
|
||||
id: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee',
|
||||
date: '2026-05-10',
|
||||
amount: 12500,
|
||||
description: 'Inbetalning Acme AB',
|
||||
merchant_name: 'Acme AB',
|
||||
reference: null,
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await markPaid(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-paid`,
|
||||
{ payment_date: '2026-05-12' },
|
||||
),
|
||||
detailParams(COMPANY_ID, INVOICE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(409)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('INVOICE_PAID_LIKELY_DUPLICATE')
|
||||
expect(body.error.details.candidates).toHaveLength(1)
|
||||
expect(body.error.details.candidates[0].match_reason).toBe('name_amount_fuzzy')
|
||||
expect(mockPayment).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('proceeds when force=true even if a matching transaction exists', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
invoices: [
|
||||
{ data: SENT_INVOICE, error: null },
|
||||
{ data: PAID_INVOICE, error: null },
|
||||
],
|
||||
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
|
||||
// transactions queue not consulted: force=true short-circuits the guard
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await markPaid(
|
||||
new Request(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-paid`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer test-fixture-not-a-real-key',
|
||||
'Content-Type': 'application/json',
|
||||
// Fresh idempotency key for the force retry (the original is body-hash bound)
|
||||
'Idempotency-Key': 'idem2222-2222-4abc-8def-1234567890ab',
|
||||
},
|
||||
body: JSON.stringify({ force: true, payment_date: '2026-05-12' }),
|
||||
},
|
||||
),
|
||||
detailParams(COMPANY_ID, INVOICE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.status).toBe('paid')
|
||||
expect(mockPayment).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('dry-run surfaces 409 INVOICE_PAID_LIKELY_DUPLICATE before previewing', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
invoices: { data: SENT_INVOICE, error: null },
|
||||
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
|
||||
transactions: {
|
||||
data: [
|
||||
{
|
||||
id: 'ffffffff-ffff-4fff-8fff-ffffffffffff',
|
||||
date: '2026-05-10',
|
||||
amount: 12500,
|
||||
description: 'Inbetalning Acme AB',
|
||||
merchant_name: 'Acme AB',
|
||||
reference: null,
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await markPaid(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-paid?dry_run=true`,
|
||||
{ payment_date: '2026-05-12' },
|
||||
),
|
||||
detailParams(COMPANY_ID, INVOICE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(409)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('INVOICE_PAID_LIKELY_DUPLICATE')
|
||||
expect(mockPayment).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects keys without invoices:write scope', async () => {
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: USER_ID,
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
} from '@/lib/bookkeeping/invoice-entries'
|
||||
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { findDuplicatePaymentCandidatesForInvoice } from '@/lib/invoices/duplicate-payment-candidates'
|
||||
import type { CreateJournalEntryInput, EntityType, Invoice } from '@/types'
|
||||
|
||||
const INVOICE_MARK_PAID_RESPONSE_COLUMNS =
|
||||
@@ -74,6 +75,7 @@ registerEndpoint({
|
||||
'Custom `lines` must balance (sum of debits = sum of credits, both > 0). Otherwise returns 400 INVOICE_PAID_LINES_UNBALANCED.',
|
||||
'For foreign-currency invoices, supply `exchange_rate_difference` (SEK delta vs the invoice\'s booked rate) to book the FX adjustment correctly. Omitting it on a non-SEK invoice will mis-book the FX gain/loss.',
|
||||
'Cash basis (kontantmetoden) recognizes revenue HERE, not at :mark-sent. The dashboard tracks this via company_settings.accounting_method.',
|
||||
'Duplicate-payment guard: if an unlinked inbound bank transaction looks like this payment, returns 409 INVOICE_PAID_LIKELY_DUPLICATE with candidate transactions. Retry with `force: true` to bypass — but the retry MUST use a fresh Idempotency-Key (the original is body-hash bound; reusing it returns 400 IDEMPOTENCY_KEY_REUSE). The guard is also evaluated under dry-run, so a successful dry-run does not guarantee a successful commit.',
|
||||
],
|
||||
example: {
|
||||
request: { payment_date: '2026-05-12' },
|
||||
@@ -143,6 +145,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
line_description?: string
|
||||
}[]
|
||||
| undefined
|
||||
let force = false
|
||||
if (rawBody) {
|
||||
const parsed = MarkInvoicePaidSchema.safeParse(rawBody)
|
||||
if (!parsed.success) {
|
||||
@@ -159,6 +162,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
exchangeRateDifference = parsed.data.exchange_rate_difference
|
||||
bodyPaymentDate = parsed.data.payment_date
|
||||
customLines = parsed.data.lines
|
||||
force = parsed.data.force === true
|
||||
}
|
||||
|
||||
// Pre-flight: fetch invoice with relations needed for journal entry.
|
||||
@@ -263,6 +267,44 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
const newPaidAmount =
|
||||
Math.round(((typed.paid_amount ?? 0) + paymentAmount) * 100) / 100
|
||||
|
||||
// Duplicate-payment guard: surface a likely-matching unlinked inbound
|
||||
// bank transaction before booking (or before dry-run preview, so a
|
||||
// successful dry-run can't mask the warning). Skipped on partial
|
||||
// payments (paymentAmount < remaining is an explicit, deliberate action),
|
||||
// on force=true, and on invoices without a resolved customer name.
|
||||
const remainingForGuard = typed.remaining_amount ?? typed.total
|
||||
const paidRoundedGuard = Math.round(paymentAmount * 100) / 100
|
||||
const remainingRoundedGuard = Math.round(remainingForGuard * 100) / 100
|
||||
if (!force && paidRoundedGuard >= remainingRoundedGuard) {
|
||||
const customerName = typed.customer?.name
|
||||
if (!customerName) {
|
||||
ctx.log.warn('duplicate-payment guard skipped', {
|
||||
reason: 'missing_customer_name',
|
||||
invoiceId,
|
||||
})
|
||||
} else {
|
||||
const candidates = await findDuplicatePaymentCandidatesForInvoice(ctx.supabase, {
|
||||
companyId: ctx.companyId!,
|
||||
invoice: { invoice_number: typed.invoice_number, customer_name: customerName },
|
||||
paymentAmount,
|
||||
paymentDate,
|
||||
})
|
||||
if (candidates.length > 0) {
|
||||
return v1ErrorResponseFromCode('INVOICE_PAID_LIKELY_DUPLICATE', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { candidates },
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if (force) {
|
||||
ctx.log.warn('duplicate-payment guard bypassed', {
|
||||
reason: 'force=true',
|
||||
invoiceId,
|
||||
userId: ctx.userId,
|
||||
paymentAmount,
|
||||
})
|
||||
}
|
||||
|
||||
if (ctx.dryRun) {
|
||||
return dryRunPreview(
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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, '')
|
||||
}
|
||||
|
||||
@@ -27,6 +27,25 @@ const nextConfig: NextConfig = {
|
||||
destination: '/kpi',
|
||||
permanent: true,
|
||||
},
|
||||
// Docs canonicalised to docs.gnubok.se. Every `docs_url` field on the
|
||||
// v1 error envelope still points at this host; the 308 forwards both
|
||||
// humans and agents to the docs subdomain without us needing to
|
||||
// mass-update structured-errors.
|
||||
{
|
||||
source: '/docs/api',
|
||||
destination: 'https://docs.gnubok.se/',
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: '/docs/api/:path*',
|
||||
destination: 'https://docs.gnubok.se/:path*',
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: '/llms-full.txt',
|
||||
destination: 'https://docs.gnubok.se/llms-full.txt',
|
||||
permanent: true,
|
||||
},
|
||||
]
|
||||
},
|
||||
async headers() {
|
||||
|
||||
Reference in New Issue
Block a user