* feat(reconciliation): skattekonto bridge engine, sync-time twin proposals, account-keyed facade The engine half of the reconciliation page (design: Avstämningsmotorn). - lib/reconciliation/skattekonto-reconciliation.ts: getSkattekontoReconciliationStatus anchors at the saldo snapshot and returns the bridge (saldo hos Skatteverket, händelser som saknas, 1630-rader utan händelse, ignorerade, ingående skillnad, bokfört), the item buckets the page shows (proposed, unmatched external, unmatched ledger, matched, ignored, upcoming), opening_difference, unexplained_difference (0,00 by construction when data is consistent), dead-link handling (a link to a reversed/draft entry counts as unlinked and is flagged), awaiting_external for ledger lines within 5 days of the snapshot, staleness, and a window that scopes item lists without hiding older rows. Core reads skattekonto_transactions and the extension's snapshot row directly; no @/extensions import. - lib/reconciliation/gl-balance.ts: one ledger-balance helper with the trial-balance predicate status IN (posted, reversed). The drift check summed posted only, which misstated 1630 for any company with a storno on the account; skattekonto-drift.ts now delegates to the helper. - Proposals at sync: migration 20260823120000 adds suggested_journal_entry_id / suggested_at (ON DELETE SET NULL, partial index on open rows); the sync calls refreshSkattekontoProposals after the upsert. findMatchSuggestionsBulk now assigns one-to-one across rows (AGI period first, then nearest date) and falls back to an entry whose 1630 lines net to the amount (split lines); a proposal is never a link. - lib/reconciliation/service.ts + schemas.ts: the account-keyed facade (bank:<cash_account_id> | skattekonto | manual:NNNN) with listReconciliationAccounts (enabled cash accounts folded per IBAN, skattekonto when configured) and getAccountStatus dispatching to the bank engine or the new one; shared Zod shapes for the v1 registry, MCP schemas and the UI (PR 2). Tests: identity on a mixed fixture, storno pair, stale snapshot, awaiting window, window scoping, failed ledger read, live-linked entries never proposed; matcher one-to-one and split-line cases; proposal refresh writes/clears; service dedupe and dispatch. No UI in this PR. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reconciliation): roundOre instead of inline öre rounding (guard ratchet) The antipattern ratchet counts Math.round(x*100)/100; the new engine used it in five places. Switch to roundOre from @/lib/money and ratchet the baseline down by the three occurrences this removes net of the matcher rewrite. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reconciliation): three doors over one engine: dashboard routes, v1 API and MCP tools for account-keyed reconciliation PR 2 of the Avstämning build (design: Avstämning via API och MCP). Every door calls lib/reconciliation/{service,items,actions}.ts; none re-implements a link. - lib/reconciliation/items.ts: listAccountItems per account_key, the page's buckets (proposed, unmatched_external, unmatched_ledger, matched, ignored, upcoming), limit/offset; skattekonto from the engine, bank from the scoped transactions + unlinked GL lines (netted per entry). - lib/reconciliation/actions.ts: matchPairs (pairs or use_proposals, dry run, partial success with codes), unmatchLink, setItemIgnored; emits reconciliation.matched / reconciliation.unmatched. - lib/skatteverket/skattekonto-link.ts: canonical core link semantics for a skattekonto row (single line or entry net on 1630, live-link guard, race-safe update, unlink, ignore); the extension keeps its own matchSkattekontoToEntry until its tests are ported. - Dashboard routes /api/reconciliation/accounts[...]: list, status, items, links (POST), links/{linkId} (DELETE), items/{itemId}/ignore (POST); apply directly (a human clicked). - v1 routes /api/v1/companies/{id}/reconciliation/accounts[...]: same six, withApiV1, new scopes reconciliation:read / reconciliation:write (write is a staging scope for SoD), Idempotency-Key + dry_run on writes, registered for OpenAPI, load-routes, skills/accounted-api regenerated. Legacy bank routes and their transactions:* scopes unchanged. - MCP: gnubok_get_reconciliation_status takes account_key (legacy bank path untouched), new gnubok_list_reconciliation_items (default catalog), gnubok_reconcile_match (stages reconciliation_match, preflight = status) and gnubok_reconcile_unmatch (stages reconciliation_unmatch), both search-only to stay under the tools/list payload ceiling; gnubok_link_transaction_to_journal_entry moved to search. Executors in commit.ts; risk tiers medium/low; migration pair 20260823130000/130001 adds the two op types to the CHECK constraint (value list = live prod as of 2026-08-23 + the two); close_period loadout updated. Tests: service/actions/items/link unit tests, v1 route tests (401/403/400/404/ happy, idempotency, dry run), dashboard route tests, MCP tool tests + the guard suite (payload ceiling, descriptions, staging meta, qualified ids). Guards and apiskill:check green; no type errors in changed files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reconciliation): refresh the v1 spec snapshot and keep the ignore update readable by the phantom-column guard The six new v1 reconciliation endpoints and the two new scopes were not recorded in the spec snapshot, and setSkattekontoRowIgnored updated through one conditional payload, which the phantom-column scanner cannot read (ceiling 380 -> 381). Two literal payloads instead; snapshot updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reconciliation): the Avstämning page, one body for every account with an outside truth /reconciliation in Arbeta (after Transaktioner), on the approved layout: an account rail on the left (bank accounts and the skattekonto, logo or monogram, last fetch, status dot, URL-owned selection), and for the selected account four tiles (outside, ledger, difference, unexplained), the bridge that explains the difference, an actions row (link the proposed pairs, book the unbooked skattekonto events, run the bank matcher) and a full-width table banded by bucket with proposal rows linkable one by one. Every read and write goes through the PR 2 dashboard routes, so the page shows exactly what the v1 API and the MCP tools see. Also: nav item, command palette entry, sv/en strings. Period picker, manual match mode and sign-off are deliberately not here (PR 4/5). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reconciliation): sign-off, period picker, Hem row and the three doors for it "Markera som avstämd t.o.m. <datum>" as an append-only attestation: account_reconciliations (who signed which account through which date, with the numbers as they stood; reopen stamps instead of deletes; RLS members write as themselves, viewers read). Policy in one place (lib/reconciliation/signoff.ts): refused with an unexplained difference unless forced with a note, refused past today or past the skattekonto snapshot, refused at or before an active sign-off; reopen is the undo. Every status read now carries the latest active sign-off and the rail shows "avstämt t.o.m.". Three doors: dashboard routes (GET/POST .../signoff, POST .../reopen), v1 (same, scope reconciliation:signoff, Idempotency-Key, dry-run, registry + regenerated API skill), MCP gnubok_reconcile_signoff (search catalog, stages reconciliation_signoff after a policy dry run; executor + risk tier + op-type CHECK migration pair). Events reconciliation.signed_off / reconciliation.reopened, and the four reconciliation events join the public webhook set (additive; API version unchanged, changelog section added). Page: räkenskapsår + range picker in the header (own preset memory, opens on this month) scoping the bridge, the items and the default sign-off date; sign-off dialog with the forced-with-note path; reopen on hover. Hem: worklist category reconciliation_due ("Konton att stämma av"), zero until the company has signed anything off. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reconciliation): classify reconciliation:signoff as a tenant write for the MCP role guard gnubok_reconcile_signoff carries the deliberately separate reconciliation:signoff scope; the central viewer guard keys on the :write/:approve/:manage suffixes, so a viewer could reach the tool (RLS would still refuse the row, but the guard is the intended layer). Add :signoff to the classifier; the strictness test that caught it now passes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(providers): serve local rate-limiter waiters in arrival order Two callers that both found the in-memory bucket empty each set their own timeout; the timeouts expired at the same instant from different timer lists and which woke first was platform-dependent. hydrateInvoices relies on "started first, requested first" to serve open invoices before paid ones, so lib/providers/__tests__/hydrate-invoices.test.ts flipped on CI (twice on #1817) while holding locally. A promise queue makes the local waiters FIFO without changing the rate; the Upstash path is untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit 14a7599bf2c6fa7f97de6ffab3dc4cf4d0e1827d) --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
272 lines
14 KiB
TypeScript
272 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',
|
|
// 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',
|
|
|
|
// ── 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',
|
|
// 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',
|
|
|
|
// ── 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'
|
|
}
|