/** * Link a bank transaction to an already-posted journal entry without creating * new bookkeeping. Optionally settle a customer invoice in the same call by * inserting an invoice_payments row pointing at the existing JE and flipping * the invoice status with an optimistic-lock pattern. * * Shared between two callers: * - REST: app/api/transactions/[id]/link-journal-entry/route.ts * (duplicate-payment UI: user confirms the suggested existing voucher) * - MCP commit handler: lib/pending-operations/commit.ts * (gnubok_link_transaction_to_journal_entry, agent-staged operation) * * NEVER creates a new journal entry. The match log records * 'linked_to_existing_voucher' for audit on success. */ import type { SupabaseClient } from '@supabase/supabase-js' import { eventBus } from '@/lib/events/bus' import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions' import { paidAtFromDate } from '@/lib/invoices/paid-at' import { logMatchEvent } from '@/lib/invoices/match-log' import { propagateUnderlagForBookedTransaction } from '@/lib/transactions/inbox-underlag' import { hasBankLineJunctionRow } from '@/lib/transactions/is-booked' import { createLogger } from '@/lib/logger' import type { Invoice, Transaction } from '@/types' const log = createLogger('transactions/link-journal-entry') // Codes returned by linkTransactionToJournalEntry. All map to entries in // lib/errors/structured-errors.ts so both callers (REST route, MCP commit // handler) can surface the right HTTP status and the localized message. // The TX-not-found case reuses the shared TX_CATEGORIZE_TX_NOT_FOUND code // rather than a link-specific one: it predates this route and is the // canonical "bank tx not found in this company" envelope. export type LinkTransactionJournalEntryErrorCode = | 'TX_CATEGORIZE_TX_NOT_FOUND' | 'LINK_TX_TX_ALREADY_LINKED' | 'LINK_TX_JE_NOT_FOUND' | 'LINK_TX_JE_NOT_POSTED' | 'LINK_TX_INVOICE_NOT_FOUND' | 'LINK_TX_INVOICE_NOT_OPEN' | 'LINK_TX_INVOICE_CREDIT_NOTE' | 'LINK_TX_INVOICE_CURRENCY_MISMATCH' | 'LINK_TX_INVOICE_RACE' | 'MATCH_INVOICE_RECORD_PAYMENT_FAILED' | 'LINK_TX_DB_ERROR' export interface LinkTransactionJournalEntryParams { transactionId: string journalEntryId: string invoiceId?: string } export interface LinkTransactionJournalEntryResult { transactionId: string journalEntryId: string voucherLabel: string invoiceId: string | null invoiceStatus: 'paid' | 'partially_paid' | null paidAmount: number | null remainingAmount: number | null } export type LinkTransactionJournalEntryOutcome = | { ok: true; result: LinkTransactionJournalEntryResult } | { ok: false; code: LinkTransactionJournalEntryErrorCode; details?: Record } /** * Canonical verifikat-label format: `${series}-${number}` (e.g. "A-12"). * Centralised so the MCP staging preview and the committed result can't * diverge: divergence is a BFL 5 kap 7§ traceability hazard because the * verifikationsserie label that ends up in the audit trail must match the * label the user saw at approval time. * * Fallbacks ('A' series, empty number) are defensive only; in practice a * posted verifikat always has both. Callers should never construct this * string inline: import this helper instead. */ export function formatVoucherLabel( voucherSeries: string | null | undefined, voucherNumber: number | string | null | undefined, ): string { const series = voucherSeries ?? 'A' const num = voucherNumber ?? '' return num === '' ? series : `${series}-${num}` } /** * Is `journalEntryId` a LIVE link, i.e. does it reference a posted verifikat? * * A transaction can carry a non-null `journal_entry_id` that no longer points * at a live booking: reversing (storno) or correcting an entry marks the * original `reversed`, and while both flows try to detach or re-point the * transaction (engine.ts `reverseEntry`, storno-service `relinkTransactions- * ToEntry`), those re-links are best-effort and rows reversed before #726 * (2026-06-15) were never touched at all. Such a transaction reads as "utan * koppling" in the UI: the transactions page enriches only `status='posted'` * links, so a reversed pointer renders as no link, yet the raw column is still * set. * * The "already linked" guards on the re-booking paths must mirror that same * posted-only predicate. If they treat any non-null pointer as linked, a * transaction the UI shows as free can never be re-linked or re-categorized * (issue #988). Returns true ONLY when the pointer references a posted entry; * null / missing / reversed / cancelled / draft all count as no live link, so * the caller may overwrite the stale pointer. Fails closed (returns true) on a * read error so a transient lookup blip can never detach a genuinely live link. */ export async function hasLiveJournalEntryLink( supabase: SupabaseClient, companyId: string, journalEntryId: string | null | undefined, ): Promise { if (!journalEntryId) return false const { data, error } = await supabase .from('journal_entries') .select('status') .eq('id', journalEntryId) .eq('company_id', companyId) .maybeSingle() if (error) return true return data?.status === 'posted' } export async function linkTransactionToJournalEntry( supabase: SupabaseClient, userId: string, companyId: string, params: LinkTransactionJournalEntryParams ): Promise { const { transactionId, journalEntryId, invoiceId } = params // Data minimization (GDPR Art.5(1)(c)): pull only the columns needed for // validation, optimistic-lock invoice update, invoice_payments insert, and // the compensating-rollback path. No select('*'). // transaction_voucher_links rides along on the same read: a row bulk-booked // into a samlingsverifikat or split over several verifikat (1:N, #1553) // carries journal_entry_id = NULL and must still refuse a second link. Only // 'bank_line' rows count (hasBankLineJunctionRow): a residual's 'other' row // left behind by a storno must stay re-linkable. const { data: transactionRow, 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, transaction_voucher_links(journal_entry_id, role)' ) .eq('id', transactionId) .eq('company_id', companyId) .single() if (fetchTxError || !transactionRow) { return { ok: false, code: 'TX_CATEGORIZE_TX_NOT_FOUND' } } const { transaction_voucher_links: junctionLinks, ...transaction } = transactionRow as typeof transactionRow & { transaction_voucher_links?: Array<{ journal_entry_id: string; role?: string | null }> | null } if (hasBankLineJunctionRow(junctionLinks)) { const bankLine = junctionLinks!.find((row) => (row.role ?? 'bank_line') === 'bank_line')! return { ok: false, code: 'LINK_TX_TX_ALREADY_LINKED', details: { existingJournalEntryId: bankLine.journal_entry_id }, } } // Only a LIVE (posted) pointer blocks re-linking. A pointer left behind by a // storno/correction references a 'reversed' entry: the UI already shows the // row as "utan koppling", so the guard must agree and let the user re-link it // to another verifikat (issue #988). The stale pointer is overwritten by the // optimistic-locked UPDATE below. if ( transaction.journal_entry_id && (await hasLiveJournalEntryLink(supabase, companyId, transaction.journal_entry_id as string)) ) { return { ok: false, code: 'LINK_TX_TX_ALREADY_LINKED', details: { existingJournalEntryId: transaction.journal_entry_id as string }, } } const { data: journalEntry, error: fetchJeError } = await supabase .from('journal_entries') .select('id, status, voucher_series, voucher_number, entry_date') .eq('id', journalEntryId) .eq('company_id', companyId) .single() if (fetchJeError || !journalEntry) { return { ok: false, code: 'LINK_TX_JE_NOT_FOUND' } } if (journalEntry.status !== 'posted') { return { ok: false, code: 'LINK_TX_JE_NOT_POSTED', details: { currentStatus: journalEntry.status as string }, } } type FetchedInvoice = Pick< Invoice, | 'id' | 'status' | 'total' | 'paid_amount' | 'remaining_amount' | 'currency' | 'exchange_rate' | 'paid_at' | 'invoice_number' | 'credited_invoice_id' > & { customer?: { name?: string } | null } let invoice: FetchedInvoice | null = null let newPaidAmount = 0 let newRemaining = 0 let isFullyPaid = false let newStatus: 'paid' | 'partially_paid' = 'paid' if (invoiceId) { // Data minimization (GDPR Art.5(1)(c) / SOC 2 CC6.1): explicit column // list rather than select('*, customer:customers(name)'). Adding new // PII columns to invoices won't silently widen this fetch. const { data: invoiceRow, error: fetchInvError } = await supabase .from('invoices') .select( 'id, status, total, paid_amount, remaining_amount, currency, exchange_rate, paid_at, invoice_number, credited_invoice_id, customer:customers(name)' ) .eq('id', invoiceId) .eq('company_id', companyId) .single() if (fetchInvError || !invoiceRow) { return { ok: false, code: 'LINK_TX_INVOICE_NOT_FOUND' } } if (invoiceRow.credited_invoice_id) { return { ok: false, code: 'LINK_TX_INVOICE_CREDIT_NOTE' } } if ( invoiceRow.status !== 'sent' && invoiceRow.status !== 'overdue' && invoiceRow.status !== 'partially_paid' ) { return { ok: false, code: 'LINK_TX_INVOICE_NOT_OPEN', details: { currentStatus: invoiceRow.status as string }, } } invoice = invoiceRow as unknown as FetchedInvoice // BFL 5 kap 2§ + currency-integrity guard: invoices.paid_amount and // remaining_amount are stored in the INVOICE'S currency. Mixing a // foreign-currency tx.amount into those columns silently corrupts the // ledger (a 230 SEK payment would record "230 USD paid" on a USD // invoice). This link path is for the same-currency case only; // cross-currency payments must go through /api/transactions/[id]/match- // invoice which routes through buildInvoicePaymentClearingLines and // posts the FX diff on 3960/7960. Reject here to keep the contract clear. if (transaction.currency !== invoice.currency) { return { ok: false, code: 'LINK_TX_INVOICE_CURRENCY_MISMATCH', details: { transactionCurrency: transaction.currency as string, invoiceCurrency: invoice.currency, }, } } const paidAmount = transaction.amount as number 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' } // Snapshot tx state so the compensating-rollback path can restore the row // if a subsequent step fails: otherwise a partial state would persist // (tx linked, invoice unchanged, no payment row). const priorTxState = { // null, or a stale 'reversed'-entry id we're clearing (validated not-live above) journal_entry_id: transaction.journal_entry_id, 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, } // Optimistic lock on the pointer we validated: null for a free row, or the // exact stale id for one we're detaching from a reversed entry. Locking on // the known value (rather than always .is(null)) lets the stale-pointer // overwrite through while still turning a concurrent re-link into a no-op. const previousJournalEntryId = (transaction.journal_entry_id as string | null) ?? null const txUpdate = supabase .from('transactions') .update({ journal_entry_id: journalEntryId, invoice_id: invoiceId ?? null, potential_invoice_id: null, potential_supplier_invoice_id: null, is_business: true, }) .eq('id', transactionId) .eq('company_id', companyId) const { data: updatedTxRows, error: updateTxError } = await (previousJournalEntryId === null ? txUpdate.is('journal_entry_id', null) : txUpdate.eq('journal_entry_id', previousJournalEntryId) ).select('id') if (updateTxError) { return { ok: false, code: 'LINK_TX_DB_ERROR', details: { reason: updateTxError.message } } } // CAS lost: a concurrent linker changed the pointer between the liveness // check and this write, so 0 rows matched. Fail BEFORE any invoice side // effects: otherwise we'd settle the invoice + insert an invoice_payments row // for a transaction we didn't actually link (same optimistic-lock contract as // manualLink in lib/reconciliation/bank-reconciliation.ts). if (!updatedTxRows || updatedTxRows.length === 0) { return { ok: false, code: 'LINK_TX_TX_ALREADY_LINKED', details: { existingJournalEntryId: previousJournalEntryId }, } } async function rollbackTxLink(reason: string): Promise { // SOC 2 PI1.3 (processing integrity): if a rollback itself fails, the // ledger ends up in a partial state: tx pointing at the existing // verifikat with no invoice_payments row, or the invoice row at an // intermediate paid_amount. We surface the rollback failure (IDs only, // no amounts or counterparty names) so a reconciliation job can // detect and repair the divergence. The original failure code still // goes back to the caller as the proximate cause. const { error: rollbackErr } = await supabase .from('transactions') .update(priorTxState) .eq('id', transactionId) .eq('company_id', companyId) if (rollbackErr) { log.warn('failed to roll back transaction link after subsequent step failed', { companyId, transactionId, journalEntryId, reason, rollbackError: rollbackErr.message, }) } } const paidAt = invoice && isFullyPaid ? paidAtFromDate(transaction.date) : null if (invoice && invoiceId) { const { data: updatedRows, error: updateInvError } = await supabase .from('invoices') .update({ status: newStatus, paid_at: paidAt, paid_amount: newPaidAmount, remaining_amount: newRemaining, }) .eq('id', invoiceId) .eq('company_id', companyId) .in('status', ['sent', 'overdue', 'partially_paid']) .select('id') if (updateInvError) { await rollbackTxLink('invoice update errored') return { ok: false, code: 'LINK_TX_DB_ERROR', details: { reason: updateInvError.message } } } if (!updatedRows || updatedRows.length === 0) { await rollbackTxLink('invoice optimistic lock returned 0 rows') return { ok: false, code: 'LINK_TX_INVOICE_RACE' } } // BFL 5 kap 2§ + ML 8 kap 21-23§: the payment row must record the rate // effective on the PAYMENT date, not the invoice-creation date. If // transaction.exchange_rate is null (SEK tx, no rate needed), leave the // payment row's rate null too: a downstream Riksbanken lookup can // populate it lazily if reporting needs it. Falling back to // invoice.exchange_rate would silently record the wrong (invoice-date) // rate, which corrupts the FX-diff figures in any later VAT or income // reporting. const paymentExchangeRate = transaction.exchange_rate ?? null const { error: paymentInsertError } = await supabase .from('invoice_payments') .insert({ user_id: userId, company_id: companyId, invoice_id: invoiceId, payment_date: transaction.date, amount: transaction.amount, currency: invoice.currency, exchange_rate: paymentExchangeRate, journal_entry_id: journalEntryId, transaction_id: transactionId, notes: 'Kopplad till befintlig verifikation (ingen ny bokföring skapad)', }) if (paymentInsertError && paymentInsertError.code !== '23505') { 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', invoiceId) .eq('company_id', companyId) if (invRevertErr) { log.warn('failed to revert invoice status after payment insert failed', { companyId, invoiceId, rollbackError: invRevertErr.message, }) } await rollbackTxLink('invoice_payments insert failed') return { ok: false, code: 'MATCH_INVOICE_RECORD_PAYMENT_FAILED' } } // The invoice is settled, so every transaction still carrying a suggestion // pointer at it is dead: retire them (issue #1259). No exceptTransactionId // needed: this row's own hints were already nulled by the tx update above, // so the invoice-id filter no longer selects it. if (isFullyPaid) { await clearSettledInvoiceSuggestions(supabase, companyId, 'invoice', invoiceId) } } // The transaction is now anchored to an existing verifikat: complete any // matched inbox items against it (underlag link + consumed stamp) so they // leave the active inbox. Best-effort, logged inside. await propagateUnderlagForBookedTransaction(supabase, companyId, transactionId, journalEntryId) logMatchEvent(supabase, userId, transactionId, 'linked_to_existing_voucher', { invoiceId, newState: { journal_entry_id: journalEntryId, invoice_id: invoiceId ?? null, invoice_status: invoice ? newStatus : null, }, }) if (invoice && invoiceId) { try { eventBus.emit({ type: 'invoice.match_confirmed', payload: { invoice: { ...invoice, status: newStatus, paid_at: paidAt, paid_amount: newPaidAmount, remaining_amount: newRemaining, } as Invoice, transaction: { ...transaction, journal_entry_id: journalEntryId, invoice_id: invoiceId, potential_invoice_id: null, potential_supplier_invoice_id: null, is_business: true, } as Transaction, userId, companyId, }, }) } catch { /* non-critical */ } } const voucherLabel = formatVoucherLabel( journalEntry.voucher_series as string | null, journalEntry.voucher_number as number | null, ) return { ok: true, result: { transactionId, journalEntryId, voucherLabel, invoiceId: invoiceId ?? null, invoiceStatus: invoice ? newStatus : null, paidAmount: invoice ? newPaidAmount : null, remainingAmount: invoice ? newRemaining : null, }, } }