16 KiB
Setrip — Refund Roadmap
Status implementasi sistem refund yang dapat dipercaya dan auditable — dari schema, policy, sampai integrasi gateway.
Prinsip: refund = financial event, bukan flag boolean. Setiap rupiah yang keluar harus auditable, idempotent, dan punya state machine eksplisit. Untuk MVP: tetap simple, tapi anticipate partial refund + async gateway dari hari pertama.
Audit state sekarang (baseline)
Schema (~10% sudah ada):
- ✅
BookingStatus.REFUNDED&PaymentStatus.REFUNDED— enum value ada di prisma/schema.prisma. - ❌ Model
Refundbelum ada — refund saat ini cuma flag, tanpa entity tersendiri. - ❌ Tidak ada audit trail (siapa, kapan, alasan, approval).
- ❌ Tidak bisa partial refund.
- ❌ Tidak bisa multiple refund per booking (mis. refund deposit lalu sisa).
- ❌ Tidak bisa bedakan refund vs chargeback (dispute bank).
Service/UI (~0% sudah ada):
- ❌ Tidak ada
refundService. - ❌ Tidak ada flow "organizer cancel trip → auto refund peserta PAID".
- ❌ Tidak ada UI peserta untuk request cancel + refund.
- ❌ Tidak ada UI admin untuk approve/eksekusi refund.
- ❌ Tidak ada integrasi Midtrans Refund API.
- ❌ Tidak ada reconciliation harian.
Konteks pendukung yang sudah ada:
- ✅
Booking+Paymentmodel denganamount(Int, IDR — money math safe). - ✅ Midtrans webhook handler (app/api/webhooks/midtrans/route.ts) — pola untuk refund webhook bisa di-mirror.
- ✅
bookingServicedengan transaksi serializable + retry — pola untukrefundService. - ✅ Cron infra (system crontab +
CRON_SECRET, lihat docs/CRON_SETUP.md) — siap untuk reconciliation job.
File baseline: prisma/schema.prisma, server/services/booking.service.ts, app/api/webhooks/midtrans/route.ts.
PR-R1 — Refund Schema + Service Stub (foundation) ⏳
Foundation. Bikin entity Refund + service stub yang cuma create record PENDING (belum panggil gateway). UI admin minimal: list + manual mark succeeded. Tanpa ini, semua PR berikutnya tidak bisa jalan.
Keputusan asumsi yang diusulkan:
Refund.amountInt (IDR) — bisa <payment.amountuntuk partial. Constraint:SUM(refunds.amount WHERE status=SUCCEEDED) <= payment.amountdi-enforce di service layer.idempotencyKeydi-generate sekali saat Refund dibuat — dipakai saat panggil gateway nanti (R-4) supaya retry tidak double-refund.BookingStatus.REFUNDEDdi-set sebagai derived state saat full refund SUCCEEDED. Untuk partial: tambahBookingStatus.PARTIALLY_REFUNDED(enum baru) — atau biarkan PAID + lihat Booking.refunds[]. Saran: tambah PARTIALLY_REFUNDED supaya filter list "refunded bookings" bisa pakai status saja, tidak perlu join.RefundReasonenum lengkap dari hari pertama — supaya laporan finance tidak butuh string parsing.- Approval admin wajib untuk semua refund di MVP (4-eyes principle). Auto-approve bisa di-relax di R-2 untuk SYSTEM refund.
- Admin UI sederhana — list refund PENDING + tombol approve / mark-succeeded. Reuse pattern KYC verification UI yang sudah ada.
| # | Item | Status | File |
|---|---|---|---|
| R1.1 | Model Refund + enum RefundReason, RefundStatus, RefundInitiator |
⏳ | prisma/schema.prisma |
| R1.2 | Tambah BookingStatus.PARTIALLY_REFUNDED |
⏳ | prisma/schema.prisma |
| R1.3 | Migration add_refund_model |
⏳ | prisma/migrations/ |
| R1.4 | refundRepo (create, findById, findByBooking, listPending, updateStatus) |
⏳ | server/repositories/refund.repo.ts |
| R1.5 | refundService.requestRefund(input) — create Refund PENDING + validasi amount <= payment.amount - totalRefunded |
⏳ | server/services/refund.service.ts |
| R1.6 | refundService.approveRefund(refundId, adminId) — PENDING → APPROVED |
⏳ | server/services/refund.service.ts |
| R1.7 | refundService.markSucceededManual(refundId, adminId) — APPROVED → SUCCEEDED, update Booking/Payment status. Untuk MVP refund manual transfer (non-Midtrans channel). |
⏳ | server/services/refund.service.ts |
| R1.8 | UI admin /admin/refunds — list PENDING + tombol approve & mark-succeeded |
⏳ | app/admin/refunds/page.tsx |
Tindakan manual:
- Run migration di staging → smoke test → run di production.
- Tambah
/admin/refundske admin nav.
PR-R2 — Auto-Trigger Refund saat Trip CLOSED (organizer cancel) ⏳
Saat organizer batalkan trip (status = CLOSED), semua peserta dengan Booking.status = PAID otomatis di-create Refund record dengan reason = ORGANIZER_CANCELLED, initiatedBy = SYSTEM, full amount.
Keputusan asumsi yang diusulkan:
- Trigger di service
tripService.closeTrip()(yang nge-setstatus = CLOSED). Pakai serializable transaction — close trip + create refunds atomic. - SYSTEM refund bisa auto-approve (skip admin approval) — karena policy clear: organizer cancel = 100% refund. Tapi eksekusi (mark succeeded) tetap manual atau via gateway (R-4), tidak skip.
- Notifikasi peserta: kirim email/notif "Trip dibatalkan, refund Rp X sedang diproses". (Notification system di luar scope PR ini — assume sudah ada atau di-deferred.)
- Edge case: peserta yang
AWAITING_PAY(belum bayar) tidak perlu refund — cuma updateBooking.status = CANCELLED. YangPAIDsaja yang dapat refund.
| # | Item | Status | File |
|---|---|---|---|
| R2.1 | tripService.closeTrip(tripId, organizerId) — set status CLOSED + auto-create refunds |
⏳ | server/services/trip.service.ts |
| R2.2 | Auto-approve untuk SYSTEM refund dengan reason ORGANIZER_CANCELLED | ⏳ | server/services/refund.service.ts |
| R2.3 | UI organizer: tombol "Batalkan trip" di dashboard organizer dengan confirmation modal | ⏳ | features/trip/components/cancel-trip-button.tsx |
| R2.4 | Server action cancelTripAction |
⏳ | features/trip/actions.ts |
Tindakan manual: tidak ada.
PR-R3 — Self-Service User Cancel dengan Refund Window ⏳
User bisa cancel booking sendiri, refund dihitung berdasarkan kebijakan default (hardcoded dulu, jangan polymorphic).
Keputusan asumsi yang diusulkan:
- Kebijakan default hardcoded (akan jadi data-driven di R-5):
- ≥7 hari sebelum berangkat → 80% refund (organizer ambil 20% admin fee)
- 3-7 hari sebelum berangkat → 50% refund
- <3 hari atau no-show → 0% refund (tetap create Refund record amount=0 untuk audit, atau skip — pilih skip lebih sederhana)
- Konstanta di
lib/refund-policy.ts— supaya satu sumber kebenaran, mudah diubah. - User refund tidak auto-approve — tetap butuh admin approval di MVP. Alasan: cegah abuse (spam cancel), dan validasi window calculation di sisi admin.
- Setelah refund SUCCEEDED, slot di trip kembali tersedia (
status: FULL → OPENkalau participantCount turun). - UI user: tombol "Cancel & request refund" di trip detail (kalau status booking = PAID dan trip belum berangkat).
| # | Item | Status | File |
|---|---|---|---|
| R3.1 | lib/refund-policy.ts — calculateRefundAmount(bookingAmount, daysUntilDeparture) |
⏳ | lib/refund-policy.ts |
| R3.2 | refundService.requestUserCancellation(bookingId, userId) — pakai policy + create Refund PENDING |
⏳ | server/services/refund.service.ts |
| R3.3 | UI user di trip detail: tombol "Cancel booking" + modal preview refund amount | ⏳ | features/booking/components/cancel-booking-button.tsx |
| R3.4 | Server action cancelBookingAction |
⏳ | features/booking/actions.ts |
| R3.5 | Display refund policy di trip detail (di blok info atau accordion) — transparency | ⏳ | app/trips/[id]/page.tsx |
Tindakan manual:
- Tulis copy kebijakan refund untuk halaman Terms & Privacy.
- Tambah link kebijakan di footer.
PR-R4 — Integrasi Midtrans Refund API (async, idempotent) ⏳
Sambungkan eksekusi refund ke Midtrans Refund API. Untuk channel yang support refund online (BCA VA, GoPay, dst). Channel manual transfer tetap mark-succeeded manual oleh admin.
Keputusan asumsi yang diusulkan:
- Refund API Midtrans async — POST refund return
pending, callback datang via webhook (extend app/api/webhooks/midtrans/route.ts atau buat endpoint terpisah). - Idempotency key wajib di header request — pakai
Refund.idempotencyKeyyang sudah di-generate R-1. - State transition: APPROVED → PROCESSING (saat request dikirim) → SUCCEEDED/FAILED (via webhook).
- Eksekusi via job queue (Vercel Cron daily) atau sync di server action — saran cron supaya retry-able kalau gateway down. Job: pick all APPROVED refunds, kirim ke Midtrans, update PROCESSING.
- Validasi
Payment.method— kalaumanual_transfer, refund tetap manual (no gateway call). Skip Midtrans path.
| # | Item | Status | File |
|---|---|---|---|
| R4.1 | Helper lib/midtrans-refund.ts — POST refund dengan idempotency key |
⏳ | lib/midtrans-refund.ts |
| R4.2 | refundService.executeRefund(refundId) — APPROVED → PROCESSING + call Midtrans |
⏳ | server/services/refund.service.ts |
| R4.3 | Webhook handler refund — PROCESSING → SUCCEEDED/FAILED via callback | ⏳ | app/api/webhooks/midtrans/route.ts |
| R4.4 | Cron /api/cron/execute-refunds — pick APPROVED refunds, kirim ke gateway |
⏳ | app/api/cron/execute-refunds/route.ts |
| R4.5 | Daftarkan cron */15 * * * * (every 15 min) di system crontab |
⏳ | docs/CRON_SETUP.md |
Tindakan manual:
- Aktifkan Refund API di dashboard Midtrans (perlu request ke Midtrans support untuk channel tertentu).
- Test refund di sandbox dengan dummy transaction.
- Set webhook URL refund di Midtrans dashboard (kalau beda dari payment webhook).
PR-R5 — Refund Policy Model (per-trip customization) ⏳
Pindah refund policy dari hardcoded ke data-driven. Organizer bisa pilih policy per-trip (mis. trip premium = strict cancellation).
Keputusan asumsi yang diusulkan:
- Model
RefundPolicydengan tier predefined (FLEXIBLE, MODERATE, STRICT) — bukan field bebas. Mengikuti pola Airbnb. Mencegah organizer set policy yang aneh-aneh dan membingungkan peserta. - 1 Trip → 1 RefundPolicy (foreign key di Trip). Default ke MODERATE.
- Setiap policy punya array
tiers(JSON) berisi{ minDaysBefore: number, refundPercentage: number }. - Migration: existing trip default ke MODERATE.
- UI organizer di create-trip form: dropdown pilih policy, dengan preview tier-nya.
| # | Item | Status | File |
|---|---|---|---|
| R5.1 | Model RefundPolicy + seed 3 tier (FLEXIBLE, MODERATE, STRICT) |
⏳ | prisma/schema.prisma |
| R5.2 | Foreign key Trip.refundPolicyId (default MODERATE) |
⏳ | prisma/schema.prisma |
| R5.3 | Migration + backfill existing trip ke MODERATE | ⏳ | prisma/migrations/ |
| R5.4 | Update lib/refund-policy.ts — calculateRefundAmount baca dari policy |
⏳ | lib/refund-policy.ts |
| R5.5 | UI organizer create-trip: dropdown policy + preview | ⏳ | features/trip/components/create-trip-form.tsx |
| R5.6 | UI trip detail: tampilkan policy aktif (link ke detail tier) | ⏳ | app/trips/[id]/page.tsx |
Tindakan manual:
- Run migration + backfill.
- Update copy halaman terms — sebut 3 policy tier.
PR-R6 — Reconciliation + Dispute Model (operational maturity) ⏳
Daily job match refund di DB vs settlement Midtrans. Model Dispute terpisah untuk chargeback/komplain pasca-refund.
Keputusan asumsi yang diusulkan:
- Reconciliation job: pull settlement report dari Midtrans (Settlement API) → match dengan
refund.externalRefundId→ flag drift. - Dispute model tertunda sampai ada chargeback riil. Pakai
RefundReason.DISPUTE_RESOLVEDdulu di Refund model. Bikin model terpisah hanya kalau volume dispute > X per bulan. - Alert: kalau drift > 1% dari total refund harian, kirim notif ke admin (email/Slack).
| # | Item | Status | File |
|---|---|---|---|
| R6.1 | Helper lib/midtrans-settlement.ts — pull settlement report |
⏳ | lib/midtrans-settlement.ts |
| R6.2 | Cron /api/cron/reconcile-refunds daily |
⏳ | app/api/cron/reconcile-refunds/route.ts |
| R6.3 | UI admin /admin/refunds/reconciliation — drift report |
⏳ | app/admin/refunds/reconciliation/page.tsx |
| R6.4 | (opsional, defer) Model Dispute + flow chargeback |
⏳ | prisma/schema.prisma |
Tindakan manual:
- Set up alert channel (email atau Slack webhook).
- Tetapkan threshold drift (saran: 1%).
❌ Anti-list (yang harus DITOLAK kalau muncul)
- Pakai
BookingStatus.REFUNDEDsebagai sumber kebenaran tanpa Refund model — flag-only tidak bisa partial, tidak punya audit trail. Stuck di kasus pertama. - Hapus Refund row kalau gagal — never delete financial records. Set
status = FAILED+ log alasan. Audit trail wajib. - Sync call ke Midtrans Refund API tanpa idempotency key — kalau retry karena timeout atau network error → double-refund. Kerugian finansial nyata.
- Auto-execute refund tanpa approval admin di MVP — fraud risk. Auto-approve OK untuk SYSTEM refund (organizer cancel = clear policy), tapi eksekusi tetap controlled. Bisa di-relax setelah volume + trust matang.
- Polymorphic refund policy dari awal — lompat langsung ke data-driven sebelum hardcoded teruji = over-engineering. Phasing R-3 (hardcoded) lalu R-5 (data-driven) lebih sehat.
- Trigger refund di server action user-facing tanpa state machine — user spam click → multiple refund request. Idempotency check via
(bookingId, status IN ('PENDING','APPROVED','PROCESSING'))unique-ish. - Refund partial dengan float math — selalu integer (rupiah). Hitung % dengan
Math.floor(amount * percentage / 100)supaya tidak ada sub-rupiah. - Mention "kami akan refund X%" di UI tanpa lock policy — kebijakan harus visible di trip detail SEBELUM user join, bukan kejutan saat cancel.
- Skip approval admin untuk refund di atas threshold (mis. > 5jt) — fraud risk internal. 4-eyes principle wajib untuk nominal besar, walau policy clear.
- Bundle Refund + Dispute di model yang sama — beda flow, beda inisiator (refund = merchant, dispute = bank). Separation of concern penting walau di MVP belum perlu Dispute model.
Saran phasing
PR berurutan, masing-masing mandiri (siap di-deploy). Don't bundle.
- R-1 — Schema + service stub + UI admin. Foundation, blocker untuk semua PR berikut.
- R-2 — Auto-trigger saat organizer CLOSED. Paling sering kepakai, low-complexity. Mulai dari sini setelah R-1.
- R-3 — Self-service user cancel + hardcoded policy. Komplet flow user-side.
- R-4 — Midtrans Refund API. Paling kompleks (async, idempotent, webhook). Bisa hidup tanpa ini selama admin willing manual transfer.
- R-5 — Refund policy data-driven. Quality-of-life untuk organizer, bukan blocker.
- R-6 — Reconciliation + dispute. Operational maturity, untuk volume yang lebih besar.
Bobot effort kasar: R-1 (M) → R-2 (M) → R-3 (M) → R-4 (L) → R-5 (M) → R-6 (L).
Pertanyaan terbuka sebelum mulai R-1
- MVP scope — mau prioritaskan organizer-cancel dulu (R-2, paling sering), atau langsung user cancel dengan window (R-3)? Saran: R-2 dulu — clear policy, low-complexity.
- Approval flow — semua refund butuh approval admin (lebih aman), atau auto-approve untuk SYSTEM refund? Saran: auto-approve SYSTEM, manual approve untuk USER + ADMIN_ADJUSTMENT.
- Partial refund booking status — tambah
BookingStatus.PARTIALLY_REFUNDED(lebih eksplisit) atau biarkan tetap PAID + lihatbooking.refunds[]? Saran: tambah enum baru — query-friendly. - Midtrans Refund API channel — apakah anda sudah cek channel mana yang support online refund? BCA VA + GoPay biasanya support, manual_transfer pasti tidak. Cek dashboard sebelum mulai R-4.
- Dispute model timing — bikin Dispute model di R-1 (early separation) atau defer sampai R-6? Saran: defer — YAGNI sampai ada kasus chargeback riil.
- Threshold approval admin — ada nominal di atas mana refund wajib approval 2 admin (4-eyes)? Saran: > Rp 1jt butuh dual approval, < Rp 1jt single approval cukup.