Finance & HR expense-report

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.

Under $500
Manager
→
Approved
$500 – $2,000
Manager
→
Finance
→
Approved
Over $2,000
Manager
→
Finance
→
CFO
→
Approved
International (any)
Manager
→
Financeif >$500
→
Compliance
→
Approved

Amount-tiered chain with FX lock

Template conditions compare plain data fields, so your code converts the amount to USD before submitting and stores it as amountUsd next to the original currency and amount. Approvers see the original; thresholds use the stored USD value.

  • FX rate applied once by your code, before submit
  • The international flag adds Compliance as the last level
  • CFO level only activates above $2k
  • Meals up to $25 skip the receipt requirement in the policy below
expense-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) },
});

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

01

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.

duplicate-check.ts
// 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.

02

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.

receipt-policy.ts
// 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.

03

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.

1 Before engine.submit(), call fx.convert() and put both the original and the USD amount in data
2 The template's conditions read data.amountUsd, never a live rate
3 Finance approves what was submitted — no surprises from currency movement

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