229 lines
16 KiB
Markdown
229 lines
16 KiB
Markdown
# 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.
|
|
|
|
**Progress (per 2026-05-20):** PR-R1, R2, R3 ✅ — MVP refund (schema + service, organizer-cancel auto-refund, self-service user cancel) selesai. PR-R4 / R5 / R6 ⏳ post-MVP belum dikerjakan.
|
|
|
|
---
|
|
|
|
## Audit state sekarang (baseline)
|
|
|
|
**Schema (~10% sudah ada):**
|
|
- ✅ `BookingStatus.REFUNDED` & `PaymentStatus.REFUNDED` — enum value ada di [prisma/schema.prisma](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](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](docs/CRON_SETUP.md)) — siap untuk reconciliation job.
|
|
|
|
File baseline: [prisma/schema.prisma](prisma/schema.prisma), [server/services/booking.service.ts](server/services/booking.service.ts), [app/api/webhooks/midtrans/route.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](prisma/schema.prisma) |
|
|
| R1.2 | Tambah `BookingStatus.PARTIALLY_REFUNDED` | ✅ | [prisma/schema.prisma](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](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.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](app/trips/%5Bid%5D/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](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](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](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](prisma/schema.prisma) |
|
|
| R5.2 | Foreign key `Trip.refundPolicyId` (default MODERATE) | ⏳ | [prisma/schema.prisma](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](features/trip/components/create-trip-form.tsx) |
|
|
| R5.6 | UI trip detail: tampilkan policy aktif (link ke detail tier) | ⏳ | [app/trips/[id]/page.tsx](app/trips/%5Bid%5D/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](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.
|