diff --git a/app/(dashboard)/supplier-invoices/[id]/page.tsx b/app/(dashboard)/supplier-invoices/[id]/page.tsx index d085bcda..b7ee7587 100644 --- a/app/(dashboard)/supplier-invoices/[id]/page.tsx +++ b/app/(dashboard)/supplier-invoices/[id]/page.tsx @@ -56,6 +56,15 @@ export default function SupplierInvoiceDetailPage() { const [payAmount, setPayAmount] = useState('') const [paymentDate, setPaymentDate] = useState(() => new Date().toISOString().split('T')[0]) const [isProcessing, setIsProcessing] = useState(false) + const [duplicateCandidates, setDuplicateCandidates] = useState< + Array<{ + id: string + date: string + amount: number + description: string | null + merchant_name: string | null + }> | null + >(null) const { dialogProps: confirmDialogProps, confirm: confirmAction } = useDestructiveConfirm() async function fetchInvoice() { @@ -89,22 +98,28 @@ export default function SupplierInvoiceDetailPage() { setIsProcessing(false) } - async function handleMarkPaid() { + async function handleMarkPaid(force: boolean = false) { setIsProcessing(true) const res = await fetch(`/api/supplier-invoices/${params.id}/mark-paid`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ amount: parseFloat(payAmount), payment_date: paymentDate }), + body: JSON.stringify({ amount: parseFloat(payAmount), payment_date: paymentDate, ...(force ? { force: true } : {}) }), }) const result = await res.json() if (!res.ok) { - toast({ title: 'Betalning misslyckades', description: getErrorMessage(result, { context: 'supplier_invoice' }), variant: 'destructive' }) + if (result?.error?.code === 'SI_PAID_LIKELY_DUPLICATE' && Array.isArray(result.error.details?.candidates)) { + setDuplicateCandidates(result.error.details.candidates) + setIsPayDialogOpen(false) + } else { + toast({ title: 'Betalning misslyckades', description: getErrorMessage(result, { context: 'supplier_invoice' }), variant: 'destructive' }) + } } else { toast({ title: result.status === 'paid' ? 'Betald' : 'Delbetalning registrerad', description: `${formatAmount(parseFloat(payAmount))} kr registrerat`, }) setIsPayDialogOpen(false) + setDuplicateCandidates(null) fetchInvoice() } setIsProcessing(false) @@ -593,13 +608,68 @@ export default function SupplierInvoiceDetailPage() { - + + {/* Duplicate-payment warning dialog */} + { + if (!open) setDuplicateCandidates(null) + }} + > + + + Möjlig dubbelbetalning + +
+

+ Vi hittade {duplicateCandidates?.length === 1 ? 'en banktransaktion' : 'banktransaktioner'} som + verkar matcha denna betalning. Länka den befintliga transaktionen istället för att skapa en ny + verifikation. +

+
+ {duplicateCandidates?.map((c) => ( +
+
+
{formatDate(c.date)}
+
+ {c.merchant_name || c.description || 'Banktransaktion'} +
+
+
+ {formatAmount(Math.abs(c.amount))} {invoice.currency} +
+ +
+ ))} +
+
+ + +
+
+
+
) } diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index 16f18f21..48c695a9 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -115,6 +115,22 @@ export default function TransactionsPage() { const [quickReviewOpen, setQuickReviewOpen] = useState(false) const [quickReview, setQuickReview] = useState(null) + // Prong B: prompt to match against an open supplier invoice instead of + // categorizing direct to 2440. Triggered by a 409 TX_CATEGORIZE_SUGGEST_SI_MATCH. + const [siMatchSuggestion, setSiMatchSuggestion] = useState<{ + transactionId: string + retry: () => Promise + candidates: Array<{ + supplier_invoice_id: string + invoice_number: string + invoice_date: string + remaining_amount: number + currency: string + supplier_name: string | null + }> + } | null>(null) + const [siMatchProcessing, setSiMatchProcessing] = useState(false) + // Entity type for tooltip context const [entityType, setEntityType] = useState('enskild_firma') @@ -423,6 +439,20 @@ export default function TransactionsPage() { }, [transactions.length]) const handleCategorize: CategorizeHandler = async (id, isBusiness, category, vatTreatment, accountOverride, templateId, inboxItemId) => { + return runCategorize({ id, isBusiness, category, vatTreatment, accountOverride, templateId, inboxItemId, confirmNoMatch: false }) + } + + async function runCategorize(args: { + id: string + isBusiness: boolean + category?: TransactionCategory + vatTreatment?: VatTreatment + accountOverride?: string + templateId?: string + inboxItemId?: string + confirmNoMatch: boolean + }): Promise { + const { id, isBusiness, category, vatTreatment, accountOverride, templateId, inboxItemId, confirmNoMatch } = args try { setProcessingId(id) const response = await fetch(`/api/transactions/${id}/categorize`, { @@ -435,11 +465,27 @@ export default function TransactionsPage() { account_override: accountOverride, template_id: templateId, inbox_item_id: inboxItemId, + ...(confirmNoMatch ? { confirm_no_match: true } : {}), }), }) const result = await response.json() if (!response.ok) { + if ( + result?.error?.code === 'TX_CATEGORIZE_SUGGEST_SI_MATCH' && + Array.isArray(result.error.details?.candidates) + ) { + // Prong B: invite the user to match the open supplier invoice + // instead of booking a plain 2440 categorization that would later + // create a duplicate when they hit "Markera som betald". + setSiMatchSuggestion({ + 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 }), @@ -522,6 +568,55 @@ export default function TransactionsPage() { await handleCategorize(id, false, 'private') } + async function handleMatchSuggestedSupplierInvoice(transactionId: string, supplierInvoiceId: string) { + setSiMatchProcessing(true) + try { + const response = await fetch(`/api/transactions/${transactionId}/match-supplier-invoice`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ supplier_invoice_id: supplierInvoiceId }), + }) + const result = await response.json() + if (!response.ok) { + toast({ + title: 'Matchning misslyckades', + description: getErrorMessage(result, { context: 'transaction', statusCode: response.status }), + variant: 'destructive', + }) + setSiMatchProcessing(false) + return + } + + toast({ title: 'Leverantörsfaktura matchad', description: 'Fakturan markerades som betald' }) + setSiMatchSuggestion(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, + supplier_invoice_id: supplierInvoiceId, + 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 { + setSiMatchProcessing(false) + } + } + async function handleConfirmInvoiceMatch() { if (!selectedTransaction) return const isSupplier = !!selectedTransaction.potential_supplier_invoice @@ -1340,6 +1435,64 @@ export default function TransactionsPage() { onClose={() => setSkvMatchTarget(null)} onMatched={handleSkvMatched} /> + + {/* Prong B: match-against-supplier-invoice suggestion */} + { + if (!open) setSiMatchSuggestion(null) + }} + > + + + Matcha mot leverantörsfaktura? + +
+

+ Det finns en öppen leverantörsfaktura med samma belopp från samma leverantör. Matcha mot + fakturan istället för att bokföra direkt på leverantörsskuldskontot, annars skapas en + dubblerad verifikation som måste stornas (BFL 5 kap 5 §). +

+
+ {siMatchSuggestion?.candidates.map((c) => ( +
+
+
+ {c.supplier_name || 'Leverantör'} · {c.invoice_number} +
+
+ {formatDate(c.invoice_date)} · kvar {formatCurrency(c.remaining_amount, c.currency)} +
+
+ +
+ ))} +
+
+ + +
+
+
+
) } diff --git a/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts b/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts index 83dc6310..83f5b79e 100644 --- a/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts +++ b/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts @@ -111,6 +111,8 @@ describe('POST /api/supplier-invoices/[id]/mark-paid', () => { // Fetch invoice enqueue({ data: invoice, error: null }) + // Duplicate-payment guard: no candidate transactions + enqueue({ data: [], error: null }) // Fetch company settings enqueue({ data: { accounting_method: 'accrual' }, error: null }) @@ -212,6 +214,8 @@ describe('POST /api/supplier-invoices/[id]/mark-paid', () => { }) enqueue({ data: invoice, error: null }) + // Duplicate-payment guard: no candidate transactions + enqueue({ data: [], error: null }) enqueue({ data: { accounting_method: 'cash' }, error: null }) mockCreateSupplierInvoiceCashEntry.mockResolvedValue({ id: 'je-3' }) @@ -249,6 +253,8 @@ describe('POST /api/supplier-invoices/[id]/mark-paid', () => { }) enqueue({ data: invoice, error: null }) + // Duplicate-payment guard: no candidate transactions + enqueue({ data: [], error: null }) enqueue({ data: { accounting_method: 'accrual' }, error: null }) mockCreateSupplierInvoicePaymentEntry.mockRejectedValue(new Error('Period locked')) @@ -264,6 +270,109 @@ describe('POST /api/supplier-invoices/[id]/mark-paid', () => { expect((body.error as unknown as { code: string }).code).toBe('SI_PAID_FAILED') }) + it('returns 409 SI_PAID_LIKELY_DUPLICATE when an unlinked transaction matches', async () => { + const supplier = makeSupplier() + const invoice = makeSupplierInvoice({ + id: 'si-1', + status: 'approved', + total: 10000, + remaining_amount: 10000, + paid_amount: 0, + supplier, + items: [], + }) + + enqueue({ data: invoice, error: null }) + // Duplicate-payment guard: one likely-matching unlinked transaction + enqueue({ + data: [ + { + id: 'tx-99', + date: '2026-05-10', + amount: -10000, + description: 'Faktura Leverantör AB', + merchant_name: 'Leverantör AB', + journal_entry_id: 'je-99', + }, + ], + error: null, + }) + + const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', { + method: 'POST', + body: {}, + }) + const response = await POST(request, createMockRouteParams({ id: 'si-1' })) + const { status, body } = await parseJsonResponse<{ error: { code: string; details: { candidates: unknown[] } } }>(response) + + expect(status).toBe(409) + expect(body.error.code).toBe('SI_PAID_LIKELY_DUPLICATE') + expect(body.error.details.candidates).toHaveLength(1) + expect(mockCreateSupplierInvoicePaymentEntry).not.toHaveBeenCalled() + }) + + it('proceeds when force=true even with candidates present', async () => { + const supplier = makeSupplier() + const invoice = makeSupplierInvoice({ + id: 'si-1', + status: 'approved', + total: 10000, + remaining_amount: 10000, + paid_amount: 0, + supplier, + items: [], + }) + + enqueue({ data: invoice, error: null }) + // No candidates query happens because force=true skips it + enqueue({ data: { accounting_method: 'accrual' }, error: null }) + mockCreateSupplierInvoicePaymentEntry.mockResolvedValue({ id: 'je-1' }) + enqueue({ data: [{ id: 'si-1' }], error: null }) + enqueue({ data: null, error: null }) + + const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', { + method: 'POST', + body: { force: true }, + }) + const response = await POST(request, createMockRouteParams({ id: 'si-1' })) + const { status, body } = await parseJsonResponse<{ success: boolean; status: string }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(body.status).toBe('paid') + expect(mockCreateSupplierInvoicePaymentEntry).toHaveBeenCalled() + }) + + it('skips duplicate guard on partial payment (amount < remaining)', async () => { + const supplier = makeSupplier() + const invoice = makeSupplierInvoice({ + id: 'si-1', + status: 'approved', + total: 10000, + remaining_amount: 10000, + paid_amount: 0, + supplier, + items: [], + }) + + // Note: no candidates enqueue — guard is skipped for partial payments + enqueue({ data: invoice, error: null }) + enqueue({ data: { accounting_method: 'accrual' }, error: null }) + mockCreateSupplierInvoicePaymentEntry.mockResolvedValue({ id: 'je-1' }) + enqueue({ data: [{ id: 'si-1' }], error: null }) + enqueue({ data: null, error: null }) + + const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', { + method: 'POST', + body: { amount: 3000 }, + }) + const response = await POST(request, createMockRouteParams({ id: 'si-1' })) + const { status, body } = await parseJsonResponse<{ status: string }>(response) + + expect(status).toBe(200) + expect(body.status).toBe('partially_paid') + }) + it('emits supplier_invoice.paid event', async () => { const supplier = makeSupplier() const invoice = makeSupplierInvoice({ @@ -277,6 +386,8 @@ describe('POST /api/supplier-invoices/[id]/mark-paid', () => { }) enqueue({ data: invoice, error: null }) + // Duplicate-payment guard: no candidate transactions + enqueue({ data: [], error: null }) enqueue({ data: { accounting_method: 'accrual' }, error: null }) mockCreateSupplierInvoicePaymentEntry.mockResolvedValue({ id: 'je-1' }) // Update invoice (CAS guard: returns matched row) diff --git a/app/api/supplier-invoices/[id]/mark-paid/route.ts b/app/api/supplier-invoices/[id]/mark-paid/route.ts index ba00943e..e66798a8 100644 --- a/app/api/supplier-invoices/[id]/mark-paid/route.ts +++ b/app/api/supplier-invoices/[id]/mark-paid/route.ts @@ -10,6 +10,11 @@ import { validateBody } from '@/lib/api/validate' import { MarkSupplierInvoicePaidSchema } from '@/lib/api/schemas' import { withRouteContext } from '@/lib/api/with-route-context' import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { + DUPLICATE_AMOUNT_TOLERANCE_PCT, + DUPLICATE_DATE_WINDOW_DAYS, + escapeLikePattern, +} from '@/lib/invoices/duplicate-payment-guard' import type { SupplierInvoice, SupplierInvoiceItem } from '@/types' ensureInitialized() @@ -50,6 +55,73 @@ export const POST = withRouteContext( const paymentAmount = body.amount || invoice.remaining_amount const now = new Date().toISOString() + if (body.force) { + opLog.warn('duplicate-payment guard bypassed', { + reason: 'force=true', + paymentAmount, + paymentDate, + }) + } + + // Duplicate-payment guard: if a likely-matching unlinked bank transaction + // exists for this supplier, surface it before booking a new payment entry. + // Caller can override with `force: true`. Skipped on partial payments — + // those are an explicit, deliberate action. + const paidRounded = Math.round(paymentAmount * 100) / 100 + const remainingRounded = Math.round(invoice.remaining_amount * 100) / 100 + if (!body.force && paidRounded >= remainingRounded) { + const supplierName = (invoice as SupplierInvoice & { supplier?: { name?: string } }) + .supplier?.name + if (!supplierName) { + // An invoice without a resolved supplier name is arguably *higher* risk + // for duplicate booking, not lower (BFL 5 kap 7 § — motpart should be + // identifiable). Log the skip so the gap is visible in audit. + opLog.warn('duplicate-payment guard skipped', { + reason: 'missing_supplier_name', + supplierInvoiceId: id, + }) + } + if (supplierName) { + 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 escapedSupplierName = escapeLikePattern(supplierName) + + const { data: candidates } = await supabase + .from('transactions') + .select('id, date, amount, description, merchant_name') + .eq('company_id', companyId!) + .eq('is_business', true) + .is('supplier_invoice_id', null) + .is('invoice_id', null) + .lt('amount', 0) + .gte('amount', -windowHigh) + .lte('amount', -windowLow) + .gte('date', dateLow) + .lte('date', dateHigh) + .ilike('merchant_name', `%${escapedSupplierName}%`) + .order('date', { ascending: false }) + .limit(5) + + if (candidates && candidates.length > 0) { + return errorResponseFromCode('SI_PAID_LIKELY_DUPLICATE', opLog, { + requestId, + details: { + candidates: candidates.map((c) => ({ + id: c.id, + date: c.date, + amount: c.amount, + description: c.description, + merchant_name: c.merchant_name, + })), + }, + }) + } + } + } + const { data: settings } = await supabase .from('company_settings') .select('accounting_method') diff --git a/app/api/transactions/[id]/categorize/__tests__/route.test.ts b/app/api/transactions/[id]/categorize/__tests__/route.test.ts index 3abe9c18..1dffe31e 100644 --- a/app/api/transactions/[id]/categorize/__tests__/route.test.ts +++ b/app/api/transactions/[id]/categorize/__tests__/route.test.ts @@ -271,6 +271,130 @@ describe('POST /api/transactions/[id]/categorize', () => { expect(mockCreateTransactionJournalEntry).not.toHaveBeenCalled() }) + it('returns 409 TX_CATEGORIZE_SUGGEST_SI_MATCH when 2440 mapping matches an open supplier invoice', async () => { + const tx = makeTransaction({ + id: 'tx-1', + amount: -10000, + merchant_name: 'Leverantör 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: '2440', + }) + + // Prong B: supplier lookup + enqueue({ data: [{ id: 'sup-1' }], error: null }) + // Open supplier invoices candidate query + enqueue({ + data: [ + { + id: 'si-1', + supplier_invoice_number: 'INV-2026-0042', + invoice_date: '2026-05-01', + remaining_amount: 10000, + currency: 'SEK', + supplier: { name: 'Leverantör AB' }, + }, + ], + error: null, + }) + + const request = createMockRequest('/api/transactions/tx-1/categorize', { + method: 'POST', + body: { is_business: true, category: 'expense_software' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ error: { code: string; details: { candidates: unknown[] } } }>(response) + + expect(status).toBe(409) + expect(body.error.code).toBe('TX_CATEGORIZE_SUGGEST_SI_MATCH') + expect(body.error.details.candidates).toHaveLength(1) + expect(mockCreateTransactionJournalEntry).not.toHaveBeenCalled() + }) + + it('proceeds with 2440 categorization when confirm_no_match=true', async () => { + const tx = makeTransaction({ + id: 'tx-1', + amount: -10000, + merchant_name: 'Leverantör 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: '2440', + }) + + // No supplier/invoice lookups happen because 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: 'expense_software', confirm_no_match: true }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ + success: boolean + journal_entry_created: boolean + journal_entry_id: string + }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(body.journal_entry_created).toBe(true) + expect(body.journal_entry_id).toBe('je-1') + }) + + it('does not trigger SI suggestion when 2440 has no matching open supplier invoice', async () => { + const tx = makeTransaction({ + id: 'tx-1', + amount: -10000, + merchant_name: 'Leverantör 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: '2440', + }) + + // Supplier lookup returns a supplier + enqueue({ data: [{ id: 'sup-1' }], error: null }) + // No open invoices in the amount window + enqueue({ data: [], error: null }) + // 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: 'expense_software' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ success: boolean; journal_entry_created: boolean }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(body.journal_entry_created).toBe(true) + }) + 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 e567b0d6..a4c12a8c 100644 --- a/app/api/transactions/[id]/categorize/route.ts +++ b/app/api/transactions/[id]/categorize/route.ts @@ -9,6 +9,11 @@ import { saveUserMappingRule } from '@/lib/bookkeeping/mapping-engine' import { upsertCounterpartyTemplate, buildMappingResultFromCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates' import { withRouteContext } from '@/lib/api/with-route-context' import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { + DUPLICATE_AMOUNT_TOLERANCE_PCT, + DUPLICATE_DATE_WINDOW_DAYS, + escapeLikePattern, +} from '@/lib/invoices/duplicate-payment-guard' import { isBookkeepingError } from '@/lib/bookkeeping/errors' import { getErrorMessage } from '@/lib/errors/get-error-message' import type { Logger } from '@/lib/logger' @@ -262,6 +267,86 @@ export const POST = withRouteContext( }) } + if (body.confirm_no_match && /^244\d$/.test(mappingResult.debit_account)) { + txLog.warn('supplier-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 + // to 244x leaves the invoice with status='approved' and lures the user + // into a duplicate "Markera som betald" later. Credit must be a bank/cash + // account (1xxx) — 244x against a clearing account, equity, etc. isn't a + // supplier payment and the suggestion would misdirect the user. + if ( + !body.confirm_no_match && + is_business && + transaction.amount < 0 && + /^244\d$/.test(mappingResult.debit_account) && + /^1\d{3}$/.test(mappingResult.credit_account) + ) { + const txAmountAbs = Math.abs(transaction.amount) + const windowLow = Math.round(txAmountAbs * (1 - DUPLICATE_AMOUNT_TOLERANCE_PCT) * 100) / 100 + const windowHigh = Math.round(txAmountAbs * (1 + DUPLICATE_AMOUNT_TOLERANCE_PCT) * 100) / 100 + + let supplierIds: string[] = [] + if (transaction.merchant_name) { + const escapedMerchant = escapeLikePattern(transaction.merchant_name) + const { data: matchedSuppliers } = await supabase + .from('suppliers') + .select('id') + .eq('company_id', companyId) + .ilike('name', `%${escapedMerchant}%`) + .limit(10) + supplierIds = (matchedSuppliers || []).map((s) => s.id) + } + + if (supplierIds.length > 0) { + // Restrict candidates to invoices within the date window relative to + // the bank tx date. Without this, an open invoice from years back can + // surface as a match and misdirect the user (swedish-compliance bot). + const txDateMs = new Date(transaction.date).getTime() + const invoiceDateLow = new Date(txDateMs - DUPLICATE_DATE_WINDOW_DAYS * 24 * 3600 * 1000) + .toISOString() + .split('T')[0] + const invoiceDateHigh = new Date(txDateMs + DUPLICATE_DATE_WINDOW_DAYS * 24 * 3600 * 1000) + .toISOString() + .split('T')[0] + + const { data: openInvoices } = await supabase + .from('supplier_invoices') + .select('id, supplier_invoice_number, invoice_date, remaining_amount, currency, supplier:suppliers(name)') + .eq('company_id', companyId) + .in('supplier_id', supplierIds) + .in('status', ['registered', 'approved', 'partially_paid', 'overdue']) + .gte('remaining_amount', windowLow) + .lte('remaining_amount', windowHigh) + .gte('invoice_date', invoiceDateLow) + .lte('invoice_date', invoiceDateHigh) + .order('invoice_date', { ascending: false }) + .limit(5) + + if (openInvoices && openInvoices.length > 0) { + return errorResponseFromCode('TX_CATEGORIZE_SUGGEST_SI_MATCH', txLog, { + requestId, + details: { + candidates: openInvoices.map((inv) => ({ + supplier_invoice_id: inv.id, + invoice_number: inv.supplier_invoice_number, + invoice_date: inv.invoice_date, + remaining_amount: inv.remaining_amount, + currency: inv.currency, + supplier_name: (inv.supplier as { name?: string } | null)?.name ?? null, + })), + }, + }) + } + } + } + await ensureFiscalPeriod(supabase, user.id, companyId, transaction.date, fiscalYearStartMonth, txLog) let journalEntryCreated = false diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index f8bcf836..f79c09d1 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -271,6 +271,7 @@ export const MarkSupplierInvoicePaidSchema = z.object({ payment_date: isoDate.optional(), exchange_rate_difference: z.number().optional(), notes: z.string().optional(), + force: z.boolean().optional(), }) export const UpdateSupplierInvoiceSchema = z.object({ @@ -327,6 +328,7 @@ export const CategorizeTransactionSchema = z.object({ counterparty_template_id: z.string().uuid().optional(), user_description: z.string().max(500).optional(), inbox_item_id: z.string().uuid().optional(), + confirm_no_match: z.boolean().optional(), }) export const BookTransactionSchema = z.object({ diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index 956192b8..2e90f9b1 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -269,6 +269,17 @@ const TRANSACTIONS: Record = { message_sv: 'Transaktionen kategoriserades av en annan förfrågan. Ladda om och försök igen.', message_en: 'Transaction was already categorized by another request.', }, + TX_CATEGORIZE_SUGGEST_SI_MATCH: { + httpStatus: 409, + message_sv: + 'Det finns en öppen leverantörsfaktura från samma leverantör med samma belopp. Matcha mot fakturan istället för att bokföra direkt på leverantörsskuldskontot — annars skapas en dubblerad verifikation som måste stornas (BFL 5 kap 5 §).', + message_en: + 'An open supplier invoice from the same supplier matches this amount. Suggest matching to the invoice instead of a plain 244x categorization to avoid producing a duplicate verifikation (BFL 5 kap 5 §).', + remediation: { + description: + '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_UNCATEGORIZE_NO_LINKED_ENTRY: { httpStatus: 400, message_sv: 'Transaktionen har ingen kopplad verifikation att stornera.', @@ -1129,6 +1140,17 @@ const SUPPLIER_INVOICE_WAVE4: Record = { message_sv: 'Kunde inte registrera betalningen.', message_en: 'Failed to record supplier invoice payment.', }, + SI_PAID_LIKELY_DUPLICATE: { + httpStatus: 409, + message_sv: + 'Det finns redan en obokförd 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 bank transaction was found for this supplier. Suggest linking it instead of creating a new payment entry.', + remediation: { + description: + 'Match the candidate transaction via POST /api/transactions/{id}/match-supplier-invoice, or resend mark-paid with force: true to create the payment entry anyway.', + }, + }, SI_CREDIT_ALREADY_CREDITED: { httpStatus: 409, message_sv: 'Leverantörsfakturan har redan krediterats.', diff --git a/lib/invoices/duplicate-payment-guard.ts b/lib/invoices/duplicate-payment-guard.ts new file mode 100644 index 00000000..26b97429 --- /dev/null +++ b/lib/invoices/duplicate-payment-guard.ts @@ -0,0 +1,30 @@ +/** + * Shared constants and helpers for the duplicate-payment / SI-match guards + * used by `/api/supplier-invoices/[id]/mark-paid` and + * `/api/transactions/[id]/categorize`. Both guards look for a likely-matching + * counterparty within a fuzzy amount + date window; keeping the thresholds in + * one place makes them tunable as we learn from real false-positive rates. + */ + +/** Acceptable amount drift (±) when matching a bank tx to an invoice amount. */ +export const DUPLICATE_AMOUNT_TOLERANCE_PCT = 0.02 + +/** Date window (±days) around the payment / invoice date. */ +export const DUPLICATE_DATE_WINDOW_DAYS = 60 + +/** Cap on supplier / merchant names before they enter an ILIKE pattern, to + * bound query work and avoid pathological inputs degrading the index scan. */ +const MAX_LIKE_NEEDLE_LENGTH = 200 + +/** + * Escape LIKE/ILIKE wildcards (`%`, `_`, `\`) and truncate to a safe length + * before embedding the value in an ILIKE pattern. SQL-injection is already + * handled by Supabase's parameterization; this purely prevents silent + * over-matching on names like "50% Off AB" and bounds DB work on long inputs. + */ +export function escapeLikePattern(value: string): string { + const truncated = value.length > MAX_LIKE_NEEDLE_LENGTH + ? value.slice(0, MAX_LIKE_NEEDLE_LENGTH) + : value + return truncated.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_') +}