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