diff --git a/src/invoiceAutomation.ts b/src/invoiceAutomation.ts new file mode 100644 index 0000000..d551289 --- /dev/null +++ b/src/invoiceAutomation.ts @@ -0,0 +1,213 @@ +/** + * Invoice Automation Workflows for StellarSplit SDK. + * + * Provides a composable workflow engine for automating invoice lifecycle + * events: scheduled release, auto-approval, deadline escalation, and + * chained multi-step workflows. + */ + +export type InvoiceWorkflowStatus = + | 'pending' + | 'running' + | 'completed' + | 'failed' + | 'cancelled'; + +export interface WorkflowStep> { + name: string; + execute: (ctx: TContext) => Promise; + /** Optional condition — step is skipped if this returns false */ + condition?: (ctx: TContext) => boolean; + /** Optional timeout in ms. Defaults to no timeout. */ + timeoutMs?: number; +} + +export interface WorkflowRunResult { + status: InvoiceWorkflowStatus; + context: TContext; + completedSteps: string[]; + failedStep?: string; + error?: Error; + durationMs: number; +} + +export interface AutomationTrigger { + type: 'deadline' | 'payment_received' | 'approval_count' | 'manual'; + /** For deadline triggers: unix timestamp seconds */ + deadlineTs?: number; + /** For approval_count triggers: required number of approvals */ + requiredApprovals?: number; +} + +export interface AutomationRule> { + id: string; + name: string; + trigger: AutomationTrigger; + steps: WorkflowStep[]; + /** Whether to continue if a step fails (default: false) */ + continueOnError?: boolean; +} + +/** + * Runs a sequence of workflow steps, threading context through each step. + * Supports per-step timeouts and conditional execution. + */ +export async function runWorkflow( + steps: WorkflowStep[], + initialContext: TContext, + options: { continueOnError?: boolean } = {} +): Promise> { + const startTime = Date.now(); + const completedSteps: string[] = []; + let ctx = { ...initialContext } as TContext; + let failedStep: string | undefined; + let error: Error | undefined; + let status: InvoiceWorkflowStatus = 'running'; + + for (const step of steps) { + // Evaluate optional condition + if (step.condition && !step.condition(ctx)) { + continue; + } + + try { + if (step.timeoutMs !== undefined) { + ctx = await withTimeout(step.execute(ctx), step.timeoutMs, step.name); + } else { + ctx = await step.execute(ctx); + } + completedSteps.push(step.name); + } catch (err) { + failedStep = step.name; + error = err instanceof Error ? err : new Error(String(err)); + if (!options.continueOnError) { + status = 'failed'; + return { status, context: ctx, completedSteps, failedStep, error, durationMs: Date.now() - startTime }; + } + } + } + + status = failedStep && !options.continueOnError ? 'failed' : 'completed'; + return { status, context: ctx, completedSteps, failedStep, error, durationMs: Date.now() - startTime }; +} + +function withTimeout(promise: Promise, ms: number, name: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`Step '${name}' timed out after ${ms}ms`)), ms); + promise.then( + (v) => { clearTimeout(timer); resolve(v); }, + (e) => { clearTimeout(timer); reject(e); } + ); + }); +} + +export interface InvoiceAutomationEventType { + type: 'workflow:started' | 'workflow:completed' | 'workflow:failed' | 'workflow:cancelled'; + ruleId: string; + invoiceId: string; + timestamp: number; + detail?: Record; +} + +export type AutomationEventHandler = (event: InvoiceAutomationEventType) => void; + +/** + * InvoiceAutomationEngine manages automation rules and executes them + * when their triggers are satisfied. + */ +export class InvoiceAutomationEngine = Record> { + private readonly rules = new Map>(); + private readonly eventHandlers = new Set(); + private readonly runningWorkflows = new Map(); + + /** + * Register an automation rule. + */ + registerRule(rule: AutomationRule): void { + this.rules.set(rule.id, rule); + } + + /** + * Remove a registered rule. + */ + removeRule(ruleId: string): boolean { + return this.rules.delete(ruleId); + } + + /** + * Get a registered rule by ID. + */ + getRule(ruleId: string): AutomationRule | undefined { + return this.rules.get(ruleId); + } + + /** + * List all registered rule IDs. + */ + listRuleIds(): string[] { + return Array.from(this.rules.keys()); + } + + /** + * Subscribe to workflow lifecycle events. + */ + onEvent(handler: AutomationEventHandler): () => void { + this.eventHandlers.add(handler); + return () => this.eventHandlers.delete(handler); + } + + private emit(event: InvoiceAutomationEventType): void { + for (const handler of this.eventHandlers) { + try { handler(event); } catch { /* ignore */ } + } + } + + /** + * Manually trigger a rule for a given invoice. + */ + async trigger( + ruleId: string, + invoiceId: string, + context: TContext + ): Promise> { + const rule = this.rules.get(ruleId); + if (!rule) throw new Error(`Rule '${ruleId}' not found`); + + this.emit({ type: 'workflow:started', ruleId, invoiceId, timestamp: Date.now() }); + + const result = await runWorkflow(rule.steps, context, { + continueOnError: rule.continueOnError, + }); + + if (result.status === 'completed') { + this.emit({ type: 'workflow:completed', ruleId, invoiceId, timestamp: Date.now(), detail: { completedSteps: result.completedSteps } }); + } else { + this.emit({ type: 'workflow:failed', ruleId, invoiceId, timestamp: Date.now(), detail: { failedStep: result.failedStep, error: result.error?.message } }); + } + + return result; + } + + /** + * Check if a trigger condition is currently satisfied. + */ + isTriggerSatisfied(trigger: AutomationTrigger, context: Record): boolean { + switch (trigger.type) { + case 'manual': + return true; + case 'deadline': { + const now = Math.floor(Date.now() / 1000); + return trigger.deadlineTs !== undefined && now >= trigger.deadlineTs; + } + case 'approval_count': { + const approvals = typeof context['approvalCount'] === 'number' ? context['approvalCount'] : 0; + return trigger.requiredApprovals !== undefined && approvals >= trigger.requiredApprovals; + } + case 'payment_received': { + return context['paymentReceived'] === true; + } + default: + return false; + } + } +} diff --git a/test/invoiceAutomation.test.ts b/test/invoiceAutomation.test.ts new file mode 100644 index 0000000..483b8ec --- /dev/null +++ b/test/invoiceAutomation.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + InvoiceAutomationEngine, + runWorkflow, + type WorkflowStep, + type AutomationRule, +} from '../src/invoiceAutomation'; + +type Ctx = { value: number; log: string[] }; + +const makeStep = (name: string, increment = 1): WorkflowStep => ({ + name, + execute: async (ctx) => ({ ...ctx, value: ctx.value + increment, log: [...ctx.log, name] }), +}); + +describe('runWorkflow', () => { + it('runs all steps in order and threads context', async () => { + const result = await runWorkflow( + [makeStep('step1'), makeStep('step2'), makeStep('step3')], + { value: 0, log: [] } + ); + expect(result.status).toBe('completed'); + expect(result.context.value).toBe(3); + expect(result.completedSteps).toEqual(['step1', 'step2', 'step3']); + }); + + it('stops on failed step by default', async () => { + const failStep: WorkflowStep = { + name: 'failStep', + execute: async () => { throw new Error('fail!'); }, + }; + const result = await runWorkflow( + [makeStep('step1'), failStep, makeStep('step3')], + { value: 0, log: [] } + ); + expect(result.status).toBe('failed'); + expect(result.failedStep).toBe('failStep'); + expect(result.completedSteps).toContain('step1'); + expect(result.completedSteps).not.toContain('step3'); + }); + + it('continueOnError skips failed step but completes', async () => { + const failStep: WorkflowStep = { + name: 'failStep', + execute: async () => { throw new Error('oops'); }, + }; + const result = await runWorkflow( + [makeStep('step1'), failStep, makeStep('step3')], + { value: 0, log: [] }, + { continueOnError: true } + ); + expect(result.status).toBe('completed'); + expect(result.completedSteps).toContain('step1'); + expect(result.completedSteps).toContain('step3'); + }); + + it('conditional step is skipped when condition is false', async () => { + const conditionalStep: WorkflowStep = { + name: 'conditional', + condition: (ctx) => ctx.value > 100, + execute: async (ctx) => ({ ...ctx, value: ctx.value + 1000 }), + }; + const result = await runWorkflow( + [makeStep('step1'), conditionalStep], + { value: 0, log: [] } + ); + expect(result.status).toBe('completed'); + expect(result.completedSteps).not.toContain('conditional'); + expect(result.context.value).toBe(1); + }); + + it('timeout causes step to fail', async () => { + const slowStep: WorkflowStep = { + name: 'slowStep', + timeoutMs: 10, + execute: (ctx) => new Promise((resolve) => setTimeout(() => resolve(ctx), 500)), + }; + const result = await runWorkflow([slowStep], { value: 0, log: [] }); + expect(result.status).toBe('failed'); + expect(result.error?.message).toContain('timed out'); + }, 1000); + + it('reports durationMs', async () => { + const result = await runWorkflow([makeStep('s1')], { value: 0, log: [] }); + expect(result.durationMs).toBeGreaterThanOrEqual(0); + }); +}); + +describe('InvoiceAutomationEngine', () => { + const makeRule = (id: string): AutomationRule => ({ + id, + name: `Rule ${id}`, + trigger: { type: 'manual' }, + steps: [makeStep('step1'), makeStep('step2')], + }); + + it('registers and retrieves rules', () => { + const engine = new InvoiceAutomationEngine(); + engine.registerRule(makeRule('r1')); + expect(engine.getRule('r1')).toBeDefined(); + expect(engine.listRuleIds()).toContain('r1'); + }); + + it('removes a rule', () => { + const engine = new InvoiceAutomationEngine(); + engine.registerRule(makeRule('r1')); + engine.removeRule('r1'); + expect(engine.getRule('r1')).toBeUndefined(); + }); + + it('trigger runs workflow and returns result', async () => { + const engine = new InvoiceAutomationEngine(); + engine.registerRule(makeRule('r1')); + const result = await engine.trigger('r1', 'inv-1', { value: 0, log: [] }); + expect(result.status).toBe('completed'); + expect(result.context.value).toBe(2); + }); + + it('trigger emits workflow:started and workflow:completed events', async () => { + const engine = new InvoiceAutomationEngine(); + engine.registerRule(makeRule('r1')); + const events: string[] = []; + engine.onEvent((e) => events.push(e.type)); + await engine.trigger('r1', 'inv-1', { value: 0, log: [] }); + expect(events).toContain('workflow:started'); + expect(events).toContain('workflow:completed'); + }); + + it('trigger emits workflow:failed on error', async () => { + const engine = new InvoiceAutomationEngine(); + engine.registerRule({ + id: 'fail-rule', + name: 'Fail', + trigger: { type: 'manual' }, + steps: [{ name: 'bad', execute: async () => { throw new Error('boom'); } }], + }); + const events: string[] = []; + engine.onEvent((e) => events.push(e.type)); + await engine.trigger('fail-rule', 'inv-1', { value: 0, log: [] }); + expect(events).toContain('workflow:failed'); + }); + + it('trigger throws for unknown rule', async () => { + const engine = new InvoiceAutomationEngine(); + await expect(engine.trigger('no-rule', 'inv-1', { value: 0, log: [] })).rejects.toThrow('not found'); + }); + + it('onEvent returns unsubscribe function', async () => { + const engine = new InvoiceAutomationEngine(); + engine.registerRule(makeRule('r1')); + const events: string[] = []; + const unsub = engine.onEvent((e) => events.push(e.type)); + unsub(); + await engine.trigger('r1', 'inv-1', { value: 0, log: [] }); + expect(events).toHaveLength(0); + }); + + describe('isTriggerSatisfied', () => { + const engine = new InvoiceAutomationEngine(); + + it('manual trigger always satisfied', () => { + expect(engine.isTriggerSatisfied({ type: 'manual' }, {})).toBe(true); + }); + + it('deadline trigger satisfied when past deadline', () => { + const past = Math.floor(Date.now() / 1000) - 100; + expect(engine.isTriggerSatisfied({ type: 'deadline', deadlineTs: past }, {})).toBe(true); + }); + + it('deadline trigger not satisfied when future deadline', () => { + const future = Math.floor(Date.now() / 1000) + 10000; + expect(engine.isTriggerSatisfied({ type: 'deadline', deadlineTs: future }, {})).toBe(false); + }); + + it('approval_count trigger satisfied with enough approvals', () => { + expect(engine.isTriggerSatisfied({ type: 'approval_count', requiredApprovals: 3 }, { approvalCount: 3 })).toBe(true); + }); + + it('payment_received trigger satisfied when flag set', () => { + expect(engine.isTriggerSatisfied({ type: 'payment_received' }, { paymentReceived: true })).toBe(true); + }); + }); +});