Skip to main content
BlogERP

Alur Persetujuan Multi-Level Accounts Payable dengan NestJS

Cara saya membangun sistem persetujuan AP multi-level yang dapat dikonfigurasi berdasarkan jumlah dengan NestJS dan PostgreSQL—dari desain schema hingga algoritma rantai persetujuan.

Matthews Wong

Software Engineer

Juni 202510 menit baca
Alur Persetujuan Multi-Level Accounts Payable dengan NestJS

Foto oleh Unsplash

Dalam pekerjaan konsultasi ERP lepas yang saya jalani, saya membangun modul accounts payable custom yang 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 Bisnis

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.

Desain Schema: Kebijakan yang Dapat Dikonfigurasi

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 Faktur dan Step

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.

Membangun Rantai Persetujuan

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.

Logika Pemrosesan Berurutan

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

Audit Trail dan Imutabilitas

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.

Antarmuka Persetujuan React

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.

Menguji Logika Persetujuan

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.

FAQ

Pertanyaan Umum

Jawaban singkat untuk pertanyaan yang paling sering diajukan pembaca tentang topik ini.

Mengapa ambang batas persetujuan disimpan di tabel database, bukan di-hardcode dalam kode?

Menyimpan ambang batas di tabel approval_policies memungkinkan tim keuangan menyesuaikan level persetujuan—seperti batas 5 juta dan 50 juta IDR—tanpa perlu deployment kode baru. Setiap baris menyimpan nomor level, jumlah minimum, jumlah maksimum opsional, dan peran yang diperlukan. Ini membuat alur kerja dapat dikonfigurasi tanpa melibatkan tim engineering.

Mengapa step persetujuan disimpan sebagai baris database individual, bukan sebagai array JSON pada faktur?

Baris individual di ap_approval_steps memberi setiap step timestamp dan status tersendiri, menghasilkan audit trail lengkap yang dapat direkonstruksi oleh keuangan bertahun-tahun kemudian. Pendekatan ini juga memungkinkan query SQL langsung seperti 'semua faktur yang menunggu persetujuan Direktur' tanpa parsing JSON, serta mendukung index pada (invoice_id, level, status) untuk pencarian cepat.

Apa yang terjadi jika tidak ada karyawan aktif dengan peran yang diperlukan saat faktur diajukan?

Fungsi buildApprovalChain melempar error dan faktur ditahan dalam status DRAFT, bukan diteruskan secara diam-diam. Ini mencegah faktur terjebak dalam kondisi tidak dapat ditindaklanjuti karena tidak ada yang bisa melakukan step persetujuan berikutnya.

Mengapa ID approver ditetapkan saat pengajuan faktur, bukan dicari secara dinamis di setiap step?

Menetapkan ID approver saat pengajuan memastikan pergantian personel di tengah proses tidak secara tak terduga mengalihkan persetujuan ke orang baru—manajer asli tetap mendapat notifikasi step berikutnya. Solusi yang disarankan untuk perubahan organisasi adalah admin tool terpisah yang memungkinkan penugasan ulang step individual tanpa mengulang seluruh rantai.

Bagaimana sistem menangani penolakan di tengah rantai persetujuan?

Ketika salah satu approver menolak faktur, service menandai faktur sebagai REJECTED dan membatalkan semua step yang tersisa. Faktur dan stepnya tidak pernah dihapus atau ditimpa, sehingga riwayat persetujuan lengkap—termasuk alasan penolakan dan timestamp—tetap tersedia untuk keperluan audit dan kepatuhan.