Accounts Payable Multi-Level Approval Workflow in NestJS

Photo by Unsplash

Photo by Unsplash
The accounts payable module in Commsult Indonesia's ERP handles vendor invoice processing: invoices come in, get coded to the right cost center, go through an approval workflow based on the invoice amount, and get scheduled for payment. The approval workflow is the interesting part—different approvers are required depending on how large the invoice is, and the chain needs to be configurable by finance without a code deployment. This post documents the design and implementation of that configurable multi-level approval system.
The finance team's requirement was simple to state but non-trivial to implement: invoices under 5 million IDR need one approver (a supervisor), invoices between 5 and 50 million need two approvers (supervisor then manager), and invoices above 50 million need three approvers (supervisor, manager, then director). The thresholds should be adjustable without code changes. Approval must be sequential. Rejection at any level cancels the remaining chain.
The key design decision was to store approval thresholds in a database table (approval_policies) rather than hardcoding them. Each row represents one level: a level number (1, 2, 3), a minimum amount, an optional maximum amount (NULL means no upper limit), and the role required at that level. When an invoice is submitted, the system queries approval_policies to determine which levels apply, then creates the corresponding rows in ap_approval_steps.
The ap_invoices table stores the invoice header: vendor, invoice number, amount, currency, due date, and status. The current_level column tracks which approval level is currently active. The ap_approval_steps table stores one row per level per invoice, with the specific approver assigned and the step status. When a step is approved, the service increments current_level and creates a notification for the next approver.
Accounts Payable Multi-Level Approval
──────────────────────────────────────
Invoice submitted (vendor / staff)
│
▼
┌──────────────────────────────────┐
│ Determine approval chain │
│ based on amount threshold: │
│ < 5 jt → Level 1 (Supervisor) │
│ < 50 jt → Level 2 (Manager) │
│ ≥ 50 jt → Level 3 (Director) │
└───────────────┬──────────────────┘
│
┌───────▼────────┐
│ Level 1 │──APPROVE──▶ (if threshold met, DONE)
│ Supervisor │──REJECT───▶ Notify submitter
└───────┬────────┘
│ APPROVE (amount > L1 threshold)
┌───────▼────────┐
│ Level 2 │──APPROVE──▶ (if threshold met, DONE)
│ Manager │──REJECT───▶ Notify chain
└───────┬────────┘
│ APPROVE (amount > L2 threshold)
┌───────▼────────┐
│ Level 3 │──APPROVE──▶ Final: schedule payment
│ Director │──REJECT───▶ Full chain notified
└────────────────┘Store the approval chain as individual database rows rather than as a JSON array on the invoice. This gives you a complete audit trail with timestamps for each approval action, makes it easy to query 'all invoices currently awaiting Director approval' without JSON parsing, and allows you to add indexes on (invoice_id, level, status) for fast lookups.
When an invoice is submitted, the buildApprovalChain function queries the approval_policies table to find all levels that apply to this invoice's amount. For each applicable level, it looks up an active employee with the required role. If no active employee has the required role, the function throws an error and the invoice is held in DRAFT status. This prevents invoices from getting silently stuck in an unactionable state.
The advance function handles the step-by-step progression. When an approver approves their step, the service checks whether there are additional steps with a higher level number. If yes, it increments current_level and sends a notification to the next approver. If no more steps exist, it marks the invoice as FULLY_APPROVED and triggers the payment scheduling workflow. Rejection marks the invoice as REJECTED and cancels all remaining steps.
-- approval_policies: configurable thresholds
CREATE TABLE approval_policies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
level INTEGER NOT NULL, -- 1, 2, 3
min_amount NUMERIC(15,2) NOT NULL DEFAULT 0,
max_amount NUMERIC(15,2), -- NULL = no upper limit
approver_role TEXT NOT NULL, -- e.g. 'SUPERVISOR','MANAGER','DIRECTOR'
active BOOLEAN NOT NULL DEFAULT true
);
-- ap_invoices with approval state
CREATE TABLE ap_invoices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
vendor_id UUID NOT NULL REFERENCES vendors(id),
invoice_number TEXT NOT NULL UNIQUE,
amount NUMERIC(15,2) NOT NULL,
currency CHAR(3) NOT NULL DEFAULT 'IDR',
due_date DATE NOT NULL,
status TEXT NOT NULL DEFAULT 'DRAFT', -- DRAFT|PENDING|APPROVED|REJECTED|PAID
current_level INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- approval_steps: one row per level per invoice
CREATE TABLE ap_approval_steps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
invoice_id UUID NOT NULL REFERENCES ap_invoices(id),
level INTEGER NOT NULL,
approver_id UUID NOT NULL REFERENCES employees(id),
status TEXT NOT NULL DEFAULT 'PENDING', -- PENDING|APPROVED|REJECTED
note TEXT,
actioned_at TIMESTAMPTZ,
UNIQUE (invoice_id, level)
);
// NestJS: determine approval chain and create steps
async function buildApprovalChain(invoice: ApInvoice): Promise<ApApprovalStep[]> {
const policies = await this.policyRepo.find({
where: { active: true, minAmount: LessThanOrEqual(invoice.amount) },
order: { level: 'ASC' },
});
const applicablePolicies = policies.filter(p =>
p.maxAmount === null || invoice.amount <= p.maxAmount
);
const steps: ApApprovalStep[] = [];
for (const policy of applicablePolicies) {
const approver = await this.employeeRepo.findOne({
where: { role: policy.approverRole, active: true },
});
if (!approver) throw new Error(`No active ${policy.approverRole} found`);
steps.push(this.stepRepo.create({
invoiceId: invoice.id,
level: policy.level,
approverId: approver.id,
status: 'PENDING',
}));
}
return this.stepRepo.save(steps);
}Every approval action is recorded with a timestamp and the approver's optional note in the ap_approval_steps table. The invoice and step records are never deleted or overwritten—rejected invoices remain in the database with their full approval history. For compliance, finance can reconstruct the complete approval history for any invoice going back years.
The approval chain assigns specific approver IDs at invoice submission time, not dynamically at each step. This means if the manager changes after the supervisor approves, the original manager still gets the next notification. Assign at submission, and build an admin tool for reassigning steps when organizational changes occur.
Each approver sees a queue of invoices currently assigned to them. The queue shows the vendor name, invoice number, amount, due date, and the approval history so far. Approve or Reject buttons are available, with a required text field for rejection reason. Finance managers also have a separate view showing all invoices in flight grouped by current level—useful for spotting bottlenecks.
The approval logic is the most critical piece to test correctly. Key test cases: invoice below threshold (single-step approval), invoice above threshold (multi-step sequential approval), concurrent approval attempt, rejection midway (remaining steps cancelled), and policy change while invoice is in flight (existing steps unaffected—the chain is fixed at submission time).