* 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>
460 lines
17 KiB
TypeScript
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,
|
|
},
|
|
}
|
|
}
|