style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)

Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.

Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.

Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-04 15:58:06 +02:00
committed by GitHub
co-authored by Claude Sonnet 5
parent a5154ee884
commit ec27228a8e
1588 changed files with 28388 additions and 10890 deletions
+73 -73
View File
@@ -11,7 +11,7 @@
* handling, and status transition logic apply.
*
* The executor functions previously lived in the commit route. They are kept
* private to this module — call `commitPendingOperation()` to invoke them.
* private to this module: call `commitPendingOperation()` to invoke them.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { eventBus } from '@/lib/events'
@@ -110,7 +110,7 @@ export interface CommitResult {
http_status?: number
auto_rejected?: boolean
// Set when the commit failed because the booking posts to BAS accounts not
// active in the company chart. Recoverable — the op is left 'pending' so the
// active in the company chart. Recoverable: the op is left 'pending' so the
// caller can activate the accounts and retry. Lets the route rebuild the
// structured ACCOUNTS_NOT_IN_CHART envelope (code + account_numbers).
code?: string
@@ -127,8 +127,8 @@ export interface CommitOptions {
* 'timing_ceiling' | 'migration' | 'legacy' | 'agent' | 'api_key'.
*
* Web-UI single-approval passes 'user_accept'; bulk-approval passes
* 'bulk_accept'. MCP approvals pass the relaying credential — 'api_key'
* (gnubok-mcp bridge) or 'agent' (OAuth connector) — so the immutable layer
* 'bulk_accept'. MCP approvals pass the relaying credential: 'api_key'
* (gnubok-mcp bridge) or 'agent' (OAuth connector), so the immutable layer
* records that the acknowledgment was agent-relayed rather than a
* first-party human session (agent_first_vision.md §8 P0-1). Every path is
* still human-approval-gated; agent auto-commit was removed in
@@ -138,8 +138,8 @@ export interface CommitOptions {
/**
* WHO is relaying this approval (api_key with the key's display name, plain
* user, agent_chat, …). Propagated to every journal-entry commit made by the
* operation via the runWithActor() AsyncLocalStorage scope — unlike
* commitMethod, which only the create_voucher executor threads explicitly —
* operation via the runWithActor() AsyncLocalStorage scope (unlike
* commitMethod, which only the create_voucher executor threads explicitly)
* and stamped onto journal_entries.committed_actor_* plus the audit_log
* COMMIT row by the commit_journal_entry RPC (migration 20260619120000).
* Omitted → NULL attribution, identical to pre-attribution behaviour.
@@ -439,7 +439,7 @@ async function commitCreateSupplier(
/**
* Executor for the staged create_dimension_value operation
* (gnubok_create_dimension_value — dimensions PR3). Inserts a dimension value
* (gnubok_create_dimension_value, dimensions PR3). Inserts a dimension value
* (SIE #OBJEKT) into the registry. Agents never silently mint reporting
* values: this always arrives via a human-approved pending_operation.
*
@@ -455,7 +455,7 @@ async function commitCreateDimensionValue(
): Promise<ExecutorResult> {
// Defense in depth: re-validate the staged params at the commit boundary so
// a tampered pending_operations row cannot inject a non-portable code or
// malformed dates into the registry (ASVS V4.5) — mirrors commitCreateSupplier.
// malformed dates into the registry (ASVS V4.5): mirrors commitCreateSupplier.
let validated
try {
validated = CreateDimensionValueParamsSchema.parse(params)
@@ -468,7 +468,7 @@ async function commitCreateDimensionValue(
throw err
}
// Get-or-create the system dims (1 = kostnadsställe, 6 = projekt) —
// Get-or-create the system dims (1 = kostnadsställe, 6 = projekt):
// idempotent lazy seeding. Custom dims must already exist in the registry:
// agents may stage new VALUES, never new dimensions.
if (validated.sie_dim_no === 1 || validated.sie_dim_no === 6) {
@@ -498,7 +498,7 @@ async function commitCreateDimensionValue(
}
// Value dates only make sense on accumulating dimensions (projekt-style
// ranges) — mirrors POST /api/dimensions/[id]/values.
// ranges): mirrors POST /api/dimensions/[id]/values.
if (dimension.resets_annually && (validated.start_date || validated.end_date)) {
return {
error: `Start-/slutdatum är inte tillåtna på dimensionen "${dimension.name}" (nollställs årligen).`,
@@ -521,7 +521,7 @@ async function commitCreateDimensionValue(
if (insertError) {
if (insertError.code === '23505') {
// Duplicate code — treat the existing value as success (idempotency).
// Duplicate code: treat the existing value as success (idempotency).
const { data: existing, error: existingError } = await supabase
.from('dimension_values')
.select('id, code, name, is_active')
@@ -562,8 +562,8 @@ async function commitCreateDimensionValue(
/**
* Executor for the staged retag_line_dimensions operation
* (gnubok_tag_journal_lines — dimensions PR6). Loops the staged line_ids
* through the retag_line_dimensions RPC — the ONE audited write path for
* (gnubok_tag_journal_lines, dimensions PR6). Loops the staged line_ids
* through the retag_line_dimensions RPC: the ONE audited write path for
* changing dimension tags on posted lines. The RPC enforces everything per
* line at commit time (open period, company lock date, active registry
* values, writer role, posted status) and writes an immutable
@@ -571,7 +571,7 @@ async function commitCreateDimensionValue(
*
* Partial-success semantics: one line failing (e.g. its period was locked
* between staging and approval) must not roll back the lines already
* retagged — each RPC call is its own transaction. Failures are collected
* retagged: each RPC call is its own transaction. Failures are collected
* and echoed (capped at 20) so the caller can re-stage just the failed set.
* Only when EVERY line fails does the operation as a whole fail.
*/
@@ -583,7 +583,7 @@ async function commitRetagLineDimensions(
): Promise<ExecutorResult> {
// Defense in depth: re-validate the staged params at the commit boundary so
// a tampered pending_operations row cannot inject arbitrary ids or a
// malformed bag (ASVS V4.5) — mirrors commitCreateDimensionValue.
// malformed bag (ASVS V4.5): mirrors commitCreateDimensionValue.
let validated
try {
validated = RetagLineDimensionsParamsSchema.parse(params)
@@ -628,7 +628,7 @@ async function commitRetagLineDimensions(
retagged,
unchanged,
failed_count: failed.length,
// Echo at most 20 failures — enough to act on without bloating
// Echo at most 20 failures: enough to act on without bloating
// result_data on a pathological 500-line all-but-one failure.
failed: failed.slice(0, 20),
dimensions: validated.dimensions,
@@ -710,7 +710,7 @@ async function commitCreateInvoice(
.from('customers').select('*').eq('id', customerId).eq('company_id', companyId).single()
if (customerError || !customer) {
return { error: 'Customer not found — they may have been deleted.', status: 404 }
return { error: 'Customer not found: they may have been deleted.', status: 404 }
}
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated)
@@ -741,7 +741,7 @@ async function commitCreateInvoice(
vatAmount += Math.round(lineTotal * itemRate / 100 * 100) / 100
}
// Validate any per-line revenue-account override (defense in depth — the field
// Validate any per-line revenue-account override (defense in depth: the field
// is frozen onto invoice_items and flows to generatePerRateLines()).
const overrideAccounts = Array.from(
new Set(billableItems.map((i) => i.revenue_account).filter((a): a is string => !!a)),
@@ -809,7 +809,7 @@ async function commitCreateInvoice(
const invoiceItems = items.map((item, index) => {
// Text rows store the description only and zero everything else. Keys must
// match the product branch exactly — PostgREST rejects a bulk insert whose
// match the product branch exactly: PostgREST rejects a bulk insert whose
// objects have differing key sets.
if (item.line_type === 'text') {
return {
@@ -894,7 +894,7 @@ async function commitMarkInvoicePaid(
return { error: 'Invoice can only be marked as paid when status is "sent" or "overdue"', status: 409 }
}
// Duplicate-payment guard — parity with the web mark-paid route, which the
// Duplicate-payment guard: parity with the web mark-paid route, which the
// agent path otherwise bypassed. If an unlinked inbound bank transaction
// already looks like this invoice's payment, booking a parallel payment
// voucher here creates exactly the orphan that later double-counts the
@@ -934,7 +934,7 @@ async function commitMarkInvoicePaid(
// behandlingshistorik record (BFNAR 2013:2 kap 8) so an auditor can see why
// the duplicate was allowed. Re-detect to capture the dismissed candidate;
// best-effort, never blocks the payment. Payload stays PII-safe
// (ids/amounts/dates only — no customer or merchant name).
// (ids/amounts/dates only: no customer or merchant name).
const customerName = (invoice as { customer?: { name?: string } }).customer?.name
if (customerName) {
try {
@@ -978,14 +978,14 @@ async function commitMarkInvoicePaid(
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
let journalEntryId: string | null = null
// Route on invoice state, not the company's current accounting_method —
// Route on invoice state, not the company's current accounting_method:
// an invoice booked at send under accrual must clear 1510 here even if
// the company has since switched to kontantmetoden.
const invoiceAlreadyBooked = !!(invoice as { journal_entry_id?: string | null }).journal_entry_id
const useCashEntry = !invoiceAlreadyBooked && accountingMethod === 'cash'
// Paid/remaining/status math + overpayment guard via the shared
// planInvoicePayment helper — the single source of truth across the three
// planInvoicePayment helper: the single source of truth across the three
// mark-paid surfaces (this agent path, the dashboard route, and the v1 API).
// This path settles the full remaining (no custom lines), so it can never
// overpay, but routing through the helper keeps the state identical. Runs
@@ -1027,7 +1027,7 @@ async function commitMarkInvoicePaid(
if (!journalEntryId) {
return {
error:
'Betalningen kunde inte bokföras (ingen verifikation skapades — t.ex. stängd räkenskapsperiod). ' +
'Betalningen kunde inte bokföras (ingen verifikation skapades: t.ex. stängd räkenskapsperiod). ' +
'Fakturan har inte markerats som betald.',
status: 422,
}
@@ -1078,7 +1078,7 @@ async function commitMarkInvoicePaid(
}
}
// Notify subscribers — invoice.paid fans out to registered webhooks
// Notify subscribers: invoice.paid fans out to registered webhooks
// (lib/webhooks/handler.ts). Best-effort: the payment is already committed,
// so an emit failure must not fail the operation. Parity with the v1 and
// dashboard mark-paid routes, which previously emitted while this path did not.
@@ -1128,15 +1128,15 @@ async function commitSendInvoice(
.single()
if (invoiceError || !invoice) return { error: 'Invoice not found', status: 404 }
// partially_paid/credited imply the invoice was already issued too — the
// partially_paid/credited imply the invoice was already issued too: the
// status flip below would regress them to 'sent' (PR #666 review, ASVS V2.3).
if (['sent', 'paid', 'overdue', 'partially_paid', 'credited'].includes(invoice.status)) {
return { error: 'Invoice has already been sent', status: 409 }
}
// A cancelled invoice keeps its F-series number for ML 17 kap 24§ compliance
// but is not a valid faktura — sending it would silently re-activate it (the
// but is not a valid faktura: sending it would silently re-activate it (the
// status flip below has no guard) and deliver a "MAKULERAD" PDF as if live.
// Mirrors the send route's guard (audit C17 — this agent path lacked it).
// Mirrors the send route's guard (audit C17, this agent path lacked it).
if (invoice.status === 'cancelled') {
return {
error:
@@ -1168,8 +1168,8 @@ async function commitSendInvoice(
// Preflight render: validate the PDF pipeline BEFORE consuming an F-series
// number, so a render failure can't leave a numbered-but-never-issued
// invoice (an F-series gap if the draft is later abandoned). Skipped when
// the row is already numbered (retry path) — we'd render twice for no gain.
// Mirrors the send route (audit C17 — this agent path assigned the number
// the row is already numbered (retry path): we'd render twice for no gain.
// Mirrors the send route (audit C17, this agent path assigned the number
// first and rendered unguarded).
const isFreshAllocation = !invoice.invoice_number
if (isFreshAllocation) {
@@ -1208,7 +1208,7 @@ async function commitSendInvoice(
// Override `status` to 'sent' on the in-memory copy. The DB flip happens
// after email delivery (line ~625); rendering with the stale 'draft' status
// would stamp the customer's PDF with "UTKAST – inte en giltig faktura".
// would stamp the customer's PDF with "UTKAST: inte en giltig faktura".
const renderableInvoice = { ...(invoice as Invoice), status: 'sent' as const }
const { branding, company: renderCompany } = await prepareInvoicePdfRender(
company as CompanySettings,
@@ -1363,7 +1363,7 @@ async function commitMatchTransactionInvoice(
return { error: 'Invoice is not in a matchable state', status: 409 }
}
// Overshoot guard + paid/remaining math — shared with the dashboard and v1
// Overshoot guard + paid/remaining math: shared with the dashboard and v1
// routes via planInvoicePayment. This agent/MCP path previously had NO guard,
// so a 1500 payment on a 1000 invoice was silently accepted (paid_amount >
// total, AR over-credited). Runs BEFORE the storno + JE below, so a rejected
@@ -1394,7 +1394,7 @@ async function commitMatchTransactionInvoice(
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
// Route on invoice state, not the company's current setting. Mirror of
// the match-invoice route fix — see that handler for the full rationale.
// the match-invoice route fix: see that handler for the full rationale.
const invoiceAlreadyBooked = !!(invoice as { journal_entry_id?: string | null }).journal_entry_id
const useCashEntry = !invoiceAlreadyBooked && accountingMethod === 'cash' && isFullyPaid
@@ -1686,7 +1686,7 @@ async function commitAttachDocumentToTransaction(
if (docError || !doc) return { error: 'Document not found', status: 404 }
// A document that already serves as underlag for a DIFFERENT verifikation
// cannot be pinned here — propagating would either corrupt that link or be
// cannot be pinned here: propagating would either corrupt that link or be
// blocked by the document-metadata immutability trigger. Same verifikation
// is fine (idempotent re-attach; propagation below becomes a no-op).
// Mirrors the REST route in app/api/transactions/[id]/attach-document.
@@ -1703,7 +1703,7 @@ async function commitAttachDocumentToTransaction(
// before our UPDATE acquired the row lock. Reading the post-update state
// (rather than the pre-staging state) is what makes the
// attach-then-categorize and categorize-then-attach orderings produce the
// same final state — both end with document_attachments.journal_entry_id
// same final state: both end with document_attachments.journal_entry_id
// set to the tx's journal_entry_id. (BFL 5 kap 6 § verifikation underlag.)
const { data: postUpdate, error: updateError } = await supabase
.from('transactions')
@@ -1762,14 +1762,14 @@ async function commitAttachDocumentToTransaction(
.eq('company_id', companyId)
if (linkErr) {
// The enforce_period_lock trigger blocks journal_entry_id writes when
// the target entry sits in a closed/locked period. Map to 409 — the
// the target entry sits in a closed/locked period. Map to 409: the
// dispatcher auto-rejects it, and a retry could never succeed until the
// period is unlocked, so "försök igen" would be a false promise.
const linkMsg = (linkErr as { message?: string }).message ?? ''
if (/locked\/closed fiscal period|Bokföringen är låst/i.test(linkMsg)) {
return {
error:
'Bilagan kopplades till transaktionen men verifikationens period är låst — den kunde inte länkas till verifikationen.',
'Bilagan kopplades till transaktionen men verifikationens period är låst: den kunde inte länkas till verifikationen.',
status: 409,
}
}
@@ -1782,14 +1782,14 @@ async function commitAttachDocumentToTransaction(
console.error('[commitAttach] Failed to propagate to journal entry:', linkErr)
return {
error:
'Bilagan kopplades till transaktionen men kunde inte länkas till verifikationen. Försök igen — operationen är idempotent.',
'Bilagan kopplades till transaktionen men kunde inte länkas till verifikationen. Försök igen: operationen är idempotent.',
status: 500,
}
}
}
// Rättelse audit trail (BFL 5 kap 5 §): if we replaced a non-null doc, log
// the swap to processing_history so the original is traceable. Best-effort —
// the swap to processing_history so the original is traceable. Best-effort:
// a logging failure must not roll back the (compliant) attach.
if (previousDocumentId && previousDocumentId !== documentId) {
try {
@@ -1882,7 +1882,7 @@ async function commitLinkDocumentToVoucher(
const msg = (err as Error).message ?? ''
if (/locked\/closed fiscal period|Bokföringen är låst/i.test(msg)) {
return {
error: 'Verifikationens period är låst — bilagan kan inte länkas.',
error: 'Verifikationens period är låst: bilagan kan inte länkas.',
status: 409,
}
}
@@ -2191,7 +2191,7 @@ async function commitCreateSupplierInvoiceFromInbox(
const pgErr = invoiceErr as { code?: string; message?: string } | null
const isDuplicate = pgErr?.code === '23505'
if (isDuplicate) {
// Generic 409 — supplier_invoice_number alone is already in the staged
// Generic 409: supplier_invoice_number alone is already in the staged
// params the caller submitted; we just don't echo back the supplier's
// name or row id. The UI surface uses the supplier-side ledger, not
// this error.
@@ -2217,7 +2217,7 @@ async function commitCreateSupplierInvoiceFromInbox(
// RC invariant: a reverse-charge supplier invoice never shows output VAT
// from the supplier. Zero any per-line VAT that slipped through staging so
// the registration JE's 2614/2645 self-assessed leg lines up with rutor
// 20–24 / 48 instead of double-counting input VAT into 2641. Tampered
// 20-24 / 48 instead of double-counting input VAT into 2641. Tampered
// params can't smuggle non-zero VAT into the items table.
const itemInserts = rawItems.map((item, idx) => {
const vatRate = reverseCharge ? 0 : (typeof item.vat_rate === 'number' && Number.isFinite(item.vat_rate) ? item.vat_rate : 0)
@@ -2290,7 +2290,7 @@ async function commitCreateSupplierInvoiceFromInbox(
// Attach the OCR'd source document to the verifikat so the
// registration JE has its underlag per BFL 5 kap 6 §. Linking failure
// is non-fatal — the JE is already posted and immutable; we log and
// is non-fatal: the JE is already posted and immutable; we log and
// continue so the supplier invoice stays usable.
if (documentId) {
try {
@@ -2307,7 +2307,7 @@ async function commitCreateSupplierInvoiceFromInbox(
// createSupplierInvoiceRegistrationEntry returns null ONLY when no
// fiscal period covers invoice_date (every other failure throws into
// the catch below). Without this branch the inbox item gets linked to
// an unbooked supplier invoice — the same 2440/2641 orphan the catch
// an unbooked supplier invoice: the same 2440/2641 orphan the catch
// guards against. Roll back (items first, see FK note below) and return
// an actionable error instead of silently "succeeding".
await supabase
@@ -2328,7 +2328,7 @@ async function commitCreateSupplierInvoiceFromInbox(
} catch (err) {
// Roll back: orphan supplier_invoices row without its registration JE
// understates leverantörsskuld (2440) + ingående moms (2641) on the
// momsdeklaration. Items must be deleted BEFORE the parent — the FK
// momsdeklaration. Items must be deleted BEFORE the parent: the FK
// on supplier_invoice_items.supplier_invoice_id is ON DELETE NO ACTION
// (default), so a parent-first delete would be silently blocked and
// leave the doomed invoice in the supplier ledger.
@@ -2343,7 +2343,7 @@ async function commitCreateSupplierInvoiceFromInbox(
.eq('company_id', companyId)
if (parentDeleteErr) {
// Hard inconsistency: items gone but parent stuck. Log loudly so an
// operator can clean up — this should not happen in practice.
// operator can clean up; this should not happen in practice.
log.error('Rollback partial: parent supplier_invoices delete failed after JE failure', {
companyId,
invoiceId: invoice.id,
@@ -2368,7 +2368,7 @@ async function commitCreateSupplierInvoiceFromInbox(
// Terminal state for the inbox row: created_supplier_invoice_id is the
// dedup key for next time this inbox item is touched, and it's what the UI
// and list_unmatched_documents use to drop the row out of "needs action".
// Do NOT write status here — the status CHECK only allows received|error
// Do NOT write status here: the status CHECK only allows received|error
// (migration 20260504180000); writing 'confirmed' makes Postgres reject the
// whole UPDATE, so the link column never lands and the item stays unresolved.
const { error: linkInboxErr } = await supabase
@@ -2646,7 +2646,7 @@ async function commitCreditInvoice(
// Resolve the original verifikation reference so the credit-note JE can
// point back to the corrected entry per BFL 5 kap. 5 §. We tolerate
// missing-JE on the original (legacy data) — the description simply omits
// missing-JE on the original (legacy data): the description simply omits
// the voucher reference and keeps the invoice-number reference.
let originalVoucherRef: string | undefined
if (original.journal_entry_id) {
@@ -2803,7 +2803,7 @@ async function commitImportSie(
const importOpeningBalances = Boolean(params.import_opening_balances)
const importTransactions = Boolean(params.import_transactions)
const voucherSeries = params.voucher_series as string | undefined
// Default true (not Boolean(...) — operations staged before this param
// Default true (not Boolean(...): operations staged before this param
// existed must keep the file's account names, matching the UI default).
const updateAccountNames =
params.update_account_names === undefined ? true : Boolean(params.update_account_names)
@@ -2879,7 +2879,7 @@ async function commitUndoSieImport(
/**
* Normalize raw JSON line input from pending_operations.params into the
* engine's typed line shape. Trusts shape because the MCP tool already
* validates via Zod before staging — defensive coercion only.
* validates via Zod before staging: defensive coercion only.
*/
function normalizeVoucherLines(raw: unknown): CreateJournalEntryLineInput[] {
if (!Array.isArray(raw)) return []
@@ -2894,7 +2894,7 @@ function normalizeVoucherLines(raw: unknown): CreateJournalEntryLineInput[] {
amount_in_currency: line.amount_in_currency !== undefined ? Number(line.amount_in_currency) : undefined,
exchange_rate: line.exchange_rate !== undefined ? Number(line.exchange_rate) : undefined,
tax_code: line.tax_code ? String(line.tax_code) : undefined,
// Boundary-validated with the same constraints as the Zod line schema —
// Boundary-validated with the same constraints as the Zod line schema:
// staged payloads must not bypass API-layer validation (SOC 2 PI1.1).
dimensions: coerceDimensionsBag(line.dimensions),
cost_center: line.cost_center ? String(line.cost_center) : undefined,
@@ -2920,7 +2920,7 @@ async function commitCreateVoucher(
// Re-validate balance defensively. The MCP tool already checks before
// staging, but a tampered or hand-inserted pending_operations row would
// bypass that gate. createDraftEntry runs the same check internally — this
// bypass that gate. createDraftEntry runs the same check internally: this
// is for a cleaner 400 + Swedish error before reaching the engine.
const balance = validateBalance(lines)
if (!balance.valid) {
@@ -2944,12 +2944,12 @@ async function commitCreateVoucher(
fiscalPeriodId = resolved
}
// source_type is derived here — never trust params.source_type. The MCP tool
// source_type is derived here: never trust params.source_type. The MCP tool
// stages a typed boolean (is_opening_balance), not a raw source_type string,
// so a tampered or future direct-staging path can't inject
// 'bank'/'invoice'/etc. and corrupt audit attribution. The default is
// 'manual'. We only upgrade to 'opening_balance' after independently
// re-validating the entry genuinely looks like an ingående balans — this
// re-validating the entry genuinely looks like an ingående balans: this
// matters because bank reconciliation excludes an IB from the period movement
// ONLY when source_type='opening_balance' (lib/reconciliation/bank-reconciliation.ts);
// a mislabelled 'manual' IB shows up as a phantom reconciliation difference.
@@ -2968,14 +2968,14 @@ async function commitCreateVoucher(
if (nonBalanceSheet.length > 0) {
return {
error:
`Ingående balans får bara innehålla balanskonton (klass 1–2). ` +
`Ingående balans får bara innehålla balanskonton (klass 1-2). ` +
`Dessa konton hör inte hemma i en IB: ${[...new Set(nonBalanceSheet)].join(', ')}. ` +
`Bokför resultatkonton som en vanlig verifikation utan is_opening_balance.`,
status: 400,
}
}
// Constraint 2: the entry must be dated on the fiscal period's first day —
// Constraint 2: the entry must be dated on the fiscal period's first day:
// an IB opens the period (same as the canonical flow, which dates the entry
// on period.period_start). We fetch period_start here because the resolved
// fiscalPeriodId may have come from either the explicit param or a date
@@ -3024,7 +3024,7 @@ async function commitCreateVoucher(
opts.commitMethod ?? 'user_accept'
)
// Optional inbox linking — set when gnubok_create_voucher is called with
// Optional inbox linking: set when gnubok_create_voucher is called with
// inbox_item_id (book-direct flow for kvitton). The verifikat is already
// posted and immutable; failures here are non-fatal and only affect
// discoverability (inbox row stays in "needs action" with the document
@@ -3043,7 +3043,7 @@ async function commitCreateVoucher(
// zero-rows-updated result and surfaces a structured warning. We also
// require .eq('created_supplier_invoice_id', null) so a concurrent
// create_supplier_invoice_from_inbox doesn't get clobbered either.
// Only the link column is written — the status CHECK allows received|error
// Only the link column is written: the status CHECK allows received|error
// (migration 20260504180000), so writing 'confirmed' here would fail the
// whole UPDATE and silently leave the inbox item in "needs action".
const { data: updatedRows, error: linkInboxErr } = await supabase
@@ -3064,7 +3064,7 @@ async function commitCreateVoucher(
} else if (!updatedRows || updatedRows.length === 0) {
// Race: another commit already claimed this inbox item (either as a
// journal entry or supplier invoice). The verifikat is already posted
// and immutable — we leave it; an operator can rättelse via storno
// and immutable: we leave it; an operator can rättelse via storno
// if it's a true duplicate.
log.warn('Voucher posted but inbox item was already claimed by a concurrent commit', {
inboxItemId,
@@ -3074,7 +3074,7 @@ async function commitCreateVoucher(
inboxLinked = true
}
// Only attach the OCR document when the inbox link succeeded — if a
// Only attach the OCR document when the inbox link succeeded: if a
// racing commit already owns the inbox row, the document already lives
// on its JE and re-attaching here would either fail noisily (UNIQUE on
// document_attachments.journal_entry_id, if any) or silently shift it.
@@ -3375,7 +3375,7 @@ function getSkatteverketServices(): SkatteverketCommitServices {
| Partial<SkatteverketCommitServices>
| undefined
if (!services?.commitSubmitVatDeclaration || !services?.commitSubmitAgi) {
// Extension absent or not wired. Recoverable — leave the op pending so a
// Extension absent or not wired. Recoverable: leave the op pending so a
// re-enable + re-approve works without re-staging.
throw new SkatteverketRecoverableError(
'Skatteverket-integrationen är inte tillgänglig.',
@@ -3448,7 +3448,7 @@ async function commitMatchBatchAllocate(
// `company_members.company_id = p_company_id`
// The MCP execute() handler additionally pre-checks the same IDs to
// surface clean errors before staging. This commit handler is a thin
// pass-through by design — re-querying here would triple the same
// pass-through by design: re-querying here would triple the same
// check without adding security.
const txId = params.transaction_id as string
const allocations = params.allocations
@@ -3463,7 +3463,7 @@ async function commitMatchBatchAllocate(
})
if (error) {
// Sanitised log (A.8.11, CC7.2): only error code + message, no
// payload — error.details can echo invoice IDs, amounts, etc.
// payload: error.details can echo invoice IDs, amounts, etc.
log.error('match_batch_allocate RPC error', {
code: (error as { code?: string }).code,
message: error.message,
@@ -3479,7 +3479,7 @@ async function commitMatchBatchAllocate(
}
}
// Structured audit-trail entry on success (compliance-swarm V16). Tx
// count + JE id + the source tx id only — no amounts, no
// count + JE id + the source tx id only: no amounts, no
// counterparty identifiers, no descriptions. txId is included
// intentionally so the audit trail can join successful commits back
// to the source bank tx without a separate query; it's not PII on
@@ -3562,12 +3562,12 @@ async function commitBulkBookTransactions(
}
/**
* Bulk-book selected Underlag (Dokumentinkorgen) — Lena-driven flow. Each
* Bulk-book selected Underlag (Dokumentinkorgen): Lena-driven flow. Each
* selected inbox item is booked against its matched bank transaction using one
* shared category + VAT treatment. The booking, VAT (incl. reverse charge), and
* underlag→verifikat propagation are the SAME shared core the single-item
* categorize path uses (categorizeMatchedTransaction). Items that can't be
* booked are skipped with a reason rather than failing the whole batch — the
* booked are skipped with a reason rather than failing the whole batch: the
* "Bokför valda hoppar över" contract. A per-item throw (e.g. period locked,
* accounts not in chart) is caught and recorded as a skip so one bad underlag
* never blocks the rest.
@@ -3634,7 +3634,7 @@ async function commitLinkTransactionJournalEntry(
}
// Structured audit-trail entry on success (compliance-swarm V16, SOC 2 CC4.1).
// Mirrors commitMatchBatchAllocate / commitBulkBookTransactions — IDs only,
// Mirrors commitMatchBatchAllocate / commitBulkBookTransactions: IDs only,
// no amounts or counterparty PII. invoiceId is logged as boolean to avoid
// leaking which invoices are touched while still distinguishing the two
// code paths (link-only vs link+settle).
@@ -3669,8 +3669,8 @@ async function commitLinkTransactionJournalEntry(
* transitions are applied here so the two callers stay consistent.
*
* When opts.actor is set, the entire executor runs inside a runWithActor()
* scope so EVERY journal-entry commit the operation makes — regardless of
* which entry generator produced it — carries actor attribution into
* scope so EVERY journal-entry commit the operation makes (regardless of
* which entry generator produced it) carries actor attribution into
* journal_entries.committed_actor_* and the audit_log COMMIT row via
* commitEntry() → commit_journal_entry RPC (migration 20260619120000).
*/
@@ -3694,7 +3694,7 @@ async function commitPendingOperationInner(
): Promise<CommitResult> {
// ── Capability gate (commit-time twin of the MCP dispatch gate). The actual
// external-service call (email / Skatteverket submit) happens below, so
// this is the true paid chokepoint — it also catches an op STAGED during
// this is the true paid chokepoint: it also catches an op STAGED during
// the trial then approved AFTER the grant expired, regardless of caller
// (MCP approve tool or the UI approval path). Checked BEFORE the atomic
// claim so a blocked op stays 'pending' and is re-approvable once the
@@ -3880,8 +3880,8 @@ async function commitPendingOperationInner(
} catch (err) {
// Accounts-not-in-chart is RECOVERABLE: the booking itself is valid; the
// company's chart just lacks the (standard BAS) accounts it posts to. Do
// NOT consume the op — release the atomic claim back to 'pending' so the
// user can activate the accounts and retry the SAME op — and surface the
// NOT consume the op: release the atomic claim back to 'pending' so the
// user can activate the accounts and retry the SAME op, and surface the
// structured code + numbers so the client can offer one-click activation.
if (err instanceof AccountsNotInChartError) {
await supabase
@@ -3972,7 +3972,7 @@ async function commitPendingOperationInner(
if (finalizeError) {
// The executor's side-effects already committed (and are immutable); only
// the terminal status write failed. Without surfacing this, the row would
// sit in 'committing' indefinitely — the expire sweep only targets
// sit in 'committing' indefinitely: the expire sweep only targets
// 'pending' ops, so nothing would ever reconcile it. Log loudly with the
// ids needed to finalize manually; the response still reports success
// because the actual work is done.