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