Files
accounted/lib/pending-operations/risk-tiers.ts
T
MattssonandClaude Fable 5 7411a0171b feat(mileage): körjournal with milersättning booking, MCP tools and CSV export (#1448)
* feat(mileage): körjournal with milersättning booking, MCP tools and CSV export

New mileage_trips table (RLS, booked-delete trigger per BFL retention),
lib/mileage service reusing the payroll schablon rates, /api/mileage routes
(trips CRUD, period booking to 7331, salary-run push, körjournal CSV),
Körjournal dashboard page + nav, and three staged MCP tools (search-only
catalog). Trips book as one verifikat per period via the engine; salary
path inserts mileage_taxfree line items. mileage_trips classified in the
full-archive export.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(mileage): use shared roundOre helper per tightened ratchet baseline

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mileage): pending_operations op-type migration + Swedish review findings

- New migration pair adds log_mileage_trip/book_mileage_period to the
  pending_operations operation_type CHECK (pg-real audit).
- bookMileagePeriod refuses a period spanning several employees and names
  the employee in the verifikationstext when scoped (BFL motpart).
- vehicle_registration required for förmånsbil trips (schema, service,
  MCP staging, UI surfaces the field).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mileage): claim-first booking, CSV injection guard and driver column

- bookMileagePeriod claims trips (draft to booked CAS) before creating the
  verifikat, so a concurrent second booking loses the race instead of
  double-booking; claim reverts if verifikat creation fails.
- Körjournal CSV neutralizes formula-injection triggers (OWASP) and adds a
  Förare column naming the employee per trip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mileage): resolve CodeRabbit + Swedish review round: race, drift and hardening

- Copying a round trip no longer re-doubles the stored distance.
- pushMileageToSalaryRun claims trips before inserting line items (retry can
  no longer double-pay); CLAIM_LOST replaces misleading NO_TRIPS on lost races.
- Booked trips are DB-immutable via a BEFORE UPDATE trigger (new migration
  20260807113215): only claim/link/revert transitions and notes edits pass.
- Cross-year periods rejected (schablon rates are per calendar year); payroll
  config year read from the date string, not TZ-dependent getFullYear().
- MCP staged bookings freeze the previewed trip set (trip_ids in params) and
  the commit fails on drift; validation errors return 400, not 500.
- PATCH enforces the förmånsbil regnr rule on the effective row; export
  validates dates before they reach the Content-Disposition header; employee_id
  is verified company-scoped on trip creation; stale orphaned claims released.
- UI: fetch flags reset in finally; ICU plural for draft summary; distance
  stored at the column's 1-decimal precision.
- Tests: [id] route suite, pushMileageToSalaryRun suite, claim-race, drift,
  cross-year and update-trigger pg cases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mileage): revert-to-draft must clear salary_run_id at the trigger level

New migration 20260807114924 replaces the booked-immutability function: a
booked -> draft revert now rejects rows keeping salary_run_id, closing the
DB-level double-pay path CodeRabbit flagged. pg test pins both directions;
the CLAIM_LOST unit test now asserts the revert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mileage): company-scope employee_id on PATCH (Superagent P2)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(mileage): valid v4 uuid in cross-company employee PATCH test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 14:17:21 +02:00

259 lines
14 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',
// ── 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
// 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',
// 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',
// 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',
// 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',
// 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',
// ── 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'
}