d11d0a2e90
One bank row can now settle several vouchers: journal_entry_id stays NULL and one transaction_voucher_links row per voucher carries a signed allocated_amount slice (sum must equal the row within the link tolerance, each slice bounded by the voucher's net line on the account). linkTransactionToVouchers does the locked transaction UPDATE first and rolls back on a failed junction insert; unlink and the re-booking guards understand junction-only rows; a storno of one of the N vouchers releases the row when the remaining slices no longer sum to its amount. The worksheet's right pane becomes multi-select when exactly one bank row is picked (Koppla only at difference 0); the v1/dashboard pair schemas accept allocations; the MCP reconcile resolver and executor carry 1:N pairs; skattekonto keeps single-pointer semantics. Closes #1553
497 lines
19 KiB
TypeScript
497 lines
19 KiB
TypeScript
/**
|
|
* 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<string, unknown> }
|
|
|
|
/**
|
|
* 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<boolean> {
|
|
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<LinkTransactionJournalEntryOutcome> {
|
|
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<void> {
|
|
// 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,
|
|
},
|
|
}
|
|
}
|