Alur Booking & Pembayaran
Alur paling kritis di sistem, karena menyangkut uang pasien. Aturan tertingginya satu kalimat:
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)
Tahap 1 — Menerbitkan link pembayaran
Validasi berlapis
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)
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()
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.- Terminal hanya
PaiddanFailed—CancelleddanExpiredsengaja 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. - Penandaan
Faileddi luar transaksi, lewat query builder — kalau ikut tergulung, draft diam-diam kembaliPendingdan sapuan expire memungutnya seolah tidak pernah dibayar. Query builder dipakai karena instance model yang ditinggalkan kotor akan menyertakanappointment_idyatim 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.
| Kejadian | Method | Isi pesan |
|---|---|---|
| Lunas + janji temu jadi | notifyPaidBooking() | 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 gagal | notifyManualFollowUp() | Nominal + "jadwal dikonfirmasi manual". Sengaja tidak menyebut tanggal/jam karena jadwalnya belum ada |
| Link kedaluwarsa | notifyExpired() | "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()
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/jadwal | Alasan 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 tindih | create() 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.
payment_url dihilangkan kalau kosongDraft 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
| Env | Fungsi | Default |
|---|---|---|
DUITKU_MERCHANT_CODE | Kode merchant | — |
DUITKU_API_KEY | Kunci API (untuk signature) | — |
DUITKU_ENV | sandbox | production | sandbox |
DUITKU_RESERVATION_FEE | Tarif reservasi (rupiah) | 25000 |
DUITKU_CALLBACK_URL | URL callback yang didaftarkan | https://crmrsi.balascepat.com/webhook/duitku |
DUITKU_RETURN_URL | Halaman setelah bayar | https://crmrsi.balascepat.com/booking/thanks |
DUITKU_EXPIRY_MINUTES | Umur 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 Duitku | Tersimpan sebagai |
|---|---|
SP, QR, NQ, QRIS | QRIS |
OV, SA, DA, LA, SL, JP, LF | E-Wallet |
VC | Credit-Card |
| kode lain (tidak kosong) | VA |
| kosong | null |
Semua titik gagal & penanganannya
| Titik gagal | Yang terjadi | Jejak |
|---|---|---|
| Duitku menolak invoice | Payment → Failed, AI bilang "sistem pembayaran bermasalah" | Log::warning "Gagal membuat invoice booking" |
| Signature callback salah | 200 IGNORED, tidak ada yang berubah | Log::warning "invalid signature" |
merchantOrderId bukan RESV-{id} | Callback dilewati | Log::warning "tidak berbentuk RESV-{id}" |
| Payment sudah dihapus (pasien terhapus) | Callback dilewati | Log::warning "Payment tidak ditemukan" — satu-satunya jalan ops mengetahuinya |
| Callback lunas mendahului status lokal | Payment dibuka kembali jadi Success | Log::warning "Callback lunas mendahului status lokal" |
Callback lunas ke Payment Refunded | Ditolak | Log::warning "sudah Refunded" |
Retry callback ke Payment Success | No-op | Log::info "diulang" |
Appointment gagal dibuat | Draft → Failed, pasien dikabari tindak lanjut manual | Log::error "Gagal membuat Appointment dari booking berbayar" |
| Listener melempar seluruhnya | Ditelan; sapuan 5 menit memungutnya | Log::error "Gagal mengonfirmasi booking dari pembayaran lunas" |
| Notifikasi gagal setelah booking jadi | Booking tetap valid | Log::error "pasien punya jadwal tanpa tahu apa pun" |
| Draft diselamatkan sapuan tapi notifikasi gagal | Booking tetap valid, tidak akan disapu lagi | Log::error "Booking diselamatkan sapuan tapi notifikasinya gagal" |