* feat(dimensions): PR6 retro-tagging — audited retag carve-out, workbench, staged MCP tool
Tier-2 retro-tagging (founder decision №1, approved 2026-07-02): posted
entries in OPEN periods can have their dimension tags changed through ONE
audited path — everything about the verifikat itself stays immutable.
Carve-out (migration 20260702170000): the line-immutability trigger gains a
single narrow branch — while the transaction-local GUC set by the RPC is
active, an UPDATE of a posted line is admitted iff every non-dimension
column is unchanged, enforced by a whole-row to_jsonb diff (any future
column is protected by construction; mirrors cost_center/project are in the
changeable set because they are derived views of dimensions['1']/['6']).
Precedent: mark_entry_as_opening_balance (20260613120000).
retag_line_dimensions RPC: tenant guard (20260619130100 pattern), writer
gate (viewers rejected), posted-only, open period + company lock date
enforced, every code validated against the ACTIVE registry, immutable
dimension_retag_log row (before/after/actor/reason, INSERT-only via its own
trigger, no FKs so the trail survives hard-deletes) written BEFORE the
carve-out UPDATE. Idempotent no-op without a log row. Untag ({}) supported.
Legal position per the plan: dimensions are internredovisning metadata, not
BFL 5 kap 7§ verifikat content — this is strictly more conservative than
Fortnox/Visma (dimension-only diffs, open periods only, immutable log,
storno past locks — Tier 3 has no exceptions).
Mandatory pg suite (11 tests): GUC-less updates still blocked; amounts/
description can never change even under the GUC (transaction-local);
closed/locked/lock-date, role, registry, draft and cross-tenant rejections;
log immutability; gnubok.allow_delete bulk path unaffected.
UX (all writes through the ONE RPC): pencil on posted-voucher lines in
bookkeeping/[id] ("Påverkar endast internredovisningen, inte verifikatet")
+ retag-history card; BulkTagWorkbench at /dimensions/tagging (filters,
shift-select, merge vs "Ersätt tagg" replace mode, reversal-pair warning
with "Inkludera motverifikat" auto-selection, per-line failure display).
MCP: gnubok_tag_journal_lines (bookkeeping:write) — filter block resolved
via resolve-don't-select, ≤500 lines, staged via pending_operations (new
op type migration 20260702171000, medium risk tier, shared Zod validation
boundary between staging and commit; executor loops the RPC per line with
partial-success aggregation).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dimensions): address #867 review — SQLSTATE classification, blocking storno confirm, documented divergence
- Retag route classifies RPC errors by SQLSTATE instead of message-regex:
P0001 (every rule violation in the RPC) → 409 verbatim, 42501 (tenant
guard) → 403, anything else → logged 500 with a generic message. No more
substring sniffing.
- The workbench's storno-pair warning escalates to a BLOCKING confirmation
naming the unselected counter-vouchers before apply (Srf U 14 gross
reporting — one-legged retags silently skew project P&L; the banner alone
was advisory).
- The empty-bag divergence is now documented on both schemas as intentional:
the direct dialog/workbench path allows {} (human untags phantom codes,
logged with reason), the MCP staged path rejects it (agents never
bulk-clear history).
Triage notes: the log's missing FKs are the point (behandlingshistorik must
survive undo_sie_import hard-deletes — a cascade would erase the trail);
SIE exports are generated fresh on demand, never cached, so post-retag
exports carry the new object lists automatically; date-scoped registry
values are deliberately not enforced at retag because entry creation does
not enforce them either — enforcing in one path only would be incoherent
(both belong to the PR10 rules engine).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
162 lines
8.6 KiB
TypeScript
162 lines
8.6 KiB
TypeScript
/**
|
|
* Risk tier classification for pending_operations.
|
|
*
|
|
* Used by lib/pending-operations/should-auto-commit.ts to decide whether a
|
|
* staged proposal from a trusted agent can be auto-committed without human
|
|
* review.
|
|
*
|
|
* Tiering principles:
|
|
* - **low**: no booking impact, no external side-effects, no audit risk.
|
|
* A reasonable bookkeeper would never want to manually approve these.
|
|
* - **medium**: reversible booking impact (drafts, transaction
|
|
* categorization that can be uncategorized). Auto-commit is allowed for
|
|
* trusted agents under a configurable monetary threshold.
|
|
* - **high**: irreversible or compliance-critical. Sends external messages,
|
|
* locks/closes periods, or affects tax filings. NEVER auto-committed,
|
|
* regardless of company opt-in or trust level.
|
|
*/
|
|
|
|
export type RiskLevel = 'low' | 'medium' | 'high'
|
|
|
|
export const OPERATION_RISK_TIERS: Record<string, RiskLevel> = {
|
|
// ── Low: pure data, no booking impact ─────────────────────────────
|
|
create_customer: 'low',
|
|
// Article catalog (artikelregister) is app-level master data — no journal
|
|
// impact, no external side-effect. Unlike create_supplier it carries no
|
|
// payment-routing fields, so there's no BEC/fraud surface; both create and
|
|
// update sit at the lowest tier next to create_customer.
|
|
create_article: 'low',
|
|
update_article: 'low',
|
|
// Dimension values (kostnadsställe/projekt object codes, SIE #OBJEKT) are
|
|
// reporting master data — no journal impact, no external side-effect, no
|
|
// payment-routing surface. Staged (agents never silently mint reporting
|
|
// values) but at the lowest tier next to create_customer/create_article.
|
|
create_dimension_value: 'low',
|
|
|
|
// ── Medium: reversible booking ─────────────────────────────────────
|
|
categorize_transaction: 'medium',
|
|
match_transaction_invoice: 'medium',
|
|
// Link an existing posted verifikat as payment for an invoice. Reversible by
|
|
// deleting the invoice_payments row and reverting invoice status; no journal
|
|
// entry is created or modified. Sits next to match_transaction_invoice
|
|
// semantically — both attach an existing booking to an invoice.
|
|
link_invoice_voucher: 'medium',
|
|
// Supplier-side mirror of link_invoice_voucher: link an existing posted
|
|
// verifikat (Dr 2440) as payment for a leverantörsfaktura. Reversible by
|
|
// deleting the supplier_invoice_payments row and reverting status; no journal
|
|
// entry is created or modified.
|
|
link_supplier_invoice_voucher: 'medium',
|
|
create_invoice: 'medium', // creates as draft; sending is a separate op
|
|
create_transaction: 'medium', // ingests an uncategorized row; reversible by delete
|
|
// Supplier master data carries payment-routing fields (IBAN, BIC, bankgiro,
|
|
// bank_account) that drive outgoing payment files and supplier invoice
|
|
// postings. A wrong account or org_number can enable supplier-fraud / BEC
|
|
// (silently rerouting payment), so always require explicit human approval
|
|
// rather than auto-commit.
|
|
create_supplier: 'medium',
|
|
// Pinning a doc to a tx is reversible while pre-categorization, but the link
|
|
// becomes part of the verifikation underlag (BFL 5 kap 6 §) once categorize
|
|
// propagates it. A wrong attachment requires a rättelse, so require human
|
|
// approval rather than auto-commit.
|
|
attach_document_to_transaction: 'medium',
|
|
// Linking a doc to a posted verifikation is part of räkenskapsinformation
|
|
// (BFL 5 kap 6 §) and becomes immutable once the JE is posted. Medium so a
|
|
// human confirms the doc-to-verifikat pairing before it locks.
|
|
link_document_to_voucher: 'medium',
|
|
// Dimension-only diff on posted lines (verifikat stays immutable), fully
|
|
// audited via dimension_retag_log — but it rewrites reporting history, so
|
|
// it crosses a human at medium.
|
|
retag_line_dimensions: 'medium',
|
|
|
|
// ── High: irreversible, compliance-critical, or external side-effects
|
|
send_invoice: 'high', // emails the customer
|
|
mark_invoice_paid: 'high', // posts payment journal entry
|
|
mark_invoice_sent: 'high', // assigns invoice number, accrual JE
|
|
|
|
// ── Stream 1 Phase 1 ops (added when those tools land) ─────────────
|
|
close_period: 'high',
|
|
lock_period: 'high',
|
|
unlock_period: 'high',
|
|
set_opening_balances: 'high',
|
|
run_year_end: 'high',
|
|
run_currency_revaluation: 'high',
|
|
// Planenlig avskrivning: one journal entry per asset, each independently
|
|
// reversible (storno). Mid-stakes bokslut posting — staged and human-reviewed,
|
|
// but not the irreversible tier that year-end close / period lock occupy.
|
|
post_annual_depreciation: 'medium',
|
|
import_sie: 'high',
|
|
// Hard-deletes the import's journal entries + resets voucher sequences.
|
|
// Same destructive reach as replace_sie_import; never auto-commit.
|
|
undo_sie_import: 'high',
|
|
explain_voucher_gap: 'medium',
|
|
uncategorize_transaction: 'medium',
|
|
approve_supplier_invoice: 'high',
|
|
credit_supplier_invoice: 'high',
|
|
// Create supplier invoice from inbox: stages a `registered` supplier invoice
|
|
// + its line items + document attachment. Reversible until approved (the
|
|
// approval is a separate high-risk op) but creates a leverantörsskuld row,
|
|
// so we route it through human review at medium tier.
|
|
create_supplier_invoice_from_inbox: 'medium',
|
|
credit_invoice: 'high',
|
|
convert_invoice: 'medium',
|
|
|
|
// ── Phase 4: arbitrary-line bookkeeping primitives ─────────────────
|
|
// Both accept caller-supplied account/amount/period — unlike
|
|
// uncategorize_transaction (medium), which mirrors an existing entry.
|
|
// The arbitrary-line capability is what makes these compliance-critical.
|
|
create_voucher: 'high',
|
|
correct_entry: 'high',
|
|
reverse_entry: 'high',
|
|
|
|
// ── Payroll ────────────────────────────────────────────────────────
|
|
// Salary run creation materialises a draft + per-employee base lines. The
|
|
// run is reversible while still draft, so 'medium' aligns with other
|
|
// create-draft operations. AGI generation produces the Skatteverket
|
|
// underlag (XML, BFL 7-year retention) — statutory artifact, always
|
|
// staged.
|
|
create_salary_run: 'medium',
|
|
generate_agi: 'high',
|
|
|
|
// ── Multi-tx flows (PRs #603/#606/#608/#610) ───────────────────────
|
|
// Allocate 1 bank tx across N customer or supplier invoices into one
|
|
// combined verifikat. Reversible via storno + invoice_payments delete,
|
|
// so 'medium' (same tier as match_transaction_invoice — its single-
|
|
// invoice counterpart).
|
|
match_batch_allocate: 'medium',
|
|
// Bulk-book N bank txs into 1 verifikat. The create-new branch posts
|
|
// a verifikat with caller-supplied lines (template-expanded or manual),
|
|
// the same compliance-critical surface as create_voucher. 'high'.
|
|
bulk_book_transactions: 'high',
|
|
// Bulk-book N selected Underlag (Dokumentinkorgen): one posted verifikat per
|
|
// matched bank transaction, each with VAT (incl. reverse charge) derived from
|
|
// a shared category. Posting N verifikat at once is the same compliance-
|
|
// critical surface as bulk_book_transactions, so 'high' — never auto-commit;
|
|
// approval requires confirmed=true.
|
|
bulk_book_inbox_items: 'high',
|
|
// Link a single bank tx to an already-posted verifikat (no new JE created).
|
|
// Reversible by clearing transactions.journal_entry_id and deleting any
|
|
// invoice_payments row — sits next to link_invoice_voucher semantically;
|
|
// both attach an existing booking to a different entity.
|
|
link_transaction_journal_entry: 'medium',
|
|
|
|
// ── Skatteverket filing (PR5) ──────────────────────────────────────
|
|
// External + irreversible once signed. Commit sends the declaration for
|
|
// BankID signing; the user's signature in the browser is the filing act.
|
|
// (getRiskLevel already defaults unknown → 'high'; explicit for intent.)
|
|
submit_vat_declaration: 'high',
|
|
submit_agi: 'high',
|
|
}
|
|
|
|
export function getRiskLevel(operationType: string): RiskLevel {
|
|
// Default to 'high' for unknown ops — fail-safe: unknown means human review.
|
|
return OPERATION_RISK_TIERS[operationType] ?? 'high'
|
|
}
|
|
|
|
/**
|
|
* High-risk operations are NEVER auto-committed, regardless of company opt-in
|
|
* or actor trust. Encoded here (not in DB config) so it can't be bypassed.
|
|
*/
|
|
export function isHighRisk(operationType: string): boolean {
|
|
return getRiskLevel(operationType) === 'high'
|
|
}
|