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:
| Penyebab | Gejala |
|---|---|
| Webhook tidak pernah sampai | Pelanggan sudah bayar, statusnya masih "menunggu" |
| Webhook diproses tapi gagal di tengah | Notifikasi tercatat, langganan tidak aktif |
| Refund dilakukan lewat dashboard Midtrans | Akses masih aktif padahal uang sudah dikembalikan |
| Chargeback | Sama, 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
| Biaya | Besarnya |
|---|---|
| Nilai transaksi | Hilang |
| Biaya administrasi bank | Sering lebih besar dari nilai langganan bulanan |
| Rasio chargeback tinggi | Bisa berujung penghentian layanan gateway |
| Pencegahan | Cara |
|---|---|
| Nama di tagihan kartu jelas | Pakai nama portal yang dikenal pembaca, bukan nama PT yang asing |
| Email tiap kali menagih | Terutama untuk perpanjangan otomatis — orang lupa |
| Pembatalan mudah | Orang yang tidak bisa membatalkan akan menghubungi banknya |
| Pengingat sebelum perpanjangan | 3 hari sebelumnya untuk paket tahunan |
| Refund tanpa berdebat | Untuk 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
| Data | Simpan berapa lama |
|---|---|
| Notifikasi webhook mentah | Selamanya — ini bukti saat ada sengketa |
| Jurnal keuangan | Selamanya |
| Baris langganan | Selamanya |
| Token kartu yang sudah tidak dipakai | Hapus setelah 90 hari tidak aktif |
| Log aplikasi | 30–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.