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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user