Files
accounted/lib/transactions/link-journal-entry.ts
T
Jakob WennbergandClaude Opus 5 27ae59040e fix(transactions): retire stale invoice match pointers when an invoice settles (#1313)
* fix(transactions): retire stale invoice match pointers when an invoice settles

potential_invoice_id / potential_supplier_invoice_id are write-once import
suggestions: nothing revisited them once written. With recurring same-amount
invoices, an earlier suggestion pointed transaction A at invoice X, X was then
paid off by transaction B, and A kept pointing at a fully paid invoice. The
match dialog computed its amount diff against that invoice's 0 kr
remaining_amount and reported a bogus partial payment, and the dead pointer
also blocked a fresh suggestion: both re-suggestion scans require the column
to be NULL.

Add one shared helper, clearSettledInvoiceSuggestions(), that nulls a settled
invoice's own suggestion column on every other transaction of the same
company, scoped by company_id and by that invoice id only, never widening to
the confirmed invoice_id / supplier_invoice_id links. It is best effort by
construction: every caller has already booked a payment verifikat, so a failed
cleanup logs and returns instead of failing the settle.

Wired into every path where an invoice reaches paid through a payment:
the dashboard and v1 match-invoice / match-supplier-invoice routes, the
dashboard and v1 mark-paid routes, settleInvoicePayment, the batch allocation
route (per fully settled allocation), linkInvoiceToVoucher and
linkSupplierInvoiceToVoucher, linkTransactionToJournalEntry, and the MCP
staged-operation executors for mark_invoice_paid and
match_transaction_invoice. Partial payments are deliberately left alone: a
partially paid invoice is still matchable. The v1 supplier match route also
clears its own row's hint, which it was missing next to its dashboard twin.

Read-time revalidation stays as the backstop for the paths not wired up here.
countSuggestedMatches now delegates to listSuggestedMatches, which already
revalidates candidates, so the worklist badge can no longer claim a number the
list refuses to render.

A data-only backfill migration retires the pointers already stranded in the
database. It touches no journal entry, verifikat or period-locked data, is
idempotent, and its status lists mirror lib/invoices/matchable-statuses.ts.

Fixes #1259

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(transactions): wire the MCP batch allocation into the settled-pointer cleanup

Review follow-up on the #1259 fix.

commitMatchBatchAllocate calls the same match_batch_allocate RPC as the
dashboard route, and gnubok_match_batch_allocate is a live staged MCP tool, so
an agent settling a samlingsbetalning reproduced the issue exactly: the RPC
nulls potential_invoice_id / potential_supplier_invoice_id only on the source
transaction, leaving every other transaction of the company pointing at an
invoice the batch just closed. The per-allocation loop moves into
clearSettledBatchAllocationSuggestions() so the HTTP route and the MCP executor
run the same code and cannot drift again, with a commit-path test pinning that
only the fully settled allocation is retired.

The enlarged badge scan is made safe. countSuggestedMatches now feeds up to 200
ids into listSuggestedMatches, past the 150 per .in() that countInboxDocuments
already chunks for, so the candidate lookups are chunked at IN_CLAUSE_CHUNK too
and their ids deduped. Both lookups now check .error: previously a 414, a 500 or
an RLS change produced empty maps, an empty list and a zero badge with nothing
logged. Every failure branch here logs companyId, matching the logAndZero
convention.

Also: restore the anchorSupplierInvoiceDocument doc comment above its own call
in the dashboard supplier-invoice mark-paid route (the #1259 block had been
inserted between them), and assert the transaction update payload in the v1
match-supplier-invoice test, which now covers the potential_supplier_invoice_id
null that the route was missing next to its dashboard twin.

Fixes #1259

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:52:24 +02:00

460 lines
17 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 { logMatchEvent } from '@/lib/invoices/match-log'
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('*').
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 { ok: false, code: 'TX_CATEGORIZE_TX_NOT_FOUND' }
}
// 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 now = new Date().toISOString()
if (invoice && invoiceId) {
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', 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)
}
}
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 as Invoice,
transaction: transaction 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,
},
}
}