Files
setrip/REFUND_ROADMAP.md
T
2026-05-10 22:27:21 +07:00

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 Refund belum 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 + Payment model dengan amount (Int, IDR — money math safe).
  • Midtrans webhook handler (app/api/webhooks/midtrans/route.ts) — pola untuk refund webhook bisa di-mirror.
  • bookingService dengan transaksi serializable + retry — pola untuk refundService.
  • 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.amount Int (IDR) — bisa < payment.amount untuk partial. Constraint: SUM(refunds.amount WHERE status=SUCCEEDED) <= payment.amount di-enforce di service layer.
  • idempotencyKey di-generate sekali saat Refund dibuat — dipakai saat panggil gateway nanti (R-4) supaya retry tidak double-refund.
  • BookingStatus.REFUNDED di-set sebagai derived state saat full refund SUCCEEDED. Untuk partial: tambah BookingStatus.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.
  • RefundReason enum 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:

  1. Run migration di staging → smoke test → run di production.
  2. Tambah /admin/refunds ke 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-set status = 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 update Booking.status = CANCELLED. Yang PAID saja 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 → OPEN kalau 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.tscalculateRefundAmount(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:

  1. Tulis copy kebijakan refund untuk halaman Terms & Privacy.
  2. 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.idempotencyKey yang 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 — kalau manual_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:

  1. Aktifkan Refund API di dashboard Midtrans (perlu request ke Midtrans support untuk channel tertentu).
  2. Test refund di sandbox dengan dummy transaction.
  3. 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 RefundPolicy dengan 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.tscalculateRefundAmount 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:

  1. Run migration + backfill.
  2. 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_RESOLVED dulu 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:

  1. Set up alert channel (email atau Slack webhook).
  2. Tetapkan threshold drift (saran: 1%).

Anti-list (yang harus DITOLAK kalau muncul)

  • Pakai BookingStatus.REFUNDED sebagai 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.

  1. R-1 — Schema + service stub + UI admin. Foundation, blocker untuk semua PR berikut.
  2. R-2 — Auto-trigger saat organizer CLOSED. Paling sering kepakai, low-complexity. Mulai dari sini setelah R-1.
  3. R-3 — Self-service user cancel + hardcoded policy. Komplet flow user-side.
  4. R-4 — Midtrans Refund API. Paling kompleks (async, idempotent, webhook). Bisa hidup tanpa ini selama admin willing manual transfer.
  5. R-5 — Refund policy data-driven. Quality-of-life untuk organizer, bukan blocker.
  6. 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

  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.
  2. 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.
  3. Partial refund booking status — tambah BookingStatus.PARTIALLY_REFUNDED (lebih eksplisit) atau biarkan tetap PAID + lihat booking.refunds[]? Saran: tambah enum baru — query-friendly.
  4. 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.
  5. Dispute model timing — bikin Dispute model di R-1 (early separation) atau defer sampai R-6? Saran: defer — YAGNI sampai ada kasus chargeback riil.
  6. 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.