Topik wawancara representatif

System Design Interview: Mendesain Sistem Penagihan Langganan (Subscription Billing)

Desain sistemSulit
Tim Redaksi Offer.ccDipublikasikan Diperbarui

Pertanyaan

Desain sistem penagihan langganan untuk paket bulanan dan tahunan, masa uji coba (trial), upgrade, downgrade, prorasi, perpanjangan (renewal), percobaan ulang pembayaran gagal, faktur (invoice), dan webhook. Jelaskan state machine, mesin penagihan (billing engine), idempotensi pembayaran, pengurutan event, kapasitas, rekonsiliasi, dan pemulihan dari kegagalan.

Prompt dan cakupan

Desain sebuah layanan yang mengelola langganan berulang untuk produk SaaS, media, atau keanggotaan (membership). Pelanggan dapat memilih paket bulanan atau tahunan, melakukan konversi dari masa uji coba ke akses berbayar, mengubah paket di tengah siklus penagihan, dan memulihkan akses setelah perpanjangan yang gagal. Sistem harus menghasilkan faktur yang dapat diaudit, memberi tahu produk mengenai hak akses (entitlement), dan memproses event asinkron dari penyedia pembayaran (payment provider).

Materi wawancara publik menyajikan "desain sistem penagihan langganan" sebagai pertanyaan desain sistem seputar event subscription-created dan trial-expired, bottleneck, metrik pelanggan, serta pemantauan sistem. Dokumentasi Stripe menempatkan langganan, faktur, PaymentIntents, masa uji coba, prorasi, pemulihan pendapatan (revenue recovery), dan webhook dalam siklus hidup yang sama. Artikel ini tidak mengklaim bahwa perusahaan tertentu selalu menanyakan pertanyaan ini.

Asumsikan ada 1 juta langganan aktif, rata-rata satu event terkait penagihan per langganan per hari, lonjakan perpanjangan hingga 10x lipat dari lalu lintas normal, jumlah uang disimpan dalam unit mata uang terkecil (minor unit), dan penyedia pembayaran yang dapat mengalami timeout, menduplikasi event, atau mengirimkan event setelah status lokal berubah. Sistem harus mencegah tagihan ganda, menjaga faktur tetap dapat dilacak, dan mengonvergenkan status hak akses setelah percobaan ulang.

Apa yang dievaluasi oleh pewawancara

Jawaban yang kuat memisahkan paket (plans), langganan (subscriptions), faktur (invoices), upaya pembayaran (payment attempts), dan hak akses (entitlements). Menuliskan paid=true ke dalam baris data pelanggan tidak dapat mengakomodasi prorasi, masa tenggang (grace period), pengembalian dana (refund), atau beberapa upaya pembayaran untuk satu faktur.

Sinyal penilaian berikutnya adalah batasan idempotensi yang eksplisit. Pembuatan faktur, pemanggilan API pembayaran, penanganan webhook, dan pengiriman hak akses semuanya dapat mengalami percobaan ulang (retry). Antrean saja tidak cukup untuk mencegah penagihan kedua atau otorisasi kedua.

Pewawancara juga menguji pemahaman semantik waktu dan akuntansi: billing anchor, akhir masa uji coba, zona waktu, tahun/bulan kabisat, upgrade seketika, pembatalan di akhir periode, dan jumlah tagihan yang immutable. Faktur historis tidak boleh berubah ketika harga paket berubah.

Terakhir, kandidat harus menjelaskan penanganan lonjakan beban, pembayaran gagal, event yang tiba tidak berurutan, refund manual, celah rekonsiliasi, dan perbaikan data. Hasil akhir yang diharapkan adalah mengetahui secara pasti apa yang seharusnya ditagih, apa yang telah ditagih, dan hak akses mana yang valid setelah pemulihan.

Pertanyaan klarifikasi sebelum menjawab

  • Apa billing anchor yang digunakan? Berdasarkan bulan kalender atau tanggal bergulir (rolling date)? Ini mengubah perhitungan akhir periode dan penanganan bulan Februari atau tanggal 31.
  • Kapan biaya upgrade ditagihkan? Seketika, pada periode berikutnya, atau berdasarkan pilihan pelanggan? Ini mengubah prorasi dan status pending-payment.
  • Bagaimana cara kerja downgrade? Refund langsung, berlaku pada periode berikutnya, atau menjadi kredit akun? Ini mengubah baris rincian faktur (invoice lines) dan penanganan refund.
  • Kapan pembayaran yang gagal mencabut akses? Masa tenggang hanya-baca (read-only grace period) dan penangguhan langsung memiliki aturan hak akses yang berbeda.
  • Apakah pajak, diskon, atau biaya berbasis penggunaan (usage charges) masuk dalam cakupan? Jika ya, input penentuan harga harus diberi versi sebelum finalisasi faktur.
  • Apa sumber kebenaran tunggal (source of truth) untuk hak akses? Produk dapat mengonsumsi event penyedia pembayaran secara langsung, atau sistem penagihan dapat memublikasikan event entitlement.changed. Pilihan ini memengaruhi pemutaran ulang (replay) dan konsistensi data.
  • Apakah multi-mata uang, refund, atau penyesuaian manual diperlukan? Jika ya, gunakan unit minor integer dan jangan pernah menimpa faktur historis.

Jawaban 30 detik

"Saya akan memisahkan paket, langganan, faktur, upaya pembayaran, dan hak akses. State machine langganan menentukan periode saat ini dan tindakan berikutnya; mesin penagihan membuat snapshot faktur yang immutable pada setiap batas siklus dan menggunakan invoice_id sebagai kunci idempotensi pembayaran. Konfirmasi pembayaran dilakukan melalui webhook bertanda tangan digital ditambah kueri ke penyedia, melakukan deduplikasi berdasarkan ID event, dan mengizinkan event tiba tidak berurutan. Representasikan upgrade dan downgrade sebagai baris faktur positif dan negatif yang eksplisit. Pembayaran yang gagal masuk ke state machine percobaan ulang yang dibatasi dan diberi jitter, serta hak akses mengikuti status pembayaran yang terkonfirmasi ditambah kebijakan masa tenggang yang terdokumentasi. Saya akan memvalidasi desain ini dengan rekonsiliasi, invarian status, dan injeksi kegagalan (failure injection)."

Solusi langkah demi langkah

Langkah 1: Tentukan objek dan invarian.

text
Plan(id, version, currency, interval, unit_amount, trial_days)
Subscription(id, customer_id, plan_version, status, period_start, period_end)
Invoice(id, subscription_id, period_start, period_end, currency, total, status)
InvoiceLine(id, invoice_id, source, description, quantity, unit_amount, amount)
PaymentAttempt(id, invoice_id, attempt_no, provider_key, status, provider_id)
Entitlement(id, customer_id, feature, valid_until, source_invoice_id)

Paket dapat berubah, tetapi faktur yang telah diterbitkan menyimpan versi harga dan snapshot mata uangnya. Invarian kuncinya adalah: satu periode langganan memiliki paling banyak satu faktur efektif; satu hasil dari penyedia memajukan status faktur tepat satu kali; kegagalan yang datang terlambat tidak boleh menimpa pembayaran yang telah dikonfirmasi; dan pemutaran ulang perubahan hak akses harus konvergen ke satu versi yang sama.

Langkah 2: Jalankan alur kerja dengan state machine.

text
trialing --trial_end--> active
active --renewal--> invoice_open
invoice_open --payment_succeeded--> paid
invoice_open --payment_failed--> past_due
past_due --retry_succeeded--> paid
past_due --retries_exhausted--> canceled
active --cancel_at_period_end--> canceling
canceling --period_end--> canceled

Perintah atau event yang memiliki nomor versi melakukan transisi status; web request sembarangan tidak boleh mengubah status secara langsung. Tentukan kebijakan hak akses untuk active, past_due, dan canceled: misalnya, past_due dapat mempertahankan akses hanya-baca selama tiga hari sementara canceled mencabut fitur-fitur berbayar. Catat alasan, event sumber, aktor, dan waktu untuk setiap transisi.

Langkah 3: Hasilkan faktur dan tangani perubahan paket.

Partisi worker penagihan berdasarkan period_end. Pertama, buat faktur di bawah batasan unik (unique constraint) seperti (subscription_id, period_start), lalu hitung baris rinciannya. Untuk upgrade pada hari ke-15, catat paket lama yang tidak terpakai sebagai baris negatif dan sisa durasi paket baru sebagai baris positif. Gunakan unit minor integer dan aturan detik-atau-hari yang eksplisit; jangan pernah mengurangkan nilai floating-point yang dibulatkan.

Kunci jumlah, pajak, diskon, snapshot penggunaan, dan versi paket saat faktur difinalisasi. Tugas pemrosesan periode dapat dijalankan dua kali: batasan unik akan mengembalikan faktur yang asli, dan worker akan memeriksa apakah upaya pembayaran sudah ada. Pembatalan di akhir periode mengubah cancel_at_period_end; tindakan ini tidak menghapus riwayat faktur. Pembatalan seketika membuat penyesuaian refund atau kredit.

Langkah 4: Pisahkan idempotensi pembayaran dari network timeout.

text
provider_key = "invoice:" + invoice_id + ":attempt:" + attempt_no
POST payment-provider/charges
  Idempotency-Key: provider_key

Tulis PaymentAttempt lokal sebelum memanggil penyedia pembayaran. Setelah terjadi timeout, jangan langsung menyimpulkan terjadi kegagalan; coba lagi dengan kunci yang sama atau kueri status ke penyedia. Stripe mendokumentasikan bahwa permintaan idempoten yang berulang akan mengembalikan hasil pertama dan membandingkan parameter untuk mencegah penggunaan ulang kunci secara tidak sengaja. Oleh karena itu, kunci tersebut harus merepresentasikan satu operasi bisnis, bukan satu upaya jaringan.

Jika berhasil, webhook atau kueri penyedia akan mencatat provider_id, jumlah, mata uang, dan waktu penyelesaian. Pembaruan basis data kondisional hanya mengizinkan status open atau past_due untuk berpindah ke paid; status payment_failed yang datang terlambat akan masuk ke catatan audit dan tidak dapat membatalkan pembayaran yang telah dikonfirmasi. Ketidakcocokan nominal uang atau mata uang akan dialihkan ke rekonsiliasi alih-alih memberikan atau mencabut akses secara otomatis.

Langkah 5: Tangani duplikasi dan pengurutan ulang webhook dengan inbox.

text
WebhookInbox(event_id PRIMARY KEY, received_at, payload_hash, processed_at)
Outbox(id, aggregate_id, event_type, payload, published_at)

Verifikasi tanda tangan, simpan event mentah beserta hash-nya di WebhookInbox, dan kirimkan respons acknowledge untuk event_id yang duplikat. Pemroses menggunakan versi objek, status penyedia, dan status faktur lokal untuk memutuskan apakah akan memajukan status; waktu kedatangan bukanlah jaminan urutan eksekusi. Stripe merekomendasikan webhook langganan untuk aktivitas asinkron dan perubahan hak akses, sehingga pemrosesan internal juga harus dapat diputar ulang (replayable).

Transaksi lokal dapat menuliskan status penagihan, intensi perubahan hak akses, dan catatan Outbox secara bersamaan. Publisher kemudian mengirimkan catatan tersebut ke layanan hak akses. Konsumen melakukan deduplikasi berdasarkan (aggregate_id, version), sehingga percobaan ulang dari penyedia, antrean, maupun restart pada konsumen tidak akan menghasilkan otorisasi efektif kedua.

Langkah 6: Estimasi kapasitas dan isolasi lonjakan beban.

Dengan 1 juta langganan aktif dan rata-rata satu event penagihan per langganan per hari, laju stabil (steady rate) adalah sekitar 11,6 event/detik; lonjakan perpanjangan 10x lipat adalah sekitar 116 event/detik. Beban yang lebih berat terjadi pada pemrosesan baris faktur, panggilan pembayaran, webhook, dan notifikasi yang berlangsung secara simultan. Kelompokkan pekerjaan periode berdasarkan waktu (bucketing), batasi setiap batch, dan tambahkan jitter untuk menghindari lonjakan di awal jam (top-of-hour spike).

Beban KerjaBottleneck UtamaKontrol Kendali
Pemindaian periode (Period scan)Hot index dan perebutan kunci (lock contention)Bucketing berdasarkan period_end, transaksi singkat, batasan unik
Panggilan pembayaranLatensi dan limit penyediaKonkurensi terbatas, kunci idempotensi, exponential backoff
WebhookEvent duplikat dan tidak berurutanDeduplikasi inbox, pembaruan versi kondisional, antrean replay
Sinkronisasi hak aksesBacklog downstreamOutbox, lag konsumen, kuota tenant

Buat indeks pada langganan berdasarkan ID langganan dan akhir periode; buat pekerjaan pembayaran dan notifikasi menjadi asinkron. Berikan kuota terpisah untuk tenant berskala besar, hari perpanjangan yang terkonsentrasi, dan akun dengan penggunaan tinggi agar satu tenant tidak menghabiskan seluruh konkurensi pembayaran. Angka-angka ini merupakan asumsi awal; limitasi penyedia dan pengujian kegagalan harus digunakan untuk mengkalibrasi desain.

Langkah 7: Rekonsiliasi, perbaiki, dan amati.

Lakukan rekonsiliasi harian terhadap faktur, transaksi penyedia, dan laporan penyelesaian (settlement exports). Untuk setiap faktur, periksa apakah jumlah baris rincian sama dengan total tagihan, jumlah pembayaran sama dengan jumlah jatuh tempo, dan kedaluwarsa hak akses tidak melebihi periode berbayar yang telah dikonfirmasi. Buat catatan penyesuaian atau refund yang immutable jika terdapat perbedaan; jangan pernah menimpa nominal lama.

Pantau lag pemindaian periode, latensi faktur, hasil pembayaran yang tidak diketahui, jumlah percobaan ulang, usia past_due, tingkat duplikasi dan lag pemrosesan webhook, penundaan propagasi hak akses, selisih nilai uang pada rekonsiliasi, dan kegagalan pembayaran per tenant. Catatan audit harus menautkan subscription_id, invoice_id, payment_attempt_id, ID event, dan trace ID sehingga keluhan dapat ditelusuri hingga ke respons penyedia pembayaran.

Langkah 8: Uji batasan sistem dengan failure matrix.

Injeksi eksekusi tugas periode yang berulang; crash setelah pembuatan faktur; penagihan penyedia yang berhasil namun responsnya hilang; webhook yang duplikat dan tidak berurutan; pembayaran prorasi yang gagal; masa uji coba yang berakhir tanpa metode pembayaran; pembatalan dan perpanjangan yang terjadi bersamaan; crash pada konsumen setelah publikasi Outbox; serta refund manual yang berkejaran (race condition) dengan percobaan ulang otomatis.

Kriteria penerimaan (acceptance checks) adalah: satu faktur efektif per (subscription_id, period_start); tidak ada tagihan kedua untuk satu provider_key; pembayaran yang telah dikonfirmasi tidak tertimpa oleh kegagalan yang datang terlambat; event hak akses yang diputar ulang menghasilkan versi yang sama; setiap perbedaan rekonsiliasi memiliki penyesuaian yang dapat dilacak; dan setiap tindakan otomatis dapat direkonstruksi dari catatan audit.

Contoh jawaban berkualitas tinggi

"Saya akan memisahkan paket, langganan, faktur, upaya pembayaran, dan hak akses, serta mengambil snapshot harga paket saat faktur dibuat. State machine langganan menangani masa uji coba, perpanjangan, masa tenggang, dan pembatalan. Worker periode membuat satu faktur di bawah kunci unik; perubahan paket menjadi baris faktur positif dan negatif menggunakan unit minor integer dan aturan prorasi yang eksplisit.

Sebelum memanggil penyedia pembayaran, saya menuliskan data upaya pembayaran dan menurunkan kunci idempotensi invoice_id + attempt_no yang stabil. Kondisi timeout menggunakan kunci yang sama atau melakukan kueri ke penyedia; sistem tidak boleh membuat kunci baru. Webhook diverifikasi, disimpan di inbox, dan dideduplikasi berdasarkan ID event. Pembaruan versi secara kondisional mencegah kegagalan yang datang terlambat menimpa pembayaran yang telah dikonfirmasi. Pola Outbox secara andal mengirimkan intensi hak akses ke downstream.

Saya mengisolasi konkurensi pembayaran, pemindaian periode, webhook, notifikasi, dan kuota tenant, serta menambahkan jitter pada lonjakan perpanjangan. Rekonsiliasi harian membandingkan faktur lokal, transaksi penyedia, dan settlement. Saya akan menguji tugas periode duplikat, tagihan berhasil dengan respons yang hilang, webhook yang tidak berurutan, prorasi gagal, pembatalan dan perpanjangan konkuren, serta konvergensi hak akses setelah pemutaran ulang."

Kesalahan umum

  • Kesalahan: hanya menyimpan paid pada data langganan → Mengapa gagal: riwayat faktur, upaya pembayaran, masa tenggang, dan refund menjadi hilang → Solusi: modelkan faktur, upaya pembayaran, dan hak akses secara terpisah.
  • Kesalahan: membuat ID pembayaran baru untuk setiap percobaan ulang akibat timeout → Mengapa gagal: satu tagihan bisnis dapat tereksekusi dua kali → Solusi: pertahankan satu kunci idempotensi per operasi dan kueri status yang belum diketahui.
  • Kesalahan: menerapkan event berdasarkan waktu kedatangan → Mengapa gagal: webhook dapat terduplikasi, tidak berurutan, atau tertunda → Solusi: simpan dalam inbox dan gunakan versi objek beserta transisi kondisional.
  • Kesalahan: menghitung ulang faktur lama dari harga paket hari ini → Mengapa gagal: perubahan harga akan mengubah riwayat historis → Solusi: kunci harga, pajak, diskon, penggunaan, dan versi paket pada faktur.
  • Kesalahan: langsung membatalkan langganan setelah satu kali kegagalan → Mengapa gagal: gangguan sementara pada penyedia menjadi pemadaman hak akses bagi pengguna → Solusi: gunakan kebijakan masa tenggang past_due dan state machine dunning yang terukur.
  • Kesalahan: menghapus catatan periode saat pembatalan → Mengapa gagal: audit, refund, dan rekonsiliasi kehilangan data bukti → Solusi: tambahkan catatan pembatalan dan penyesuaian sambil tetap mempertahankan riwayat.
  • Kesalahan: memperlakukan transaksi basis data sebagai transaksi pembayaran → Mengapa gagal: penyedia pembayaran berada di luar transaksi lokal → Solusi: tutup siklus dengan panggilan idempoten, webhook, kueri status, dan rekonsiliasi.
  • Kesalahan: memindai seluruh langganan pada satu waktu yang persis bersamaan → Mengapa gagal: lock basis data, panggilan pembayaran, dan notifikasi akan melonjak bersamaan → Solusi: terapkan bucketing, jitter, antrean, dan kuota pada beban kerja.

Pertanyaan lanjutan dan tanggapan

Lanjutan 1: Pembayaran upgrade gagal. Apa yang terjadi pada akses?

Pertahankan hak akses lama, buat pending_update serta faktur baru, dan ganti paket hanya setelah pembayaran terkonfirmasi. Jika produk mengizinkan upgrade sebelum penagihan berhasil, tandai hak akses baru sebagai masa tenggang terbatas dengan masa kedaluwarsa eksplisit. Pada kedua kebijakan tersebut, versi faktur dan hak akses tetap dapat dilacak; hanya mengubah plan_id saja tidak cukup.

Lanjutan 2: Tidak ada webhook penyedia yang masuk selama tiga hari. Bagaimana cara mendeteksinya?

Jalankan tugas rekonsiliasi berdasarkan status faktur lokal dan kueri status ke penyedia. Berikan peringatan (alert) untuk open yang lama, past_due, atau faktur kedaluwarsa tanpa event terminal; jika hasil kueri masih tidak diketahui, jeda pembatalan otomatis dan alihkan ke peninjauan manual. Webhook adalah notifikasi berlatensi rendah, bukan satu-satunya sumber kebenaran.

Lanjutan 3: Pembatalan dan perpanjangan tiba secara bersamaan. Mana yang menang?

Tetapkan nomor versi monotonik pada setiap perintah langganan dan serialisasikan eksekusinya dengan pembaruan kondisional atau partisi terurut. cancel_at_period_end dapat berjalan berdampingan dengan perpanjangan saat ini; pembatalan seketika akan memeriksa faktur terbuka atau upaya pembayaran sebelum menerbitkan refund atau pembatalan tagihan (void). Event audit harus dapat menjelaskan hasil akhir penagihan dan hak akses.

Lanjutan 4: Bagaimana cara menambahkan penagihan berbasis penggunaan tanpa perhitungan ganda?

Lakukan deduplikasi pada ID event penggunaan yang immutable dan buat snapshot penggunaan per periode penagihan. Event duplikat hanya meningkatkan metrik penerimaan, bukan kuantitas yang dapat ditagih. Kunci snapshot saat penyelesaian siklus; alihkan event yang datang terlambat ke periode berikutnya atau buat penyesuaian manual. Simpan versi sumber dan agregasi pada baris faktur sehingga penghitungan ulang dapat dibandingkan secara akurat.

Sumber publik

Pertanyaan terkait

Alat wawancara terkait

Gunakan Jawab untuk jawaban desain sistem

Perjelas persyaratan terlebih dahulu, lalu lanjutkan dengan skala, arsitektur, pilihan komponen, dan trade-off.

Lihat alat