fix(invoices): duplicate-payment guard on customer mark-paid + categorize (#502)

* fix(invoices): duplicate-payment guard on customer mark-paid + categorize

Two-pronged fix preventing duplicate verifikationer when a customer
invoice is marked paid OR a 19xx→1510 categorization is applied to an
inbound bank tx that already belongs to an open invoice.

Prong A (mark-paid): before booking, scan unlinked positive business
bank txs from the same customer within ±2% / ±60 days. If candidates
exist, return 409 INVOICE_PAID_LIKELY_DUPLICATE with per-candidate
match_reason (ocr_exact > name_amount_fuzzy > amount_only). Override
via `{ force: true }`. Applied to both legacy /api/invoices/[id]/
mark-paid and v1 /api/v1/.../invoices/[id]/mark-paid; v1 guard runs
before dry-run so previews can't mask the warning.

Prong B (categorize): when the user assigns 1930→1510 directly on a
positive business tx with a matching open customer invoice (by name
OR by OCR-normalized reference), return 409
TX_CATEGORIZE_SUGGEST_CI_MATCH routing them to /match-invoice.

Mirrors the supplier-side guard from #461. Shared helpers
(DUPLICATE_AMOUNT_TOLERANCE_PCT, escapeLikePattern) reused as-is.
New helper normalizeOcrReference() strips non-digits for Swedish OCR
equality. New shared candidate-finder
lib/invoices/duplicate-payment-candidates.ts keeps the legacy and v1
routes calling the same code.

Frontend:
- PaymentBookingDialog intercepts the 409, renders candidate list
  with match_reason badges (Exakt OCR-träff / Sannolik träff /
  Möjlig träff), offers "Länka transaktion" or "Bokför ändå"
  (force-retry generates a fresh Idempotency-Key for v1 callers)
- transactions/page.tsx mirrors siMatchSuggestion handling as
  ciMatchSuggestion with a parallel "Matcha mot kundfaktura?" dialog

v1 caveat documented in the route's pitfalls block:
INVOICE_PAID_LIKELY_DUPLICATE force-retry requires a fresh
Idempotency-Key because the original is body-hash bound; reusing it
returns 400 IDEMPOTENCY_KEY_REUSE.

Tests: 5 new mark-paid tests (legacy + v1) covering 409, force
bypass, partial-payment skip, ocr_exact match_reason, multi-candidate
ranking. 1 v1-only test verifying dry-run also surfaces the 409. 2
categorize Prong B tests (409 + confirm_no_match bypass).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(invoices): address compliance-swarm review on duplicate-payment guard

Three review-driven fixes:

1. **PostgREST .or() injection (OWASP V1.2.5).** `escapeLikePattern` neutralises
   LIKE wildcards but NOT PostgREST filter-DSL chars (`,`, `.`, `(`, `)`). A
   customer name like `Acme,fake.eq.true` could otherwise inject a synthetic
   filter clause into the `.or('merchant_name.ilike.%X%,description.ilike.%X%')`
   string. Replaced with two parameterised `.ilike()` queries dispatched in
   parallel and merged by id in JS. Slight perf cost (two index hits per call),
   eliminates the DSL-injection surface entirely.

2. **Date window anchored on invoice_date instead of due_date
   (swedish-accounting-compliance bot).** The Prong B categorize intercept
   filtered open customer invoices by `invoice_date ± 60d` relative to the
   bank-tx date. For invoices with 60–90 day payment terms, the actual
   payment lands well after `invoice_date`, so the legitimate match falls
   outside the window and the guard silently misses it. Switched to
   `due_date ± 60d` — the better proxy for "around when payment is expected."
   No corresponding change for Prong A (mark-paid), which is correctly
   anchored on `paymentDate` (the user-supplied or default-today date) and
   scans bank-tx dates around that anchor.

3. **Force-bypass log enrichment (ISO A.8.15, OWASP V16).** Both
   `duplicate-payment guard bypassed` warn entries now include `userId` and
   `paymentAmount`. Attribution was previously incomplete — the bypass log
   carried only `invoiceId`, which forced a join in log aggregation to
   identify the acting principal.

Tests updated for the two-query pattern (legacy mark-paid suite enqueues
two transactions-table responses per guard invocation; v1 tests already
worked with the single-entry-per-table mock semantics).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(docs): redirect /docs/api and /llms-full.txt to docs.gnubok.se

Canonical docs host is now docs.gnubok.se. Every `docs_url` field on the
v1 error envelope still points at /docs/api/* on this app; the 308
permanent redirect forwards humans and agent crawlers to the docs
subdomain without us needing to mass-update structured-errors.ts.

/llms-full.txt also routes through the docs host where it's served from
the docs site's own build.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-05-15 22:46:02 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent b94ed3bec2
commit b46b572ee9
13 changed files with 1132 additions and 7 deletions
@@ -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 })
+47
View File
@@ -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(
{