Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
213 changes: 213 additions & 0 deletions src/invoiceAutomation.ts
Original file line number Diff line number Diff line change
@@ -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<TContext = Record<string, unknown>> {
name: string;
execute: (ctx: TContext) => Promise<TContext>;
/** 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<TContext> {
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<TContext = Record<string, unknown>> {
id: string;
name: string;
trigger: AutomationTrigger;
steps: WorkflowStep<TContext>[];
/** 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<TContext>(
steps: WorkflowStep<TContext>[],
initialContext: TContext,
options: { continueOnError?: boolean } = {}
): Promise<WorkflowRunResult<TContext>> {
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<T>(promise: Promise<T>, ms: number, name: string): Promise<T> {
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<string, unknown>;
}

export type AutomationEventHandler = (event: InvoiceAutomationEventType) => void;

/**
* InvoiceAutomationEngine manages automation rules and executes them
* when their triggers are satisfied.
*/
export class InvoiceAutomationEngine<TContext extends Record<string, unknown> = Record<string, unknown>> {
private readonly rules = new Map<string, AutomationRule<TContext>>();
private readonly eventHandlers = new Set<AutomationEventHandler>();
private readonly runningWorkflows = new Map<string, AbortController>();

/**
* Register an automation rule.
*/
registerRule(rule: AutomationRule<TContext>): 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<TContext> | 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<WorkflowRunResult<TContext>> {
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<string, unknown>): 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;
}
}
}
183 changes: 183 additions & 0 deletions test/invoiceAutomation.test.ts
Original file line number Diff line number Diff line change
@@ -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<Ctx> => ({
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<Ctx> = {
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<Ctx> = {
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<Ctx> = {
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<Ctx> = {
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<Ctx> => ({
id,
name: `Rule ${id}`,
trigger: { type: 'manual' },
steps: [makeStep('step1'), makeStep('step2')],
});

it('registers and retrieves rules', () => {
const engine = new InvoiceAutomationEngine<Ctx>();
engine.registerRule(makeRule('r1'));
expect(engine.getRule('r1')).toBeDefined();
expect(engine.listRuleIds()).toContain('r1');
});

it('removes a rule', () => {
const engine = new InvoiceAutomationEngine<Ctx>();
engine.registerRule(makeRule('r1'));
engine.removeRule('r1');
expect(engine.getRule('r1')).toBeUndefined();
});

it('trigger runs workflow and returns result', async () => {
const engine = new InvoiceAutomationEngine<Ctx>();
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<Ctx>();
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<Ctx>();
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<Ctx>();
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<Ctx>();
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<Ctx>();

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);
});
});
});