I Built an npm Library for ERP Hierarchical Approval Workflows

Photo by Unsplash
The same multi-level approval pattern — sequential levels, conditional chains, an audit log, and concurrency handling — appears in nearly every ERP module that touches money or compliance: purchase orders, leave requests, vendor onboarding, budget releases. Writing it correctly each time is repetitive, and the subtle parts (especially concurrent approvals and idempotency on network retries) are easy to get wrong. Extracting it into a tested, versioned library means the logic is written once and the correctness guarantees travel with it.
Every instance carries a version number. When an approval is submitted, the engine calls updateInstance with the version it read from the database. The storage adapter compares that expected version against the stored one and throws ApprovalConflictError if they differ, meaning another writer already committed a change. The engine retries automatically with exponential backoff and optional jitter, configurable via the retryPolicy option. This is standard optimistic locking — no database-level row locks required.
Idempotency means submitting the same document twice returns the same approval instance rather than creating a duplicate. The library hashes the submission context (tenantId, documentType, documentId, templateName) into a key, checks for an existing instance with that key before writing, and returns the existing one if found. This matters because network retries and double-clicks on Submit buttons are common in real UIs — without idempotency, a slow response followed by a retry creates two separate approval workflows for the same document.
Yes. The library ships a ManualClock in the hierarchical-approval/testing sub-path. You pass it to the engine via the clock option and it replaces all internal new Date() calls. Call clock.advanceDays(3) to jump three days instantly, then call engine.escalation.tick() to manually fire the scheduler check. The ApprovalTestKit.create helper wires the ManualClock and a MemoryAdapter together so you can write fully deterministic tests with zero real I/O and no sleep calls.
The library ships a MemoryAdapter for development and tests and a PostgresAdapter for production. Any database can be used by implementing the IStorageAdapter interface, which has methods for saving and reading templates and instances, offset pagination, cursor pagination, idempotency key lookup, and audit entry appending. The one non-negotiable contract is in updateInstance: it must compare the expectedVersion argument against the stored version and throw ApprovalConflictError on mismatch — that is the foundation of the optimistic locking guarantee.

Photo by Unsplash
Eighteen months ago I built a multi-level purchase order approval system for an ERP client. Six months later I built another one for leave requests. Three months after that, a third for vendor onboarding. Each time the business logic was nearly identical — sequential levels, conditional chains based on document data, an audit trail, race condition handling when two approvers click at the same time — and each time I rewrote it from scratch. On the third rebuild I stopped, extracted the pattern, added the correctness guarantees I'd learned the hard way, and published it as hierarchical-approval on npm.
Every ERP module that involves money, headcount, or compliance has an approval step. The shapes vary — purchase orders route by amount, leave requests route by department, vendor onboarding routes by risk tier — but the underlying machinery is the same. You need: a reusable chain definition (the template), an instance that tracks each document's progress through that chain, sequential level advancement, conditional level insertion at submit time, an immutable audit log, and handling for the case where two approvers act on the same level simultaneously. Writing that correctly every time is repetitive, and the subtle parts — especially the concurrency handling — are easy to get wrong.
The pattern I kept rebuilding — every time from scratch:
Document submitted
│
▼
┌─────────────────────────────────────┐
│ Evaluate conditions on data │
│ e.g. amount > 10,000 → add CFO │
└──────────────┬──────────────────────┘
│
┌───────▼────────┐
│ Level 1 │──APPROVE──▶ advance to next level
│ Manager │──REJECT───▶ notify submitter
└───────┬────────┘
│ (if more levels)
┌───────▼────────┐
│ Level 2 │──APPROVE──▶ advance / complete
│ Director │──REJECT───▶ cancel remaining chain
└────────────────┘
Built this 3× in 18 months. On the 3rd time, I extracted it.I looked at what was available on npm before writing my own. The general-purpose workflow engines are powerful but require you to implement every guard, every condition, every audit entry yourself — they give you a state machine, not an approval workflow. The approval-specific packages I found were unmaintained, had no TypeScript support, and most were single-level only. None had idempotency, none had optimistic locking, and none had a way to inject a test clock. That last point was the deciding factor: if I cannot control time in tests, I cannot reliably test SLA and escalation logic without real waits.
The central abstraction is a template — a reusable chain definition stored by name. When a document is submitted, the engine creates an instance from that template and evaluates conditional rules against the document data to determine which levels apply. Conditions are evaluated once at submit time and frozen into the instance, so a template change mid-flight never affects an in-progress approval. The instance carries a version number for optimistic locking and an idempotency key so a double-submitted document returns the existing instance rather than creating a duplicate.
The idempotency key defaults to a SHA-256 hash of tenantId + documentType + documentId + templateName. You can override it with a custom function if your business logic requires compound keys — for example, including a fiscal period so a document can go through the same template again in a new period.
The API is deliberately minimal. Define a template once, then call submit, approve, reject, delegate, or cancel on any instance. The library handles level advancement, SLA tracking, escalation scheduling, and event emission automatically. Here is a complete purchase order approval flow with a conditional finance level:
import { ApprovalEngine } from 'hierarchical-approval';
import { MemoryAdapter } from 'hierarchical-approval/adapters/memory';
const engine = new ApprovalEngine({
adapter: new MemoryAdapter(),
tenantId: 'acme',
});
// Define the approval chain once — reuse across any document
await engine.defineTemplate({
name: 'purchase-order',
documentType: 'purchase_order',
levels: [
{ level: 1, name: 'Manager',
approvers: [{ type: 'user', userId: 'mgr-1' }], mode: 'any' },
],
// Finance level only activates if amount > 10,000
conditions: [{
when: { field: 'amount', operator: '>', value: 10000 },
addLevels: [{
level: 2, name: 'Finance',
approvers: [{ type: 'user', userId: 'fin-1' }], mode: 'any',
}],
}],
slaDeadlineDays: 2,
});
// Submit a document — idempotent (retry-safe)
const instance = await engine.submit({
templateName: 'purchase-order',
documentId: 'po-0042',
documentType: 'purchase_order',
submittedBy: 'alice',
data: { amount: 15000 },
});
// Approve at each level
await engine.approve(instance.id, { approverId: 'mgr-1' });
await engine.approve(instance.id, { approverId: 'fin-1' });
// instance.status === 'approved'Clean API aside, most of the library's code is in four correctness problems that come up in production but are invisible in simple examples.
In an ERP with hundreds of active users, two managers can approve the same level at the same time. Without a concurrency control, both reads see version 1, both writes succeed, and the second approval silently overwrites the first — the audit log loses an entry and the level may skip incorrectly. The library solves this with a version field on every instance. Every write calls updateInstance with the version it read; the storage adapter throws ApprovalConflictError if the stored version has moved. The engine retries automatically with exponential backoff, configurable via retryPolicy.
Network retries and button double-clicks are a real problem in approval UIs. Submitting the same document twice should not create two instances. The library hashes the submission context into an idempotency key, checks for an existing instance with that key before writing, and returns the existing one if found. This is transparent to the caller — the second submit call returns the same instance as the first without any special handling.
// Configurable retry policy for optimistic locking
const engine = new ApprovalEngine({
adapter: new PostgresAdapter({ connectionString: process.env.DATABASE_URL }),
tenantId: 'acme',
retryPolicy: {
maxAttempts: 5,
baseDelayMs: 100,
maxDelayMs: 2000,
jitter: true, // randomise delay to avoid thundering-herd
},
});
// Idempotency: submitting the same document twice
// returns the existing instance instead of creating a duplicate
const a = await engine.submit({ templateName: 'po', documentId: 'po-0042', ... });
const b = await engine.submit({ templateName: 'po', documentId: 'po-0042', ... });
console.log(a.id === b.id); // trueTemplate snapshots insulate in-flight instances from template updates. When you call updateTemplate, the engine creates a new template version with an incremented version counter. Existing instances keep their original escalation and SLA configuration in a frozen templateSnapshot. Update templates freely — you will not break running approvals.
The library is designed to live inside whatever infrastructure you already have. Six adapter interfaces let you connect your notification system, metrics backend, audit sink, job scheduler, authorization policy, and operation middleware without forking the library. All adapters are fire-and-forget — a failure in any adapter is caught, logged, and swallowed. A broken Kafka connection will never prevent an approval from completing.
// Plug in whatever notification/metrics/audit system you use
const engine = new ApprovalEngine({
adapter: pgAdapter,
tenantId: 'acme',
// Fire notifications after every approval event
notificationAdapter: {
notify: async (event) => {
if (event.type === 'approval:level_advanced') {
await slack.post(event.recipients, 'Your approval is needed');
}
},
},
// Write to Kafka / S3 / CloudTrail in addition to the DB
auditAdapter: {
append: async (tenantId, instanceId, entry, instance) => {
await kafka.send({ topic: 'approvals.audit',
messages: [{ value: JSON.stringify({ tenantId, entry }) }] });
},
},
// Prometheus / Datadog counters
metricsAdapter: {
increment: (metric, labels) => counters[metric].labels(labels).inc(),
timing: (metric, ms, labels) => histograms[metric].observe(ms / 1000),
},
// Authorization: signing authority cap
authorizationPolicy: {
authorize: async (ctx) => {
if (ctx.operation === 'approve') {
const cap = await budgetSystem.getCap(ctx.actorId);
if (ctx.instance.data.amount > cap)
return 'Signing cap exceeded';
}
},
},
});The hardest part of testing approval workflows is time. SLA breach, escalation, and deadline tests all require time to pass, and real timers in tests are slow, flaky, and a debugging nightmare. The library ships a ManualClock and an ApprovalTestKit in a dedicated testing sub-path. ManualClock lets you jump days instantly. ApprovalTestKit.create gives you a pre-wired engine with a MemoryAdapter and ManualClock. ApprovalTestKit.fullyApprove drives an instance through all levels in one call. No real timers, no database, deterministic in all cases.
import { ApprovalTestKit, ManualClock } from 'hierarchical-approval/testing';
test('SLA breach fires after 2 days', async () => {
// Pre-wired: MemoryAdapter + ManualClock at 2025-01-01
const { engine, clock } = ApprovalTestKit.create();
await engine.defineTemplate({
name: 'invoice',
documentType: 'invoice',
levels: [{ level: 1, name: 'Mgr',
approvers: [{ type: 'user', userId: 'mgr' }], mode: 'any' }],
slaDeadlineDays: 2,
});
const { id } = await engine.submit({
templateName: 'invoice', documentId: 'inv-1',
documentType: 'invoice', submittedBy: 'alice', data: {},
});
const breached = [];
engine.on('approval:sla_breached', (p) => breached.push(p.instanceId));
// No real timers — jump 3 days instantly
clock.advanceDays(3);
await engine['escalation'].tick();
expect(breached).toContain(id); // deterministic, zero ms wall-clock
await engine.shutdown();
});
// Walk all levels in one line
const final = await ApprovalTestKit.fullyApprove(engine, id, {
1: 'manager-id',
2: 'director-id',
});
// final.status === 'approved'The library is published on npm as hierarchical-approval, written in TypeScript 5 with strict mode enabled, dual CJS and ESM output, and full type declarations. Peer dependency on pg is optional and only needed for the Postgres adapter. The testing sub-path is intentionally kept out of the main bundle so it never ships to production.
hierarchical-approval on npm
TypeScript-first. Dual CJS/ESM. Zero required peer deps for the core.
npmjs.com/package/hierarchical-approval