D
P
0

Web Development

Refund Gagal 422 “amount is required” Padahal Docs Bilang Opsional — Selalu Kirim `amount.value`

28 Juni 2026·4 menit baca
Refund Gagal 422 “amount is required” Padahal Docs Bilang Opsional — Selalu Kirim `amount.value`

Saya lagi nyambungin API refund dari sebuah payment provider ke platform booking yang saya bangun. Skenarionya sederhana: admin pencet tombol "Refund", lalu pembayaran sebuah booking dikembalikan penuh. Dokumentasinya bilang field amount itu opsional — kalau dikosongkan, katanya provider otomatis melakukan refund penuh. Jadi saya percaya saja, dan saya kirim request tanpa amount.

Hasilnya: gagal. Bukan dengan pesan yang ramah, tapi dengan HTTP 422 dan body seperti ini:

{
  "status": 422,
  "title": "Unprocessable Entity",
  "detail": "amount is required for payments"
}

Kode yang memicunya kira-kira begini. Saya kirim amount sebagai null untuk menandakan "refund penuh", persis seperti yang saya tangkap dari docs:

async function refundPayment(paymentId, amount = null) {
  const res = await fetch(`https://api.provider.test/v2/payments/${paymentId}/refunds`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      "Content-Type": "application/json",
    },
    // amount sengaja null = "full refund" menurut docs
    body: JSON.stringify({ amount }),
  });
 
  if (!res.ok) {
    throw new Error(`Refund failed: ${res.status}`);
  }
  return res.json();
}

Setiap kali tombolnya dipencet, yang muncul cuma 422. Dan ironisnya, field yang katanya "opsional" itulah yang ditolak.

Kenapa ini terjadi

Saya berhenti percaya pada prosa di halaman dokumentasi dan mulai membaca body respons 4xx yang sebenarnya. Pesannya gamblang: amount is required for payments. Jadi untuk tipe transaksi yang saya pakai ini, amount bukan opsional sama sekali — dia efektif wajib. Kalimat "opsional untuk refund penuh" di docs itu setengah benar di kasus lain, tapi tidak berlaku di jalur yang saya lewati.

Ini pola yang berulang dengan API pembayaran: docs sering bilang sebuah field "optional", padahal di balik layar validator mereka menolaknya kalau kosong. Yang jadi sumber kebenaran bukan paragraf di dokumentasi, melainkan body respons 422 yang dikirim balik oleh server. Begitu saya baca string itu apa adanya, akar masalahnya langsung jelas: saya harus mengirim objek amount yang konkret, lengkap dengan value dan currency-nya, bukan null.

Perbaikannya

Solusinya: selalu kirim amount eksplisit sebagai objek { value, currency }. Untuk mempertahankan konsep "refund penuh", saya bikin wrapper-nya pintar — kalau pemanggil mengoper null, wrapper otomatis memakai total booking/pembayaran penuh sebagai default. Jadi dari sisi pemanggil, "refund penuh" tetap bisa dipanggil dengan ringkas, tapi yang benar-benar dikirim ke provider selalu amount.value yang nyata.

async function refundPayment(paymentId, { fullTotal, currency }, amount = null) {
  // Provider bilang amount "opsional", tapi 422-nya bilang lain.
  // Kalau caller minta refund penuh (null), kirim total penuh secara eksplisit.
  const refundAmount = amount ?? {
    value: fullTotal,   // mis. "49.00" — string desimal, sesuai format provider
    currency,           // mis. "EUR"
  };
 
  const res = await fetch(`https://api.provider.test/v2/payments/${paymentId}/refunds`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ amount: refundAmount }),
  });
 
  if (!res.ok) {
    const body = await res.text();
    throw new Error(`Refund failed: ${res.status} ${body}`);
  }
  return res.json();
}

Begitu amount.value selalu terisi, 422-nya hilang dan refund penuh jalan mulus.

Tapi ada satu masalah lagi yang muncul terpisah. Suatu kali panggilan refund berjalan lambat, dan UI-nya ngegantung — tombolnya stuck di spinner, admin tidak tahu apakah refund-nya berhasil atau tidak. Saya tidak mau pernah membiarkan jaringan yang lambat menyandera tampilan. Jadi saya bungkus request-nya dalam Promise.race dengan timeout, lalu setelah itu saya rekonsiliasi state booking langsung dari server:

function withTimeout(promise, ms) {
  const timeout = new Promise((_, reject) =>
    setTimeout(() => reject(new Error("timeout")), ms)
  );
  return Promise.race([promise, timeout]);
}
 
async function handleRefundClick(paymentId, booking) {
  try {
    await withTimeout(refundPayment(paymentId, booking, null), 8000);
  } catch (err) {
    // Lambat atau timeout bukan berarti gagal — jangan biarkan spinner stuck.
  } finally {
    // Sumber kebenaran: tanya server status booking yang sebenarnya.
    await reconcileBookingState(booking.id);
    setSpinner(false);
  }
}

Sekarang, lambat atau cepat, UI tidak pernah terjebak. Setelah timeout, saya tanya ulang ke server apa status booking yang sebenarnya, lalu UI mengikuti kebenaran itu — bukan menebak dari respons yang mungkin tidak pernah datang tepat waktu.

Pelajaran

Dua hal yang saya bawa pulang. Pertama, docs API pembayaran kadang bohong soal field yang "opsional". Jangan percaya prosa — baca body respons 4xx yang sebenarnya, dan kirim field itu secara eksplisit kalau servernya menuntutnya. String amount is required for payments itu lebih jujur daripada satu paragraf dokumentasi mana pun.

Kedua, perlakukan setiap panggilan jaringan ke payment provider sebagai sesuatu yang bisa gagal. Jangan pernah berasumsi dia balas dengan cepat. Bungkus dengan timeout, dan setelahnya rekonsiliasi state dari server sebagai sumber kebenaran. Dengan begitu, jaringan yang lambat hanya jadi sedikit lebih lambat — bukan UI yang stuck selamanya dan admin yang menebak-nebak apakah uang client sudah kembali atau belum.