diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index 48c695a9..1a08d440 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -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 + 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('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() { + + {/* Prong B (customer side): match-against-customer-invoice suggestion */} + { + if (!open) setCiMatchSuggestion(null) + }} + > + + + Matcha mot kundfaktura? + +
+

+ 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 §). +

+
+ {ciMatchSuggestion?.candidates.map((c) => ( +
+
+
+ + {c.customer_name || 'Kund'} · {c.invoice_number ?? '—'} + + {c.match_reason === 'ocr_exact' && ( + Exakt OCR-träff + )} +
+
+ {formatDate(c.invoice_date)} · kvar {formatCurrency(c.remaining_amount, c.currency)} +
+
+ +
+ ))} +
+
+ + +
+
+
+
) } diff --git a/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts b/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts index ecc72694..72caefbe 100644 --- a/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts +++ b/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts @@ -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 }) diff --git a/app/api/invoices/[id]/mark-paid/route.ts b/app/api/invoices/[id]/mark-paid/route.ts index b22d51bc..b81e318e 100644 --- a/app/api/invoices/[id]/mark-paid/route.ts +++ b/app/api/invoices/[id]/mark-paid/route.ts @@ -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') diff --git a/app/api/transactions/[id]/categorize/__tests__/route.test.ts b/app/api/transactions/[id]/categorize/__tests__/route.test.ts index 1dffe31e..5d00e66e 100644 --- a/app/api/transactions/[id]/categorize/__tests__/route.test.ts +++ b/app/api/transactions/[id]/categorize/__tests__/route.test.ts @@ -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', diff --git a/app/api/transactions/[id]/categorize/route.ts b/app/api/transactions/[id]/categorize/route.ts index a4c12a8c..6f8fb20b 100644 --- a/app/api/transactions/[id]/categorize/route.ts +++ b/app/api/transactions/[id]/categorize/route.ts @@ -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() + 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 diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/__tests__/route.test.ts index ed628451..1877bbc6 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/__tests__/route.test.ts @@ -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, diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts index 399b367e..52249b3f 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts @@ -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( { diff --git a/components/invoices/PaymentBookingDialog.tsx b/components/invoices/PaymentBookingDialog.tsx index 3485ac58..45afd95e 100644 --- a/components/invoices/PaymentBookingDialog.tsx +++ b/components/invoices/PaymentBookingDialog.tsx @@ -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 = { + 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(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 ( @@ -221,7 +261,58 @@ export default function PaymentBookingDialog({ - {!isInitialized ? ( + {duplicateCandidates && duplicateCandidates.length > 0 ? ( +
+
+

Möjlig dubblettbetalning

+

+ {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.`} +

+
+
    + {duplicateCandidates.map((c) => { + const reasonVariant: 'success' | 'secondary' | 'outline' = + c.match_reason === 'ocr_exact' + ? 'success' + : c.match_reason === 'name_amount_fuzzy' + ? 'secondary' + : 'outline' + return ( +
  • +
    +
    + {MATCH_REASON_LABEL[c.match_reason]} + + {formatDate(c.date)} + + + {formatCurrency(c.amount, invoice.currency)} + +
    +

    + {c.merchant_name || c.description || '—'} +

    +
    + +
  • + ) + })} +
+
+ ) : !isInitialized ? (
@@ -386,10 +477,25 @@ export default function PaymentBookingDialog({ - + {duplicateCandidates && duplicateCandidates.length > 0 ? ( + + ) : ( + + )}
diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index df7fadb9..4c7675f3 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -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(), }) // ============================================================ diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index a926d852..8e4263f7 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -280,6 +280,17 @@ const TRANSACTIONS: Record = { '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 = { 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.', diff --git a/lib/invoices/duplicate-payment-candidates.ts b/lib/invoices/duplicate-payment-candidates.ts new file mode 100644 index 00000000..05f04c5a --- /dev/null +++ b/lib/invoices/duplicate-payment-candidates.ts @@ -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 = { + ocr_exact: 0, + name_amount_fuzzy: 1, + amount_only: 2, +} + +const MATCH_REASON_CONFIDENCE: Record = { + 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 { + 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() + 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' +} diff --git a/lib/invoices/duplicate-payment-guard.ts b/lib/invoices/duplicate-payment-guard.ts index 26b97429..69a2be6a 100644 --- a/lib/invoices/duplicate-payment-guard.ts +++ b/lib/invoices/duplicate-payment-guard.ts @@ -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, '') +} diff --git a/next.config.ts b/next.config.ts index d0a0535a..8b7e8af0 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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() {