Accounts Payable Approval
Multi-level invoice approval chains that auto-scale with transaction amount. Under $1k needs one sign-off; over $50k routes to the board. All built at submission time — no manual chain wiring required.
import { ApprovalEngine } from 'hierarchical-approval';
import { PostgresAdapter } from 'hierarchical-approval/adapters/postgres';
const engine = new ApprovalEngine({
adapter: new PostgresAdapter({ connectionString: process.env.DATABASE_URL }),
tenantId: 'acme-corp',
// Resolves every { type: 'role' } approver below
orgProvider: { getUsersByRole: (role) => directory.usersWithRole(role) },
});
await engine.defineTemplate({
name: 'accounts-payable',
documentType: 'invoice',
// Level 1: department head, always required
levels: [
{
level: 1,
name: 'Department head',
approvers: [{ type: 'role', role: 'dept-head' }],
mode: 'any',
},
],
// Each rule adds its level when the invoice data matches
conditions: [
{
when: { field: 'amount', operator: '>', value: 1_000 },
addLevels: [
{
level: 2,
name: 'Finance manager',
approvers: [{ type: 'role', role: 'finance-manager' }],
mode: 'any',
},
],
},
{
when: { field: 'amount', operator: '>', value: 10_000 },
addLevels: [
{
level: 3,
name: 'CFO',
approvers: [{ type: 'role', role: 'cfo' }],
mode: 'any',
},
],
},
{
when: { field: 'amount', operator: '>', value: 50_000 },
addLevels: [
{
level: 4,
name: 'Board',
approvers: [{ type: 'role', role: 'board-member' }],
mode: 'any', // any one board member approves
},
],
},
],
slaDeadlineDays: 7,
}); // Submit an invoice for approval
const instance = await engine.submit({
templateName: 'accounts-payable',
documentId: 'INV-2847',
documentType: 'invoice',
submittedBy: 'emp-4821',
data: {
vendorId: 'vendor-99',
amount: 15_000,
currency: 'USD',
departmentId: 'dept-engineering',
},
});
// instance.status === 'pending'
// instance.currentLevel === 1 (awaiting the department head)
// instance.levels.length === 3 (amount > 10,000 adds Finance and CFO)
console.log(instance.id); // Department head approves (level 1)
await engine.approve(instance.id, {
approverId: 'user-dept-head-42',
comment: 'Approved - valid vendor contract',
});
// The instance advances to level 2 (Finance manager)
// INotificationAdapter receives approval:level_advanced
// Finance manager approves (level 2)
await engine.approve(instance.id, {
approverId: 'user-finance-mgr',
comment: 'Within Q3 budget',
});
// The CFO is now the open level (level 3)... The cases you will hit in production.
Three scenarios that bring down hand-rolled implementations — all handled by the library.
Concurrent approval race condition
Two managers in the same approval pool both click "Approve" within milliseconds. Without optimistic locking, both writes succeed — corrupting the workflow state.
import { ApprovalConflictError } from 'hierarchical-approval';
// Two approvers act on the same instance at the same moment.
// The storage adapter rejects the stale write; the engine retries it
// (3 attempts by default) before giving up.
try {
await engine.approve(instanceId, { approverId: 'approver-a' });
} catch (err) {
if (err instanceof ApprovalConflictError) {
// Still conflicting after every retry. Re-fetch to see the current state.
const current = await engine.getInstance(instanceId);
console.log('Current level:', current.currentLevel);
}
}
The library increments a version field on every write.
A stale write throws ApprovalConflictError; the engine retries it
up to 3 times by default, so you only see the error if the conflict persists.
SLA breach escalation
When slaDeadlineDays elapses with no decision, the engine emits
approval:sla_breached. A level's escalationAfterDays adds
an escalation approver. Both run on a scan that polls every minute by default;
production can hand that scan to a real job scheduler.
// Wire a real job scheduler for the escalation scan (instead of polling)
import { BullMQScheduler } from './adapters/bullmq-scheduler';
const engine = new ApprovalEngine({
adapter: new PostgresAdapter({ connectionString: process.env.DATABASE_URL }),
schedulerAdapter: new BullMQScheduler({ redis: redisConnection }),
// The engine calls scheduler.scheduleAt(id, runAt, callback) for each scan;
// BullMQ fires the callback at that time instead of a setInterval poll
}); ISchedulerAdapter receives a deadline and a callback.
Wire BullMQ, Temporal, or EventBridge — whatever your stack already uses.
Approver on leave — delegation
The department head is on vacation. Without delegation, an invoice waiting on them sits until it breaches SLA.
// Dept head going on leave: hand the open level to a colleague
await engine.delegate(instance.id, {
fromApprover: 'user-dept-head-42',
toApprover: 'user-backup-head',
reason: 'On leave until 15 Aug',
until: new Date('2027-08-15'),
});
// 'user-backup-head' can now approve this instance's open level.
// When 'until' passes, the scheduled scan reassigns the seat back to
// 'user-dept-head-42'. The audit trail records the delegation.
Delegation applies to one instance's open level. With until set it
reverts automatically; omit it for a permanent handoff. For standing cover across
every invoice, supply an outOfOfficeProvider instead.
Your next approval workflow
ships faster than the last one.
Drop it into any Node.js project. No database required for local development.
$ npm install hierarchical-approval Peer dep for Postgres: npm install pg @types/pg