Langganan berulang & penagihan otomatis
Perpanjangan otomatis adalah bedanya antara portal dengan pendapatan berulang dan portal yang harus menjual ulang tiap bulan. Bagian tersulitnya bukan menagih — tapi menangani penagihan yang gagal.
Intisari
- Dua jalur: Subscription API (Midtrans yang menjadwalkan) atau token kartu (kamu yang menjadwalkan).
- Token kartu dari alur one-click/two-clicks disimpan sebagai
saved_token_id— bukan nomor kartunya. - Kegagalan penagihan itu normal: kartu kedaluwarsa, saldo kurang, bank menolak sesaat.
- Rangkaian percobaan ulang (dunning) plus masa tenggang menyelamatkan pelanggan yang sebenarnya ingin bertahan.
- Pembatalan harus mudah dan segera terlihat — memperumitnya menghasilkan sengketa kartu, yang jauh lebih mahal.
Dua jalur
| Subscription API | Token kartu, jadwal sendiri | |
|---|---|---|
| Siapa yang menjadwalkan | Midtrans | Kamu |
| Percobaan ulang | Bawaan, bisa dikonfigurasi | Kamu yang menulis |
| Kendali atas waktu & jumlah | Terbatas | Penuh |
| Ubah paket di tengah periode | Rumit | Mudah |
| Metode pembayaran | Kartu, GoPay | Kartu, GoPay |
| Kerja awal | Sedikit | Lebih banyak |
Untuk portal berita dengan beberapa paket dan kemungkinan promo, jalur kedua biasanya lebih tepat: kamu sudah punya penyapu berkala dan jurnal notifikasi dari materi sebelumnya, dan menjadwalkan sendiri berarti logika bisnis penagihanmu ada di satu tempat yang bisa kamu uji.
Menyimpan token, bukan kartu
// Saat pembayaran pertama dengan kartu, minta tokennya disimpan.
// Nomor kartu TIDAK PERNAH menyentuh servermu.
{
transaction_details: { order_id, gross_amount },
credit_card: {
secure: true, // 3D Secure
save_card: true, // minta token untuk penagihan berikutnya
},
}
CREATE TABLE metode_bayar (
id INT AUTO_INCREMENT PRIMARY KEY,
member_id INT NOT NULL,
saved_token VARCHAR(255) NOT NULL,
masked_card VARCHAR(32) NULL, -- "48111111-1114"
jenis VARCHAR(16) NOT NULL, -- "credit_card" | "gopay"
kedaluwarsa DATE NULL,
utama TINYINT(1) NOT NULL DEFAULT 0,
dibuat_pada DATETIME NOT NULL,
UNIQUE KEY uq_token (saved_token),
INDEX idx_member (member_id)
) ENGINE=InnoDB;
Yang kamu simpan hanya token dan empat digit terakhir. Nomor kartu lengkap, CVV, dan masa berlaku tidak pernah melewati servermu — Snap yang menanganinya langsung ke Midtrans. Ini yang menjaga lingkup kepatuhan PCI-mu tetap kecil. Kalau ada bagian dari sistemmu yang bisa melihat nomor kartu, seluruh sistem itu masuk lingkup audit, dan itu pekerjaan berbulan-bulan.
Menagih dengan token
export async function tagihUlang(m: MetodeBayar, jumlah: number, orderId: string) {
const basis = env.MIDTRANS_PRODUKSI
? "https://api.midtrans.com/v2"
: "https://api.sandbox.midtrans.com/v2";
const res = await fetch(`${basis}/charge`, {
method: "POST",
signal: AbortSignal.timeout(15_000),
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: otorisasi(),
},
body: JSON.stringify({
payment_type: "credit_card",
transaction_details: { order_id: orderId, gross_amount: jumlah },
credit_card: { token_id: m.saved_token },
}),
});
return res.json();
}
Penjadwal penagihan
// Tugas terjadwal harian
export async function tagihYangJatuhTempo() {
const jatuhTempo = await dbBaca
.selectFrom("member as m")
.innerJoin("metode_bayar as b", (j) =>
j.onRef("b.member_id", "=", "m.id").on("b.utama", "=", 1),
)
.select(["m.id", "m.tier", "m.langganan_sampai", "b.saved_token", "b.id as metodeId"])
.where("m.perpanjang_otomatis", "=", 1)
.where("m.langganan_sampai", "<=", besok())
.where("m.langganan_sampai", ">", sepuluhHariLalu()) // jangan kejar yang sudah lama mati
.limit(500)
.execute();
for (const m of jatuhTempo) {
const orderId = `RNW-${m.id}-${hariIniISO()}`; // deterministik = idempoten
try {
const hasil = await tagihUlang(m, hargaPaket(m.tier), orderId);
await prosesNotifikasi(hasil); // jalur yang sama dengan webhook
} catch (e) {
await catatKegagalanTagih(m.id, String(e));
}
}
}
order_id yang deterministik itu trik yang layak dicatat.
RNW-4471-2026-08-27 selalu sama untuk member dan tanggal yang sama. Kalau penjadwal berjalan
dua kali karena bug atau karena dua task menjalankannya bersamaan, Midtrans menolak order_id
yang berulang — dan pelanggan tidak ditagih dua kali. Idempotensi diberikan gratis oleh sisi Midtrans.
Dunning: menangani penagihan yang gagal
| Hari | Tindakan | Akses |
|---|---|---|
| 0 | Tagih. Gagal. | Aktif |
| 0 | Email: "pembayaran gagal, kami coba lagi 2 hari lagi" | Aktif |
| 2 | Tagih lagi | Aktif |
| 5 | Tagih lagi + email "perbarui metode pembayaran" | Aktif (masa tenggang) |
| 8 | Percobaan terakhir | Aktif |
| 10 | Turunkan ke gratis + email | Berakhir |
| 10–90 | Data & bookmark disimpan; satu klik untuk aktif lagi | Gratis |
Angka-angka ini adalah keputusan bisnis, dan dampaknya besar. Sebagian besar kegagalan penagihan pertama bukan karena pelanggan ingin berhenti — kartu kedaluwarsa, saldo sedang kosong, bank menolak sesaat. Memutus akses di hari pertama mengubah gangguan teknis kecil menjadi kehilangan pelanggan permanen. Rangkaian percobaan dengan komunikasi yang jelas memulihkan sebagian besar di antaranya.
Kartu yang akan kedaluwarsa
-- Kirim pengingat 30 hari sebelumnya
SELECT m.id, m.email, b.masked_card, b.kedaluwarsa
FROM member m
JOIN metode_bayar b ON b.member_id = m.id AND b.utama = 1
WHERE m.perpanjang_otomatis = 1
AND b.kedaluwarsa BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 30 DAY);
Ini pencegahan yang paling murah dari seluruh materi ini. Satu email sebulan sebelumnya menghindarkan seluruh rangkaian dunning, dan pelanggan tidak pernah mengalami gangguan akses sama sekali.
Pembatalan
export const batalkanPerpanjangan = defineAction({
handler: async (_i, ctx) => {
const m = ctx.locals.member;
if (!m) throw new ActionError({ code: "UNAUTHORIZED" });
await dbTulis.updateTable("member")
.set({ perpanjang_otomatis: 0, dibatalkan_pada: new Date() })
.where("id", "=", m.id)
.execute();
// Akses tetap sampai periode yang sudah dibayar berakhir.
return { berlakuSampai: m.langgananSampai };
},
});
| Aturan | Kenapa |
|---|---|
| Bisa dibatalkan sendiri, tanpa menghubungi siapa pun | Alur pembatalan yang sulit menghasilkan chargeback — jauh lebih mahal daripada kehilangan pelanggan |
| Akses tetap sampai periode berakhir | Sudah dibayar. Memutusnya seketika adalah alasan sah untuk sengketa |
| Konfirmasi lewat email | Mencegah "saya sudah batal tapi masih ditagih" |
| Tampilkan tanggal berakhirnya dengan jelas | Menghapus seluruh kategori tiket dukungan |
| Aktifkan lagi dengan satu klik | Sebagian akan kembali; jangan buat mereka mendaftar ulang |
Latihan: bangun penjadwal penagihan dengan order_id deterministik. Uji
idempotensinya: jalankan dua kali di hari yang sama dan pastikan hanya ada satu tagihan. Lalu simulasikan
kegagalan dengan kartu uji yang selalu ditolak, dan implementasikan langkah dunning hari ke-2 dan ke-5.
Verifikasi akses tetap aktif selama masa tenggang.
Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.