Alur Persetujuan Multi-Level Accounts Payable dengan NestJS

Foto oleh Unsplash

Foto oleh Unsplash
Modul accounts payable di ERP custom Commsult Indonesia menangani pemrosesan faktur vendor. Alur persetujuan adalah bagian yang menarik—approver yang berbeda diperlukan tergantung seberapa besar fakturnya, dan rantai persetujuan perlu dapat dikonfigurasi oleh keuangan tanpa deployment kode.
Persyaratan tim keuangan: faktur di bawah 5 juta IDR membutuhkan satu approver (supervisor), faktur antara 5 dan 50 juta membutuhkan dua approver (supervisor lalu manajer), dan faktur di atas 50 juta membutuhkan tiga approver. Ambang batas harus dapat disesuaikan tanpa perubahan kode.
Keputusan desain utama adalah menyimpan ambang batas persetujuan di tabel database (approval_policies) daripada hardcode. Setiap baris mewakili satu level: nomor level, jumlah minimum, jumlah maksimum opsional, dan peran yang diperlukan.
Tabel ap_invoices menyimpan header faktur. Kolom current_level melacak level persetujuan yang saat ini aktif. Tabel ap_approval_steps menyimpan satu baris per level per faktur. Ketika step disetujui, service menambah current_level dan membuat notifikasi untuk approver berikutnya.
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
└────────────────┘Simpan rantai persetujuan sebagai baris database individual, bukan sebagai array JSON pada faktur. Ini memberi Anda audit trail lengkap dengan timestamp dan memudahkan query tanpa parsing JSON.
Ketika faktur diajukan, fungsi buildApprovalChain memquery tabel approval_policies untuk menemukan semua level yang berlaku. Jika tidak ada karyawan aktif dengan peran yang diperlukan, fungsi melempar error dan faktur ditahan dalam status DRAFT.
Fungsi advance menangani perkembangan langkah demi langkah. Ketika approver menyetujui step mereka, service memeriksa apakah ada step tambahan dengan level lebih tinggi. Jika ya, increment current_level dan kirim notifikasi ke approver berikutnya. Jika tidak ada, tandai faktur sebagai FULLY_APPROVED.
-- 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);
}Setiap tindakan persetujuan dicatat dengan timestamp dan catatan opsional di tabel ap_approval_steps. Rekaman tidak pernah dihapus atau ditimpa—faktur yang ditolak tetap ada dengan riwayat persetujuan lengkapnya.
Rantai persetujuan menetapkan ID approver spesifik pada saat pengajuan faktur, bukan secara dinamis. Jika manajer berubah setelah supervisor menyetujui, manajer asli masih mendapat notifikasi. Bangun admin tool untuk menugaskan ulang step ketika perubahan organisasi terjadi.
Setiap approver melihat antrian faktur yang saat ini ditugaskan kepada mereka. Antrian menampilkan nama vendor, nomor faktur, jumlah, tanggal jatuh tempo, dan riwayat persetujuan sejauh ini. Tombol Setujui atau Tolak tersedia dengan kolom teks yang diperlukan untuk alasan penolakan.
Kasus uji utama: faktur di bawah ambang batas (persetujuan satu step), faktur di atas ambang (multi-step berurutan), percobaan persetujuan bersamaan, penolakan di tengah jalan, dan perubahan kebijakan saat faktur sedang dalam proses.