Finance & ERP accounts-payable

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.

Define the template

The first level is static. Each entry in conditions tests the invoice data at submission and adds a level when it matches. role approvers are resolved through your orgProvider.

  • Conditions are evaluated on every engine.submit()
  • Amount thresholds add levels, so a $500 invoice gets one level and a $60,000 invoice four
  • Board level uses mode: 'any' — any one member approves
  • slaDeadlineDays sets the overall SLA; approval:sla_breached fires when it elapses
ap-template.ts
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 and approve

engine.submit() evaluates the template's conditions against data and creates the instance. Each engine.approve() advances to the next level and fires your INotificationAdapter.

  • Instance stores full audit trail from the start
  • Each approval recorded with actor, timestamp, comment
  • Final approval sets status: approved automatically
submit.ts
// 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);
approve.ts
// 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.

01

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.

handle-conflict.ts
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.

02

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.

scheduler.ts
// 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.

03

Approver on leave — delegation

The department head is on vacation. Without delegation, an invoice waiting on them sits until it breaches SLA.

delegate.ts
// 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