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

Rekonsiliasi, refund & pertanyaan keuangan

Sistem pembayaran yang benar bukan yang tidak pernah salah, melainkan yang bisa membuktikan bahwa ia tidak salah — setiap hari, tanpa membuka dashboard.

Intisari

  • Rekonsiliasi harian membandingkan transaksi di databasemu dengan transaksi di Midtrans.
  • Selisih itu normal dan sementara untuk transaksi hari ini; yang tidak normal adalah selisih pada hari kemarin.
  • Refund harus mencabut akses, dan pencabutannya harus tercatat sebagai peristiwa terpisah.
  • Chargeback mahal: biaya administrasi ditambah nilai transaksi. Cegah dengan deskripsi tagihan yang jelas.
  • Simpan semua notifikasi mentah selamanya — itu satu-satunya bukti saat ada sengketa.

Kenapa rekonsiliasi bukan opsional

Ada empat cara databasemu bisa menyimpang dari kenyataan, dan semuanya sudah kita temui:

PenyebabGejala
Webhook tidak pernah sampaiPelanggan sudah bayar, statusnya masih "menunggu"
Webhook diproses tapi gagal di tengahNotifikasi tercatat, langganan tidak aktif
Refund dilakukan lewat dashboard MidtransAkses masih aktif padahal uang sudah dikembalikan
ChargebackSama, ditambah biaya

Baris ketiga yang paling sering. Tim dukungan menyelesaikan keluhan lewat dashboard Midtrans karena itu paling cepat — dan sistemmu tidak pernah tahu.

Rekonsiliasi harian

// Tugas terjadwal, tiap pagi untuk transaksi KEMARIN
export async function rekonsiliasi(tanggal: string) {
  const lokal = await dbBaca
    .selectFrom("langganan")
    .select(["order_id", "jumlah", "status"])
    .where("aktif_pada", ">=", awalHari(tanggal))
    .where("aktif_pada", "<", awalHari(besokDari(tanggal)))
    .execute();

  const temuan: Temuan[] = [];

  for (const l of lokal) {
    const jauh = await statusSebenarnya(l.order_id);

    if (jauh.transaction_status === "refund" && l.status === "aktif") {
      temuan.push({ jenis: "refund-tidak-tercatat", orderId: l.order_id });
    }
    if (Number(jauh.gross_amount) !== l.jumlah) {
      temuan.push({ jenis: "jumlah-berbeda", orderId: l.order_id });
    }
  }

  if (temuan.length > 0) {
    await kirimLaporan(temuan);      // ke Slack, email, atau apa pun yang dibaca orang
  }

  return temuan;
}

Rekonsiliasi untuk kemarin, bukan hari ini. Transaksi hari ini masih bergerak: ada yang menunggu transfer bank, ada notifikasi yang belum tiba. Selisih di sana normal dan akan menghasilkan peringatan palsu setiap hari — dan peringatan yang selalu menyala akan diabaikan orang dalam dua minggu. Laporkan hanya yang seharusnya sudah selesai.

Refund

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

  const res = await fetch(`${basis}/${encodeURIComponent(orderId)}/refund`, {
    method: "POST",
    headers: { "Content-Type": "application/json", Authorization: otorisasi() },
    body: JSON.stringify({ reason: alasan }),
    signal: AbortSignal.timeout(15_000),
  });

  const hasil = await res.json();

  if (hasil.status_code === "200") {
    await dbTulis.transaction().execute(async (trx) => {
      await trx.updateTable("langganan")
        .set({ status: "dikembalikan", dikembalikan_pada: new Date() })
        .where("order_id", "=", orderId)
        .execute();

      await cabutAksesByOrder(trx, orderId);

      // Peristiwa terpisah, bukan cuma perubahan kolom.
      await trx.insertInto("jurnal_keuangan").values({
        order_id: orderId,
        jenis: "refund",
        alasan,
        oleh: olehId,
        pada: new Date(),
      }).execute();
    });
  }

  return hasil;
}

jurnal_keuangan terpisah dari kolom status karena pertanyaan yang akan datang bukan "apa status pesanan ini" melainkan "kenapa pesanan ini dikembalikan dan siapa yang memutuskannya". Kolom status hanya menyimpan keadaan terakhir; jurnal menyimpan riwayatnya.

Chargeback

BiayaBesarnya
Nilai transaksiHilang
Biaya administrasi bankSering lebih besar dari nilai langganan bulanan
Rasio chargeback tinggiBisa berujung penghentian layanan gateway
PencegahanCara
Nama di tagihan kartu jelasPakai nama portal yang dikenal pembaca, bukan nama PT yang asing
Email tiap kali menagihTerutama untuk perpanjangan otomatis — orang lupa
Pembatalan mudahOrang yang tidak bisa membatalkan akan menghubungi banknya
Pengingat sebelum perpanjangan3 hari sebelumnya untuk paket tahunan
Refund tanpa berdebatUntuk nominal kecil, hampir selalu lebih murah daripada chargeback

Baris pertama adalah penyebab chargeback paling umum dan paling mudah diperbaiki. Pelanggan melihat "PT XYZ MEDIA" di tagihan kartunya, tidak mengenalinya, dan melaporkannya sebagai transaksi tidak sah — padahal ia memang berlangganan portalmu. Pastikan deskriptornya berisi nama portal yang mereka kenal.

Laporan yang akan diminta

-- Pendapatan berulang bulanan (MRR), disetarakan
SELECT
  DATE_FORMAT(aktif_pada, '%Y-%m') AS bulan,
  SUM(CASE paket WHEN 'bulanan' THEN jumlah
                 WHEN 'tahunan' THEN jumlah / 12 END) AS mrr,
  COUNT(*) AS transaksi
FROM langganan
WHERE status = 'aktif'
GROUP BY bulan
ORDER BY bulan DESC;

-- Churn: berapa yang tidak memperpanjang bulan lalu
SELECT COUNT(*) AS berhenti
FROM member
WHERE langganan_sampai BETWEEN
      DATE_SUB(CURDATE(), INTERVAL 1 MONTH) AND CURDATE()
  AND perpanjang_otomatis = 0;

-- Tingkat keberhasilan penagihan otomatis
SELECT
  DATE(dibuat_pada) AS hari,
  SUM(status = 'aktif') AS berhasil,
  SUM(status = 'gagal') AS gagal
FROM langganan
WHERE order_id LIKE 'RNW-%'
  AND dibuat_pada >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
GROUP BY hari
ORDER BY hari DESC;

Kueri terakhir yang paling berguna untuk operasional. Tingkat keberhasilan penagihan yang turun mendadak biasanya berarti sesuatu berubah di sisi gateway atau di kode penjadwalmu — dan kamu ingin tahu dalam hitungan hari, bukan saat laporan keuangan bulanan keluar.

Simpan semuanya

DataSimpan berapa lama
Notifikasi webhook mentahSelamanya — ini bukti saat ada sengketa
Jurnal keuanganSelamanya
Baris langgananSelamanya
Token kartu yang sudah tidak dipakaiHapus setelah 90 hari tidak aktif
Log aplikasi30–90 hari

Latihan: tulis tugas rekonsiliasi yang membandingkan transaksi kemarin di databasemu dengan GET Status API. Jalankan pada data sandbox-mu. Lalu buat satu selisih dengan sengaja — refund lewat dashboard Midtrans tanpa memberi tahu aplikasimu — dan pastikan rekonsiliasi menemukannya keesokan harinya.

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