Snap — alur checkout langganan
Snap menangani antarmuka pembayaran; tugasmu adalah membuat pesanan, menyimpan niatnya, dan menunggu webhook. Bukan menunggu pembeli kembali.
Intisari
- Server key hanya di server, selamanya. Client key boleh ke browser.
order_idharus unik selamanya di merchant-mu — Midtrans menolak yang berulang.gross_amountditentukan server dari harga paket di database. Jangan pernah dari input klien.- Halaman sukses bukan bukti pembayaran. Yang mengaktifkan langganan adalah webhook.
- Snap token kedaluwarsa; buat baru tiap kali pembeli memulai, jangan simpan lalu dipakai ulang.
Bentuk alurnya
1. Pembeli klik "Berlangganan"
2. Server: buat baris langganan status "menunggu", buat order_id unik
3. Server: minta Snap token ke Midtrans (server key)
4. Browser: buka Snap dengan token itu
5. Pembeli bayar
6. Browser: Midtrans mengembalikan pembeli ke halaman "terima kasih" ← BUKAN bukti
7. Server: Midtrans mengirim webhook ← INI buktinya
8. Server: verifikasi tanda tangan, aktifkan langganan
Langkah 6 dan 7 bisa datang dalam urutan mana pun, dan langkah 6 bisa tidak pernah terjadi. Pembeli bisa menutup tab setelah membayar. Bisa kehabisan baterai. Bisa membayar lewat transfer bank tiga jam kemudian. Sistem yang mengaktifkan langganan di halaman "terima kasih" akan salah di ketiga kasus itu — dan lebih buruk lagi, siapa pun bisa membuka URL halaman itu langsung tanpa membayar apa pun.
Membuat pesanan
// src/lib/midtrans.ts
import { env } from "./env";
const BASIS = env.MIDTRANS_PRODUKSI
? "https://app.midtrans.com/snap/v1"
: "https://app.sandbox.midtrans.com/snap/v1";
function otorisasi() {
// Basic auth: server key sebagai username, sandi kosong
return "Basic " + Buffer.from(`${env.MIDTRANS_SERVER_KEY}:`).toString("base64");
}
export async function buatSnapToken(p: {
orderId: string;
jumlah: number;
paket: { kode: string; nama: string };
pembeli: { id: number; nama: string; email: string };
}) {
const res = await fetch(`${BASIS}/transactions`, {
method: "POST",
signal: AbortSignal.timeout(10_000),
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: otorisasi(),
},
body: JSON.stringify({
transaction_details: {
order_id: p.orderId,
gross_amount: p.jumlah, // integer rupiah, ditentukan server
},
item_details: [{
id: p.paket.kode,
name: p.paket.nama.slice(0, 50), // Midtrans memotong di 50 karakter
price: p.jumlah,
quantity: 1,
}],
customer_details: {
first_name: p.pembeli.nama.slice(0, 20),
email: p.pembeli.email,
},
callbacks: {
finish: `${env.SITE_URL}/langganan/selesai?order=${p.orderId}`,
},
expiry: { unit: "hours", duration: 24 },
}),
});
if (!res.ok) {
const teks = await res.text();
throw new Error(`Midtrans menolak: ${res.status} ${teks}`);
}
return (await res.json()) as { token: string; redirect_url: string };
}
Action yang memulainya
export const mulaiLangganan = defineAction({
input: z.object({ paket: z.enum(["bulanan", "tahunan"]) }),
handler: async ({ paket }, ctx) => {
const member = ctx.locals.member;
if (!member) throw new ActionError({ code: "UNAUTHORIZED" });
// Harga dari DATABASE, bukan dari klien.
const p = await ambilPaket(paket);
if (!p) throw new ActionError({ code: "NOT_FOUND" });
// order_id unik selamanya, dan bisa dibaca manusia saat menelusuri masalah.
const orderId = `LGN-${member.id}-${Date.now()}-${randomBytes(3).toString("hex")}`;
const idLangganan = await dbTulis.transaction().execute(async (trx) => {
const hasil = await trx.insertInto("langganan").values({
member_id: member.id,
paket: p.kode,
jumlah: p.harga,
order_id: orderId,
status: "menunggu",
dibuat_pada: new Date(),
}).executeTakeFirstOrThrow();
return Number(hasil.insertId);
});
// Panggilan jaringan DI LUAR transaksi (Fase 2).
const snap = await buatSnapToken({
orderId,
jumlah: p.harga,
paket: p,
pembeli: member,
});
await dbTulis.updateTable("langganan")
.set({ snap_token: snap.token })
.where("id", "=", idLangganan)
.execute();
return { token: snap.token, redirectUrl: snap.redirect_url };
},
});
Harga dari database adalah aturan yang tidak bisa ditawar. Kalau gross_amount
berasal dari input klien — bahkan lewat field tersembunyi — siapa pun bisa berlangganan setahun seharga
seribu rupiah. Ini kerentanan yang masih rutin ditemukan di toko daring Indonesia, dan konsekuensinya
langsung ke pendapatan.
Membuka Snap di browser
// src/components/TombolLangganan.vue
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { actions } from "astro:actions";
const props = defineProps<{ clientKey: string; produksi: boolean }>();
const memproses = ref(false);
const galat = ref<string | null>(null);
onMounted(() => {
const s = document.createElement("script");
s.src = props.produksi
? "https://app.midtrans.com/snap/snap.js"
: "https://app.sandbox.midtrans.com/snap/snap.js";
s.dataset.clientKey = props.clientKey;
document.head.appendChild(s);
});
async function bayar(paket: "bulanan" | "tahunan") {
memproses.value = true;
galat.value = null;
const { data, error } = await actions.mulaiLangganan({ paket });
if (error || !data) {
galat.value = "Gagal memulai pembayaran. Coba lagi.";
memproses.value = false;
return;
}
(window as any).snap.pay(data.token, {
onSuccess: () => { location.href = "/langganan/selesai"; },
onPending: () => { location.href = "/langganan/menunggu"; },
onError: () => { galat.value = "Pembayaran gagal."; memproses.value = false; },
onClose: () => { memproses.value = false; },
});
}
</script>
Perhatikan bahwa onSuccess hanya mengarahkan halaman — ia tidak mengaktifkan apa
pun. Callback ini berjalan di browser pembeli dan bisa dipalsukan dari konsol dalam sepuluh detik.
Ia berguna untuk pengalaman pengguna, tidak untuk kebenaran.
Halaman "selesai" yang jujur
---
export const prerender = false;
const orderId = Astro.url.searchParams.get("order");
const langganan = orderId ? await ambilLanggananByOrder(orderId, Astro.locals.member?.id) : null;
Astro.response.headers.set("Cache-Control", "private, no-store");
---
{langganan?.status === "aktif" ? (
<>
<h1>Terima kasih — langgananmu aktif</h1>
<a href="/berita">Mulai membaca</a>
</>
) : (
<>
<h1>Pembayaran sedang diproses</h1>
<p>Beberapa metode pembayaran butuh waktu sampai beberapa menit. Kami akan
mengirim email begitu langgananmu aktif — tidak perlu membayar lagi.</p>
<p>Nomor pesanan: <code>{orderId}</code></p>
</>
)}
Halaman ini membaca status dari database, yang hanya bisa diubah oleh webhook. Kalau webhook-nya belum datang, ia mengatakan yang sebenarnya alih-alih berbohong. Menampilkan nomor pesanan juga mengurangi beban dukungan: pembeli yang menghubungi punya sesuatu yang bisa dicari.
Sandbox lebih dulu
| Hal | Sandbox | Produksi |
|---|---|---|
| Basis URL Snap | app.sandbox.midtrans.com | app.midtrans.com |
| Server key | SB-Mid-server-… | Mid-server-… |
| Kartu uji | Tersedia di dokumentasi | — |
| Webhook | Butuh URL publik (pakai terowongan saat lokal) | URL produksi |
Uji seluruh alur di sandbox, termasuk kasus gagal, kasus kedaluwarsa, dan kasus webhook yang datang dua kali. Alur pembayaran adalah bagian yang paling mahal kalau salah dan paling mudah diuji lebih dulu.
Latihan: bangun alur lengkap di sandbox sampai halaman "selesai". Lalu buktikan bahwa halaman
itu tidak bisa dibohongi: buka /langganan/selesai?order=LGN-1-999-abc untuk pesanan yang
tidak pernah dibayar, dan pastikan ia tidak menampilkan "aktif". Coba juga membuka nomor pesanan milik
member lain — harus tidak ditemukan.
Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.