From bac2a445a9110c85fd528f0452a0deeac098f75a Mon Sep 17 00:00:00 2001 From: prissca Date: Mon, 20 Jul 2026 14:03:20 +0100 Subject: [PATCH] feat: add financial operational controls --- docs/financial-incident-controls.md | 22 ++++++ lib/operations/controls.test.ts | 23 ++++++ lib/operations/controls.ts | 110 ++++++++++++++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 docs/financial-incident-controls.md create mode 100644 lib/operations/controls.test.ts create mode 100644 lib/operations/controls.ts diff --git a/docs/financial-incident-controls.md b/docs/financial-incident-controls.md new file mode 100644 index 00000000..c4200a24 --- /dev/null +++ b/docs/financial-incident-controls.md @@ -0,0 +1,22 @@ +# Financial incident controls + +Call `evaluateOperationalControl` immediately before every server-side domain +mutation. A missing control store fails closed for the protected write. Reads, +login, support and history stay available. Accepted webhooks must first be +stored durably; paused downstream processing is retried and never discarded. + +Controls are narrow by operation and optional provider. Global pause/read-only +changes require a second administrator. Every activation, bypass, expiry and +recovery must use the security audit log with actor, reason and incident ID. +Bypass is reserved for already-accepted idempotent completion. + +Provider adapters own a `ProviderCircuitBreaker`. When open they queue durable +work and return the safe public error. After the recovery interval a single +half-open probe is allowed; success closes the breaker and failure reopens it. + +## Recovery checklist + +1. Confirm the incident owner and reconcile durable webhook/payment receipts. +2. Test the affected provider with a non-financial recovery probe. +3. Move from paused to degraded and monitor errors and duplicate protection. +4. Re-enable the narrow operation, record the approver, and close the incident. diff --git a/lib/operations/controls.test.ts b/lib/operations/controls.test.ts new file mode 100644 index 00000000..81beb27e --- /dev/null +++ b/lib/operations/controls.test.ts @@ -0,0 +1,23 @@ +import { strict as assert } from "node:assert" +import { evaluateOperationalControl, ProviderCircuitBreaker, validateControlChange, type OperationalControl } from "./controls" + +const control: OperationalControl = { + id: "ctrl-1", version: 1, operation: "wallet.fund", state: "paused", + reason: "Provider incident", incidentId: "INC-7", actorId: "admin-a", + startsAt: new Date("2026-07-20T10:00:00Z"), +} +assert.equal(evaluateOperationalControl("wallet.fund", [control], { now: new Date("2026-07-20T11:00:00Z") }).allowed, false) +assert.equal(evaluateOperationalControl("wallet.debit", [control]).allowed, true) +assert.equal(evaluateOperationalControl("wallet.fund", null).code, "CONTROL_UNAVAILABLE") +assert.equal(evaluateOperationalControl("wallet.fund", [control], { idempotentCompletion: true }).allowed, true) + +const breaker = new ProviderCircuitBreaker({ failureThreshold: 2, windowMs: 1_000, recoveryMs: 100 }) +breaker.recordFailure(0) +breaker.recordFailure(1) +assert.equal(breaker.canRequest(50), false) +assert.equal(breaker.canRequest(101), true) +breaker.recordSuccess() +assert.equal(breaker.snapshot().state, "closed") + +assert.throws(() => validateControlChange({ ...control, operation: "*", approvedBy: undefined })) +assert.doesNotThrow(() => validateControlChange({ ...control, operation: "*", approvedBy: "admin-b" })) diff --git a/lib/operations/controls.ts b/lib/operations/controls.ts new file mode 100644 index 00000000..7123b625 --- /dev/null +++ b/lib/operations/controls.ts @@ -0,0 +1,110 @@ +export const FINANCIAL_OPERATIONS = [ + "wallet.fund", "wallet.debit", "investment.create", "down-payment.create", + "repayment.create", "payout.create", "kyc.upload", "account.link", "admin.adjust", +] as const +export type FinancialOperation = typeof FINANCIAL_OPERATIONS[number] +export type ControlState = "enabled" | "degraded" | "paused" | "read-only" + +export interface OperationalControl { + id: string + version: number + operation: FinancialOperation | "*" + provider?: string + state: ControlState + reason: string + incidentId: string + actorId: string + approvedBy?: string + startsAt: Date + expiresAt?: Date +} + +export interface ControlDecision { + allowed: boolean + state: ControlState + code?: "OPERATION_PAUSED" | "READ_ONLY" | "CONTROL_UNAVAILABLE" + message?: string + controlId?: string +} + +export function evaluateOperationalControl( + operation: FinancialOperation, + controls: OperationalControl[] | null, + options: { provider?: string; now?: Date; idempotentCompletion?: boolean } = {} +): ControlDecision { + if (controls === null) { + return { allowed: false, state: "paused", code: "CONTROL_UNAVAILABLE", message: "This operation is temporarily unavailable." } + } + const now = options.now ?? new Date() + const active = controls + .filter((control) => + (control.operation === operation || control.operation === "*") && + (!control.provider || control.provider === options.provider) && + control.startsAt <= now && + (!control.expiresAt || control.expiresAt > now) + ) + .sort((a, b) => impact(b.state) - impact(a.state) || b.version - a.version)[0] + + if (!active || active.state === "enabled" || active.state === "degraded") { + return { allowed: true, state: active?.state ?? "enabled", controlId: active?.id } + } + if (options.idempotentCompletion) { + return { allowed: true, state: active.state, controlId: active.id } + } + return { + allowed: false, + state: active.state, + code: active.state === "read-only" ? "READ_ONLY" : "OPERATION_PAUSED", + message: "This financial operation is temporarily unavailable. Your existing records remain available.", + controlId: active.id, + } +} + +function impact(state: ControlState) { + return { enabled: 0, degraded: 1, "read-only": 2, paused: 3 }[state] +} + +export class ProviderCircuitBreaker { + private state: "closed" | "open" | "half-open" = "closed" + private failures: number[] = [] + private openedAt?: number + constructor( + private readonly options = { failureThreshold: 5, windowMs: 60_000, recoveryMs: 30_000 } + ) {} + + canRequest(now = Date.now()) { + if (this.state === "open" && this.openedAt !== undefined && now - this.openedAt >= this.options.recoveryMs) { + this.state = "half-open" + } + return this.state !== "open" + } + + recordSuccess() { + this.failures = [] + this.openedAt = undefined + this.state = "closed" + } + + recordFailure(now = Date.now()) { + this.failures = this.failures.filter((time) => now - time <= this.options.windowMs) + this.failures.push(now) + if (this.state === "half-open" || this.failures.length >= this.options.failureThreshold) { + this.state = "open" + this.openedAt = now + } + } + + snapshot() { + return { state: this.state, failuresInWindow: this.failures.length, openedAt: this.openedAt } + } +} + +export function validateControlChange(control: OperationalControl) { + if (!control.reason.trim() || !control.incidentId.trim()) throw new Error("reason and incident ID are required") + if (control.operation === "*" && control.state !== "enabled" && !control.approvedBy) { + throw new Error("global controls require maker-checker approval") + } + if (control.approvedBy && control.approvedBy === control.actorId) { + throw new Error("approver must differ from the control author") + } +}