Employee Expense Reimbursement
Small expenses route through manager only. Large ones escalate to Finance and CFO. International expenses always get Compliance review. The USD amount is computed once, when you submit, so later FX movements don't change who has to approve.
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) },
});
// Conditions read plain data fields, so convert to USD before submitting
// and store the result as data.amountUsd (see edge case 03).
await engine.defineTemplate({
name: 'expense-report',
documentType: 'expense',
levels: [
{
level: 1,
name: 'Direct manager',
approvers: [{ type: 'role', role: 'direct-manager' }],
mode: 'any',
},
],
conditions: [
{
when: { field: 'amountUsd', operator: '>', value: 500 },
addLevels: [
{
level: 2,
name: 'Finance',
approvers: [{ type: 'role', role: 'finance-controller' }],
mode: 'any',
},
],
},
{
when: { field: 'amountUsd', operator: '>', value: 2_000 },
addLevels: [
{
level: 3,
name: 'CFO',
approvers: [{ type: 'role', role: 'cfo' }],
mode: 'any',
},
],
},
{
when: { field: 'isInternational', operator: '==', value: true },
addLevels: [
{
level: 4,
name: 'Compliance',
approvers: [{ type: 'role', role: 'compliance-officer' }],
mode: 'any',
},
],
},
],
}); Three ways expense workflows break in production.
Common failure modes in expense systems that hierarchical-approval prevents.
Duplicate expense detection
An employee accidentally submits the same expense twice — same category, amount, and currency within days of each other. Both get approved, finance pays twice.
// Detect duplicate expense reports before submitting
const DUPLICATE_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
async function isDuplicate(
employeeId: string,
category: string,
totalAmount: number,
currency: string
): Promise<boolean> {
const recent = await engine.queryInstances({
documentType: 'expense',
submittedBy: employeeId,
fromDate: new Date(Date.now() - DUPLICATE_WINDOW_MS),
// Matches instances whose data contains all of these pairs
data: { category, totalAmount, currency },
});
return recent.items.some((instance) => instance.status !== 'rejected');
}
if (await isDuplicate(employeeId, category, amount, currency)) {
throw new Error('Duplicate expense report detected within the last 7 days');
}
Check before engine.submit(). queryInstances matches the
stored data fields exactly, so the 7-day window is the only fuzziness.
Receipt validation at the boundary
An employee submits a $500 equipment expense with no receipts attached. Finance approves it anyway — because nothing in the workflow stops them.
// Hold approval until receipts are attached (IAuthorizationPolicy)
import { ApprovalEngine } from 'hierarchical-approval';
import type { IAuthorizationPolicy, AuthorizationContext } from 'hierarchical-approval';
const MEAL_RECEIPT_THRESHOLD_USD = 25;
class ReceiptPolicy implements IAuthorizationPolicy {
authorize(ctx: AuthorizationContext): string | undefined {
if (ctx.operation !== 'approve') return undefined;
const { receipts, category, amountUsd } = ctx.instance.data as {
receipts: string[];
category: string;
amountUsd: number;
};
const requiresReceipt =
category !== 'meals' || amountUsd > MEAL_RECEIPT_THRESHOLD_USD;
if (requiresReceipt && receipts.length === 0) {
return 'Receipt required for this expense category and amount';
}
return undefined; // allow
}
}
const engine = new ApprovalEngine({
adapter: new PostgresAdapter({ connectionString: process.env.DATABASE_URL }),
tenantId: 'acme-corp',
authorizationPolicy: new ReceiptPolicy(),
});
// approve() now throws ApprovalForbiddenError until the employee attaches them:
await engine.updateData(instance.id, {
updatedBy: 'emp-4821',
data: { receipts: ['receipt-001.pdf'] },
}); IAuthorizationPolicy denies approve while the receipts
array is empty for categories that require them. The engine throws ApprovalForbiddenError,
so the rule holds whatever the UI does. The policy is not consulted on submit.
Foreign currency amount drift
An employee submits a €1,800 expense (below the $2k CFO threshold at today's rate). By the time Finance approves it two weeks later, the rate has moved — the expense is now over the threshold.
engine.submit(), call fx.convert() and put both the original and the USD amount in data data.amountUsd, never a live rate
The levels are decided when you submit. They only change if you call
engine.updateData() with new values, which re-evaluates the conditions.
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