Skip to main content
BlogERP

Accounts Payable Multi-Level Approval Workflow in NestJS

How I built a configurable, amount-based multi-level AP approval system with NestJS and PostgreSQL—from schema design to the approval chain algorithm.

Matthews Wong

Software Engineer

June 202510 min read
Accounts Payable Multi-Level Approval Workflow in NestJS

Photo by Unsplash

In my freelance ERP consulting work, I built an accounts payable module that 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 Business Requirement

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.

Schema Design: Configurable Policies

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.

Invoice and Step Tables

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.

Building the Approval Chain

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.

Sequential Processing Logic

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);
}

Audit Trail and Immutability

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.

The React Approval Interface

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.

Testing the Approval Logic

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

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

Why are approval thresholds stored in a database table instead of hardcoded in the application?

Storing thresholds in the approval_policies table lets the finance team adjust approval levels—such as the 5 million and 50 million IDR cutoffs—without requiring a code deployment. Each row captures the level number, minimum amount, optional maximum amount, and the role required at that level. This makes the workflow configurable without involving engineering.

Why are approval steps stored as individual database rows rather than a JSON array on the invoice?

Individual rows in ap_approval_steps give each step its own timestamp and status, producing a complete audit trail that finance can reconstruct years later. It also enables direct SQL queries like 'all invoices awaiting Director approval' without JSON parsing, and allows indexes on (invoice_id, level, status) for fast lookups.

What happens if no active employee holds the required role when an invoice is submitted?

The buildApprovalChain function throws an error and the invoice is held in DRAFT status rather than moving forward silently. This prevents invoices from getting stuck in an unactionable state where no one can act on the next approval step.

Why are approver IDs fixed at submission time rather than resolved dynamically at each step?

Fixing approver IDs at submission time means a mid-workflow personnel change does not redirect the approval to a new person unexpectedly—the original manager still receives the next notification. The recommended mitigation for organizational changes is a separate admin tool that allows reassigning individual steps without restarting the entire chain.

How does the system handle rejection midway through the approval chain?

When any approver rejects an invoice, the service marks the invoice as REJECTED and cancels all remaining pending steps. The invoice and its steps are never deleted or overwritten, so the full approval history—including the rejection reason and timestamp—remains available for compliance review.