Files
accounted/lib/transactions/link-journal-entry.ts
T
MattssonandClaude Fable 5.1 50b6299699 feat(rot-rut): match Skatteverket's payout against the begäran from the bank row (#2271)
* feat(rot-rut): match Skatteverket's payout against the begäran from the bank row

A ROT/RUT invoice is stored with remaining_amount net of the deduction, so
once the customer pays it flips to paid and drops out of the matchable set.
Skatteverket's payout for the 1513 share then lands as an income row with no
candidate: the only clearing path was a headless settle endpoint that never
linked the bank row.

The candidate is the payout request (one lump sum per begäran, possibly
covering several invoices), modelled exactly like the supplier-invoice hint:

- migration 20260904020000: transactions.potential_rot_rut_payout_request_id
- pure matcher (exact amount vs decided_total ?? requested_total, boosted
  when Skatteverket is named, ambiguous when two requests share the amount)
- hint written at bank ingest and by batch-match-invoices; cleared by the
  link and reconciliation paths and by clearSettledInvoiceSuggestions
- shared settle service (lib/invoices/rot-rut-settle.ts) used by the existing
  settle route and the new POST /api/transactions/[id]/match-rot-rut-payout,
  which books debit 19xx / credit 1513 and links the row in one call
- transactions inbox pill, own confirm dialog listing the covered invoices,
  manual fallback section in the invoice picker, worklist and Att göra rows

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HmEpYNMHycUPzSwBECEzZ5
Signed-off-by: Emil <emilmattsson14@gmail.com>

* fix(rot-rut): cap the payout at the begäran, CAS on the request and on stale pointers

Skeptic findings on 6aa7b2e5c:
- a bank row larger than the begäran was booked in full, driving 1513 into
  a credit balance and rewriting decided_total to the bank amount: refuse
  amount > decided_total ?? requested_total in the service and block the
  dialog's confirm with the reason
- two concurrent settles could both attach and credit 1513 twice: the
  request update now locks on settlement_journal_entry_id IS NULL and the
  loser returns ROT_RUT_SETTLE_RACE (409) with its orphan voucher id
- a row with a stale (reversed) journal_entry_id passed the route guard but
  always lost the null-only link CAS: the route forwards the pointer it read
  and the service locks on that value, as link-journal-entry does
- the pinned underlag on the bank row now propagates onto the voucher

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HmEpYNMHycUPzSwBECEzZ5
Signed-off-by: Emil <emilmattsson14@gmail.com>

* fix(rot-rut): review round: SEK gate, voucher-less paid matchable, hint-write errors, one live voucher per begäran

CodeRabbit findings on a93dc46b8, one batch:
- picker and dialog only offer a begäran to SEK rows (the route refuses
  other currencies, so the manual flow no longer dead-ends)
- a voucher-less `paid` request (beslut recorded via PATCH, money not yet
  booked) is matchable; settled means a settlement voucher exists
- ingest and batch-match check the hint update's error before draining the
  pool or counting the match
- the invoice.match_confirmed payload clears the payout hint like the row
- migration 20260904021000: partial unique index on journal_entries
  (company_id, source_id) for live rot_rut_payout entries, so two racing
  settles cannot both book a voucher; pg test included

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HmEpYNMHycUPzSwBECEzZ5
Signed-off-by: Emil <emilmattsson14@gmail.com>

---------

Signed-off-by: Emil <emilmattsson14@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 14:33:41 +02:00

500 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, potential_rot_rut_payout_request_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,
potential_rot_rut_payout_request_id: transaction.potential_rot_rut_payout_request_id ?? null,
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,
potential_rot_rut_payout_request_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_rot_rut_payout_request_id: null,
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,
},
}
}