diff --git a/app/(dashboard)/invoices/[id]/page.tsx b/app/(dashboard)/invoices/[id]/page.tsx index 71dab82f..d169809e 100644 --- a/app/(dashboard)/invoices/[id]/page.tsx +++ b/app/(dashboard)/invoices/[id]/page.tsx @@ -33,6 +33,7 @@ import { import { useCanWrite } from '@/lib/hooks/use-can-write' import PaymentBookingDialog from '@/components/invoices/PaymentBookingDialog' import SendInvoiceDialog from '@/components/invoices/SendInvoiceDialog' +import CorrectionAffordance from '@/components/bookkeeping/CorrectionAffordance' import { Dialog, DialogContent, @@ -63,6 +64,10 @@ interface InvoiceWithRelations extends Invoice { customer: Customer items: InvoiceItem[] sent_at?: string + // Optional reference to the issuance verifikation. Populated by the + // backend when the invoice flow auto-books an entry on send; absent on + // older invoices and on companies where issuance is not auto-booked. + journal_entry_id?: string | null } export default function InvoiceDetailPage({ params }: { params: Promise<{ id: string }> }) { @@ -690,6 +695,39 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st )} + {invoice.journal_entry_id && ( + <> + +
+ Bokföring +
+ + Visa verifikation + + {canWrite && ( + + {({ open, isLoading }) => ( + + )} + + )} +
+
+ + )} diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index 1a08d440..00a0db4e 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -699,7 +699,7 @@ export default function TransactionsPage() { } } - async function handleConfirmInvoiceMatch() { + async function handleConfirmInvoiceMatch(opts?: { force?: boolean; expected_journal_entry_id?: string }) { if (!selectedTransaction) return const isSupplier = !!selectedTransaction.potential_supplier_invoice const isCustomer = !!selectedTransaction.potential_invoice @@ -711,9 +711,19 @@ export default function TransactionsPage() { const url = isSupplier ? `/api/transactions/${selectedTransaction.id}/match-supplier-invoice` : `/api/transactions/${selectedTransaction.id}/match-invoice` - const body = isSupplier + const body: Record = isSupplier ? { supplier_invoice_id: selectedTransaction.potential_supplier_invoice!.id } : { invoice_id: selectedTransaction.potential_invoice!.id } + if (!isSupplier && opts?.force) { + body.force = true + // Bind the override to the candidate the user saw in the dialog. + // The server re-detects the candidate and rejects the bypass if + // the id doesn't match, so an empty value here surfaces as a + // clean validation error instead of silently widening the guard. + if (opts.expected_journal_entry_id) { + body.expected_journal_entry_id = opts.expected_journal_entry_id + } + } const response = await fetch(url, { method: 'POST', @@ -777,6 +787,77 @@ export default function TransactionsPage() { } } + async function handleLinkToExistingVoucher(journalEntryId: string) { + if (!selectedTransaction) return + const invoiceId = selectedTransaction.potential_invoice?.id ?? null + setIsConfirmingMatch(true) + try { + const response = await fetch( + `/api/transactions/${selectedTransaction.id}/link-journal-entry`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + journal_entry_id: journalEntryId, + ...(invoiceId ? { invoice_id: invoiceId } : {}), + }), + }, + ) + const result = await response.json() + if (!response.ok) { + toast({ + title: 'Kunde inte koppla till befintlig verifikation', + description: getErrorMessage(result, { context: 'transaction' }), + variant: 'destructive', + }) + setIsConfirmingMatch(false) + return + } + + const voucherLabel = (result as { voucher_label?: string }).voucher_label ?? '' + toast({ + title: 'Bankhändelsen kopplad', + description: voucherLabel + ? `Kopplad till verifikation ${voucherLabel}. Ingen ny bokföring skapad.` + : 'Ingen ny bokföring skapad.', + }) + setMatchDialogOpen(false) + + // Animate out + update local state, same pattern as handleConfirmInvoiceMatch. + setExitingIds((prev) => new Set(prev).add(selectedTransaction.id)) + setTimeout(() => { + setTransactions((prev) => + prev.map((t) => + t.id === selectedTransaction.id + ? { + ...t, + invoice_id: invoiceId, + potential_invoice_id: null, + potential_invoice: undefined, + is_business: true, + journal_entry_id: journalEntryId, + } + : t, + ), + ) + setExitingIds((prev) => { + const next = new Set(prev) + next.delete(selectedTransaction.id) + return next + }) + setSelectedTransaction(null) + setIsConfirmingMatch(false) + }, 350) + } catch { + toast({ + title: 'Koppling misslyckades', + description: 'Verifikationen kunde inte kopplas. Försök igen.', + variant: 'destructive', + }) + setIsConfirmingMatch(false) + } + } + async function handleMatchInvoice(transactionId: string, invoiceId: string): Promise { try { const response = await fetch(`/api/transactions/${transactionId}/match-invoice`, { @@ -1386,6 +1467,7 @@ export default function TransactionsPage() { transaction={selectedTransaction} isConfirming={isConfirmingMatch} onConfirm={handleConfirmInvoiceMatch} + onLinkToExisting={handleLinkToExistingVoucher} /> }) => { + const { id: transactionId } = await params + const { supabase, companyId, log, requestId } = ctx + + // Membership is enforced by withRouteContext (see its docstring) — the + // resolved companyId is always a company the caller is a member of, so + // intra-company multi-user visibility of transaction metadata here is + // the intended tenancy model. The selected column set is intentionally + // narrow (id, date, amount, journal_entry_id) so this endpoint cannot + // leak description / counterparty fields that aren't required to + // surface a duplicate-payment candidate. GDPR Art.5(1)(c)/(f). + const { data: transaction, error } = await supabase + .from('transactions') + .select('id, date, amount, journal_entry_id') + .eq('id', transactionId) + .eq('company_id', companyId) + .single() + + if (error || !transaction) { + return errorResponseFromCode('TX_CATEGORIZE_TX_NOT_FOUND', log, { requestId }) + } + + // Already linked → no possible duplicate to surface. + if (transaction.journal_entry_id) { + return NextResponse.json({ candidate: null }) + } + + try { + const candidate = await detectDuplicatePaymentVoucher(supabase, { + companyId: companyId!, + transactionId, + transactionDate: transaction.date, + transactionAmount: transaction.amount, + }) + return NextResponse.json({ candidate }) + } catch (err) { + log.warn('duplicate-payment-voucher detection failed', err as Error) + // Fail-open: returning null preserves current UX. The POST still + // runs its own check, so a missed pre-flight doesn't allow a + // duplicate booking. + return NextResponse.json({ candidate: null }) + } + }, +) diff --git a/app/api/transactions/[id]/link-journal-entry/__tests__/route.test.ts b/app/api/transactions/[id]/link-journal-entry/__tests__/route.test.ts new file mode 100644 index 00000000..cf7bc792 --- /dev/null +++ b/app/api/transactions/[id]/link-journal-entry/__tests__/route.test.ts @@ -0,0 +1,365 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + createMockRequest, + parseJsonResponse, + createMockRouteParams, + createQueuedMockSupabase, + makeTransaction, + makeInvoice, +} from '@/tests/helpers' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => Promise.resolve(mockSupabase), +})) + +vi.mock('@/lib/invoices/match-log', () => ({ + logMatchEvent: vi.fn(), +})) + +vi.mock('@/lib/events/bus', () => ({ + eventBus: { emit: vi.fn() }, +})) + +vi.mock('@/lib/init', () => ({ + ensureInitialized: vi.fn(), +})) + +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), +})) + +import { POST } from '../route' + +const TX_UUID = '550e8400-e29b-41d4-a716-446655440000' +const JE_UUID = '550e8400-e29b-41d4-a716-446655440001' +const INV_UUID = '550e8400-e29b-41d4-a716-446655440002' + +describe('POST /api/transactions/[id]/link-journal-entry', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + beforeEach(() => { + vi.clearAllMocks() + reset() + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + }) + + it('returns 400 when journal_entry_id is missing', async () => { + const request = createMockRequest(`/api/transactions/${TX_UUID}/link-journal-entry`, { + method: 'POST', + body: {}, + }) + const response = await POST(request, createMockRouteParams({ id: TX_UUID })) + const { status } = await parseJsonResponse(response) + expect(status).toBe(400) + }) + + it('returns 404 when transaction not found', async () => { + enqueue({ data: null, error: { message: 'not found' } }) + const request = createMockRequest(`/api/transactions/${TX_UUID}/link-journal-entry`, { + method: 'POST', + body: { journal_entry_id: JE_UUID }, + }) + const response = await POST(request, createMockRouteParams({ id: TX_UUID })) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + expect(status).toBe(404) + expect(body.error.code).toBe('TX_CATEGORIZE_TX_NOT_FOUND') + }) + + it('returns 400 when transaction is already linked', async () => { + enqueue({ + data: makeTransaction({ id: TX_UUID, journal_entry_id: 'je-prior' }), + error: null, + }) + const request = createMockRequest(`/api/transactions/${TX_UUID}/link-journal-entry`, { + method: 'POST', + body: { journal_entry_id: JE_UUID }, + }) + const response = await POST(request, createMockRouteParams({ id: TX_UUID })) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + expect(status).toBe(400) + expect(body.error.code).toBe('LINK_TX_TX_ALREADY_LINKED') + }) + + it('returns 404 when journal entry not found', async () => { + enqueue({ + data: makeTransaction({ id: TX_UUID, journal_entry_id: null }), + error: null, + }) + enqueue({ data: null, error: { message: 'not found' } }) + + const request = createMockRequest(`/api/transactions/${TX_UUID}/link-journal-entry`, { + method: 'POST', + body: { journal_entry_id: JE_UUID }, + }) + const response = await POST(request, createMockRouteParams({ id: TX_UUID })) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + expect(status).toBe(404) + expect(body.error.code).toBe('LINK_TX_JE_NOT_FOUND') + }) + + it('returns 400 when journal entry is not posted', async () => { + enqueue({ + data: makeTransaction({ id: TX_UUID, journal_entry_id: null }), + error: null, + }) + enqueue({ + data: { + id: JE_UUID, + status: 'draft', + voucher_series: 'A', + voucher_number: 1, + entry_date: '2026-05-15', + }, + error: null, + }) + + const request = createMockRequest(`/api/transactions/${TX_UUID}/link-journal-entry`, { + method: 'POST', + body: { journal_entry_id: JE_UUID }, + }) + const response = await POST(request, createMockRouteParams({ id: TX_UUID })) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + expect(status).toBe(400) + expect(body.error.code).toBe('LINK_TX_JE_NOT_POSTED') + }) + + it('happy path: links tx without invoice, no new bookkeeping created', async () => { + enqueue({ + data: makeTransaction({ id: TX_UUID, journal_entry_id: null, amount: 1000, date: '2026-05-15' }), + error: null, + }) + enqueue({ + data: { + id: JE_UUID, + status: 'posted', + voucher_series: 'A', + voucher_number: 12, + entry_date: '2026-05-15', + }, + error: null, + }) + // Update transaction + enqueue({ data: null, error: null }) + // logMatchEvent insert + enqueue({ data: null, error: null }) + + const request = createMockRequest(`/api/transactions/${TX_UUID}/link-journal-entry`, { + method: 'POST', + body: { journal_entry_id: JE_UUID }, + }) + const response = await POST(request, createMockRouteParams({ id: TX_UUID })) + const { status, body } = await parseJsonResponse<{ + success: boolean + journal_entry_id: string + voucher_label: string + invoice_id: string | null + invoice_status: string | null + }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(body.journal_entry_id).toBe(JE_UUID) + expect(body.voucher_label).toBe('A12') + expect(body.invoice_id).toBeNull() + expect(body.invoice_status).toBeNull() + }) + + it('happy path with invoice: links tx, flips invoice to paid, inserts invoice_payments', async () => { + enqueue({ + data: makeTransaction({ id: TX_UUID, journal_entry_id: null, amount: 1000, date: '2026-05-15' }), + error: null, + }) + enqueue({ + data: { + id: JE_UUID, + status: 'posted', + voucher_series: 'A', + voucher_number: 1, + entry_date: '2026-05-15', + }, + error: null, + }) + enqueue({ + data: makeInvoice({ + id: INV_UUID, + status: 'sent', + total: 1000, + remaining_amount: 1000, + paid_amount: 0, + currency: 'SEK', + }), + error: null, + }) + // Update transaction + enqueue({ data: null, error: null }) + // Update invoice (optimistic lock returns updated row) + enqueue({ data: [{ id: INV_UUID }], error: null }) + // Insert invoice_payments + enqueue({ data: null, error: null }) + // logMatchEvent + enqueue({ data: null, error: null }) + + const request = createMockRequest(`/api/transactions/${TX_UUID}/link-journal-entry`, { + method: 'POST', + body: { journal_entry_id: JE_UUID, invoice_id: INV_UUID }, + }) + const response = await POST(request, createMockRouteParams({ id: TX_UUID })) + const { status, body } = await parseJsonResponse<{ + success: boolean + invoice_status: string | null + paid_amount: number | null + remaining_amount: number | null + }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(body.invoice_status).toBe('paid') + expect(body.paid_amount).toBe(1000) + expect(body.remaining_amount).toBe(0) + }) + + it('returns 404 when invoice_id supplied but invoice not found', async () => { + enqueue({ + data: makeTransaction({ id: TX_UUID, journal_entry_id: null }), + error: null, + }) + enqueue({ + data: { + id: JE_UUID, + status: 'posted', + voucher_series: 'A', + voucher_number: 1, + entry_date: '2026-05-15', + }, + error: null, + }) + enqueue({ data: null, error: { message: 'not found' } }) + + const request = createMockRequest(`/api/transactions/${TX_UUID}/link-journal-entry`, { + method: 'POST', + body: { journal_entry_id: JE_UUID, invoice_id: INV_UUID }, + }) + const response = await POST(request, createMockRouteParams({ id: TX_UUID })) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + expect(status).toBe(404) + expect(body.error.code).toBe('LINK_TX_INVOICE_NOT_FOUND') + }) + + it('returns 400 when supplied invoice is not in an open state', async () => { + enqueue({ + data: makeTransaction({ id: TX_UUID, journal_entry_id: null }), + error: null, + }) + enqueue({ + data: { + id: JE_UUID, + status: 'posted', + voucher_series: 'A', + voucher_number: 1, + entry_date: '2026-05-15', + }, + error: null, + }) + enqueue({ + data: makeInvoice({ id: INV_UUID, status: 'paid' }), + error: null, + }) + + const request = createMockRequest(`/api/transactions/${TX_UUID}/link-journal-entry`, { + method: 'POST', + body: { journal_entry_id: JE_UUID, invoice_id: INV_UUID }, + }) + const response = await POST(request, createMockRouteParams({ id: TX_UUID })) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + expect(status).toBe(400) + expect(body.error.code).toBe('LINK_TX_INVOICE_NOT_OPEN') + }) + + it('returns 409 LINK_TX_INVOICE_RACE when optimistic lock loses and rolls back the tx link', async () => { + enqueue({ + data: makeTransaction({ id: TX_UUID, journal_entry_id: null, amount: 1000, date: '2026-05-15' }), + error: null, + }) + enqueue({ + data: { + id: JE_UUID, + status: 'posted', + voucher_series: 'A', + voucher_number: 1, + entry_date: '2026-05-15', + }, + error: null, + }) + enqueue({ + data: makeInvoice({ id: INV_UUID, status: 'sent', total: 1000, remaining_amount: 1000 }), + error: null, + }) + // Update transaction succeeds + enqueue({ data: null, error: null }) + // Optimistic invoice update returns 0 rows + enqueue({ data: [], error: null }) + // Compensating rollback: restore prior tx state + enqueue({ data: null, error: null }) + + const request = createMockRequest(`/api/transactions/${TX_UUID}/link-journal-entry`, { + method: 'POST', + body: { journal_entry_id: JE_UUID, invoice_id: INV_UUID }, + }) + const response = await POST(request, createMockRouteParams({ id: TX_UUID })) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + expect(status).toBe(409) + expect(body.error.code).toBe('LINK_TX_INVOICE_RACE') + }) + + it('rolls back both the tx link and the invoice update when invoice_payments insert fails', async () => { + enqueue({ + data: makeTransaction({ id: TX_UUID, journal_entry_id: null, amount: 1000, date: '2026-05-15' }), + error: null, + }) + enqueue({ + data: { + id: JE_UUID, + status: 'posted', + voucher_series: 'A', + voucher_number: 1, + entry_date: '2026-05-15', + }, + error: null, + }) + enqueue({ + data: makeInvoice({ + id: INV_UUID, + status: 'sent', + total: 1000, + remaining_amount: 1000, + paid_amount: 0, + }), + error: null, + }) + // Update transaction succeeds + enqueue({ data: null, error: null }) + // Optimistic invoice update succeeds + enqueue({ data: [{ id: INV_UUID }], error: null }) + // invoice_payments insert fails with non-23505 error + enqueue({ data: null, error: { code: '99999', message: 'unexpected' } }) + // Compensating invoice revert + enqueue({ data: null, error: null }) + // Compensating tx rollback + enqueue({ data: null, error: null }) + + const request = createMockRequest(`/api/transactions/${TX_UUID}/link-journal-entry`, { + method: 'POST', + body: { journal_entry_id: JE_UUID, invoice_id: INV_UUID }, + }) + const response = await POST(request, createMockRouteParams({ id: TX_UUID })) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + expect(status).toBe(500) + expect(body.error.code).toBe('MATCH_INVOICE_RECORD_PAYMENT_FAILED') + }) +}) diff --git a/app/api/transactions/[id]/link-journal-entry/route.ts b/app/api/transactions/[id]/link-journal-entry/route.ts new file mode 100644 index 00000000..36e7a02c --- /dev/null +++ b/app/api/transactions/[id]/link-journal-entry/route.ts @@ -0,0 +1,295 @@ +/** + * POST /api/transactions/[id]/link-journal-entry + * + * Link a bank transaction to an already-posted journal entry without + * creating new bookkeeping. Used by the duplicate-payment UI when the user + * confirms the suggested candidate already books this receipt — typically + * a manual verifikation made outside the match-invoice flow. + * + * Body: + * - journal_entry_id (required): the existing posted JE to link to. + * - invoice_id (optional): when supplied, also inserts an + * invoice_payments row pointing at the existing JE and flips the + * invoice status to 'paid' / 'partially_paid'. Same optimistic-lock + * pattern as match-invoice. Omit when linking against a JE that + * doesn't relate to a customer invoice (uncommon but supported). + * + * Effects: + * - transactions.journal_entry_id = je_id + * - transactions.is_business = true + * - transactions.potential_invoice_id = null + * - transactions.potential_supplier_invoice_id = null + * - if invoice_id provided: + * - invoice_payments row inserted (transaction_id, amount, journal_entry_id) + * - invoice.status / paid_amount / remaining_amount updated + * + * NEVER creates a new journal entry; the underlying double-entry already + * exists. The match log records 'linked_to_existing_voucher' for audit. + */ +import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { validateBody } from '@/lib/api/validate' +import { LinkTransactionJournalEntrySchema } from '@/lib/api/schemas' +import { logMatchEvent } from '@/lib/invoices/match-log' +import { eventBus } from '@/lib/events/bus' +import { ensureInitialized } from '@/lib/init' +import type { Invoice, Transaction } from '@/types' + +ensureInitialized() + +export const POST = withRouteContext( + 'transaction.link_journal_entry', + async (request, ctx, { params }: { params: Promise<{ id: string }> }) => { + const { id: transactionId } = await params + const { user, supabase, companyId, log, requestId } = ctx + + const validation = await validateBody(request, LinkTransactionJournalEntrySchema, { + log, + operation: 'transaction.link_journal_entry', + }) + if (!validation.success) return validation.response + const { journal_entry_id, invoice_id } = validation.data + + const txLog = log.child({ transactionId, journalEntryId: journal_entry_id, invoiceId: invoice_id }) + + // Data minimization (GDPR Art.5(1)(c)): pull only the columns the route + // actually uses for validation, the optimistic-lock invoice update, the + // invoice_payments insert, and the compensating-rollback path. Avoid + // `select('*')` so freshly-added columns (PII or otherwise) never leak + // into the request scope or downstream logs by accident. + const { data: transaction, error: fetchTxError } = await supabase + .from('transactions') + .select( + 'id, date, amount, currency, exchange_rate, journal_entry_id, invoice_id, is_business, potential_invoice_id, potential_supplier_invoice_id', + ) + .eq('id', transactionId) + .eq('company_id', companyId) + .single() + + if (fetchTxError || !transaction) { + return errorResponseFromCode('TX_CATEGORIZE_TX_NOT_FOUND', txLog, { requestId }) + } + + if (transaction.journal_entry_id) { + return errorResponseFromCode('LINK_TX_TX_ALREADY_LINKED', txLog, { + requestId, + details: { existingJournalEntryId: transaction.journal_entry_id }, + }) + } + + const { data: journalEntry, error: fetchJeError } = await supabase + .from('journal_entries') + .select('id, status, voucher_series, voucher_number, entry_date') + .eq('id', journal_entry_id) + .eq('company_id', companyId) + .single() + + if (fetchJeError || !journalEntry) { + return errorResponseFromCode('LINK_TX_JE_NOT_FOUND', txLog, { requestId }) + } + + if (journalEntry.status !== 'posted') { + return errorResponseFromCode('LINK_TX_JE_NOT_POSTED', txLog, { + requestId, + details: { currentStatus: journalEntry.status }, + }) + } + + // If invoice_id supplied, validate + prepare invoice update. + let invoice: (Invoice & { customer?: { name?: string } | null }) | null = null + let newPaidAmount = 0 + let newRemaining = 0 + let isFullyPaid = false + let newStatus: 'paid' | 'partially_paid' = 'paid' + + if (invoice_id) { + const { data: invoiceRow, error: fetchInvError } = await supabase + .from('invoices') + .select('*, customer:customers(name)') + .eq('id', invoice_id) + .eq('company_id', companyId) + .single() + + if (fetchInvError || !invoiceRow) { + return errorResponseFromCode('LINK_TX_INVOICE_NOT_FOUND', txLog, { requestId }) + } + + if ( + invoiceRow.status !== 'sent' && + invoiceRow.status !== 'overdue' && + invoiceRow.status !== 'partially_paid' + ) { + return errorResponseFromCode('LINK_TX_INVOICE_NOT_OPEN', txLog, { + requestId, + details: { currentStatus: invoiceRow.status }, + }) + } + + invoice = invoiceRow as Invoice & { customer?: { name?: string } | null } + + const paidAmount = transaction.amount + newPaidAmount = Math.round(((invoice.paid_amount || 0) + paidAmount) * 100) / 100 + const currentRemaining = invoice.remaining_amount ?? (invoice.total - (invoice.paid_amount || 0)) + newRemaining = Math.max(0, Math.round((currentRemaining - paidAmount) * 100) / 100) + isFullyPaid = newRemaining <= 0 + newStatus = isFullyPaid ? 'paid' : 'partially_paid' + } + + // Capture pre-link values so the compensating-rollback path below can + // restore the row if the optimistic invoice update loses its race or + // the invoice_payments insert fails. Without this snapshot a partial + // state would persist: tx linked, invoice unchanged, no payment row. + const priorTxState = { + journal_entry_id: transaction.journal_entry_id, // validated null above + invoice_id: transaction.invoice_id, + potential_invoice_id: transaction.potential_invoice_id, + potential_supplier_invoice_id: transaction.potential_supplier_invoice_id, + is_business: transaction.is_business, + } + + // Link the transaction first. If a subsequent step fails the compensating + // path below restores priorTxState. Doing the tx update before the invoice + // update preserves the "transaction disappears from inbox" UX even if the + // invoice update races. + const { error: updateTxError } = await supabase + .from('transactions') + .update({ + journal_entry_id, + invoice_id: invoice_id ?? null, + potential_invoice_id: null, + potential_supplier_invoice_id: null, + is_business: true, + }) + .eq('id', transactionId) + .eq('company_id', companyId) + .is('journal_entry_id', null) + + if (updateTxError) { + txLog.error('failed to link transaction to journal entry', updateTxError) + return errorResponse(updateTxError, txLog, { requestId }) + } + + async function rollbackTxLink(reason: string) { + const { error: rollbackErr } = await supabase + .from('transactions') + .update(priorTxState) + .eq('id', transactionId) + .eq('company_id', companyId) + if (rollbackErr) { + // Best-effort: the original error is more useful to surface; a + // failed rollback gets warn-logged so a reconciliation job can pick + // up the partial state offline. PI1.3 risk is documented here so + // the audit trail is honest about the remaining gap. + txLog.warn('failed to roll back transaction link after subsequent step failed', { + rollbackError: rollbackErr.message, + reason, + }) + } + } + + const now = new Date().toISOString() + + if (invoice && invoice_id) { + // Optimistic lock: only flip status if invoice is still matchable. + const { data: updatedRows, error: updateInvError } = await supabase + .from('invoices') + .update({ + status: newStatus, + paid_at: isFullyPaid ? now : null, + paid_amount: newPaidAmount, + remaining_amount: newRemaining, + }) + .eq('id', invoice_id) + .eq('company_id', companyId) + .in('status', ['sent', 'overdue', 'partially_paid']) + .select('id') + + if (updateInvError) { + await rollbackTxLink('invoice update errored') + txLog.error('failed to update invoice status', updateInvError) + return errorResponse(updateInvError, txLog, { requestId }) + } + + if (!updatedRows || updatedRows.length === 0) { + await rollbackTxLink('invoice optimistic lock returned 0 rows') + return errorResponseFromCode('LINK_TX_INVOICE_RACE', txLog, { requestId }) + } + + const { error: paymentInsertError } = await supabase + .from('invoice_payments') + .insert({ + user_id: user.id, + company_id: companyId, + invoice_id, + payment_date: transaction.date, + amount: transaction.amount, + currency: invoice.currency, + exchange_rate: invoice.exchange_rate, + journal_entry_id, + transaction_id: transactionId, + notes: 'Kopplad till befintlig verifikation (ingen ny bokföring skapad)', + }) + + if (paymentInsertError && paymentInsertError.code !== '23505') { + // Compensate: revert the invoice update and the tx link before + // surfacing the error so the ledger doesn't carry an invoice that + // says "paid" with no corresponding payment row. + const { error: invRevertErr } = await supabase + .from('invoices') + .update({ + status: invoice.status, + paid_at: invoice.paid_at ?? null, + paid_amount: invoice.paid_amount ?? 0, + remaining_amount: invoice.remaining_amount ?? invoice.total, + }) + .eq('id', invoice_id) + .eq('company_id', companyId) + if (invRevertErr) { + txLog.warn('failed to revert invoice status after payment insert failed', { + rollbackError: invRevertErr.message, + }) + } + await rollbackTxLink('invoice_payments insert failed') + txLog.error('failed to record invoice payment', paymentInsertError) + return errorResponseFromCode('MATCH_INVOICE_RECORD_PAYMENT_FAILED', txLog, { requestId }) + } + } + + logMatchEvent(supabase, user.id, transactionId, 'linked_to_existing_voucher', { + invoiceId: invoice_id, + newState: { + journal_entry_id, + invoice_id: invoice_id ?? null, + invoice_status: invoice ? newStatus : null, + }, + }) + + if (invoice && invoice_id) { + try { + eventBus.emit({ + type: 'invoice.match_confirmed', + payload: { + invoice: invoice as Invoice, + transaction: transaction as Transaction, + userId: user.id, + companyId, + }, + }) + } catch (err) { + txLog.warn('invoice.match_confirmed event emission failed', err as Error) + } + } + + return NextResponse.json({ + success: true, + journal_entry_id, + voucher_label: `${journalEntry.voucher_series ?? 'A'}${journalEntry.voucher_number ?? ''}`, + invoice_id: invoice_id ?? null, + invoice_status: invoice ? newStatus : null, + paid_amount: invoice ? newPaidAmount : null, + remaining_amount: invoice ? newRemaining : null, + }) + }, + { requireWrite: true }, +) diff --git a/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts b/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts index fa5ec61a..6dfa154c 100644 --- a/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts +++ b/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts @@ -32,6 +32,11 @@ vi.mock('@/lib/invoices/match-log', () => ({ logMatchEvent: vi.fn(), })) +const mockDetectDuplicate = vi.fn() +vi.mock('@/lib/invoices/duplicate-payment-detection', () => ({ + detectDuplicatePaymentVoucher: (...args: unknown[]) => mockDetectDuplicate(...args), +})) + vi.mock('@/lib/events/bus', () => ({ eventBus: { emit: vi.fn() }, })) @@ -53,6 +58,9 @@ import { POST } from '../route' const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000' const VALID_UUID_2 = '550e8400-e29b-41d4-a716-446655440001' +const CANDIDATE_UUID = '550e8400-e29b-41d4-a716-446655440003' +const STALE_UUID = '550e8400-e29b-41d4-a716-446655440004' +const OTHER_CANDIDATE_UUID = '550e8400-e29b-41d4-a716-446655440005' describe('POST /api/transactions/[id]/match-invoice', () => { const mockUser = { id: 'user-1', email: 'test@test.se' } @@ -61,6 +69,8 @@ describe('POST /api/transactions/[id]/match-invoice', () => { vi.clearAllMocks() reset() mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + // Default to no soft-duplicate detected — happy-path tests don't care. + mockDetectDuplicate.mockResolvedValue(null) }) it('returns 401 when not authenticated', async () => { @@ -205,6 +215,8 @@ describe('POST /api/transactions/[id]/match-invoice', () => { enqueue({ data: tx, error: null }) // Fetch invoice enqueue({ data: invoice, error: null }) + // Hard-duplicate check: no prior payment voucher for this invoice + enqueue({ data: [], error: null }) // Fetch company settings enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) @@ -271,6 +283,8 @@ describe('POST /api/transactions/[id]/match-invoice', () => { enqueue({ data: tx, error: null }) // Fetch invoice enqueue({ data: invoice, error: null }) + // Hard-duplicate check: no prior payment voucher for this invoice + enqueue({ data: [], error: null }) mockReverseEntry.mockResolvedValue({ id: 'je-storno' }) // Clear journal_entry_id on transaction @@ -315,6 +329,8 @@ describe('POST /api/transactions/[id]/match-invoice', () => { enqueue({ data: tx, error: null }) enqueue({ data: invoice, error: null }) + // Hard-duplicate check: no prior payment voucher + enqueue({ data: [], error: null }) mockReverseEntry.mockRejectedValue(new Error('Period locked')) @@ -345,6 +361,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { enqueue({ data: tx, error: null }) enqueue({ data: invoice, error: null }) + enqueue({ data: [], error: null }) // hard-duplicate check enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-partial' }) @@ -388,6 +405,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { enqueue({ data: tx, error: null }) enqueue({ data: invoice, error: null }) + enqueue({ data: [], error: null }) // hard-duplicate check enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null }) mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-clearing' }) @@ -426,6 +444,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { enqueue({ data: tx, error: null }) enqueue({ data: invoice, error: null }) + enqueue({ data: [], error: null }) // hard-duplicate check enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-1' }) @@ -454,6 +473,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { enqueue({ data: tx, error: null }) enqueue({ data: invoice, error: null }) + enqueue({ data: [], error: null }) // hard-duplicate check enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-1' }) @@ -479,6 +499,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { enqueue({ data: tx, error: null }) enqueue({ data: invoice, error: null }) + enqueue({ data: [], error: null }) // hard-duplicate check enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) mockCreateInvoicePaymentJournalEntry.mockRejectedValue(new Error('Period locked')) @@ -508,4 +529,216 @@ describe('POST /api/transactions/[id]/match-invoice', () => { expect(body.journal_entry_id).toBeNull() expect(body.journal_entry_error).toBe('Period locked') }) + + // ──────────────────────────────────────────────────────────────── + // Duplicate-payment guards (Phase A4) + // ──────────────────────────────────────────────────────────────── + + it('returns 409 MATCH_INVOICE_ALREADY_HAS_PAYMENT_VOUCHER when a payment row already links a JE for a sent invoice', async () => { + const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null }) + const invoice = makeInvoice({ + id: VALID_UUID, + status: 'sent', + total: 12500, + remaining_amount: 12500, + }) + + enqueue({ data: tx, error: null }) + enqueue({ data: invoice, error: null }) + // Hard-duplicate check returns a row pointing at the existing JE + enqueue({ data: [{ journal_entry_id: 'je-existing' }], error: null }) + + const request = createMockRequest('/api/transactions/tx-1/match-invoice', { + method: 'POST', + body: { invoice_id: VALID_UUID }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ error: { code: string; details?: { existing_journal_entry_id?: string } } }>(response) + + expect(status).toBe(409) + expect(body.error.code).toBe('MATCH_INVOICE_ALREADY_HAS_PAYMENT_VOUCHER') + expect(body.error.details?.existing_journal_entry_id).toBe('je-existing') + expect(mockCreateInvoicePaymentJournalEntry).not.toHaveBeenCalled() + }) + + it('does NOT run hard-duplicate guard for partially_paid invoices (legitimate additional payment)', async () => { + const tx = makeTransaction({ id: 'tx-1', amount: 2500, invoice_id: null, date: '2024-06-15' }) + const invoice = makeInvoice({ + id: VALID_UUID, + status: 'partially_paid', + total: 12500, + remaining_amount: 2500, + paid_amount: 10000, + }) + + enqueue({ data: tx, error: null }) + enqueue({ data: invoice, error: null }) + // Hard-duplicate check is skipped for partially_paid; jump straight to settings + enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) + + mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-partial-extra' }) + enqueue({ data: [{ id: VALID_UUID }], error: null }) // update invoice + enqueue({ data: null, error: null }) // insert invoice_payments + enqueue({ data: null, error: null }) // update tx + enqueue({ data: null, error: null }) // logMatchEvent + + const request = createMockRequest('/api/transactions/tx-1/match-invoice', { + method: 'POST', + body: { invoice_id: VALID_UUID }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ success: boolean; invoice_status: string }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(body.invoice_status).toBe('paid') + }) + + it('returns 409 MATCH_INVOICE_POSSIBLE_DUPLICATE when the soft-duplicate detector finds a manual voucher', async () => { + const tx = makeTransaction({ id: 'tx-1', amount: 1000, invoice_id: null, date: '2026-05-15' }) + const invoice = makeInvoice({ id: VALID_UUID, status: 'sent', total: 1000, remaining_amount: 1000 }) + + enqueue({ data: tx, error: null }) + enqueue({ data: invoice, error: null }) + enqueue({ data: [], error: null }) // hard-duplicate check: clean + + mockDetectDuplicate.mockResolvedValueOnce({ + journal_entry_id: 'je-manual', + voucher_label: 'A12', + entry_date: '2026-05-15', + description: 'Inbetalning faktura', + amount: 1000, + bank_account_number: '1930', + reason: 'exact_amount_same_date', + }) + + const request = createMockRequest('/api/transactions/tx-1/match-invoice', { + method: 'POST', + body: { invoice_id: VALID_UUID }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ + error: { code: string; details?: { candidate?: { journal_entry_id: string; voucher_label: string } } } + }>(response) + + expect(status).toBe(409) + expect(body.error.code).toBe('MATCH_INVOICE_POSSIBLE_DUPLICATE') + expect(body.error.details?.candidate?.journal_entry_id).toBe('je-manual') + expect(body.error.details?.candidate?.voucher_label).toBe('A12') + expect(mockCreateInvoicePaymentJournalEntry).not.toHaveBeenCalled() + }) + + it('force=true bypasses the soft-duplicate guard when the candidate echo matches', async () => { + const tx = makeTransaction({ id: 'tx-1', amount: 1000, invoice_id: null, date: '2026-05-15' }) + const invoice = makeInvoice({ + id: VALID_UUID, + status: 'sent', + total: 1000, + remaining_amount: 1000, + invoice_number: 'F-2024099', + }) + + enqueue({ data: tx, error: null }) + enqueue({ data: invoice, error: null }) + enqueue({ data: [], error: null }) // hard-duplicate check: clean + + // force=true re-detects the candidate to verify the echoed id matches. + mockDetectDuplicate.mockResolvedValueOnce({ + journal_entry_id: CANDIDATE_UUID, + voucher_label: 'A12', + entry_date: '2026-05-15', + description: 'Inbetalning faktura', + amount: 1000, + bank_account_number: '1930', + reason: 'exact_amount_same_date', + }) + + enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) + + mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-forced' }) + enqueue({ data: [{ id: VALID_UUID }], error: null }) // update invoice + enqueue({ data: null, error: null }) // insert invoice_payments + enqueue({ data: null, error: null }) // update tx + enqueue({ data: null, error: null }) // logMatchEvent + + const request = createMockRequest('/api/transactions/tx-1/match-invoice', { + method: 'POST', + body: { invoice_id: VALID_UUID, force: true, expected_journal_entry_id: CANDIDATE_UUID }, + }) + 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-forced') + expect(mockDetectDuplicate).toHaveBeenCalledTimes(1) + }) + + it('returns 400 when force=true is sent without expected_journal_entry_id', async () => { + const request = createMockRequest('/api/transactions/tx-1/match-invoice', { + method: 'POST', + body: { invoice_id: VALID_UUID, force: true }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status } = await parseJsonResponse(response) + // Refusal happens at the schema layer (refine) before any DB work. + expect(status).toBe(400) + }) + + it('returns 409 MATCH_INVOICE_FORCE_CANDIDATE_MISMATCH when the echoed candidate no longer matches', async () => { + const tx = makeTransaction({ id: 'tx-1', amount: 1000, invoice_id: null, date: '2026-05-15' }) + const invoice = makeInvoice({ id: VALID_UUID, status: 'sent', total: 1000, remaining_amount: 1000 }) + + enqueue({ data: tx, error: null }) + enqueue({ data: invoice, error: null }) + enqueue({ data: [], error: null }) // hard-duplicate check: clean + + // Re-detection returns a different candidate than the caller echoed. + mockDetectDuplicate.mockResolvedValueOnce({ + journal_entry_id: OTHER_CANDIDATE_UUID, + voucher_label: 'A99', + entry_date: '2026-05-15', + description: 'Annan verifikation', + amount: 1000, + bank_account_number: '1930', + reason: 'exact_amount_same_date', + }) + + const request = createMockRequest('/api/transactions/tx-1/match-invoice', { + method: 'POST', + body: { invoice_id: VALID_UUID, force: true, expected_journal_entry_id: STALE_UUID }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ + error: { code: string; details?: { expected_journal_entry_id?: string; detected_journal_entry_id?: string } } + }>(response) + + expect(status).toBe(409) + expect(body.error.code).toBe('MATCH_INVOICE_FORCE_CANDIDATE_MISMATCH') + expect(body.error.details?.expected_journal_entry_id).toBe(STALE_UUID) + expect(body.error.details?.detected_journal_entry_id).toBe(OTHER_CANDIDATE_UUID) + expect(mockCreateInvoicePaymentJournalEntry).not.toHaveBeenCalled() + }) + + it('returns 409 MATCH_INVOICE_FORCE_CANDIDATE_MISMATCH when no current duplicate exists for the force call', async () => { + const tx = makeTransaction({ id: 'tx-1', amount: 1000, invoice_id: null, date: '2026-05-15' }) + const invoice = makeInvoice({ id: VALID_UUID, status: 'sent', total: 1000, remaining_amount: 1000 }) + + enqueue({ data: tx, error: null }) + enqueue({ data: invoice, error: null }) + enqueue({ data: [], error: null }) // hard-duplicate check: clean + + // Detection returns null — the duplicate the caller saw has resolved. + mockDetectDuplicate.mockResolvedValueOnce(null) + + const request = createMockRequest('/api/transactions/tx-1/match-invoice', { + method: 'POST', + body: { invoice_id: VALID_UUID, force: true, expected_journal_entry_id: STALE_UUID }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + expect(status).toBe(409) + expect(body.error.code).toBe('MATCH_INVOICE_FORCE_CANDIDATE_MISMATCH') + expect(mockCreateInvoicePaymentJournalEntry).not.toHaveBeenCalled() + }) }) diff --git a/app/api/transactions/[id]/match-invoice/route.ts b/app/api/transactions/[id]/match-invoice/route.ts index 454c8be7..ed7c32b1 100644 --- a/app/api/transactions/[id]/match-invoice/route.ts +++ b/app/api/transactions/[id]/match-invoice/route.ts @@ -11,6 +11,7 @@ import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structure import { validateBody } from '@/lib/api/validate' import { MatchInvoiceSchema } from '@/lib/api/schemas' import { logMatchEvent } from '@/lib/invoices/match-log' +import { detectDuplicatePaymentVoucher } from '@/lib/invoices/duplicate-payment-detection' import { eventBus } from '@/lib/events/bus' import { ensureInitialized } from '@/lib/init' import type { EntityType, Invoice, Transaction } from '@/types' @@ -40,7 +41,7 @@ export const POST = withRouteContext( operation: 'transaction.match_invoice', }) if (!validation.success) return validation.response - const { invoice_id } = validation.data + const { invoice_id, force, expected_journal_entry_id } = validation.data const txLog = log.child({ transactionId, invoiceId: invoice_id }) @@ -100,6 +101,96 @@ export const POST = withRouteContext( }) } + // Hard-duplicate guard: if the invoice is 'sent'/'overdue' but already + // has a payment voucher attached (status leak), refuse — booking again + // would double-credit 1510 / double-debit 1930. Partially-paid invoices + // pass through; additional payments are legitimate. + if (invoice.status === 'sent' || invoice.status === 'overdue') { + const { data: existingPayments } = await supabase + .from('invoice_payments') + .select('journal_entry_id') + .eq('company_id', companyId) + .eq('invoice_id', invoice_id) + .not('journal_entry_id', 'is', null) + .limit(1) + if (existingPayments && existingPayments.length > 0) { + return errorResponseFromCode('MATCH_INVOICE_ALREADY_HAS_PAYMENT_VOUCHER', txLog, { + requestId, + details: { + existing_journal_entry_id: (existingPayments[0] as { journal_entry_id: string }).journal_entry_id, + }, + }) + } + } + + // Soft-duplicate guard: scan for a manual verifikation that already + // books this bank receipt outside the invoice flow. The customer's + // exact case: they posted Dr 1930 / Cr 3100 by hand; the matcher + // would otherwise create a second voucher and double-book. Bypassed + // with force=true after the user reviews the candidate in the UI. + // + // force=true is bound to a specific candidate via expected_journal_entry_id + // (validated by the schema). We re-detect the candidate server-side and + // refuse the bypass if it no longer matches: a stale or fabricated + // expected id cannot wave the guard away. The pre-flight runs even when + // a candidate is detected so the audit log records the verifikation the + // user opted to dismiss. + let dismissedCandidateId: string | null = null + try { + const candidate = await detectDuplicatePaymentVoucher(supabase, { + companyId: companyId!, + transactionId, + transactionDate: transaction.date, + transactionAmount: transaction.amount, + }) + if (!force) { + if (candidate) { + return errorResponseFromCode('MATCH_INVOICE_POSSIBLE_DUPLICATE', txLog, { + requestId, + details: { candidate }, + }) + } + } else { + if (!candidate || candidate.journal_entry_id !== expected_journal_entry_id) { + // Either no current duplicate (force is moot — caller should retry + // without force) or the candidate the caller claims to have seen + // doesn't match what we detect now. Reject so an automation can't + // smuggle force=true past the guard with a guessed id. + return errorResponseFromCode('MATCH_INVOICE_FORCE_CANDIDATE_MISMATCH', txLog, { + requestId, + details: { + expected_journal_entry_id, + detected_journal_entry_id: candidate?.journal_entry_id ?? null, + }, + }) + } + dismissedCandidateId = candidate.journal_entry_id + } + } catch (err) { + // Detection failure must not block the non-force match — log and + // continue. force=true requires a successful detection, so re-throw + // its branch as a clean 500 via the wrapper. + if (force) { + txLog.error('duplicate-payment-voucher detection failed under force=true', err as Error) + return errorResponse(err, txLog, { requestId }) + } + txLog.warn('duplicate-payment-voucher detection failed (continuing)', err as Error) + } + + if (force && dismissedCandidateId) { + txLog.warn('soft-duplicate guard bypassed', { + reason: 'force=true', + requestId, + transactionId, + invoiceId: invoice_id, + userId: user.id, + // The verifikation the user reviewed and dismissed. Recorded so the + // override can be traced back to the specific duplicate that was + // surfaced in the pre-flight UI. + dismissedJournalEntryId: dismissedCandidateId, + }) + } + // Storno conflicting auto-categorization JE before any other state change. // If storno fails, return immediately — nothing else has been modified. if (transaction.journal_entry_id) { diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts b/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts index 2b61dadc..b1191588 100644 --- a/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts @@ -30,6 +30,7 @@ import { reverseEntry } from '@/lib/bookkeeping/engine' import { AccountsNotInChartError, isBookkeepingError } from '@/lib/bookkeeping/errors' import { getErrorMessage } from '@/lib/errors/get-error-message' import { logMatchEvent } from '@/lib/invoices/match-log' +import { detectDuplicatePaymentVoucher } from '@/lib/invoices/duplicate-payment-detection' import { eventBus } from '@/lib/events/bus' import type { EntityType, Invoice, Transaction } from '@/types' @@ -122,7 +123,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }, }) } - const { invoice_id } = parsed.data + const { invoice_id, force, expected_journal_entry_id } = parsed.data const txLog = ctx.log.child({ transactionId: txId, invoiceId: invoice_id }) const { data: transaction, error: fetchTxErr } = await ctx.supabase @@ -184,6 +185,86 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }) } + // Hard-duplicate guard: status leak — the invoice still says + // 'sent'/'overdue' but already has a payment voucher attached. Mirror + // of the internal route's defensive check. + if (invoice.status === 'sent' || invoice.status === 'overdue') { + const { data: existingPayments } = await ctx.supabase + .from('invoice_payments') + .select('journal_entry_id') + .eq('company_id', ctx.companyId!) + .eq('invoice_id', invoice_id) + .not('journal_entry_id', 'is', null) + .limit(1) + if (existingPayments && existingPayments.length > 0) { + return v1ErrorResponseFromCode('MATCH_INVOICE_ALREADY_HAS_PAYMENT_VOUCHER', txLog, { + requestId: ctx.requestId, + details: { + existing_journal_entry_id: + (existingPayments[0] as { journal_entry_id: string }).journal_entry_id, + }, + }) + } + } + + // Soft-duplicate guard: a manual verifikation already books this + // bank receipt. Bypassed only when the caller echoes the candidate's + // journal_entry_id back in expected_journal_entry_id (validated by + // the schema). The Idempotency-Key body hash already prevents replay + // with a different body, and re-detecting the candidate here means an + // automation can't fabricate or stale-roll an id past the guard. + let dismissedCandidateId: string | null = null + try { + const candidate = await detectDuplicatePaymentVoucher(ctx.supabase, { + companyId: ctx.companyId!, + transactionId: txId, + transactionDate: transaction.date, + transactionAmount: transaction.amount, + }) + if (!force) { + if (candidate) { + return v1ErrorResponseFromCode('MATCH_INVOICE_POSSIBLE_DUPLICATE', txLog, { + requestId: ctx.requestId, + details: { candidate }, + }) + } + } else { + if (!candidate || candidate.journal_entry_id !== expected_journal_entry_id) { + return v1ErrorResponseFromCode('MATCH_INVOICE_FORCE_CANDIDATE_MISMATCH', txLog, { + requestId: ctx.requestId, + details: { + expected_journal_entry_id, + detected_journal_entry_id: candidate?.journal_entry_id ?? null, + }, + }) + } + dismissedCandidateId = candidate.journal_entry_id + } + } catch (err) { + if (force) { + txLog.error('duplicate-payment-voucher detection failed under force=true', err as Error) + return v1ErrorResponse(err, txLog, { requestId: ctx.requestId }) + } + txLog.warn('duplicate-payment-voucher detection failed (continuing)', err as Error) + } + + if (force && dismissedCandidateId) { + txLog.warn('soft-duplicate guard bypassed', { + reason: 'force=true', + requestId: ctx.requestId, + transactionId: txId, + invoiceId: invoice_id, + // Attribute the override to the calling user AND the API key. The + // user identifier alone is not enough for v1 — a single user can + // hold multiple keys (CI bot, integration, personal), and revocation + // / abuse triage needs to know which key was used. + userId: ctx.userId, + apiKeyId: ctx.apiKeyId, + // The verifikation the caller acknowledged and dismissed. + dismissedJournalEntryId: dismissedCandidateId, + }) + } + if (transaction.journal_entry_id) { try { await reverseEntry(ctx.supabase, ctx.companyId!, ctx.userId, transaction.journal_entry_id) diff --git a/components/bookkeeping/CorrectionAffordance.tsx b/components/bookkeeping/CorrectionAffordance.tsx new file mode 100644 index 00000000..f3b1f277 --- /dev/null +++ b/components/bookkeeping/CorrectionAffordance.tsx @@ -0,0 +1,95 @@ +'use client' + +/** + * Lazy entry point for CorrectionEntryDialog when the user is not on the + * /bookkeeping/[id] page (e.g. invoice detail, transaction row). Renders a + * trigger (button or link slot) that, on click, fetches the journal entry + * with its lines and opens the existing CorrectionEntryDialog. + * + * Used by: + * - /invoices/[id] when invoice.journal_entry_id is set + * - /transactions row menu when transaction.journal_entry_id is set + * + * Surfacing the storno+rättelse flow at the point where users notice the + * mistake matters — the dialog itself was already correct (it pre-fills + * lines and emits the storno+correction pair per BFL), but it was hidden + * behind a deep-link the customer never reached. + */ +import { useState } from 'react' +import CorrectionEntryDialog from '@/components/bookkeeping/CorrectionEntryDialog' +import { useToast } from '@/components/ui/use-toast' +import { getErrorMessage } from '@/lib/errors/get-error-message' +import type { JournalEntry } from '@/types' + +interface Props { + journalEntryId: string + onCorrected?: () => void + /** + * Render prop: receives the click handler and current loading state. + * Letting the caller render its own trigger keeps the affordance visually + * native to its host page (link on invoice detail, menu item in dropdown). + */ + children: (args: { open: () => void; isLoading: boolean }) => React.ReactNode +} + +export default function CorrectionAffordance({ journalEntryId, onCorrected, children }: Props) { + const { toast } = useToast() + const [entry, setEntry] = useState(null) + const [open, setOpen] = useState(false) + const [isLoading, setIsLoading] = useState(false) + + async function handleOpen() { + if (isLoading) return + setIsLoading(true) + try { + const res = await fetch(`/api/bookkeeping/journal-entries/${journalEntryId}`) + const json = await res.json() + if (!res.ok) { + toast({ + title: 'Kunde inte hämta verifikationen', + description: getErrorMessage(json, { context: 'journal_entry', statusCode: res.status }), + variant: 'destructive', + }) + return + } + const fetched = json.data as JournalEntry + if (fetched.status !== 'posted') { + toast({ + title: 'Verifikationen kan inte ändras', + description: + 'Endast bokförda verifikationer kan rättas. Utkast hanteras direkt under bokföringen.', + variant: 'destructive', + }) + return + } + setEntry(fetched) + setOpen(true) + } catch (err) { + toast({ + title: 'Kunde inte hämta verifikationen', + description: getErrorMessage(err, { context: 'journal_entry' }), + variant: 'destructive', + }) + } finally { + setIsLoading(false) + } + } + + return ( + <> + {children({ open: handleOpen, isLoading })} + {entry && ( + { + setOpen(false) + setEntry(null) + onCorrected?.() + }} + /> + )} + + ) +} diff --git a/components/transactions/InvoiceMatchDialog.tsx b/components/transactions/InvoiceMatchDialog.tsx index c8a689ed..fb39f8db 100644 --- a/components/transactions/InvoiceMatchDialog.tsx +++ b/components/transactions/InvoiceMatchDialog.tsx @@ -1,17 +1,29 @@ 'use client' +import { useEffect, useState } from 'react' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog' import { formatCurrency, formatDate } from '@/lib/utils' import { CheckCircle2, AlertTriangle } from 'lucide-react' import type { TransactionWithInvoice } from './transaction-types' +interface DuplicateCandidate { + journal_entry_id: string + voucher_label: string + entry_date: string + description: string | null + amount: number + bank_account_number: string + reason: 'exact_amount_same_date' | 'exact_amount_within_window' +} + interface InvoiceMatchDialogProps { open: boolean onOpenChange: (open: boolean) => void transaction: TransactionWithInvoice | null isConfirming: boolean - onConfirm: () => void + onConfirm: (opts?: { force?: boolean; expected_journal_entry_id?: string }) => void + onLinkToExisting?: (journalEntryId: string) => void } export default function InvoiceMatchDialog({ @@ -20,9 +32,43 @@ export default function InvoiceMatchDialog({ transaction, isConfirming, onConfirm, + onLinkToExisting, }: InvoiceMatchDialogProps) { const isSupplierInvoice = !!transaction?.potential_supplier_invoice const isCustomerInvoice = !!transaction?.potential_invoice + const transactionId = transaction?.id ?? null + + // Customer-side only: pre-flight check for a manual verifikation that + // already books this receipt. Supplier-side duplicate-payment surfacing + // is handled by the mark-paid guard on the supplier-invoice side; here + // we only need the customer flow for the reported issue. + const [candidate, setCandidate] = useState(null) + const [isCheckingDuplicate, setIsCheckingDuplicate] = useState(false) + + useEffect(() => { + if (!open || !transactionId || !isCustomerInvoice || !onLinkToExisting) { + setCandidate(null) + return + } + let cancelled = false + async function check() { + setIsCheckingDuplicate(true) + try { + const res = await fetch(`/api/transactions/${transactionId}/duplicate-payment-check`) + if (!res.ok) return + const data = (await res.json()) as { candidate: DuplicateCandidate | null } + if (!cancelled) setCandidate(data.candidate ?? null) + } catch { + // Fail-open: hide the warning panel; the server still enforces the guard. + } finally { + if (!cancelled) setIsCheckingDuplicate(false) + } + } + check() + return () => { + cancelled = true + } + }, [open, transactionId, isCustomerInvoice, onLinkToExisting]) // The invoice candidate the dialog is about, normalized to a single shape. // Supplier invoices show the negative-amount paid-out match; customer @@ -43,6 +89,66 @@ export default function InvoiceMatchDialog({ {transaction && (isCustomerInvoice || isSupplierInvoice) && (
+ {/* Duplicate-payment warning — customer-side only, only when a candidate exists */} + {candidate && isCustomerInvoice && ( +
+
+ +
+

Möjlig dubblettbokning

+

+ Det finns redan en bokförd verifikation {candidate.voucher_label} på samma belopp ({formatCurrency(candidate.amount, transaction.currency)}) {candidate.reason === 'exact_amount_same_date' ? 'på samma datum' : `inom ±7 dagar (${formatDate(candidate.entry_date)})`}. + Har du redan bokfört denna betalning manuellt? +

+ {candidate.description && ( + // Truncate to a short head before render. The + // description is free-text and may carry a customer + // name or note that's not strictly required to + // identify the verifikation (voucher_label + amount + + // date already do that). Cap length to keep the + // dialog tight and limit incidental PII surfacing + // in the rendered DOM. GDPR Art.5(1)(c). +

+ {candidate.description.length > 80 + ? `${candidate.description.slice(0, 80).trimEnd()}…` + : candidate.description} +

+ )} +
+
+ {onLinkToExisting && ( +
+ + +
+ )} +
+ )} + {/* Transaction details */}

Transaktion

@@ -156,7 +262,7 @@ export default function InvoiceMatchDialog({ - diff --git a/components/transactions/InvoicePicker.tsx b/components/transactions/InvoicePicker.tsx index b3900756..dca4d4bf 100644 --- a/components/transactions/InvoicePicker.tsx +++ b/components/transactions/InvoicePicker.tsx @@ -26,6 +26,11 @@ export default function InvoicePicker({ transaction, onSelect, isProcessing }: I useEffect(() => { if (!company) return + // Capture the company id once so the async closure below never + // dereferences a `company` that has flipped to null between renders. + // The earlier non-null assertions allowed a stale render to query + // against an undefined company_id; pinning the value avoids that. + const companyId = company.id let cancelled = false async function load() { setIsLoading(true) @@ -39,14 +44,38 @@ export default function InvoicePicker({ transaction, onSelect, isProcessing }: I const { data } = await supabase .from('invoices') .select('*, customer:customers(*)') - .eq('company_id', company!.id) + .eq('company_id', companyId) .eq('document_type', 'invoice') .in('status', ['sent', 'overdue', 'partially_paid']) .gt('remaining_amount', 0) .order('invoice_date', { ascending: false }) .limit(200) if (cancelled) return - setInvoices((data as OpenInvoice[]) || []) + const all = (data as OpenInvoice[]) || [] + + // Status-leak guard: if an invoice still says 'sent'/'overdue' but + // already has a payment voucher attached (manual or system), hide it. + // Partially-paid invoices intentionally pass through — they may take + // more payments. Mirrors the server-side filter in findMatchingInvoices. + const fullIds = all + .filter((inv) => inv.status === 'sent' || inv.status === 'overdue') + .map((inv) => inv.id) + let visible = all + if (fullIds.length > 0) { + const { data: paid } = await supabase + .from('invoice_payments') + .select('invoice_id') + .eq('company_id', companyId) + .in('invoice_id', fullIds) + .not('journal_entry_id', 'is', null) + if (cancelled) return + const paidSet = new Set( + ((paid as { invoice_id: string }[] | null) ?? []).map((r) => r.invoice_id), + ) + visible = all.filter((inv) => !paidSet.has(inv.id)) + } + + setInvoices(visible) setIsLoading(false) } load() diff --git a/components/transactions/TransactionHistoryList.tsx b/components/transactions/TransactionHistoryList.tsx index 12d94783..56fdd4c6 100644 --- a/components/transactions/TransactionHistoryList.tsx +++ b/components/transactions/TransactionHistoryList.tsx @@ -21,6 +21,8 @@ import { Trash2, } from 'lucide-react' import { TransactionAttachmentIndicator } from './TransactionAttachmentIndicator' +import CorrectionAffordance from '@/components/bookkeeping/CorrectionAffordance' +import { useCanWrite } from '@/lib/hooks/use-can-write' import type { TransactionWithInvoice, HistoryFilter } from './transaction-types' import type { SkattekontoTransactionWithSuggestion, @@ -216,6 +218,11 @@ function BankHistoryRow({ onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void onDelete?: (id: string) => void }) { + // Viewers must not see write affordances. CorrectionAffordance opens a + // dialog that stages a storno + correction journal entry; the API path + // already 403s for viewers but rendering the trigger creates a confusing + // dead end. Mirrors the canWrite gate on the invoice detail page. + const { canWrite } = useCanWrite() return ( @@ -272,6 +279,26 @@ function BankHistoryRow({ Bokförd + + Visa verifikation + + {canWrite && ( + + {({ open, isLoading }) => ( + + )} + + )} ) : ( <> diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 4c7675f3..3139e8b0 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -360,8 +360,35 @@ export const BookInboxItemDirectlySchema = z.object({ transaction_id: uuid.optional(), }) -export const MatchInvoiceSchema = z.object({ - invoice_id: uuid, +export const MatchInvoiceSchema = z + .object({ + invoice_id: uuid, + // Bypass the soft-duplicate guard (MATCH_INVOICE_POSSIBLE_DUPLICATE). + // Set after the user reviews the candidate verifikation and confirms it + // is not this payment. v1 callers must use a fresh Idempotency-Key on + // the retry — the original is body-hash bound. + force: z.boolean().optional(), + // Required whenever force=true. Echoes the journal_entry_id of the + // candidate the user reviewed in the duplicate-payment-check pre-flight. + // The server re-detects the candidate and refuses force=true unless the + // re-detected id matches this value. That binds the override to a + // specific, user-seen duplicate so an automation can't sweep through + // force=true to bypass the guard without ever consulting the candidate. + expected_journal_entry_id: uuid.optional(), + }) + .refine((v) => !v.force || !!v.expected_journal_entry_id, { + message: 'expected_journal_entry_id is required when force=true', + path: ['expected_journal_entry_id'], + }) + +export const LinkTransactionJournalEntrySchema = z.object({ + journal_entry_id: uuid, + // Optional invoice to settle alongside the link. When provided, the + // server inserts an invoice_payments row pointing at the existing JE + // and flips the invoice status with the same optimistic-lock pattern + // as the match-invoice route. Omit to only link the bank transaction + // (e.g. when the JE doesn't relate to a customer invoice). + invoice_id: uuid.optional(), }) export const CreateTransactionFromDocumentSchema = z.object({ diff --git a/lib/api/with-route-context.ts b/lib/api/with-route-context.ts index e3800438..95d99c43 100644 --- a/lib/api/with-route-context.ts +++ b/lib/api/with-route-context.ts @@ -45,6 +45,13 @@ export interface RouteContext { * resolved, so handlers can treat this as guaranteed non-null. Routes that * need to opt out of the guarantee (e.g. onboarding) shouldn't use * withRouteContext. + * + * Membership invariant: `getActiveCompanyId` only returns a company the + * authenticated user is a current member of (it validates + * `company_members` and excludes archived companies). The handler may + * therefore treat `companyId` as "a company the caller is authorized to + * read", and routes that mutate state additionally enforce a non-viewer + * role via `requireWrite: true`. ASVS V8.2.1 / SOC 2 CC6.3. */ companyId: string } diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index 8e4263f7..aeb3a902 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -356,6 +356,60 @@ const MATCH_INVOICE: Record = { message_sv: 'Matchningen registrerades men verifikationen kunde inte skapas.', message_en: 'Match recorded but the journal entry could not be created.', }, + MATCH_INVOICE_ALREADY_HAS_PAYMENT_VOUCHER: { + httpStatus: 409, + message_sv: + 'Fakturan har redan en betalningsverifikation. Koppla istället bankhändelsen till befintlig verifikation, eller rätta tidigare bokföring först.', + message_en: + 'Invoice already has a payment journal entry. Link the bank transaction to the existing voucher instead, or correct the prior bookkeeping first.', + }, + MATCH_INVOICE_POSSIBLE_DUPLICATE: { + httpStatus: 409, + message_sv: + 'Det finns redan en bokförd verifikation på samma belopp och datum. Har du redan bokfört denna betalning? Koppla bankhändelsen till befintlig verifikation, eller skapa ny verifikation ändå om de inte hör ihop.', + message_en: + 'A posted journal entry already books the same amount on a nearby date. The user may have already booked this payment manually — link to the existing voucher or pass force=true to create a new one anyway.', + }, + MATCH_INVOICE_FORCE_CANDIDATE_MISMATCH: { + httpStatus: 409, + message_sv: + 'Verifikationen som dubblettkontrollen visade matchar inte längre. Stäng dialogen och försök igen så att rätt verifikation visas.', + message_en: + 'The candidate journal entry echoed in expected_journal_entry_id does not match the one detected at request time. Re-run the duplicate-payment pre-flight to obtain the current candidate, then retry.', + }, +} + +const LINK_TX_JE: Record = { + LINK_TX_JE_NOT_FOUND: { + httpStatus: 404, + message_sv: 'Verifikationen kunde inte hittas.', + message_en: 'Journal entry not found.', + }, + LINK_TX_JE_NOT_POSTED: { + httpStatus: 400, + message_sv: 'Endast bokförda verifikationer kan kopplas till en banktransaktion.', + message_en: 'Only posted journal entries can be linked to a transaction.', + }, + LINK_TX_TX_ALREADY_LINKED: { + httpStatus: 400, + message_sv: 'Transaktionen är redan kopplad till en verifikation.', + message_en: 'Transaction is already linked to a journal entry.', + }, + LINK_TX_INVOICE_NOT_FOUND: { + httpStatus: 404, + message_sv: 'Fakturan kunde inte hittas.', + message_en: 'Invoice not found.', + }, + LINK_TX_INVOICE_NOT_OPEN: { + httpStatus: 400, + message_sv: 'Fakturan är inte i ett obetalt läge och kan inte kopplas.', + message_en: 'Invoice is not in an unpaid state.', + }, + LINK_TX_INVOICE_RACE: { + httpStatus: 409, + message_sv: 'Fakturan ändrades samtidigt. Försök igen.', + message_en: 'Invoice status changed concurrently. Retry the request.', + }, } const MATCH_SI: Record = { @@ -1523,6 +1577,7 @@ const REGISTRY: Record = { ...BOOKKEEPING, ...TRANSACTIONS, ...MATCH_INVOICE, + ...LINK_TX_JE, ...MATCH_SI, ...INVOICE, ...SUPPLIER_INVOICE, diff --git a/lib/invoices/__tests__/duplicate-payment-detection.test.ts b/lib/invoices/__tests__/duplicate-payment-detection.test.ts new file mode 100644 index 00000000..4a721bff --- /dev/null +++ b/lib/invoices/__tests__/duplicate-payment-detection.test.ts @@ -0,0 +1,348 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { detectDuplicatePaymentVoucher } from '../duplicate-payment-detection' +import { createQueuedMockSupabase } from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() + +describe('detectDuplicatePaymentVoucher', () => { + beforeEach(() => { + reset() + }) + + function makeLineRow(opts: { + je_id: string + account: string + debit: number + date: string + voucher_label?: string + source_type?: string | null + description?: string | null + }) { + const [series, ...numParts] = (opts.voucher_label ?? 'A1').split('') + const num = parseInt(numParts.join(''), 10) || 1 + return { + account_number: opts.account, + debit_amount: opts.debit, + journal_entry: { + id: opts.je_id, + entry_date: opts.date, + description: opts.description ?? `Voucher ${opts.je_id}`, + voucher_series: series, + voucher_number: num, + status: 'posted', + source_type: opts.source_type ?? 'manual', + company_id: 'company-1', + }, + } + } + + it('returns null when transaction amount is 0', async () => { + const result = await detectDuplicatePaymentVoucher(supabase as never, { + companyId: 'company-1', + transactionId: 'tx-1', + transactionDate: '2026-05-15', + transactionAmount: 0, + }) + expect(result).toBeNull() + }) + + it('returns null when transaction date is invalid', async () => { + const result = await detectDuplicatePaymentVoucher(supabase as never, { + companyId: 'company-1', + transactionId: 'tx-1', + transactionDate: 'not-a-date', + transactionAmount: 1000, + }) + expect(result).toBeNull() + }) + + it('returns null when no lines are found', async () => { + enqueue({ data: [], error: null }) + const result = await detectDuplicatePaymentVoucher(supabase as never, { + companyId: 'company-1', + transactionId: 'tx-1', + transactionDate: '2026-05-15', + transactionAmount: 1000, + }) + expect(result).toBeNull() + }) + + it('returns the candidate when an unlinked manual JE matches exactly on the same date', async () => { + enqueue({ + data: [ + makeLineRow({ + je_id: 'je-1', + account: '1930', + debit: 1000, + date: '2026-05-15', + voucher_label: 'A12', + }), + ], + error: null, + }) + // invoice_payments link check (no links) + enqueue({ data: [], error: null }) + // transactions link check (no links) + enqueue({ data: [], error: null }) + + const result = await detectDuplicatePaymentVoucher(supabase as never, { + companyId: 'company-1', + transactionId: 'tx-1', + transactionDate: '2026-05-15', + transactionAmount: 1000, + }) + + expect(result).not.toBeNull() + expect(result!.journal_entry_id).toBe('je-1') + expect(result!.bank_account_number).toBe('1930') + expect(result!.reason).toBe('exact_amount_same_date') + expect(result!.amount).toBe(1000) + }) + + it('returns within_window reason when JE date is close but not equal', async () => { + enqueue({ + data: [ + makeLineRow({ + je_id: 'je-2', + account: '1930', + debit: 500, + date: '2026-05-12', + voucher_label: 'A5', + }), + ], + error: null, + }) + enqueue({ data: [], error: null }) + enqueue({ data: [], error: null }) + + const result = await detectDuplicatePaymentVoucher(supabase as never, { + companyId: 'company-1', + transactionId: 'tx-1', + transactionDate: '2026-05-15', + transactionAmount: 500, + }) + + expect(result).not.toBeNull() + expect(result!.reason).toBe('exact_amount_within_window') + }) + + it('excludes JEs that are already linked via invoice_payments', async () => { + enqueue({ + data: [ + makeLineRow({ + je_id: 'je-3', + account: '1930', + debit: 1000, + date: '2026-05-15', + }), + ], + error: null, + }) + // invoice_payments has a row linking this JE + enqueue({ data: [{ journal_entry_id: 'je-3' }], error: null }) + enqueue({ data: [], error: null }) + + const result = await detectDuplicatePaymentVoucher(supabase as never, { + companyId: 'company-1', + transactionId: 'tx-1', + transactionDate: '2026-05-15', + transactionAmount: 1000, + }) + + expect(result).toBeNull() + }) + + it('excludes JEs already linked from another transaction', async () => { + enqueue({ + data: [ + makeLineRow({ + je_id: 'je-4', + account: '1930', + debit: 1000, + date: '2026-05-15', + }), + ], + error: null, + }) + enqueue({ data: [], error: null }) + // another transaction already links this JE + enqueue({ data: [{ id: 'tx-other', journal_entry_id: 'je-4' }], error: null }) + + const result = await detectDuplicatePaymentVoucher(supabase as never, { + companyId: 'company-1', + transactionId: 'tx-1', + transactionDate: '2026-05-15', + transactionAmount: 1000, + }) + + expect(result).toBeNull() + }) + + it('excludes storno entries', async () => { + enqueue({ + data: [ + makeLineRow({ + je_id: 'je-storno', + account: '1930', + debit: 1000, + date: '2026-05-15', + source_type: 'storno', + }), + ], + error: null, + }) + enqueue({ data: [], error: null }) + enqueue({ data: [], error: null }) + + const result = await detectDuplicatePaymentVoucher(supabase as never, { + companyId: 'company-1', + transactionId: 'tx-1', + transactionDate: '2026-05-15', + transactionAmount: 1000, + }) + + expect(result).toBeNull() + }) + + it('excludes correction entries', async () => { + enqueue({ + data: [ + makeLineRow({ + je_id: 'je-corr', + account: '1930', + debit: 1000, + date: '2026-05-15', + source_type: 'correction', + }), + ], + error: null, + }) + enqueue({ data: [], error: null }) + enqueue({ data: [], error: null }) + + const result = await detectDuplicatePaymentVoucher(supabase as never, { + companyId: 'company-1', + transactionId: 'tx-1', + transactionDate: '2026-05-15', + transactionAmount: 1000, + }) + + expect(result).toBeNull() + }) + + it('picks the same-date candidate over a within-window candidate', async () => { + enqueue({ + data: [ + makeLineRow({ + je_id: 'je-far', + account: '1930', + debit: 1000, + date: '2026-05-12', + voucher_label: 'A1', + }), + makeLineRow({ + je_id: 'je-same', + account: '1930', + debit: 1000, + date: '2026-05-15', + voucher_label: 'A2', + }), + ], + error: null, + }) + enqueue({ data: [], error: null }) + enqueue({ data: [], error: null }) + + const result = await detectDuplicatePaymentVoucher(supabase as never, { + companyId: 'company-1', + transactionId: 'tx-1', + transactionDate: '2026-05-15', + transactionAmount: 1000, + }) + + expect(result).not.toBeNull() + expect(result!.journal_entry_id).toBe('je-same') + expect(result!.reason).toBe('exact_amount_same_date') + }) + + it('matches absolute value for negative transaction amounts (expense)', async () => { + enqueue({ + data: [ + makeLineRow({ + je_id: 'je-x', + account: '1930', + debit: 250, + date: '2026-05-15', + }), + ], + error: null, + }) + enqueue({ data: [], error: null }) + enqueue({ data: [], error: null }) + + const result = await detectDuplicatePaymentVoucher(supabase as never, { + companyId: 'company-1', + transactionId: 'tx-1', + transactionDate: '2026-05-15', + transactionAmount: -250, + }) + + // Note: while the match-invoice route only handles income, the + // detector itself is amount-direction agnostic — it just finds JEs + // that book the same magnitude on the bank side. Callers gate by + // direction. + expect(result).not.toBeNull() + expect(result!.amount).toBe(250) + }) + + it('skips lines whose amount differs by more than 0.01', async () => { + enqueue({ + data: [ + makeLineRow({ + je_id: 'je-off', + account: '1930', + debit: 1001, + date: '2026-05-15', + }), + ], + error: null, + }) + + const result = await detectDuplicatePaymentVoucher(supabase as never, { + companyId: 'company-1', + transactionId: 'tx-1', + transactionDate: '2026-05-15', + transactionAmount: 1000, + }) + + expect(result).toBeNull() + }) + + it('ignores the caller transaction even if it carries a journal_entry_id link', async () => { + enqueue({ + data: [ + makeLineRow({ + je_id: 'je-caller', + account: '1930', + debit: 1000, + date: '2026-05-15', + }), + ], + error: null, + }) + enqueue({ data: [], error: null }) + // The caller transaction itself links the JE (defensive — shouldn't happen + // in normal flow because we call this before the link, but a retry could). + enqueue({ data: [{ id: 'tx-caller', journal_entry_id: 'je-caller' }], error: null }) + + const result = await detectDuplicatePaymentVoucher(supabase as never, { + companyId: 'company-1', + transactionId: 'tx-caller', + transactionDate: '2026-05-15', + transactionAmount: 1000, + }) + + expect(result).not.toBeNull() + expect(result!.journal_entry_id).toBe('je-caller') + }) +}) diff --git a/lib/invoices/__tests__/invoice-matching.test.ts b/lib/invoices/__tests__/invoice-matching.test.ts index 6ffc1e2e..58173acc 100644 --- a/lib/invoices/__tests__/invoice-matching.test.ts +++ b/lib/invoices/__tests__/invoice-matching.test.ts @@ -8,7 +8,13 @@ import { getBestInvoiceMatch, } from '../invoice-matching' import type { Transaction, Invoice, Customer } from '@/types' -import { makeTransaction, makeInvoice, makeCustomer, createMockSupabase } from '@/tests/helpers' +import { + makeTransaction, + makeInvoice, + makeCustomer, + createMockSupabase, + createQueuedMockSupabase, +} from '@/tests/helpers' // ============================================================ // amountsMatchExact @@ -353,3 +359,82 @@ describe('getBestInvoiceMatch', () => { expect(result).not.toBeNull() }) }) + +// ============================================================ +// findMatchingInvoices — paid-voucher status-leak guard +// ============================================================ +// +// Defensive filter added because manual verifikationer (booked outside the +// match-invoice flow) leave the invoice in 'sent' status even though a +// payment voucher exists via invoice_payments. Matching such an invoice +// would double-book the bank receipt. Tests verify that: +// - sent/overdue invoices with an invoice_payments.journal_entry_id are +// excluded from the candidate list +// - partially_paid invoices remain candidates regardless (they may take +// more payments legitimately) +// - invoices without payment rows still pass through unchanged + +describe('findMatchingInvoices — status-leak guard', () => { + it('excludes a sent invoice that already has a payment voucher', async () => { + const { supabase: queuedSupabase, enqueue } = createQueuedMockSupabase() + const inv = { + ...makeInvoice({ + id: 'inv-leaked', + total: 1000, + status: 'sent', + remaining_amount: 1000, + currency: 'SEK', + }), + customer: makeCustomer({ name: 'Acme AB' }), + } + enqueue({ data: [inv], error: null }) + enqueue({ data: [{ invoice_id: 'inv-leaked' }], error: null }) + + const tx = makeTransaction({ amount: 1000, description: 'Acme payment', reference: null }) + const result = await findMatchingInvoices(queuedSupabase as never, 'company-1', tx) + expect(result).toEqual([]) + }) + + it('keeps a partially_paid invoice as a candidate even with a prior payment voucher', async () => { + const { supabase: queuedSupabase, enqueue } = createQueuedMockSupabase() + const inv = { + ...makeInvoice({ + id: 'inv-partial', + total: 1000, + status: 'partially_paid', + remaining_amount: 400, + currency: 'SEK', + }), + customer: makeCustomer({ name: 'Acme AB' }), + } + enqueue({ data: [inv], error: null }) + // The status-leak guard only queries when there are sent/overdue rows; + // partially_paid invoices skip the second query, so no enqueue needed. + + const tx = makeTransaction({ amount: 400, description: 'Acme partial', reference: null }) + const result = await findMatchingInvoices(queuedSupabase as never, 'company-1', tx) + expect(result).toHaveLength(1) + expect(result[0].invoice.id).toBe('inv-partial') + }) + + it('passes sent invoices through when no payment rows exist for them', async () => { + const { supabase: queuedSupabase, enqueue } = createQueuedMockSupabase() + const inv = { + ...makeInvoice({ + id: 'inv-clean', + total: 1000, + status: 'sent', + remaining_amount: 1000, + currency: 'SEK', + }), + customer: makeCustomer({ name: 'Acme AB' }), + } + enqueue({ data: [inv], error: null }) + enqueue({ data: [], error: null }) + + const tx = makeTransaction({ amount: 1000, description: 'Acme payment', reference: null }) + const result = await findMatchingInvoices(queuedSupabase as never, 'company-1', tx) + expect(result).toHaveLength(1) + expect(result[0].invoice.id).toBe('inv-clean') + }) +}) diff --git a/lib/invoices/__tests__/reminder-processor.test.ts b/lib/invoices/__tests__/reminder-processor.test.ts new file mode 100644 index 00000000..902e7f86 --- /dev/null +++ b/lib/invoices/__tests__/reminder-processor.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const chainCalls: Array<{ method: string; args: unknown[] }> = [] + +vi.mock('@supabase/ssr', () => { + const buildChain = (): unknown => + new Proxy( + {}, + { + get(_t, prop) { + if (prop === 'then') { + return (resolve: (v: unknown) => void) => + resolve({ data: [], error: null, count: null }) + } + return (...args: unknown[]) => { + chainCalls.push({ method: String(prop), args }) + return buildChain() + } + }, + }, + ) + + return { + createServerClient: vi.fn(() => ({ + from: vi.fn(() => buildChain()), + rpc: vi.fn(() => buildChain()), + })), + } +}) + +vi.mock('@/lib/email/service', () => ({ + getEmailService: () => ({ + sendEmail: vi.fn().mockResolvedValue({ success: true }), + }), +})) + +import { + processOverdueReminders, + determineReminderLevel, + calculateDaysOverdue, +} from '../reminder-processor' + +describe('determineReminderLevel', () => { + it('returns null below the level-1 threshold', () => { + expect(determineReminderLevel(10, [])).toBeNull() + }) + + it('returns 1 at 15 days overdue', () => { + expect(determineReminderLevel(15, [])).toBe(1) + }) + + it('returns 2 at 30 days when level 1 already sent', () => { + expect(determineReminderLevel(30, [1])).toBe(2) + }) + + it('returns 3 at 45 days when 1 and 2 already sent', () => { + expect(determineReminderLevel(45, [1, 2])).toBe(3) + }) + + it('returns null when all levels have been sent', () => { + expect(determineReminderLevel(60, [1, 2, 3])).toBeNull() + }) +}) + +describe('calculateDaysOverdue', () => { + it('returns a positive number for a past due date', () => { + const tenDaysAgo = new Date() + tenDaysAgo.setDate(tenDaysAgo.getDate() - 10) + const days = calculateDaysOverdue(tenDaysAgo.toISOString().split('T')[0]) + expect(days).toBeGreaterThanOrEqual(9) + expect(days).toBeLessThanOrEqual(10) + }) +}) + +describe('processOverdueReminders — credit-note filter', () => { + beforeEach(() => { + chainCalls.length = 0 + }) + + it('excludes credit notes via .is("credited_invoice_id", null)', async () => { + await processOverdueReminders() + + const isCall = chainCalls.find( + (c) => c.method === 'is' && c.args[0] === 'credited_invoice_id', + ) + + expect( + isCall, + 'overdue-invoice query must filter out credit notes — credit notes have a negative total and must never trigger a payment reminder (e.g. KR-F2026002)', + ).toBeDefined() + expect(isCall?.args[1]).toBeNull() + }) + + it('combines the credit-note filter with status=sent and due_date cutoff', async () => { + await processOverdueReminders() + + const eqStatus = chainCalls.find( + (c) => c.method === 'eq' && c.args[0] === 'status', + ) + const isCreditedNull = chainCalls.find( + (c) => c.method === 'is' && c.args[0] === 'credited_invoice_id', + ) + const lteDueDate = chainCalls.find( + (c) => c.method === 'lte' && c.args[0] === 'due_date', + ) + + expect(eqStatus?.args[1]).toBe('sent') + expect(isCreditedNull?.args[1]).toBeNull() + expect(lteDueDate).toBeDefined() + }) +}) diff --git a/lib/invoices/duplicate-payment-detection.ts b/lib/invoices/duplicate-payment-detection.ts new file mode 100644 index 00000000..fdfdeec6 --- /dev/null +++ b/lib/invoices/duplicate-payment-detection.ts @@ -0,0 +1,188 @@ +/** + * Detect a "soft duplicate" payment voucher for a bank transaction. + * + * Scenario: the user manually booked the receipt as a verifikation + * (Dr 19xx / Cr 1510 or Cr 30xx) *outside* the match-invoice flow. The + * invoice's status stays 'sent', no `invoice_payments` row exists, and the + * matcher would happily propose a second payment voucher — double-booking + * the bank receipt. + * + * Heuristic: a posted journal entry within a tight date window whose lines + * debit a bank/cash account (BAS 19xx) for the same amount, and which is + * not already linked to any transaction or invoice payment, is almost + * certainly the manual booking. We surface it as a candidate; the API + * refuses the match unless the caller passes `force: true`. + * + * Mirrors `findDuplicatePaymentCandidatesForInvoice` (which scans for the + * reverse direction — unlinked transactions that look like a manually-marked + * invoice payment). + */ +import type { SupabaseClient } from '@supabase/supabase-js' + +/** ± days around the transaction date considered "the same payment". */ +const DATE_WINDOW_DAYS = 7 + +/** BAS "kassa och bank" range. 1910-1919 = kassa, 1920-1949 = bank/giro. */ +const BANK_ACCOUNT_LOW = 1910 +const BANK_ACCOUNT_HIGH = 1949 + +export interface DuplicateVoucherCandidate { + journal_entry_id: string + voucher_label: string + entry_date: string + description: string | null + amount: number + bank_account_number: string + reason: 'exact_amount_same_date' | 'exact_amount_within_window' +} + +interface DetectArgs { + companyId: string + transactionId: string + transactionDate: string + transactionAmount: number +} + +/** + * Find the single most likely manual verifikation that already books this + * bank transaction. Returns null when no candidate is found. + * + * Filters applied: + * - posted status (drafts cannot be a duplicate by definition) + * - entry date within ±DATE_WINDOW_DAYS of transaction.date + * - has a line that debits a BAS 19xx (kassa/bank) account for the same + * rounded amount (within 0.01 SEK) + * - not already linked from `transactions.journal_entry_id` (for any row) + * - not already referenced by `invoice_payments.journal_entry_id` + * - not the storno/correction entry for any prior original (source_type + * excluded — those are valid second-line vouchers, not duplicates) + */ +export async function detectDuplicatePaymentVoucher( + supabase: SupabaseClient, + args: DetectArgs, +): Promise { + const { companyId, transactionId, transactionDate, transactionAmount } = args + const targetAmount = Math.round(Math.abs(transactionAmount) * 100) / 100 + if (targetAmount === 0) return null + + const dateMs = new Date(transactionDate).getTime() + if (Number.isNaN(dateMs)) return null + const lowDate = new Date(dateMs - DATE_WINDOW_DAYS * 24 * 3600 * 1000) + .toISOString() + .split('T')[0] + const highDate = new Date(dateMs + DATE_WINDOW_DAYS * 24 * 3600 * 1000) + .toISOString() + .split('T')[0] + + // Query journal_entry_lines for bank-account debits within the window. + // The join filters by company_id at the parent — RLS handles isolation, + // but we filter explicitly as defense-in-depth. + const { data: lines, error } = await supabase + .from('journal_entry_lines') + .select( + `account_number, + debit_amount, + journal_entry:journal_entries!inner( + id, + entry_date, + description, + voucher_series, + voucher_number, + status, + source_type, + company_id + )`, + ) + .eq('journal_entry.company_id', companyId) + .eq('journal_entry.status', 'posted') + .gte('journal_entry.entry_date', lowDate) + .lte('journal_entry.entry_date', highDate) + .gte('account_number', String(BANK_ACCOUNT_LOW)) + .lte('account_number', String(BANK_ACCOUNT_HIGH)) + .gt('debit_amount', 0) + .limit(50) + + if (error || !lines || lines.length === 0) return null + + // Narrow to lines whose debit matches the transaction amount within 0.01 SEK. + type LineRow = { + account_number: string + debit_amount: number | string + journal_entry: { + id: string + entry_date: string + description: string | null + voucher_series: string | null + voucher_number: number | null + status: string + source_type: string | null + } + } + const candidates = (lines as unknown as LineRow[]) + .filter((l) => { + const debit = Math.round(Number(l.debit_amount) * 100) / 100 + return Math.abs(debit - targetAmount) < 0.01 + }) + // System-generated payment vouchers (invoice_paid etc.) ARE valid + // duplicates to surface — those are exactly the case where the user + // already booked through a different flow. Only exclude reversals + // and corrections, which are bookkeeping noise rather than payment + // candidates the user would want to link to. + .filter((l) => l.journal_entry.source_type !== 'storno' && l.journal_entry.source_type !== 'correction') + + if (candidates.length === 0) return null + + // Exclude entries already linked from invoice_payments or any transaction. + const entryIds = candidates.map((l) => l.journal_entry.id) + + const [{ data: paymentLinks }, { data: txLinks }] = await Promise.all([ + supabase + .from('invoice_payments') + .select('journal_entry_id') + .eq('company_id', companyId) + .in('journal_entry_id', entryIds), + supabase + .from('transactions') + .select('id, journal_entry_id') + .eq('company_id', companyId) + .in('journal_entry_id', entryIds), + ]) + + const linkedIds = new Set() + for (const row of (paymentLinks ?? []) as { journal_entry_id: string | null }[]) { + if (row.journal_entry_id) linkedIds.add(row.journal_entry_id) + } + for (const row of (txLinks ?? []) as { id: string; journal_entry_id: string | null }[]) { + // A transaction can link to its own JE via the current match flow — but + // we're called *before* that link is created, so the caller's own + // transactionId shouldn't appear. Guard anyway in case of a retry. + if (row.journal_entry_id && row.id !== transactionId) { + linkedIds.add(row.journal_entry_id) + } + } + + const unlinked = candidates.filter((l) => !linkedIds.has(l.journal_entry.id)) + if (unlinked.length === 0) return null + + // Pick the best candidate: same-date beats within-window; otherwise pick + // the closest by date difference. + const targetDateMs = new Date(transactionDate).getTime() + unlinked.sort((a, b) => { + const aDiff = Math.abs(new Date(a.journal_entry.entry_date).getTime() - targetDateMs) + const bDiff = Math.abs(new Date(b.journal_entry.entry_date).getTime() - targetDateMs) + return aDiff - bDiff + }) + + const best = unlinked[0] + const sameDate = best.journal_entry.entry_date === transactionDate + + return { + journal_entry_id: best.journal_entry.id, + voucher_label: `${best.journal_entry.voucher_series ?? 'A'}${best.journal_entry.voucher_number ?? ''}`, + entry_date: best.journal_entry.entry_date, + description: best.journal_entry.description, + amount: Math.round(Number(best.debit_amount) * 100) / 100, + bank_account_number: best.account_number, + reason: sameDate ? 'exact_amount_same_date' : 'exact_amount_within_window', + } +} diff --git a/lib/invoices/invoice-matching.ts b/lib/invoices/invoice-matching.ts index cc9bab42..83e8b316 100644 --- a/lib/invoices/invoice-matching.ts +++ b/lib/invoices/invoice-matching.ts @@ -143,6 +143,30 @@ export async function findMatchingInvoices( return [] } + // Defensive filter: exclude invoices that already have a payment voucher + // attached but whose status leaked (still 'sent'/'overdue'). Partially-paid + // invoices can legitimately take more payments, so they pass through. + // Without this, a status leak would double-book the receipt. + const fullCandidateIds = invoices + .filter((inv) => inv.status === 'sent' || inv.status === 'overdue') + .map((inv) => inv.id as string) + const paidIds = new Set() + if (fullCandidateIds.length > 0) { + const { data: paymentRows } = await supabase + .from('invoice_payments') + .select('invoice_id') + .eq('company_id', companyId) + .in('invoice_id', fullCandidateIds) + .not('journal_entry_id', 'is', null) + for (const row of paymentRows ?? []) { + paidIds.add((row as { invoice_id: string }).invoice_id) + } + } + const filteredInvoices = invoices.filter((inv) => !paidIds.has(inv.id as string)) + if (filteredInvoices.length === 0) { + return [] + } + const matches: InvoiceMatch[] = [] // OCR/Bankgiro reference matching — highest confidence @@ -150,7 +174,7 @@ export async function findMatchingInvoices( const txReference = (transaction as Transaction & { reference?: string | null }).reference if (txReference) { const normalizedRef = txReference.replace(/\s+/g, '') - for (const invoice of invoices) { + for (const invoice of filteredInvoices) { // Match against invoice_number (used as OCR reference in Swedish payments) const invoiceRef = invoice.invoice_number?.replace(/\s+/g, '') if (invoiceRef && normalizedRef === invoiceRef) { @@ -168,7 +192,7 @@ export async function findMatchingInvoices( } } - for (const invoice of invoices) { + for (const invoice of filteredInvoices) { // Currency filter - must match or be SEK equivalent const currencyMatch = invoice.currency === transaction.currency || diff --git a/lib/invoices/match-log.ts b/lib/invoices/match-log.ts index f01e27bf..60a4b360 100644 --- a/lib/invoices/match-log.ts +++ b/lib/invoices/match-log.ts @@ -6,6 +6,7 @@ type MatchAction = | 'auto_suggested' | 'suggestion_cleared' | 'storno_conflict_resolved' + | 'linked_to_existing_voucher' /** * Log a payment match event to the append-only audit trail. diff --git a/lib/invoices/reminder-processor.ts b/lib/invoices/reminder-processor.ts index b09c386c..aea9f57a 100644 --- a/lib/invoices/reminder-processor.ts +++ b/lib/invoices/reminder-processor.ts @@ -143,6 +143,7 @@ export async function processOverdueReminders(): Promise customer:customers(*) `) .eq('status', 'sent') + .is('credited_invoice_id', null) .lte('due_date', cutoffDate.toISOString().split('T')[0]) .order('due_date', { ascending: true })