Skip to main content
BlogERP

ERP Billing Schedule Kontrak dan Down Payment Tanpa Double Bill

Modelkan kontrak sebagai baris billing schedule, period 0 sebagai down payment, dengan planned amount dan link invoice unik agar period tak ditagih dua kali.

Matthews Wong

Software Engineer

Oktober 202610 menit baca
ERP Billing Schedule Kontrak dan Down Payment Tanpa Double Bill

Ringkasan Utama

Modelkan penagihan setiap kontrak sebagai baris schedule: period 0 untuk down payment, period 1 sampai N sesudahnya, masing-masing dengan planned amount dan link invoice yang nullable serta unique. Buat setiap invoice dari baris berikutnya yang belum terhubung, lalu klaim baris itu secara kondisional dalam transaction yang sama. Satu period tidak bisa ditagih dua kali.

Versi pertama recurring billing di sebuah ERP biasanya tombol copy. Finance membuka invoice bulan lalu, menekan generate berikutnya, dan sistem meng-clone invoice itu dengan tanggal baru. Cara ini jalan sampai ada kontrak dengan down payment, period terakhir yang tidak rata, atau dua orang menekan tombol yang sama dalam satu menit.

Post ini membahas desain yang saya pakai di modul AP/AR sebuah ERP yang saya bangun, yaitu yang dijelaskan di case study ANCoraPRO di situs ini. Saya membaca ulang schema serta service proposal dan invoice sebelum menulis. Semua di bawah ini adalah design pattern dengan nama generik dan contoh yang ditulis ulang, bukan kode klien, dan saya tandai jelas mana yang pilihan desain dan bukan hasil pengukuran.

Kenapa meng-clone invoice bulan lalu berhenti bekerja

Invoice hasil clone tidak punya ingatan tentang kontraknya. Ia menyalin amount, jadi total kontrak tidak pernah dicek terhadap apa yang sudah ditagih. Ia menyalin pola tanggal, jadi down payment atau period terakhir yang lebih pendek bergantung pada manusia yang ingat pengecualiannya. Dan tidak ada apa pun di database yang menyatakan period 3 sudah ditagih, sehingga satu-satunya pengaman adalah orang yang sedang menatap layar.

Solusinya adalah berhenti menjadikan invoice sebagai sumber kebenaran untuk rencana penagihan. Rencananya adalah schedule milik kontrak, dibuat sekali, dan invoice dihasilkan darinya. Inilah langkah antara quotation yang disetujui dan uang masuk yang dibiarkan implisit oleh post tentang workflow quotation-to-cash, dan data inilah yang memungkinkan otomasi di bagian-bagian berikutnya.

Data model: satu baris untuk setiap momen penagihan

Di ERP yang saya bangun, ada satu tabel di antara kontrak (di sana disebut proposal) dan invoice. Setiap baris adalah satu momen penagihan dari satu kontrak. Bentuk di bawah ditulis ulang dengan nama generik, tetapi tiga keputusan di dalamnya adalah yang asli: kolom type di samping index, planned amount yang disimpan di baris, dan link invoice yang nullable serta unique.

// Illustrative schema, generic names. One row per billable moment of one contract.
model BillingScheduleRow {
  id            String   @id @default(uuid())
  contractId    String
  periodIndex   Int                 // 0 = down payment, 1..N = regular periods
  periodType    String              // "down_payment" | "period": the meaning lives HERE
  periodLabel   String              // "Down Payment", "Mar 2026"
  dueDate       DateTime
  plannedAmount Decimal  @db.Decimal(15, 2)  // fixed when the schedule is created
  invoiceId     String?  @unique    // null = not billed yet; one invoice, one row
  invoice       Invoice? @relation(fields: [invoiceId], references: [id])

  @@index([contractId])
}
  • periodIndex mengurutkan baris dan periodType menyatakan jenis barisnya. Index 0 adalah down payment, 1 sampai N adalah period biasa. Type disimpan terpisah supaya tidak ada kode yang menebak makna dari angka ajaib.
  • plannedAmount dikunci saat schedule dibuat. Invoice kemudian menyalinnya, jadi amount di invoice adalah konsekuensi dari rencana, bukan hitungan baru yang bisa salah.
  • invoiceId nullable dan unique. Null berarti belum ditagih. Kalau terisi, tepat satu invoice memiliki baris ini, dan database menolak satu invoice memiliki dua baris.

Unique constraint pada kolom nullable kerjanya lebih sempit dari kelihatannya, dan ini penting nanti. PostgreSQL tidak menganggap dua nilai null sama dalam unique constraint, jadi sebanyak apa pun baris yang belum ditagih boleh ada bersamaan. Yang dilarang adalah satu invoice id muncul di dua baris. Prisma mengekspresikan hal yang sama dengan menandai relation scalar yang optional sebagai unique, yang membuat relasinya one-to-one.

Period 0: down payment adalah baris, bukan flag

Model yang menggoda adalah menaruh boolean di invoice, is down payment, dan membiarkan schedule hanya untuk period biasa. Akibatnya down payment tak terlihat oleh rencana: schedule tidak lagi berjumlah sama dengan nilai kontrak, dan setiap report harus ingat menambahkan kasus khusus itu kembali.

Menjadikannya baris nol berarti rencananya lengkap di satu tabel, diurutkan oleh satu kolom. Sebuah komentar di kode service yang saya baca menjelaskan kenapa kolom type ada: dulu link dipilih dengan aturan yang menguji apakah index sama dengan nol untuk down payment dan satu untuk lainnya, dan aturan itu rusak setiap kali frontend mengubah konvensi index. Memilih baris target berdasarkan periodType menghilangkan ketergantungan pada angka itu.

Simpan makna di satu kolom dan urutan di kolom lain. Index yang sekaligus urutan dan makna akan rusak begitu ada yang menyisipkan milestone di antara period.

Membuat schedule sekali, dengan aturan sisa pembulatan

Schedule dibuat saat kontrak disetujui. Generator menelusuri rentang tanggal sesuai billing frequency, menghitung jumlah period, membagi amount sama rata, dan membuat baris terakhir menyerap sisa pembulatan. Sketsa di bawah adalah tulisan ulang saya dengan down payment ditambahkan di depan, karena service yang saya baca membuat period biasa saja dan menerima entri down payment bersama request invoice.

// Illustrative: build the whole plan once, at approval time.
async function createSchedule(contract: Contract, downPayment = 0) {
  // Idempotent: a re-approval after a rejection must not double the plan.
  const existing = await prisma.billingScheduleRow.count({
    where: { contractId: contract.id },
  });
  if (existing > 0) return;

  const periods = countPeriods(contract.start, contract.end, contract.frequency);
  const remaining = contract.amount - downPayment;
  const base = Math.floor(remaining / periods);

  const rows = [];
  if (downPayment > 0) {
    rows.push({
      contractId: contract.id, periodIndex: 0, periodType: "down_payment",
      periodLabel: "Down Payment", dueDate: contract.start, plannedAmount: downPayment,
    });
  }
  for (let i = 1; i <= periods; i++) {
    rows.push({
      contractId: contract.id, periodIndex: i, periodType: "period",
      periodLabel: labelFor(contract.start, i), dueDate: endOfPeriod(contract.start, i),
      // The last row absorbs the rounding remainder so the plan sums exactly.
      plannedAmount: i === periods ? remaining - base * (periods - 1) : base,
    });
  }
  await prisma.billingScheduleRow.createMany({ data: rows });
}

// 1000 over 3 periods: 333, 333, 334. Never 333, 333, 333 with a 1 unit hole.

Dua sifat lebih penting daripada aritmetikanya. Generator bersifat idempotent: ia langsung keluar kalau kontrak sudah punya baris, jadi approval ulang setelah penolakan tidak menggandakan schedule. Dan jumlah baris sama dengan nilai kontrak by construction, yang bisa Anda assert dengan satu query alih-alih percaya bahwa pembulatan berjalan benar. Satuan mata uang utuh dan pembagian rata adalah penyederhanaan saya; kontrak dengan period tidak rata perlu planned amount yang diberikan, bukan diturunkan.

Membuat invoice dari sebuah baris

Langkah invoice mencari baris berikutnya yang belum punya invoice dengan tipe period biasa, mengambil planned amount, label, dan due date-nya, membuat invoice, lalu menghubungkan baris ke invoice itu. Di kode yang saya baca, pembuatan dan link berjalan dalam satu database transaction, jadi tidak mungkin ada invoice dengan barisnya masih belum diklaim karena link gagal di tengah jalan.

await prisma.$transaction(async (tx) => {
  // The next regular period nobody has billed. Down payment rows are picked by type.
  const row = await tx.billingScheduleRow.findFirst({
    where: { contractId, invoiceId: null, periodType: "period" },
    orderBy: { periodIndex: "asc" },
  });
  if (!row) throw new Error("Nothing left to bill on this contract");

  const invoice = await tx.invoice.create({
    data: {
      contractId,
      subtotal: row.plannedAmount,   // copied from the plan, never recalculated by hand
      billingPeriod: row.periodLabel,
      dueDate: row.dueDate,
    },
  });

  // Wrong: update by id. Two racing requests both find the same row and the
  // second silently overwrites the first invoice's link.
  // await tx.billingScheduleRow.update({ where: { id: row.id }, data: { invoiceId: invoice.id } });

  // Right: claim it only if it is still free, and insist the claim took effect.
  const claimed = await tx.billingScheduleRow.updateMany({
    where: { id: row.id, invoiceId: null },
    data: { invoiceId: invoice.id },
  });
  if (claimed.count !== 1) throw new Error("Period already billed"); // rolls the invoice back too
});

Batas transaction itu juga yang menjaga audit trail tetap jujur, bagian dari accounts receivable yang menjadi sandaran otomasi AR di ERP: satu baris, satu invoice, satu entri history yang ditulis atasnya.

Versi yang saya baca mencari baris dulu lalu meng-update berdasarkan id. Itu benar kalau hanya satu request berjalan pada satu waktu. Dua request pada detik yang sama bisa menemukan baris belum-terhubung yang sama. Klaim baris dengan update kondisional, seperti di bagian berikutnya.

Kegagalan yang dicegah oleh schedule

Kegagalan yang saya maksud adalah double billing sebuah period: dua invoice untuk bulan yang sama pada kontrak yang sama, ditemukan oleh klien dan bukan oleh finance. Saya tidak pernah melihat ini terjadi di production, jadi saya tidak akan mengemasnya jadi cerita perang. Ini kegagalan yang desainnya dibuat supaya secara struktural sulit terjadi, dan tabel berikut memetakan tiap cara terjadinya ke pengaman yang menghentikannya.

Bagaimana terjadinyaPendekatan clone invoicePendekatan baris schedule
Seseorang menekan generate dua kaliDua invoice, hanya pengecekan layar yang menghalangiPenekanan kedua tidak menemukan baris belum-terhubung lalu berhenti
Ada down paymentFlag di satu invoice, rencana tidak berjumlah totalBaris 0 di dalam rencana, total selalu cocok
Satu invoice terhubung ke dua barisTidak ada yang mencegahUnique constraint pada link invoice menolaknya
Dua request mengklaim satu baris bersamaanKeduanya berhasilPerlu klaim kondisional, lihat di bawah

Baris terakhir adalah batas yang jujur. Unique constraint mencegah satu invoice memiliki dua baris, tetapi tidak mencegah dua invoice berbeda berebut satu baris, karena update biasa berdasarkan id hanya menimpa null. Solusinya adalah membuat klaim itu sendiri kondisional dan memeriksa apakah ia berhasil, seperti pada sketsa. Pada isolation Read Committed bawaan PostgreSQL, updater kedua menunggu yang pertama lalu mengevaluasi ulang klausa WHERE terhadap versi baris yang baru, sehingga kondisi link invoice null gagal dan update mengenai nol baris. Jalan lain adalah mengunci baris lebih dulu dengan SELECT FOR UPDATE, dengan konsekuensi lock ditahan selama transaction berjalan.

Mengubah kontrak setelah penagihan berjalan

Kasus yang sulit datang setelah invoice pertama. Ketika tanggal atau billing frequency kontrak berubah, schedule yang usang harus dibangun ulang, dan kode yang saya baca hanya melakukannya kalau belum ada baris yang di-invoice. Kalau ada baris yang terhubung, rebuild dilewati, warning dicatat dengan pesan untuk menyelesaikan manual, dan pemanggil diberi tahu bahwa regenerasi dilewati. Alasannya ada di komentar: menghapus baris yang sudah di-invoice akan membuat invoice yang menunjuk ke sana menjadi orphan.

Saya menganggap itu default yang baik tetapi jawaban yang belum lengkap. Ia melindungi ledger dan melempar pekerjaan sebenarnya ke manusia. Yang akan saya tambahkan adalah operasi eksplisit yang membatalkan ekor schedule yang belum ditagih dan membuat ekor baru mulai dari period berikutnya, tanpa menyentuh baris yang sudah di-invoice. Dua batasan lain di kode yang saya baca: amount melewati number JavaScript sebelum masuk tabel, padahal saya akan memakai Decimal dari ujung ke ujung karena tipe numeric PostgreSQL memang untuk ketepatan, dan schedule hanya dibuat otomatis untuk kontrak recurring.

Checklist hari Senin

Kalau Anda akan membangun recurring atau staged billing, ini langkah-langkah dalam urutan yang akan saya ambil.

  1. Tambahkan tabel schedule dengan periodIndex, periodType, plannedAmount, dan link invoice yang nullable serta unique sebelum membuat tombol generate apa pun.
  2. Modelkan down payment sebagai baris 0 di tabel yang sama, dan pilih baris berdasarkan type, bukan aritmetika index.
  3. Buat schedule dari kontrak secara idempotent, dan assert di test bahwa planned amount berjumlah sama dengan nilai kontrak.
  4. Buat invoice dan klaim barisnya dalam satu transaction, dengan update kondisional yang harus mengenai tepat satu baris.
  5. Tuliskan apa yang terjadi ketika kontrak diubah setelah invoice pertama, karena di situlah pekerjaan manual bersembunyi.

Aturan yang saya bawa dari sini: rencana milik kontrak, invoice milik satu baris dari rencana, dan database, bukan layar, yang harus menyatakan bahwa sebuah period sudah terambil.

FAQ

Pertanyaan Umum

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

Apa itu billing schedule dalam kontrak di ERP?

Billing schedule adalah tabel dengan satu baris untuk setiap momen penagihan sebuah kontrak, masing-masing berisi due date, label, dan planned amount. Invoice dibuat dari baris-baris itu, bukan di-clone dari invoice sebelumnya. Schedule adalah rencananya dan invoice adalah catatan bahwa sebuah baris sudah ditagih.

Bagaimana memodelkan down payment dalam billing schedule?

Modelkan sebagai period 0 di tabel yang sama dengan period biasa, dengan kolom period type sendiri. Dengan begitu rencana tetap lengkap dan jumlah barisnya sama dengan nilai kontrak. Pilih baris berdasarkan type, bukan aritmetika index, karena index yang membawa makna akan rusak ketika konvensinya berubah.

Bagaimana mencegah invoice dibuat dua kali untuk period yang sama?

Beri setiap baris schedule link invoice yang nullable dengan unique constraint, dan selalu buat invoice dari baris berikutnya yang link-nya null. Lalu klaim baris dengan update kondisional dalam transaction yang sama dengan insert invoice, dan rollback kecuali tepat satu baris berubah. Unique constraint saja tidak mencegah dua request berebut satu baris.

Bolehkah kolom unique di PostgreSQL bernilai null?

Boleh. PostgreSQL tidak menganggap dua nilai null sama dalam unique constraint, jadi banyak baris bisa berisi null sekaligus. Constraint hanya menolak dua baris dengan nilai non-null yang sama, persis yang Anda butuhkan untuk link invoice yang optional.

Apa yang terjadi kalau kontrak berubah setelah invoice pertama terbit?

Jangan hapus baris schedule yang sudah menunjuk ke invoice, karena itu membuat invoice menjadi orphan. Default yang aman adalah menolak regenerasi begitu ada baris yang terhubung dan meminta orang menyelesaikannya. Desain yang lebih baik hanya membatalkan ekor yang belum ditagih lalu membuat ekor baru mulai dari period berikutnya.