* fix(invoices): bank match stores the applied amount, not cash received, in invoice_payments The dashboard match-invoice route, its v1 twin and the pending-operation match_transaction_invoice executor wrote invoice_payments.amount as the cash received in invoice currency. When a whole-krona bank line settles an öre-carrying remaining (the customer pays the rounded "Att betala"), planInvoicePayment advances paid_amount by the remaining only and books the öre on 3740, so the row exceeded the receivable by the absorbed öre: remaining 999.60, bank 1 000.00 gave a 1 000.00 row against a 999.60 paid_amount. The kontantmetod cut-off then pushed a -0.40 receivable with negative scaled moms, the historical AR ledger showed -0.40 outstanding on a paid invoice, and a storno of the payment voucher restored paid_amount 0.40 off (issue #2250). PR #2236 defined the amount for the manual, MCP and Stripe paths as the amount APPLIED to the invoice (new paid_amount minus the prior one). The three bank-match paths now share that definition through one helper, appliedPaymentAmount() in lib/invoices/invoice-payment-row.ts, which recordInvoicePaymentRow() uses as well. Every other field of the row (payment date, currency, exchange rate, journal entry, bank transaction, notes) is unchanged. Without a residual the applied amount equals the cash received, so ordinary matches post identical rows; cross-currency rows are now öre-rounded like paid_amount instead of the 4-decimal spot conversion, so row and paid_amount agree. Existing rows carrying the overshoot are not repaired here; that is a separate call. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015qgLgdt4mLmha1ZLFMwq1u * refactor(invoices): one writer for invoice_payments rows Rework of the #2250 fix from first principles. The bank-match paths did not just get the amount wrong; the class of bug is that invoice_payments rows were hand-built at five product sites (dashboard bank match, its v1 twin, the pending-operation match, the link-to-existing-voucher flow, and the #2236 paths through the helper), each computing its own fields with no single definition of what the row means. recordInvoicePaymentRow() (lib/invoices/invoice-payment-row.ts) is now the one writer. Its options grew by what the bank paths set, all optional with today's defaults so the #2236 callers are unchanged: transactionId (default null), exchangeRate (the rate actually used; omitted = invoice.exchange_rate, explicit null stored as null) and notes (default null). The failure result carries the Postgres SQLSTATE so the routes keep mapping a unique violation (23505) exactly as before. The applied-amount formula is an internal detail of that file again. Routed through the writer: app/api/transactions/[id]/match-invoice, the v1 match-invoice twin, commitMatchTransactionInvoice in lib/pending-operations/commit.ts, and lib/transactions/link-journal-entry.ts (strict plan, same currency only: its amount is unchanged, it now shares the row semantics). The pending-operation path used to drop the insert error on the floor; it stays non-fatal but is logged with ids. Guard: scripts/checks/no-new-antipatterns.mjs gains direct-invoice-payment-insert, a file-set rule with no baseline (0 today): .from('invoice_payments').insert( or .upsert( anywhere under app/, lib/ or extensions/ outside lib/invoices/invoice-payment-row.ts fails npm run check:guards. Operator scripts under scripts/ are out of its scope on purpose. Tests: the writer's unit tests cover the new options, the explicit-null rate, the SQLSTATE passthrough and the öre-rounded prior-paid subtraction; the per-path 3740 tests from the first commit stand; mock insert slots now return the row id the writer selects back. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015qgLgdt4mLmha1ZLFMwq1u --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
509 lines
20 KiB
TypeScript
509 lines
20 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 { recordInvoicePaymentRow } from '@/lib/invoices/invoice-payment-row'
|
|
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
|
|
|
|
// The AR sub-ledger row goes through the single writer
|
|
// (lib/invoices/invoice-payment-row.ts). This path plans strictly (no öre
|
|
// absorption, same currency only), so the applied amount IS the bank line
|
|
// to the öre: the writer is used for one set of row semantics, not for a
|
|
// different number. A unique violation (23505) means the row already
|
|
// exists for this voucher and is not an error here.
|
|
const recorded = await recordInvoicePaymentRow(supabase, {
|
|
userId,
|
|
companyId,
|
|
invoice: {
|
|
id: invoiceId,
|
|
currency: invoice.currency,
|
|
exchange_rate: invoice.exchange_rate,
|
|
paid_amount: invoice.paid_amount,
|
|
},
|
|
paymentDate: transaction.date,
|
|
newPaidAmount,
|
|
journalEntryId,
|
|
transactionId,
|
|
exchangeRate: paymentExchangeRate,
|
|
notes: 'Kopplad till befintlig verifikation (ingen ny bokföring skapad)',
|
|
})
|
|
|
|
if (!recorded.ok && recorded.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,
|
|
},
|
|
}
|
|
}
|