* feat(rot-rut): overview page, beslutsfil import, avslag reclaim, MCP list + settle Follow-up to #2239/#2360 for firms whose every invoice carries ROT/RUT. - /invoices/rot-rut: tiles (at Skatteverket on 1513, awaiting beslut, refused to book, ready to request) and one row per begaran with mark uploaded, cancel, download and "Bokfor nekat belopp"; the Fakturor button links here, ?rot-rut=1 still opens the file dialog. - Beslutsfil import from the UI through the existing import route. - Reclaim of the share Skatteverket refused: one voucher debit 1510 / credit 1513 per invoice (source_type rot_rut_reclaim), CAS-attached to the begaran and guarded by a partial unique index; the invoice reopens for the refused share via invoices.deduction_reclaimed_total, with the customer-share formula and its SQL twin gaining the same term. The payment dialog and bank match then settle the reopened remaining as a plain 1510 clearing; a booked kontantmetod invoice is proposed accrual- shaped so revenue is never recognised twice. Unknown per-invoice split of a partial beslut is refused, never allocated. - MCP: gnubok_list_rot_rut_payout_requests (search-only read) and gnubok_settle_rot_rut_payout (staged write, op settle_rot_rut_payout) sharing one pre-flight + settle with the dashboard match route. - Migrations 20260907140000 (reclaim state, source_type, INSERT guard), 20260907140100/140101 (pending_operations op type). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9wvsGnvu5tHGqYnJnjnaB * chore(rot-rut): renumber migrations after merging main Main already carries 20260907143000 and 20260907150000, so the three rot-rut migrations move to 20260907160000/160100/160101 to keep the applied order monotonic (see memory: migration-version-collisions). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9wvsGnvu5tHGqYnJnjnaB * fix(rot-rut): close the reclaim gaps found by skeptics, CI and review Skeptic refutations (#2397): - payment-sync recomputes remaining with deduction_reclaimed_total, so a storno of a payment on a reopened invoice no longer strands the refused share (R1). - Reclaim refused while an invoice sits in a later live begäran (ROT_RUT_RECLAIM_INVOICE_REREQUESTED); the overview and the MCP list hide the action for the same case (C2). - A reclaimed invoice is blocked from a new begäran (DEDUCTION_RECLAIMED) until the reclaim voucher is reversed (R2/C3). - Storno of the reclaim voucher syncs the invoices and the begäran back (rot-rut-reclaim-reversal.ts, hooked into reverseEntry) (R3). - A paid invoice with NULL paid_amount counts its customer share as paid (C4). Crediting an invoice with a reclaimed share is refused on the dashboard, v1 and MCP paths (R4). CI and review: - Build: custom-coded MCP errors via Object.assign, not codedError. - pg-real: column default for default_voucher_series_per_source_type re-stated with rot_rut_reclaim (20260907160200); the default test now re-applies the latest default migration. - Checks: accounted-api skill regenerated (journal-entries source types). - CodeRabbit/Superagent: per-item refused shares must reconcile with the request-level beslut; per-invoice reopen through the idempotent RPC apply_rot_rut_reclaim_invoice (20260907160300) with a resume path; update-stage settle failures keep the voucher id (failed_partial); Stockholm calendar date for the booking; existing-voucher tab uses the same proposal method; MCP stage checks bank_line junction rows. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9wvsGnvu5tHGqYnJnjnaB * fix(rot-rut): carry the voucher id through the match outcome type; date the reclaim on the beslut - The shared match outcome now declares journalEntryId on update-stage errors, matching the settle service (Core Build TS2339 on 2d6cece1a). - The reclaim voucher is dated on the Swedish calendar day of Skatteverkets beslut (decided_at), today only when no decision date is recorded, and the confirm dialog states the date (Swedish accounting review: BFL 5 kap 6-7 §, datum for affarshandelsen). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9wvsGnvu5tHGqYnJnjnaB * fix(rot-rut): reclaim RPCs validate the share and derive the invoice state; idempotent revert; v1 credit guard reads the column - apply_rot_rut_reclaim_invoice (20260907160400 replaces the 160300 signature) takes only the refused share, validates it against the locked item, request and invoice, and derives remaining_amount and status from the INSERT-guard formula (review: caller-supplied accounting values, CWE-862). revert_rot_rut_reclaim_invoice mirrors it for a reversed reclaim voucher; the request link is cleared only after every leg. - v1 credit route projection includes deduction_reclaimed_total so the reclaim guard actually fires there. - Overview keeps "Bokfor nekat belopp" available while legs are pending (resume after a partial failure). - Match and settle routes attach journal_entry_id on update-stage errors. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9wvsGnvu5tHGqYnJnjnaB --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
321 lines
17 KiB
TypeScript
321 lines
17 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',
|
|
update_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',
|
|
// Kontoplan reference data: adding an account has no journal impact (a
|
|
// wrong account only becomes bookable, nothing is booked), and update is
|
|
// limited to name/description/VAT-default/SRU/is_active: the same surface
|
|
// update_article covers for articles. No payment routing, no external
|
|
// side-effects.
|
|
create_account: 'low',
|
|
update_account: 'low',
|
|
// Verifikat notes are annotation metadata, not räkenskapsinformation: the
|
|
// journal_entries immutability trigger (20260608120000) permits exactly a
|
|
// notes-only diff on committed entries and rejects anything more, so the
|
|
// op cannot touch booking data even if tampered with.
|
|
set_voucher_note: 'low',
|
|
// Ignoring a bank transaction flips transactions.is_ignored and nothing
|
|
// else: no verifikat, no ledger impact, reversible with the same op
|
|
// (ignored: false). The DB CHECK transactions_is_ignored_no_journal_entry
|
|
// and the executor's isTransactionBooked() refusal keep it off booked rows,
|
|
// so the op cannot hide a booking even if tampered with (issue #1661).
|
|
ignore_transaction: 'low',
|
|
// Kundorder (sales orders) never book: an order is the non-ledger document
|
|
// between agreement and invoice. Creating one, moving it through its
|
|
// header state machine (confirm / cancel / reopen) and registering
|
|
// delivered quantities write only sales_orders / sales_order_items, no
|
|
// verifikat and no external side-effect, and all three are re-editable
|
|
// (cancel is refused while invoices exist, reopen undoes it).
|
|
create_sales_order: 'low',
|
|
transition_sales_order: 'low',
|
|
register_sales_order_delivery: 'low',
|
|
|
|
// ── Medium: reversible booking ─────────────────────────────────────
|
|
categorize_transaction: 'medium',
|
|
match_transaction_invoice: 'medium',
|
|
// Skatteverkets ROT/RUT utbetalning matched to its begäran: one bank row
|
|
// booked debit 19xx / credit 1513 and linked. Storno-reversible like the
|
|
// other bank-row matches, so the same tier as match_transaction_invoice.
|
|
settle_rot_rut_payout: '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
|
|
// Rewrites a DRAFT in place (header + full item replace). Same tier as
|
|
// create_invoice: the target has no verifikat yet (isEditableInvoiceDraft
|
|
// is re-checked at commit), so the edit is fully reversible by editing again.
|
|
update_invoice: 'medium',
|
|
// Creates an unnumbered DRAFT kundfaktura from a confirmed order through
|
|
// the same builder as create_invoice (nothing is booked or sent at commit;
|
|
// the draft can be deleted). Same tier as create_invoice.
|
|
create_invoice_from_sales_order: 'medium',
|
|
// Recurring invoice schedules: the commit only creates/edits the monthly
|
|
// template (nothing is booked or sent at commit time), and the schedule is
|
|
// pausable/deletable before the next cron run. Not 'low' because an
|
|
// approved schedule keeps generating numbered invoices without any further
|
|
// approval. With params.auto_send === true the schedule also keeps EMAILING
|
|
// the customer every cycle: a standing order for the same external
|
|
// side-effect that puts one-off send_invoice at 'high', so getRiskLevel
|
|
// escalates these two to 'high' when it can see that param.
|
|
create_recurring_schedule: 'medium',
|
|
update_recurring_schedule: 'medium',
|
|
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',
|
|
// Company payment settings control where customers send money on future
|
|
// invoices. Treat changes like supplier payment-routing data: reversible,
|
|
// but never eligible for silent low-risk auto-commit.
|
|
update_company_settings: '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',
|
|
// Same rationale as link_document_to_voucher, N rows in one staged op.
|
|
link_documents_to_vouchers: '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',
|
|
post_kontantmetod_cutoff: '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',
|
|
// Removes a DRAFT (never a posted invoice): no booking impact, but both
|
|
// outcomes are irreversible: an unnumbered draft is hard-deleted (row gone)
|
|
// and a numbered draft is makulerad, permanently consuming its F-series
|
|
// number. 'high' so a destructive delete is never auto-committed.
|
|
delete_draft_invoice: 'high',
|
|
|
|
// ── 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',
|
|
// Payslip line edits are draft-run-only (BFL: once the run advances its
|
|
// numbers feed a verifikation) and re-editable until then, but they change
|
|
// a pay outcome: human review at medium, never silent.
|
|
update_payslip_line: 'medium',
|
|
// Draft-only edit of one employee's per-run base salary; no booking impact
|
|
// until the run is calculated and booked (both separately staged).
|
|
set_run_salary: 'medium',
|
|
// Draft-only header edit (payment_date / voucher_series / notes): freely
|
|
// re-editable while draft and changes no pay outcome, matching the v1
|
|
// PATCH's risk: 'low'. A payment_date change clears the roster's
|
|
// calculation_breakdown so a stale calculation cannot be booked, and the
|
|
// booking that makes payment_date matter is separately staged at 'high'.
|
|
update_salary_run: 'low',
|
|
// Absence rows drive sjuklön math and the statutory AGI Frånvarouppgift.
|
|
// Reversible via delete, but not audit-free: medium.
|
|
register_absence: 'medium',
|
|
// Employee master data carries PII (personnummer, encrypted at staging
|
|
// time: pending_operations.params never holds the plaintext) plus bank
|
|
// payment-routing fields: same BEC rationale as create_supplier.
|
|
create_employee: 'medium',
|
|
update_employee: 'medium',
|
|
// Cutover state for mid-year migrations (YTD, vacation balances, karens
|
|
// adjustment). Editable until the employee has a booked run; wrong values
|
|
// skew payslips and the vacation-liability report, so human review.
|
|
set_employee_opening_balances: 'medium',
|
|
// Booking a salary run posts 2-4 immutable verifikationer via the engine
|
|
// (net, tax, avgifter, vacation accrual) and advances the run through
|
|
// approved/paid on the way. Same irreversible tier as create_voucher.
|
|
book_salary_run: 'high',
|
|
// Deleting absence days is the inverse of register_absence and changes
|
|
// sjuklön/karens math for any draft run covering the range: same tier.
|
|
delete_absence: 'medium',
|
|
// Semesterårsavslut: closes every employee's vacation year, rolls sparade
|
|
// dagar (5-year expiry -> forced payout), and may post a 2920/2940
|
|
// adjustment verifikation. Irreversible in practice (no reopen flow):
|
|
// never auto-committed.
|
|
vacation_year_close: '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',
|
|
// Account-keyed reconciliation (lib/reconciliation/actions.ts). A match
|
|
// pairs outside rows with existing verifikat across any reconcilable
|
|
// account (bank or skattekonto); it writes nothing to the ledger and is
|
|
// undone by reconciliation_unmatch, so 'medium' like its single-bank-tx
|
|
// sibling above. Unmatch only clears a pointer: 'low'.
|
|
reconciliation_match: 'medium',
|
|
reconciliation_unmatch: 'low',
|
|
// Sign-off writes the attestation row others rely on (overview, Hem, auditor)
|
|
// but nothing in the ledger, and reopen undoes it: 'medium'.
|
|
reconciliation_signoff: 'medium',
|
|
// Residual booking writes one small verifikat (bank fee / interest /
|
|
// rounding, capped at RESIDUAL_MAX_AMOUNT) against the bank account and
|
|
// links the selection: a typed, bounded booking like categorize_transaction,
|
|
// undone by storno + unmatch, so 'medium' rather than create_voucher's 'high'.
|
|
reconciliation_residual: 'medium',
|
|
// Book synced skattekonto rows as posted verifikat: 1630 against the
|
|
// skattekonto_rules-matched counter account, amounts straight from the
|
|
// synced Skatteverket data. No caller-supplied lines (the agent passes only
|
|
// row ids), reversible via storno: same bounded-booking tier as
|
|
// book_mileage_period, not create_voucher's arbitrary-line 'high'.
|
|
book_skattekonto_row: 'medium',
|
|
book_skattekonto_rows: 'medium',
|
|
|
|
// ── Körjournal (mileage) ───────────────────────────────────────────
|
|
// A trip row is pure travel documentation: no booking impact until a
|
|
// separate book operation. Same tier as create_customer.
|
|
log_mileage_trip: 'low',
|
|
// Books one verifikat with fixed lines derived from logged trips (7331 +
|
|
// whitelisted counter account) at the DB-configured schablon rate: not the
|
|
// arbitrary-line surface that makes create_voucher 'high'. Reversible via
|
|
// storno: same tier as post_annual_depreciation.
|
|
book_mileage_period: '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',
|
|
}
|
|
|
|
/**
|
|
* Op types whose tier depends on the staged params, not just the type.
|
|
* Checked inside getRiskLevel so every caller that can pass params gets the
|
|
* escalation for free; callers without params in scope fall back to the
|
|
* static (never lower) tier.
|
|
*/
|
|
function paramEscalatedRisk(
|
|
operationType: string,
|
|
params: Record<string, unknown> | undefined,
|
|
): RiskLevel | null {
|
|
// A recurring schedule with auto_send=true is indefinite outbound email
|
|
// with no further approval per send: the exact external side-effect that
|
|
// makes one-off send_invoice 'high'.
|
|
if (
|
|
(operationType === 'create_recurring_schedule' ||
|
|
operationType === 'update_recurring_schedule') &&
|
|
params?.auto_send === true
|
|
) {
|
|
return 'high'
|
|
}
|
|
return null
|
|
}
|
|
|
|
export function getRiskLevel(
|
|
operationType: string,
|
|
params?: Record<string, unknown>,
|
|
): RiskLevel {
|
|
const escalated = paramEscalatedRisk(operationType, params)
|
|
if (escalated) return escalated
|
|
// 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, params?: Record<string, unknown>): boolean {
|
|
return getRiskLevel(operationType, params) === 'high'
|
|
}
|