Lewati ke konten utama

Alur Booking & Pembayaran

Alur paling kritis di sistem, karena menyangkut uang pasien. Aturan tertingginya satu kalimat:

Aturan emas

Setiap rupiah yang masuk WAJIB menghasilkan janji temu. Kalau tidak bisa otomatis, harus meninggalkan jejak yang bisa ditemukan ops. Tidak boleh ada uang masuk yang hilang tanpa jejak.


Konsep: draft ≠ janji temu

Ini pemisahan sengaja: kalau setiap permintaan booking langsung mengunci jadwal, kuota dokter habis oleh booking yang tidak pernah dibayar.


Alur lengkap (jalur bahagia)


Validasi berlapis

Parsing jam yang ketat

Jam diisi LLM dari teks bebas seperti "jam 9", jadi 9:00 diterima lalu di-pad jadi 09:00. Tapi komponen di luar rentang tidak boleh lolos: Carbon menggulung 2030-13-45 diam-diam jadi 2031-02-14. Karena itu hasil parsing dibandingkan ulang dengan input — jadwal karangan tidak akan pernah ditagihkan ke pasien.

Urutan yang disengaja: invoice dulu, baru batalkan draft lama

Kalau urutannya dibalik dan Duitku gagal, pasien akan kehilangan link lama tanpa mendapat pengganti.

payment_url disimpan, bukan sekadar dikembalikan

Balasan AI yang membawa link itu best-effort: LLM bisa error setelah tool berjalan, batas iterasi loop tool bisa tercapai (mengembalikan teks kosong), atau gelembung berisi link bisa gagal terkirim. Tanpa salinan di database, invoice Duitku hidup sementara pasien tidak memegang apa pun — lalu menerima pemberitahuan kedaluwarsa untuk link yang tak pernah ia terima.

Apa yang dibuat di database

merchantOrderId yang dikirim ke Duitku berbentuk RESV-{payment_id}. Format ini yang nanti dibaca ulang saat callback.


Tahap 2 — Menerima callback Duitku

Endpoint: POST /webhook/duitku (dikecualikan dari CSRF)

Selalu balas 200

Controller selalu menjawab 200, bahkan saat signature tidak valid. Kalau menjawab non-200, Duitku akan mengulang callback tanpa henti untuk pembayaran yang sebenarnya sudah selesai.

Penjagaan idempoten: callbackMayWrite()

Duitku mengulang callback sampai menerima 200, jadi callback ganda adalah hal normal. Tapi aturannya bukan sekadar "sudah terminal → berhenti":

Kenapa Expired bisa dibuka kembali: Expired hanyalah tebakan lokal — sapuan 5 menit menandainya berdasarkan jam kami sendiri, sementara link Duitku masih benar-benar bisa dibayar. Kalau callback 00 mendarat setelah itu dan kami membuangnya, hasilnya: uang masuk, jadwal tidak pernah ada, dan tidak ada apa pun untuk dicari ops.

Kenapa Failed juga bisa: Failed hanya lahir dari dua sebab — (a) Duitku pernah mengirim resultCode non-00, atau (b) pembuatan invoice gagal. Untuk (b), Duitku tidak punya order itu sama sekali sehingga mustahil mengirim callback. Jadi callback 00 yang mendarat pasti berasal dari (a): uangnya sungguh tertagih. Duitku yang menang.

Kenapa Refunded tidak: refund adalah keputusan sadar yang terjadi setelah lunas. Callback lama yang datang belakangan tidak boleh membalikkannya.

Kenapa event dipancarkan setelah commit


Tahap 3 — Listener mengonfirmasi booking

ConfirmBookingOnPayment berjalan sinkron (bukan queued). Itu pilihan sadar: selama sinkron, HTTP 200 dari webhook berarti janji temunya benar-benar sudah ada.

Di dalam confirmPaid()

Tiga detail teknis yang tidak boleh diubah
  1. lockForUpdate() — Duitku bisa mengirim dua callback bersamaan. Baris draft dikunci sejak dibaca supaya pemeriksaan status terminal dan penulisannya tidak terbelah → mencegah dua Appointment lahir dari satu draft.
  2. Terminal hanya Paid dan FailedCancelled dan Expired sengaja BUKAN terminal, karena link lama masih bisa dibayar. Konsekuensi yang diterima: membayar dua link menghasilkan dua janji temu (dirapikan manual ops) — jauh lebih murah daripada pasien membayar tanpa jadwal.
  3. Penandaan Failed di luar transaksi, lewat query builder — kalau ikut tergulung, draft diam-diam kembali Pending dan sapuan expire memungutnya seolah tidak pernah dibayar. Query builder dipakai karena instance model yang ditinggalkan kotor akan menyertakan appointment_id yatim yang ditolak FK.

Empat template pesan pembayaran

Semua disusun server-side di kode, bukan oleh AI, supaya nominal/dokter/tanggal mustahil salah sebut. Sumbernya BookingConfirmationNotifier.

KejadianMethodIsi pesan
Lunas + janji temu jadinotifyPaidBooking()Nominal, dokter, poliklinik, jadwal (tanggal panjang Bahasa Indonesia), ID janji temu, imbauan datang 15 menit lebih awal
Lunas tanpa draft (manual dari Chat Rooms)notifyPaidReservation()Nominal saja — tidak ada jadwal yang bisa disebut
Lunas tapi janji temu gagalnotifyManualFollowUp()Nominal + "jadwal dikonfirmasi manual". Sengaja tidak menyebut tanggal/jam karena jadwalnya belum ada
Link kedaluwarsanotifyExpired()"Link kedaluwarsa, jadwal belum kami kunci. Balas kalau ingin jadwal ulang"

Nominal diambil dari Payment, bukan dari draft

Draft menyimpan tarif yang berlaku saat link dibuat; Payment adalah baris yang benar-benar ditagihkan dan dilunasi Duitku. Kalau keduanya berbeda, yang jujur bagi pasien adalah jumlah yang betul-betul ia bayar.

Akun WhatsApp pengirim

Penjagaan berlapis

BookingConfirmationNotifier menjaga jaringan dan pencatatan (keduanya ditelan + dilog). Yang tidak dijaga adalah penyusunan teksnya — ia membaca relasi $booking->doctor dan memanggil CarbonImmutable::parse() sebelum sampai ke pengiriman, jadi data rusak masih bisa melempar. Karena itu setiap pemanggil membungkusnya dalam penjagaan sendiri, dengan pesan log yang membedakan "konfirmasi gagal" dari "notifikasi gagal".


Sapuan rekonsiliasi (jaring pengaman)

Perintah: bookings:reconcile · Jadwal: setiap 5 menit, withoutOverlapping(), onOneServer()

Sengaja TANPA gerbang config

Berbeda dari simrs:sync dan contacts:sync yang bisa dimatikan lewat .env, sapuan ini tidak punya saklar. Alasannya: ia adalah jaring pengaman untuk uang yang SUDAH diterima. Flag yang bisa mematikannya diam-diam justru menciptakan ulang kegagalan yang ia perbaiki — dan tak akan ada yang sadar sampai ada pasien menagih jadwal yang tidak pernah ada. Untuk menghentikannya di suatu lingkungan, cukup tidak menjalankan cron schedule:run.

Urutan 1 → 2 itu wajib

Draft yang Payment-nya sudah Success tapi belum terkonfirmasi harus dibukukan jadi janji temu, bukan dipertimbangkan untuk kedaluwarsa. Penjagaannya sengaja dua lapis (urutan dan expireDue() yang melewati Payment Success) — menghapus salah satunya berarti satu bug kecil di sisi lain langsung berakibat pasien yang sudah membayar menerima pesan "link kedaluwarsa".

Celah yang ditutup sapuan ini

Aturan "saudara yang sudah lunas" (paidSiblingFor)

Masalahnya: pasien sering minta link kedua (mis. ganti tanggal), lalu membayar link pertama. Tanpa aturan ini:

  • Pasien bertanya "sudah saya bayar" → dijawab dengan draft pengganti yang masih Pending → "pembayaran belum kami terima" kepada orang yang sudah punya janji temu terkonfirmasi → risiko bayar dua kali.
  • Draft pengganti kedaluwarsa → pasien yang sudah lunas menerima "link kedaluwarsa, jadwal belum kami kunci" → kebohongan telanjang.

Definisinya: draft Paid milik nomor yang sama, yang expires_at-nya >= created_at draft yang sedang dinilai.

Kenapa penandanya ini
❌ Bukan kesamaan dokter/jadwalAlasan paling umum minta link kedua justru mengubah tanggal/jam
❌ Bukan kedekatan waktu ke now()Pasien sering bertanya jauh setelah kedua jendela tertutup
✅ Jendela pembayaran yang tumpang tindihcreate() menerbitkan pengganti hanya dengan membatalkan draft Pending, jadi setiap link yang jendelanya masih terbuka saat draft baru lahir memang link yang sedang digantikan

Konsekuensi yang diterima: pasien yang membayar sebuah link lalu memesan janji temu kedua dalam jendela expiry_minutes yang sama tidak menerima pemberitahuan kedaluwarsa untuk booking kedua itu. Yang hilang cuma satu pengingat — jauh lebih murah daripada memberi tahu pasien yang sudah lunas bahwa jadwalnya belum terkunci.


Tool check_payment_status

Dipakai saat pasien bilang "sudah saya bayar" atau kehilangan link.

Kunci payment_url dihilangkan kalau kosong

Draft lama (sebelum kolom itu ada) tidak punya nilainya. Kuncinya dihapus sama sekali, bukan diisi null atau string kosong — karena playbook menyuruh model menyalin payment_url persis apa adanya, dan kunci kosong adalah undangan mengarang URL. Kalimat penutupnya pun berbeda: kalau link masih ada, model tidak menawarkan booking baru.

ok:false hanya dikembalikan saat memang tidak ada booking — status lain (termasuk gagal dan kedaluwarsa) adalah pembacaan berhasil atas keadaan nyata, karena playbook menyuruh model minta bantuan agen manusia setiap kali ok:false.


Konfigurasi Duitku

EnvFungsiDefault
DUITKU_MERCHANT_CODEKode merchant
DUITKU_API_KEYKunci API (untuk signature)
DUITKU_ENVsandbox | productionsandbox
DUITKU_RESERVATION_FEETarif reservasi (rupiah)25000
DUITKU_CALLBACK_URLURL callback yang didaftarkanhttps://crmrsi.balascepat.com/webhook/duitku
DUITKU_RETURN_URLHalaman setelah bayarhttps://crmrsi.balascepat.com/booking/thanks
DUITKU_EXPIRY_MINUTESUmur link pembayaran (menit)60

Base URL: https://api-sandbox.duitku.com (sandbox) atau https://api-prod.duitku.com (production).

Signature saat membuat invoice: SHA-256(merchantCode + timestampMs + apiKey), dikirim di header x-duitku-signature.

Signature saat verifikasi callback: MD5(merchantCode + amount + merchantOrderId + apiKey).

Pemetaan metode pembayaran

Kode DuitkuTersimpan sebagai
SP, QR, NQ, QRISQRIS
OV, SA, DA, LA, SL, JP, LFE-Wallet
VCCredit-Card
kode lain (tidak kosong)VA
kosongnull

Semua titik gagal & penanganannya

Titik gagalYang terjadiJejak
Duitku menolak invoicePaymentFailed, AI bilang "sistem pembayaran bermasalah"Log::warning "Gagal membuat invoice booking"
Signature callback salah200 IGNORED, tidak ada yang berubahLog::warning "invalid signature"
merchantOrderId bukan RESV-{id}Callback dilewatiLog::warning "tidak berbentuk RESV-{id}"
Payment sudah dihapus (pasien terhapus)Callback dilewatiLog::warning "Payment tidak ditemukan" — satu-satunya jalan ops mengetahuinya
Callback lunas mendahului status lokalPayment dibuka kembali jadi SuccessLog::warning "Callback lunas mendahului status lokal"
Callback lunas ke Payment RefundedDitolakLog::warning "sudah Refunded"
Retry callback ke Payment SuccessNo-opLog::info "diulang"
Appointment gagal dibuatDraft → Failed, pasien dikabari tindak lanjut manualLog::error "Gagal membuat Appointment dari booking berbayar"
Listener melempar seluruhnyaDitelan; sapuan 5 menit memungutnyaLog::error "Gagal mengonfirmasi booking dari pembayaran lunas"
Notifikasi gagal setelah booking jadiBooking tetap validLog::error "pasien punya jadwal tanpa tahu apa pun"
Draft diselamatkan sapuan tapi notifikasi gagalBooking tetap valid, tidak akan disapu lagiLog::error "Booking diselamatkan sapuan tapi notifikasinya gagal"