← Semua pembelajaran / Astro Nol → Portal Berita
Fase 6 · Midtrans & Langganan

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 APIToken kartu, jadwal sendiri
Siapa yang menjadwalkanMidtransKamu
Percobaan ulangBawaan, bisa dikonfigurasiKamu yang menulis
Kendali atas waktu & jumlahTerbatasPenuh
Ubah paket di tengah periodeRumitMudah
Metode pembayaranKartu, GoPayKartu, GoPay
Kerja awalSedikitLebih 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

HariTindakanAkses
0Tagih. Gagal.Aktif
0Email: "pembayaran gagal, kami coba lagi 2 hari lagi"Aktif
2Tagih lagiAktif
5Tagih lagi + email "perbarui metode pembayaran"Aktif (masa tenggang)
8Percobaan terakhirAktif
10Turunkan ke gratis + emailBerakhir
10–90Data & bookmark disimpan; satu klik untuk aktif lagiGratis

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 };
  },
});
AturanKenapa
Bisa dibatalkan sendiri, tanpa menghubungi siapa punAlur pembatalan yang sulit menghasilkan chargeback — jauh lebih mahal daripada kehilangan pelanggan
Akses tetap sampai periode berakhirSudah dibayar. Memutusnya seketika adalah alasan sah untuk sengketa
Konfirmasi lewat emailMencegah "saya sudah batal tapi masih ditagih"
Tampilkan tanggal berakhirnya dengan jelasMenghapus seluruh kategori tiket dukungan
Aktifkan lagi dengan satu klikSebagian 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.