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 terjadinya | Pendekatan clone invoice | Pendekatan baris schedule |
|---|---|---|
| Seseorang menekan generate dua kali | Dua invoice, hanya pengecekan layar yang menghalangi | Penekanan kedua tidak menemukan baris belum-terhubung lalu berhenti |
| Ada down payment | Flag di satu invoice, rencana tidak berjumlah total | Baris 0 di dalam rencana, total selalu cocok |
| Satu invoice terhubung ke dua baris | Tidak ada yang mencegah | Unique constraint pada link invoice menolaknya |
| Dua request mengklaim satu baris bersamaan | Keduanya berhasil | Perlu 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.
- Tambahkan tabel schedule dengan periodIndex, periodType, plannedAmount, dan link invoice yang nullable serta unique sebelum membuat tombol generate apa pun.
- Modelkan down payment sebagai baris 0 di tabel yang sama, dan pilih baris berdasarkan type, bukan aritmetika index.
- Buat schedule dari kontrak secara idempotent, dan assert di test bahwa planned amount berjumlah sama dengan nilai kontrak.
- Buat invoice dan klaim barisnya dalam satu transaction, dengan update kondisional yang harus mengenai tepat satu baris.
- 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.
Sumber
- Dokumentasi PostgreSQL: unique constraint dan nilai null
- Dokumentasi PostgreSQL: isolation Read Committed dan UPDATE bersamaan
- Dokumentasi PostgreSQL: explicit locking dan SELECT FOR UPDATE
- Dokumentasi PostgreSQL: tipe numeric untuk nilai uang yang eksak
- Dokumentasi Prisma: relasi one-to-one dan field optional unique

