import { randomUUID } from 'crypto'; import { buildScopeKey } from '../policy'; import type { SpendScope, SpendStore, SpendWindow } from '../types'; export interface OutcomeSpendHold { holdId: string; scopeKey: string; window: SpendWindow; heldCents: number; limitCents: number; before: number; after: number; released: boolean; } export interface OutcomeSpendLedgerOptions { keyPrefix?: string; } export class OutcomeSpendLimitError extends Error { constructor(message: string) { super(message); this.name = 'OutcomeSpendLimitError'; } } export class OutcomeAtomicLedgerError extends Error { constructor(message: string) { super(message); this.name = 'OutcomeAtomicLedgerError'; } } export class OutcomeSpendLedger { private readonly keyPrefix: string; constructor(private readonly store: SpendStore, options: OutcomeSpendLedgerOptions = {}) { this.keyPrefix = options.keyPrefix ?? 'outcome:v1'; } async authorize(args: { scope: SpendScope; window: SpendWindow; amountCents: number; limitCents: number; }): Promise { validateCents('amountCents', args.amountCents); validateCents('limitCents', args.limitCents); if (args.window === 'per_call') return args.amountCents <= args.limitCents; const current = await this.store.getWindowSpend(this.scopeKey(args.scope), args.window); return current + args.amountCents <= args.limitCents; } async hold(args: { scope: SpendScope; window: SpendWindow; amountCents: number; limitCents: number; }): Promise { validateCents('amountCents', args.amountCents); validateCents('limitCents', args.limitCents); const scopeKey = this.scopeKey(args.scope); if (args.window === 'per_call') { if (args.amountCents > args.limitCents) { throw new OutcomeSpendLimitError('Outcome per-call cap would be exceeded before provider dispatch'); } return { holdId: randomUUID(), scopeKey, window: args.window, heldCents: args.amountCents, limitCents: args.limitCents, before: 0, after: args.amountCents, released: false, }; } if (!this.store.reserveWindowSpend) { throw new OutcomeAtomicLedgerError('Outcome runtime requires SpendStore.reserveWindowSpend for atomic cap holds'); } const reservation = await this.store.reserveWindowSpend(scopeKey, args.window, args.amountCents, args.limitCents); if (!reservation.allowed) { throw new OutcomeSpendLimitError('Outcome period cap would be exceeded before provider dispatch'); } return { holdId: randomUUID(), scopeKey, window: args.window, heldCents: args.amountCents, limitCents: args.limitCents, before: reservation.before, after: reservation.after, released: false, }; } async settle(hold: OutcomeSpendHold, actualCents: number): Promise { validateCents('actualCents', actualCents); if (hold.released) return hold.after; hold.released = true; const refundCents = Math.max(0, hold.heldCents - actualCents); if (hold.window === 'per_call') return Math.max(0, hold.heldCents - refundCents); if (!this.store.settleWindowSpend) return hold.after; if (refundCents === 0) return hold.after; return this.store.settleWindowSpend(hold.scopeKey, hold.window, -refundCents); } async refund(hold: OutcomeSpendHold): Promise { if (hold.released) return hold.after; hold.released = true; if (hold.window === 'per_call') return 0; if (!this.store.settleWindowSpend) return hold.after; return this.store.settleWindowSpend(hold.scopeKey, hold.window, -hold.heldCents); } dynamicLowBalanceThreshold(limitCents: number): number { validateCents('limitCents', limitCents); return Math.max(1, Math.min(50, Math.ceil(limitCents * 0.1))); } scopeKey(scope: SpendScope): string { return this.keyPrefix + ':' + buildScopeKey(scope); } } function validateCents(name: string, value: number): void { if (!Number.isSafeInteger(value) || value < 0) { throw new Error(name + ' must be a non-negative safe integer'); } }