* fix(inbox): booked items no longer strand in Att gora as matched-forever A matched inbox item only left the active inbox when created_journal_entry_id was stamped, and only categorizeTransactionCore stamped it. Booking the matched transaction through any other path (the /book dialog route, bulk-book, link-to-existing-voucher) or matching a receipt to an already-booked transaction (receipt hunt approvals, attach-document, match-transaction) left the item "linked" forever, pointing at a transaction that had already left the transactions work list. Todays hunt fix (#1524) turned this July-old gap into a visible flood of stuck items. Two-part fix, because stamps alone cannot cover the reported case: created_journal_entry_id is UNIQUE (20260515090000), so on a bulk-book samlingsverifikat only one of N matched items can ever carry it. Write side: lib/transactions/inbox-underlag.ts is the shared implementation all paths now call. It links matched items' documents to the anchoring verifikat (BFL 5 kap 6-7 kap: underlag on the verifikation) and stamps created_journal_entry_id best-effort (CAS on null, unique_violation tolerated). Wired into categorize-core (replacing its inline block), /book, bulk-book, linkTransactionToJournalEntry, both attach paths (REST + pending-operation), and the inbox match-transaction handler. The attach paths and the doc-conflict guard also resolve bulk-booked transactions through transaction_voucher_links, which they previously treated as unbooked. Read side: GET /items (and /items/:id) enrich matched-but-unstamped items with matched_transaction_journal_entry_id, and the workspace derives "booked" from it. This is what clears the stuck rows already in prod without a status backfill, and what covers the N-1 samlingsverifikat items the UNIQUE constraint refuses to stamp. Bulk-book selection filters exclude such items so "Bokfor valda" no longer offers 409 fodder. scripts/backfill-inbox-booked-underlag.ts (dry-run by default) repairs the historical document->verifikat links the old paths never made. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(inbox): stamp only settled underlag, and give the backfill behandlingshistorik Both from the Swedish accounting compliance review. The consumed-stamp is now conditional on the underlag actually referencing a verifikat: stamping over a failed document link hid the item from the .is('created_journal_entry_id', null) query forever, leaving a posted verifikation without its underlag reference (BFL 5 kap 6-7 kap) and nothing left to surface or repair it. A failed link now leaves the item unstamped so re-runs and the backfill can finish the job; a document preserved on another verifikat still counts as settled. The backfill script now appends an InboxUnderlagBackfilled event per repaired transaction to processing_history (BFNAR 2013:2 kap 8): a mass repair touching underlag-to-verifikat linkage leaves a changelog trail distinguishing it from the original booking action. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(inbox): backfill writes behandlingshistorik through the shared appender From the Swedish accounting compliance review round 2: a hand-rolled processing_history insert in the backfill script could drift from the shared row shape and skip the PII validation. appendProcessingHistory now delegates to appendProcessingHistoryWithClient, which takes a caller-supplied service-role client, so standalone scripts write behandlingshistorik through the exact same code path as the app (BFNAR 2013:2 kap 8: one reconcilable change log across writers). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(inbox): leave the item unstamped when its document belongs to another verifikat Swedish accounting review round 3: refusing to steal the document was right, but stamping the item consumed anyway hid the fact that the transaction's own verifikat ended up with no underlag reference from it (BFL 5 kap 6-7 kap). The anchored-elsewhere case now leaves created_journal_entry_id null so the mismatch keeps surfacing for reconciliation, same posture as a failed link. Also documents in the backfill script header why its writes cannot land in locked periods: linkToJournalEntry's UPDATE is guarded by the enforce_period_lock DB trigger, which fires for service-role writes too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
388 lines
15 KiB
TypeScript
388 lines
15 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 { 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')
|
|
.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'>[]
|
|
|
|
// 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 },
|
|
})
|
|
}
|
|
|
|
// 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 },
|
|
)
|