โ† Semua pembelajaran / Astro Nol โ†’ Portal Berita
Fase 6 ยท Midtrans & Langganan

Idempotensi & notifikasi yang datang tidak berurutan

Midtrans menyatakan sendiri bahwa notifikasi bisa terkirim ganda dan bisa tiba tidak berurutan. Kode yang tidak menyiapkan keduanya akan salah menghitung uang.

Intisari

  • Kunci idempotensi: transaction_id per notifikasi, order_id per pesanan.
  • Simpan tiap notifikasi mentah di tabel tersendiri dengan UNIQUE โ€” itu jurnalnya.
  • Notifikasi bisa terbalik: settlement tiba sebelum pending. Jangan menurunkan status.
  • Kalau ragu, panggil GET Status API โ€” itu sumber kebenaran, bukan urutan kedatangan.
  • Perpanjangan langganan harus dihitung dari tanggal berakhir yang ada, bukan dari hari ini.

Jurnal notifikasi

CREATE TABLE notifikasi_pembayaran (
  id             BIGINT AUTO_INCREMENT PRIMARY KEY,
  transaction_id VARCHAR(64)  NOT NULL,
  order_id       VARCHAR(100) NOT NULL,
  status         VARCHAR(32)  NOT NULL,
  isi            JSON         NOT NULL,
  diterima_pada  DATETIME     NOT NULL,
  UNIQUE KEY uq_notif (transaction_id, status),
  INDEX idx_order (order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

UNIQUE (transaction_id, status) yang melakukan pekerjaan berat. Notifikasi yang sama persis tidak bisa masuk dua kali; perubahan status yang sah tetap bisa.

Pemrosesan yang idempoten

// src/lib/pembayaran.ts
export async function prosesNotifikasi(n: Notifikasi) {
  await dbTulis.transaction().execute(async (trx) => {
    // 1. Catat. Kalau duplikat, berhenti di sini.
    const catat = await trx
      .insertInto("notifikasi_pembayaran")
      .values({
        transaction_id: n.transaction_id,
        order_id: n.order_id,
        status: n.transaction_status,
        isi: JSON.stringify(n),
        diterima_pada: new Date(),
      })
      .ignore()                       // MySQL: INSERT IGNORE
      .executeTakeFirstOrThrow();

    if (catat.numInsertedOrUpdatedRows === 0n) {
      return;                          // sudah pernah diproses
    }

    // 2. Ambil langganannya dan KUNCI barisnya.
    const lgn = await trx
      .selectFrom("langganan")
      .selectAll()
      .where("order_id", "=", n.order_id)
      .forUpdate()
      .executeTakeFirst();

    if (!lgn) {
      console.warn(JSON.stringify({ level: "warn", pesan: "order tidak dikenal", order_id: n.order_id }));
      return;                          // tetap 200: mengulanginya tidak akan menolong
    }

    // 3. Jangan turunkan status yang sudah final.
    if (FINAL.has(lgn.status) && n.transaction_status === "pending") {
      return;
    }

    // 4. Terapkan.
    if (pembayaranBerhasil(n)) {
      await aktifkan(trx, lgn);
    } else if (["deny", "cancel", "expire"].includes(n.transaction_status)) {
      await trx.updateTable("langganan")
        .set({ status: "gagal", diperbarui_pada: new Date() })
        .where("id", "=", lgn.id).execute();
    } else if (["refund", "partial_refund", "chargeback"].includes(n.transaction_status)) {
      await cabutAkses(trx, lgn);
    }
  });
}

const FINAL = new Set(["aktif", "gagal", "dibatalkan"]);

.forUpdate() itu penting. Dua notifikasi untuk pesanan yang sama bisa tiba bersamaan dan ditangani dua task ECS berbeda. Tanpa kunci baris, keduanya membaca status "menunggu", keduanya menyimpulkan "belum aktif", dan keduanya memperpanjang langganan โ€” pembeli mendapat dua bulan dari satu pembayaran. forUpdate membuat yang kedua menunggu sampai yang pertama selesai.

Perpanjangan yang benar

async function aktifkan(trx: Trx, lgn: Langganan) {
  const paket = await ambilPaket(lgn.paket, trx);

  // Perpanjang dari tanggal berakhir yang ADA kalau masih berlaku,
  // bukan dari hari ini. Kalau tidak, pelanggan yang memperpanjang
  // lebih awal kehilangan sisa waktunya.
  const m = await trx.selectFrom("member")
    .select("langganan_sampai")
    .where("id", "=", lgn.member_id)
    .executeTakeFirstOrThrow();

  const mulai =
    m.langganan_sampai && m.langganan_sampai > new Date()
      ? m.langganan_sampai
      : new Date();

  const sampai = new Date(mulai);
  sampai.setDate(sampai.getDate() + paket.durasiHari);

  await trx.updateTable("member")
    .set({ tier: paket.tier, langganan_sampai: sampai })
    .where("id", "=", lgn.member_id)
    .execute();

  await trx.updateTable("langganan")
    .set({ status: "aktif", aktif_pada: new Date(), berlaku_sampai: sampai })
    .where("id", "=", lgn.id)
    .execute();
}

Menghitung dari hari ini adalah bug yang langsung terasa sebagai penipuan oleh pelanggan. Bayangkan langganan berakhir 30 September dan pelanggan memperpanjang 20 September. Kalau perpanjangan dihitung dari hari ini, ia kehilangan sepuluh hari yang sudah dibayar. Yang akan terjadi berikutnya adalah tiket dukungan dan pelanggan yang belajar untuk selalu menunggu sampai detik terakhir โ€” yang buruk untuk arus kasmu.

Notifikasi yang tiba tidak berurutan

Yang seharusnya:  pending โ†’ settlement
Yang bisa terjadi: settlement โ†’ pending

Midtrans menyatakan ini terjadi dalam kasus yang sangat jarang. Kalau kodemu memproses apa adanya, status langganan akan turun dari "aktif" ke "menunggu" โ€” dan pelanggan yang sudah membayar tiba-tiba melihat paywall lagi.

Dua pertahanan, dipakai bersamaan: jangan pernah menurunkan status final (langkah 3 di kode di atas), dan kalau ragu, tanya.

GET Status API sebagai wasit

export async function statusSebenarnya(orderId: string) {
  const basis = env.MIDTRANS_PRODUKSI
    ? "https://api.midtrans.com/v2"
    : "https://api.sandbox.midtrans.com/v2";

  const res = await fetch(`${basis}/${encodeURIComponent(orderId)}/status`, {
    headers: { Accept: "application/json", Authorization: otorisasi() },
    signal: AbortSignal.timeout(8000),
  });

  if (!res.ok) throw new Error(`status API: ${res.status}`);
  return res.json();
}

Panggil ini dalam tiga situasi: saat notifikasi tiba tidak berurutan, saat pelanggan melapor sudah membayar tapi belum aktif, dan sebagai penyapu berkala untuk pesanan yang menggantung. Jangan memanggilnya di dalam handler webhook โ€” itu melanggar anggaran 5 detik.

Penyapu berkala

// Dijalankan tiap 15 menit sebagai tugas terjadwal (Fase 10)
export async function sapuPesananMenggantung() {
  const menggantung = await dbBaca
    .selectFrom("langganan")
    .select(["id", "order_id"])
    .where("status", "=", "menunggu")
    .where("dibuat_pada", "<", new Date(Date.now() - 20 * 60 * 1000))
    .where("dibuat_pada", ">", new Date(Date.now() - 48 * 60 * 60 * 1000))
    .limit(100)
    .execute();

  for (const l of menggantung) {
    try {
      const s = await statusSebenarnya(l.order_id);
      await prosesNotifikasi(s);       // idempoten โ€” aman dipanggil ulang
    } catch (e) {
      console.error(JSON.stringify({ level: "error", order_id: l.order_id, e: String(e) }));
    }
  }
}

Penyapu ini yang menyelamatkanmu saat webhook gagal. Skenario nyata: deploy ECS berlangsung 90 detik, dan selama itu beberapa webhook mendapat 502. Midtrans mengirim ulang beberapa kali, tapi kalau gangguannya lebih panjang, ada pembayaran yang tidak pernah tercatat. Penyapu menemukannya 15 menit kemudian tanpa satu pun pelanggan perlu menghubungi dukungan.

Batas 48 jam di bawah mencegah penyapu terus memeriksa pesanan yang memang ditinggalkan pembeli selamanya.

Yang harus bisa kamu jawab

PertanyaanDari mana jawabannya
"Saya sudah bayar tapi belum aktif"Cari order_id di notifikasi_pembayaran
"Kenapa langganan saya diperpanjang dua kali?"Jurnal notifikasi menunjukkan berapa yang diterima
"Berapa pendapatan bulan ini?"Jumlahkan langganan berstatus aktif, bukan notifikasi
"Notifikasi mana yang gagal diproses?"Log terstruktur dengan order_id

Latihan: kirim notifikasi webhook yang sama persis tiga kali berturut-turut dan buktikan langganan_sampai hanya maju sekali. Lalu kirim settlement diikuti pending dan pastikan statusnya tetap aktif. Terakhir, jalankan dua notifikasi bersamaan dengan curl paralel dan verifikasi forUpdate mencegah perpanjangan ganda.

Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.