* fix(transactions): close booking duplicate guard blind spots G1-G3 The booking-time duplicate guard missed the most common bank-fee twin shapes: - G1: the sibling scan matched on the EXACT date only, so a duplicate import with a drifted date (CSV bokforingsdag vs PSD2 valutadag) was invisible. The scan now uses a +-3 day window with a deterministic ranking where exact-date candidates always outrank drifted ones (force=true re-detection stays bound to the reviewed candidate). - G2: booked-ness required transactions.journal_entry_id, so bulk-booked (transaction_voucher_links) and multi-allocated (invoice_payments / supplier_invoice_payments) siblings read as unbooked. The scan now batch-fetches the anchor rows and resolves the verifikat via getPrimaryJournalEntryId (is_transaction_booked semantics). - G3: the ledger scan excluded every voucher linked to any transaction, so a voucher booked from a date-drifted duplicate row escaped BOTH halves and the booking proceeded with no warning. A voucher whose linking transaction itself matches the target (same ore in the same currency, compatible cash account, date in the window) is now returned as the twin with transaction_id set. All candidate picks keep explicit total-order tiebreakers so a force re-detect returns the same candidate the user reviewed, and the SEK-or-null amount contract is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): offer match/ignore for sibling duplicates and route all 409s into the dialog The duplicate dialog hid its match action for sibling-transaction candidates (canMatch required transaction_id === null), so the user who most needed steering saw only 'Bokfor anda'. manualLink explicitly allows N:1 links, so the match action is now offered for both candidate kinds. Sibling candidates get question-form body copy ('vill du matcha mot verifikatet i stallet?') and an additional 'Ignorera transaktionen' action via the existing POST /api/transactions/[id]/ignore, which is the correct resolution when the row itself is a duplicate import (matching would double-count the bank side, booking the ledger side). Two clients dead-ended the TRANSACTION_BOOK_POSSIBLE_DUPLICATE 409 in a destructive toast with no way forward: - the counterparty-template branch of handleQuickReviewConfirm now sets the shared duplicateWarning state exactly like runCategorize, with the force retry bound to the reviewed candidate's voucher - BankReconciliationView's quick-book now opens the same dialog, with match/ignore refreshing the reconciliation lists New sv/en strings: dialog_duplicate_body_sibling, dialog_duplicate_ignore, dialog_duplicate_ignore_failed. File-level parity tests pin the 409 routing and the dialog affordances. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): duplicate guard on the bulk-book samlingsverifikation path /api/transactions/bulk-book never called detectBookingDuplicate, so a batch containing an already-booked twin minted a second verifikat with no warning. The route now runs the shared per-tx guard before the RPC, with intra-batch exclusions (the other selected txs are distinct events the user picked, and the link-existing target voucher is the batch's own destination), returning 409 TRANSACTION_BOOK_POSSIBLE_DUPLICATE with the candidate and the flagged tx id. BulkBookDialog routes the 409 into DuplicateBookingDialog for review (view voucher / cancel / book anyway) instead of a dead-end toast; 'Bokfor anda' re-runs the batch with force=true. On force the route re-detects and records each dismissed candidate as BankTransactionDuplicateDismissed in behandlingshistorik (BFNAR 2013:2 kap 8), parity with the /categorize bypass. Detection failures stay fail-open. Note: the MCP RPC twin (gnubok_bulk_book_transactions) bypasses this route and remains unguarded; guarding inside the RPC needs a migration and is out of scope here. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): gate the duplicate-dialog ignore hint on the action being present The sibling body copy mentioned ignoring the row, but two render sites (the manual booking form and the bulk dialog) show sibling candidates without the ignore action. The guidance now lives in a separate dialog_duplicate_ignore_hint string rendered only when the Ignorera button itself renders, so copy never points at a button that is not there. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
487 lines
20 KiB
TypeScript
487 lines
20 KiB
TypeScript
import { NextResponse } from 'next/server'
|
|
import { withRouteContext } from '@/lib/api/with-route-context'
|
|
import { validateBody } from '@/lib/api/validate'
|
|
import { BulkBookSchema } from '@/lib/api/schemas'
|
|
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
|
import { applyTemplate } from '@/lib/bookkeeping/template-library'
|
|
import { mergeDimensionBags } from '@/lib/bookkeeping/dimension-resolver'
|
|
import {
|
|
applyDimensionRules,
|
|
assertMandatoryDimensions,
|
|
fetchActiveDimensionRules,
|
|
} from '@/lib/bookkeeping/dimension-rules'
|
|
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
|
|
import { propagateUnderlagForBookedTransaction } from '@/lib/transactions/inbox-underlag'
|
|
import { detectBookingDuplicate } from '@/lib/transactions/booking-duplicate-detection'
|
|
import { appendProcessingHistory } from '@/lib/processing-history/append'
|
|
import { eventBus } from '@/lib/events/bus'
|
|
import { ensureInitialized } from '@/lib/init'
|
|
import type { BookingTemplateLibraryLine, Transaction } from '@/types'
|
|
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
|
|
|
ensureInitialized()
|
|
|
|
interface RpcOk {
|
|
ok: true
|
|
mode: 'link_existing' | 'create_new'
|
|
journal_entry_id: string
|
|
voucher_series: string | null
|
|
voucher_number: number | null
|
|
linked_tx_count: number
|
|
tx_sum: number
|
|
docs_linked: number
|
|
}
|
|
|
|
interface RpcErr {
|
|
ok: false
|
|
code: string
|
|
details?: Record<string, unknown>
|
|
}
|
|
|
|
interface ComputedLine {
|
|
account_number: string
|
|
debit_amount: number
|
|
credit_amount: number
|
|
currency: string
|
|
line_description?: string
|
|
sort_order?: number
|
|
// Dimensions PR7: bag persisted by the RPC onto journal_entry_lines
|
|
// (with cost_center/project mirrors derived server-side).
|
|
dimensions?: Record<string, string>
|
|
}
|
|
|
|
function round2(n: number): number {
|
|
return Math.round(n * 100) / 100
|
|
}
|
|
|
|
/**
|
|
* POST /api/transactions/bulk-book
|
|
*
|
|
* Bulk-book N bank transactions on the same date into one combined
|
|
* verifikat (samlingsverifikation per BFL 5 kap 6§). Two flows:
|
|
*
|
|
* 1. Link to existing voucher: { tx_ids, existing_journal_entry_id }.
|
|
* No new JE; the RPC just inserts N transaction_voucher_links rows.
|
|
*
|
|
* 2. Create new from template: { tx_ids, template_id, mode,
|
|
* entry_description }. The route fetches the template, expands it
|
|
* per the chosen mode, and passes the resulting balanced lines to
|
|
* the RPC. The RPC then commits the verifikat atomically.
|
|
*
|
|
* `applyTemplate` lives in TS (ratio / VAT math); the RPC stays focused
|
|
* on locking, balance, and link insertion.
|
|
*/
|
|
export const POST = withRouteContext(
|
|
'transaction.bulk_book',
|
|
async (request, ctx) => {
|
|
const { user, supabase, companyId, log, requestId } = ctx
|
|
|
|
const validation = await validateBody(request, BulkBookSchema, {
|
|
log,
|
|
operation: 'transaction.bulk_book',
|
|
})
|
|
if (!validation.success) return validation.response
|
|
const body = validation.data
|
|
|
|
const opLog = log.child({ txCount: body.tx_ids.length })
|
|
|
|
// Fetch the selected txs ONCE, up front, for every path. The template
|
|
// branch needs the amounts to expand the template; all three branches
|
|
// need the currencies for the homogeneity gate below.
|
|
const { data: txs, error: txError } = await supabase
|
|
.from('transactions')
|
|
.select('id, amount, currency, description, date, amount_sek, exchange_rate, cash_account_id')
|
|
.in('id', body.tx_ids)
|
|
.eq('company_id', companyId)
|
|
|
|
if (txError || !txs || txs.length === 0) {
|
|
return errorResponseFromCode('BULK_BOOK_TXS_NOT_FOUND', opLog, { requestId })
|
|
}
|
|
if (txs.length !== body.tx_ids.length) {
|
|
return errorResponseFromCode('BULK_BOOK_TXS_NOT_FOUND', opLog, {
|
|
requestId,
|
|
details: { expected: body.tx_ids.length, found: txs.length },
|
|
})
|
|
}
|
|
|
|
const txTyped = txs as Pick<
|
|
Transaction,
|
|
'id' | 'amount' | 'currency' | 'description' | 'date' | 'amount_sek' | 'exchange_rate' | 'cash_account_id'
|
|
>[]
|
|
|
|
// Currency homogeneity, enforced BEFORE the branch split so it covers
|
|
// all three paths (template, manual_lines, existing_journal_entry_id).
|
|
// BFL 4 kap 6 § requires the bokföring to be presented in one and the
|
|
// same redovisningsvaluta. A samlingsverifikation mixing e.g. SEK and
|
|
// EUR has no representable single belopp: summing the raw amounts adds
|
|
// 100 EUR to 100 SEK as if they were one unit, and the verifikat would
|
|
// then state an amount matching no affärshändelse (BFL 5 kap 7 §
|
|
// "belopp"). Nothing the caller can pass makes that correct without
|
|
// per-tx FX rates, so this refuses instead of warning. Mirrors the MCP
|
|
// twin (gnubok_bulk_book_transactions), which guards the same three
|
|
// paths; cross-currency batches belong in the FX-aware batch-allocate
|
|
// flow (kursdifferens on 7960/3960).
|
|
// NULL currency is legacy for the column default 'SEK' (the codebase
|
|
// reads it that way everywhere), so it is normalized before comparison:
|
|
// a NULL/SEK selection is not a currency mix and must stay bookable.
|
|
const currencies = new Set(txTyped.map((t) => t.currency ?? 'SEK'))
|
|
if (currencies.size > 1) {
|
|
return errorResponseFromCode('BULK_BOOK_MIXED_CURRENCY', opLog, {
|
|
requestId,
|
|
details: { currencies: Array.from(currencies).sort() },
|
|
})
|
|
}
|
|
const currency = txTyped[0]!.currency ?? 'SEK'
|
|
|
|
// A HOMOGENEOUS foreign batch is refused too: the RPC writes the line
|
|
// amounts into journal_entry_lines.debit_amount/credit_amount, which are
|
|
// ALWAYS kronor, and neither the route nor the RPC carries an exchange
|
|
// rate here. Two EUR transactions of 100 + 200 would produce a verifikat
|
|
// whose 300 is read as kronor by balansräkning, momsdeklaration and SIE
|
|
// export. Foreign-currency transactions are booked individually through
|
|
// the FX-aware flows, which resolve a rate and book the kursdifferens.
|
|
// The RPC enforces the same refusal for callers that bypass this route.
|
|
if (currency !== 'SEK') {
|
|
return errorResponseFromCode('BULK_BOOK_FOREIGN_CURRENCY', opLog, {
|
|
requestId,
|
|
details: { currency },
|
|
})
|
|
}
|
|
|
|
// Booking-time duplicate guard, parity with /categorize and /book: each
|
|
// selected tx is about to be anchored to a verifikat, and a twin already
|
|
// in the ledger means one affärshändelse gets booked twice (felaktig
|
|
// bokföring per BFL). Per-tx detection with intra-batch exclusions: the
|
|
// OTHER selected txs are distinct events the user explicitly picked, and
|
|
// the link-existing target voucher is the batch's own destination, so
|
|
// neither may flag. Checked in tx_ids order so the flagged tx is
|
|
// deterministic. Soft guard: the caller re-runs with force=true after the
|
|
// user reviews the candidate; detection failures never block a booking.
|
|
const txById = new Map(txTyped.map((tx) => [tx.id, tx]))
|
|
const duplicateExclusions = {
|
|
excludeTransactionIds: body.tx_ids,
|
|
excludeJournalEntryIds: body.existing_journal_entry_id ? [body.existing_journal_entry_id] : [],
|
|
}
|
|
const detectForTx = (tx: (typeof txTyped)[number]) =>
|
|
detectBookingDuplicate(
|
|
supabase,
|
|
companyId!,
|
|
{
|
|
id: tx.id,
|
|
date: tx.date,
|
|
// `amount` is denominated in `currency`; the guard's FX contract
|
|
// needs the row's own conversion fields alongside.
|
|
amount: tx.amount,
|
|
currency: tx.currency ?? null,
|
|
amount_sek: tx.amount_sek ?? null,
|
|
exchange_rate: tx.exchange_rate ?? null,
|
|
cash_account_id: tx.cash_account_id ?? null,
|
|
},
|
|
duplicateExclusions,
|
|
)
|
|
if (body.force !== true) {
|
|
for (const txId of body.tx_ids) {
|
|
const tx = txById.get(txId)
|
|
if (!tx) continue
|
|
let candidate = null
|
|
try {
|
|
candidate = await detectForTx(tx)
|
|
} catch (err) {
|
|
opLog.warn('bulk-book duplicate detection failed (continuing)', { err, txId })
|
|
}
|
|
if (candidate) {
|
|
return errorResponseFromCode('TRANSACTION_BOOK_POSSIBLE_DUPLICATE', opLog, {
|
|
requestId,
|
|
details: { candidate, transaction_id: txId },
|
|
})
|
|
}
|
|
}
|
|
} else {
|
|
// force=true bypassed the guard. Booking over a DETECTED possible
|
|
// double-booking is a bookkeeping decision that needs a durable
|
|
// behandlingshistorik record (BFNAR 2013:2 kap 8), parity with the
|
|
// /categorize and agent bypass paths. Best-effort; never blocks.
|
|
for (const txId of body.tx_ids) {
|
|
const tx = txById.get(txId)
|
|
if (!tx) continue
|
|
try {
|
|
const dismissed = await detectForTx(tx)
|
|
if (!dismissed) continue
|
|
opLog.warn('bulk-book duplicate guard bypassed', {
|
|
reason: 'force=true',
|
|
requestId,
|
|
txId,
|
|
dismissedJournalEntryId: dismissed.journal_entry_id,
|
|
})
|
|
await appendProcessingHistory({
|
|
companyId: companyId!,
|
|
correlationId: txId,
|
|
aggregateType: 'BankTransaction',
|
|
aggregateId: txId,
|
|
eventType: 'BankTransactionDuplicateDismissed',
|
|
payload: {
|
|
transaction_id: txId,
|
|
dismissed_transaction_id: dismissed.transaction_id,
|
|
dismissed_journal_entry_id: dismissed.journal_entry_id,
|
|
// Null when the candidate's SEK value could not be established;
|
|
// the foreign figures below then carry the durable record.
|
|
amount_ore: dismissed.amount != null ? Math.round(dismissed.amount * 100) : null,
|
|
dismissed_currency: dismissed.currency,
|
|
dismissed_amount_in_currency: dismissed.amount_in_currency,
|
|
entry_date: dismissed.entry_date,
|
|
amount_verified: dismissed.amount_verified,
|
|
unverified_reason: dismissed.unverified_reason,
|
|
via: 'bulk_book_force',
|
|
},
|
|
actor: { type: 'user', id: user.id },
|
|
occurredAt: new Date(),
|
|
})
|
|
} catch (err) {
|
|
opLog.error('failed to append duplicate-dismissal behandlingshistorik', err as Error)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Three paths now (PR #608):
|
|
// 1. existing_journal_entry_id → null new_entry, RPC links txs to JE.
|
|
// 2. template_id → route expands template per mode, builds lines.
|
|
// 3. manual_lines → caller-built lines pass straight through.
|
|
let newEntryPayload: { description: string; lines: ComputedLine[] } | null = null
|
|
|
|
if (body.manual_lines && body.entry_description) {
|
|
// Manual mode. The Zod schema validated the 4-digit format; the
|
|
// RPC's balance + bank-leg + negative-amount + both-sides-nonzero
|
|
// guards still run downstream. What's missing is verifying the
|
|
// account_numbers exist in this company's chart_of_accounts:
|
|
// without it a typo or adversarial caller could post to a BAS
|
|
// account that doesn't exist, corrupting the hauptbok and
|
|
// breaking SIE export. Single roundtrip allowlist check.
|
|
const accountNumbers = Array.from(
|
|
new Set(body.manual_lines.map((l) => l.account_number)),
|
|
)
|
|
const { data: knownAccounts, error: accountsError } = await supabase
|
|
.from('chart_of_accounts')
|
|
.select('account_number')
|
|
.eq('company_id', companyId)
|
|
.eq('is_active', true)
|
|
.in('account_number', accountNumbers)
|
|
if (accountsError) {
|
|
opLog.error('chart_of_accounts lookup failed', accountsError)
|
|
return errorResponseFromCode('BULK_BOOK_RPC_FAILED', opLog, {
|
|
requestId,
|
|
details: { message: getUserErrorMessage(accountsError) },
|
|
})
|
|
}
|
|
const validSet = new Set(
|
|
(knownAccounts ?? []).map((a: { account_number: string }) => a.account_number),
|
|
)
|
|
const invalid = accountNumbers.filter((n) => !validSet.has(n))
|
|
if (invalid.length > 0) {
|
|
return errorResponseFromCode('BULK_BOOK_INVALID_ACCOUNT', opLog, {
|
|
requestId,
|
|
details: { invalid_accounts: invalid },
|
|
})
|
|
}
|
|
newEntryPayload = {
|
|
description: body.entry_description,
|
|
lines: body.manual_lines.map((l, i) => ({
|
|
account_number: l.account_number,
|
|
debit_amount: round2(l.debit_amount),
|
|
credit_amount: round2(l.credit_amount),
|
|
currency: l.currency,
|
|
line_description: l.line_description,
|
|
sort_order: i,
|
|
// Dimensions PR7: per-line bag wins over the header default.
|
|
dimensions: mergeDimensionBags(body.default_dimensions, l.dimensions),
|
|
})),
|
|
}
|
|
} else if (body.template_id && body.mode && body.entry_description) {
|
|
// Fetch the template. RLS scopes to user's companies + system templates,
|
|
// so we don't need a company_id filter here.
|
|
const { data: template, error: templateError } = await supabase
|
|
.from('booking_template_library')
|
|
.select('id, name, lines, is_active')
|
|
.eq('id', body.template_id)
|
|
.single()
|
|
|
|
if (templateError || !template) {
|
|
return errorResponseFromCode('BULK_BOOK_TEMPLATE_NOT_FOUND', opLog, { requestId })
|
|
}
|
|
if (!template.is_active) {
|
|
return errorResponseFromCode('BULK_BOOK_TEMPLATE_NOT_FOUND', opLog, {
|
|
requestId,
|
|
details: { reason: 'template_inactive' },
|
|
})
|
|
}
|
|
|
|
const templateLines = (template.lines ?? []) as BookingTemplateLibraryLine[]
|
|
|
|
// The tx rows (amount + currency) were fetched and currency-gated
|
|
// above; the RPC still re-validates date, direction, and
|
|
// not-already-booked.
|
|
const txAbsAmounts = txTyped.map((t) => Math.abs(t.amount))
|
|
const totalAbs = round2(txAbsAmounts.reduce((s, a) => s + a, 0))
|
|
|
|
const lines: ComputedLine[] = []
|
|
let sortOrder = 0
|
|
|
|
if (body.mode === 'sum_per_account') {
|
|
// One application of the template at the summed amount → one line
|
|
// per template line. Compact verifikat; per-tx detail recoverable
|
|
// via transaction_voucher_links.
|
|
const applied = applyTemplate(templateLines, totalAbs)
|
|
for (const formLine of applied) {
|
|
const debit = parseFloat(formLine.debit_amount || '0') || 0
|
|
const credit = parseFloat(formLine.credit_amount || '0') || 0
|
|
if (debit === 0 && credit === 0) continue
|
|
lines.push({
|
|
account_number: formLine.account_number,
|
|
debit_amount: round2(debit),
|
|
credit_amount: round2(credit),
|
|
currency,
|
|
line_description: formLine.line_description || undefined,
|
|
sort_order: sortOrder++,
|
|
// Dimensions PR7: header default applies to all template lines.
|
|
dimensions: body.default_dimensions,
|
|
})
|
|
}
|
|
} else {
|
|
// one_line_per_tx: apply template per tx, prefix description with
|
|
// a short tx reference so the verifikat preserves per-row audit
|
|
// detail (BFL 5 kap 7§ motpart identification).
|
|
for (const tx of txTyped) {
|
|
const applied = applyTemplate(templateLines, Math.abs(tx.amount))
|
|
for (const formLine of applied) {
|
|
const debit = parseFloat(formLine.debit_amount || '0') || 0
|
|
const credit = parseFloat(formLine.credit_amount || '0') || 0
|
|
if (debit === 0 && credit === 0) continue
|
|
const txTag = (tx.description || '').slice(0, 40).trim()
|
|
lines.push({
|
|
account_number: formLine.account_number,
|
|
debit_amount: round2(debit),
|
|
credit_amount: round2(credit),
|
|
currency,
|
|
line_description: txTag
|
|
? `${formLine.line_description ?? ''}: ${txTag}`.trim()
|
|
: formLine.line_description || undefined,
|
|
sort_order: sortOrder++,
|
|
// Dimensions PR7: header default applies to all template lines.
|
|
dimensions: body.default_dimensions,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
newEntryPayload = {
|
|
description: body.entry_description,
|
|
lines,
|
|
}
|
|
}
|
|
|
|
// Account dimension rules (dimensions PR10): the bulk-book RPC bypasses
|
|
// the TS engine, so the policy layer runs here — defaults/fixed applied
|
|
// to the computed lines, then 'required' asserted. Zero rules (the
|
|
// default) or a failed fetch changes nothing (fail-open, same posture as
|
|
// the engine).
|
|
if (newEntryPayload) {
|
|
const rules = await fetchActiveDimensionRules(supabase, companyId!)
|
|
if (rules === null) {
|
|
opLog.warn('dimension rule fetch failed — policy skipped (fail-open)')
|
|
}
|
|
if (rules && rules.length > 0) {
|
|
newEntryPayload.lines = applyDimensionRules(newEntryPayload.lines, rules)
|
|
try {
|
|
assertMandatoryDimensions(newEntryPayload.lines, rules)
|
|
} catch (err) {
|
|
const mapped = bookkeepingErrorResponse(err)
|
|
if (mapped) return mapped
|
|
throw err
|
|
}
|
|
}
|
|
}
|
|
|
|
// p_user_id removed in PR #608 (round-3 hardening pattern applied
|
|
// consistently). RPC resolves the caller via auth.uid().
|
|
const { data, error } = await supabase.rpc('bulk_book_transactions', {
|
|
p_tx_ids: body.tx_ids,
|
|
p_existing_journal_entry_id: body.existing_journal_entry_id ?? null,
|
|
p_new_entry: newEntryPayload,
|
|
p_company_id: companyId,
|
|
})
|
|
|
|
if (error) {
|
|
opLog.error('bulk_book_transactions RPC error', error)
|
|
return errorResponseFromCode('BULK_BOOK_RPC_FAILED', opLog, {
|
|
requestId,
|
|
details: { message: getUserErrorMessage(error) },
|
|
})
|
|
}
|
|
|
|
const result = data as RpcOk | RpcErr | null
|
|
if (!result || !result.ok) {
|
|
const code = (result as RpcErr | null)?.code ?? 'BULK_BOOK_RPC_FAILED'
|
|
const details = (result as RpcErr | null)?.details
|
|
return errorResponseFromCode(code, opLog, { requestId, details })
|
|
}
|
|
|
|
// Complete any matched inbox items against the samlingsverifikat: link
|
|
// their underlag and stamp them consumed so they leave the active inbox.
|
|
// Best-effort, logged inside; created_journal_entry_id is UNIQUE so at
|
|
// most one item can carry the stamp; the inbox list derives "booked"
|
|
// from the voucher links for the rest.
|
|
for (const txId of body.tx_ids) {
|
|
await propagateUnderlagForBookedTransaction(
|
|
supabase,
|
|
companyId!,
|
|
txId,
|
|
result.journal_entry_id,
|
|
)
|
|
}
|
|
|
|
// Emit one transaction.reconciled event per tx so existing subscribers
|
|
// (reminder cancellation, automation, processing-history) keep working.
|
|
// Best-effort; a failure here does not roll back the booking.
|
|
const { data: linkedTxs } = await supabase
|
|
.from('transactions')
|
|
.select('*')
|
|
.in('id', body.tx_ids)
|
|
.eq('company_id', companyId)
|
|
|
|
if (linkedTxs) {
|
|
for (const tx of linkedTxs as Transaction[]) {
|
|
try {
|
|
await eventBus.emit({
|
|
type: 'transaction.reconciled',
|
|
payload: {
|
|
transaction: tx,
|
|
journalEntryId: result.journal_entry_id,
|
|
method: 'manual',
|
|
userId: user.id,
|
|
companyId,
|
|
},
|
|
})
|
|
} catch (err) {
|
|
opLog.warn('bulk_book transaction.reconciled emission failed', {
|
|
err,
|
|
txId: tx.id,
|
|
journalEntryId: result.journal_entry_id,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return NextResponse.json({
|
|
data: {
|
|
mode: result.mode,
|
|
journal_entry_id: result.journal_entry_id,
|
|
voucher_series: result.voucher_series,
|
|
voucher_number: result.voucher_number,
|
|
linked_tx_count: result.linked_tx_count,
|
|
tx_sum: result.tx_sum,
|
|
docs_linked: result.docs_linked,
|
|
},
|
|
})
|
|
},
|
|
{ requireWrite: true },
|
|
)
|