251 lines
16 KiB
Markdown
251 lines
16 KiB
Markdown
# Setrip — Payment Roadmap
|
|
|
|
Rencana implementasi flow pembayaran: handle trip gratis, halaman detail payment, dan integrasi Midtrans.
|
|
|
|
> **Prinsip:** Sumber kebenaran pembayaran = record di tabel khusus, bukan timestamp di `TripParticipant`. Manual transfer & Midtrans = dua provider dari pipeline yang sama. Trip gratis = first-class case (bukan paid trip dengan amount 0).
|
|
|
|
---
|
|
|
|
## Audit state sekarang (baseline)
|
|
|
|
- Field `TripParticipant.markedPaidAt` + `paymentConfirmedAt` — dual-timestamp manual.
|
|
- Flow: user klik "Saya sudah bayar" → organizer klik "Konfirmasi pembayaran". Tidak ada record transaksi terpisah, tidak ada bukti transfer, tidak ada amount snapshot.
|
|
- Tidak ada halaman `/trips/[id]/payment`. Tombol bayar inline di trip detail.
|
|
- **Bug**: trip gratis (`price === 0`) tetap melewati flow yang sama — peserta tetap harus klik "Saya sudah bayar" dan organizer tetap harus konfirmasi.
|
|
|
|
File terkait: [server/services/trip.service.ts](server/services/trip.service.ts) (`markParticipantPayment`, `confirmParticipantPayment`), [features/booking/actions.ts](features/booking/actions.ts), [features/booking/components/organizer-payment-queue.tsx](features/booking/components/organizer-payment-queue.tsx), [features/trip/components/join-trip-button.tsx](features/trip/components/join-trip-button.tsx), [server/repositories/participant.repo.ts](server/repositories/participant.repo.ts).
|
|
|
|
---
|
|
|
|
## PR A — Free trip handling + halaman detail payment (manual flow) ✅
|
|
|
|
Selesai. `tsc --noEmit` lulus. Tanpa schema baru, tanpa migration.
|
|
|
|
**Keputusan akses control:** halaman `/trips/[id]/payment` boleh diakses peserta apapun yang aktif (PENDING/CONFIRMED). Peserta PENDING bisa lihat nominal + rekening untuk persiapan, tapi diberi notice "tunggu approve dulu sebelum transfer". Organizer trip-nya sendiri di-redirect ke trip detail.
|
|
|
|
| # | Item | Status | File |
|
|
|---|---|---|---|
|
|
| A1 | Helper `lib/trip-pricing.ts` dengan `isFreeTrip` & `isPaidTrip` | ✅ | [lib/trip-pricing.ts](lib/trip-pricing.ts) |
|
|
| A2 | Service guard: `markParticipantPayment` & `confirmParticipantPayment` reject trip gratis | ✅ | [server/services/trip.service.ts](server/services/trip.service.ts) |
|
|
| A3 | UI gate di JoinTripButton: hide flow pembayaran kalau gratis | ✅ | [features/trip/components/join-trip-button.tsx](features/trip/components/join-trip-button.tsx) |
|
|
| A4 | UI gate di trip detail: skip `OrganizerPaymentQueue` kalau gratis | ✅ | [app/trips/[id]/page.tsx](app/trips/[id]/page.tsx) |
|
|
| A5 | Halaman `/trips/[id]/payment` (server component dengan akses kontrol) | ✅ | [app/trips/[id]/payment/page.tsx](app/trips/%5Bid%5D/payment/page.tsx) |
|
|
| A6 | Konten halaman trip gratis: banner 🎉 + status keikutsertaan + CTA | ✅ | `FreeTripSection` di [page.tsx](app/trips/%5Bid%5D/payment/page.tsx) |
|
|
| A7 | Konten halaman trip berbayar: timeline + rekening organizer + tombol "Saya sudah bayar" + status | ✅ | `PaidTripSection`, `PaymentTimeline`, `BankRow` di [page.tsx](app/trips/%5Bid%5D/payment/page.tsx) |
|
|
| A8 | Link dari trip detail → `/trips/[id]/payment` (replace tombol inline) | ✅ | `showPaymentLink` di [join-trip-button.tsx](features/trip/components/join-trip-button.tsx) |
|
|
| A9 | Metadata + `robots: noindex` halaman payment | ✅ | [page.tsx](app/trips/%5Bid%5D/payment/page.tsx) — `metadata.robots: { index: false, follow: false }` |
|
|
| A+ | `MarkPaidButton` (client component) untuk action "Saya sudah bayar" | ✅ | [features/booking/components/mark-paid-button.tsx](features/booking/components/mark-paid-button.tsx) |
|
|
| A+ | `CopyButton` (client component) untuk copy nomor rekening / nominal | ✅ | [features/booking/components/copy-button.tsx](features/booking/components/copy-button.tsx) |
|
|
|
|
**Tindakan manual:** tidak ada. Tidak ada migration di PR A.
|
|
|
|
**Catatan edge case yang sudah dihandle:**
|
|
- Organizer trip-nya sendiri buka halaman → redirect ke trip detail.
|
|
- User belum login → redirect ke login dengan `callbackUrl`.
|
|
- User belum join / sudah cancel → tampil notice "kamu belum terdaftar".
|
|
- Trip dengan organizer yang belum APPROVED verifikasinya → tampil notice "rekening belum tersedia, hubungi organizer langsung" (tidak crash).
|
|
- Peserta PENDING di-warning "tunggu approve dulu sebelum transfer".
|
|
- Tombol "Saya sudah bayar" hanya muncul untuk CONFIRMED + belum mark + bank tersedia.
|
|
|
|
---
|
|
|
|
## PR B — Refactor schema ke `Booking` + `Payment` (provider MANUAL only) ✅
|
|
|
|
Selesai. `tsc --noEmit` lulus. Pondasi untuk Midtrans sudah siap.
|
|
|
|
### Schema (target)
|
|
|
|
```prisma
|
|
model Booking {
|
|
id String @id @default(cuid())
|
|
tripId String
|
|
trip Trip @relation(fields: [tripId], references: [id])
|
|
userId String
|
|
user User @relation(fields: [userId], references: [id])
|
|
participantId String @unique
|
|
participant TripParticipant @relation(fields: [participantId], references: [id])
|
|
|
|
/// Snapshot harga saat booking dibuat — protect dari perubahan trip.price.
|
|
amount Int
|
|
currency String @default("IDR")
|
|
status BookingStatus @default(PENDING)
|
|
|
|
payments Payment[]
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@index([tripId, status])
|
|
@@index([userId])
|
|
}
|
|
|
|
enum BookingStatus {
|
|
PENDING // join belum approve atau belum bayar
|
|
AWAITING_PAY // approved, tinggal bayar
|
|
PAID // lunas (manual confirm atau gateway settlement)
|
|
CANCELLED
|
|
REFUNDED
|
|
EXPIRED
|
|
}
|
|
|
|
model Payment {
|
|
id String @id @default(cuid())
|
|
bookingId String
|
|
booking Booking @relation(fields: [bookingId], references: [id])
|
|
|
|
provider PaymentProvider
|
|
/// order_id eksternal — untuk MANUAL referensi internal, untuk MIDTRANS dikirim ke gateway. Unik per attempt.
|
|
externalOrderId String @unique
|
|
externalTxId String?
|
|
method String? // bca_va, gopay, qris, manual_transfer, dst
|
|
amount Int
|
|
status PaymentStatus @default(PENDING)
|
|
|
|
rawCallback Json?
|
|
snapToken String?
|
|
expiresAt DateTime?
|
|
|
|
paidAt DateTime?
|
|
failedAt DateTime?
|
|
rejectionReason String?
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@index([bookingId, status])
|
|
@@index([provider, status])
|
|
}
|
|
|
|
enum PaymentProvider {
|
|
MANUAL
|
|
MIDTRANS
|
|
}
|
|
|
|
enum PaymentStatus {
|
|
PENDING
|
|
AWAITING
|
|
PAID
|
|
FAILED
|
|
EXPIRED
|
|
CANCELLED
|
|
REFUNDED
|
|
}
|
|
```
|
|
|
|
### Tugas
|
|
|
|
| # | Item | Status | File |
|
|
|---|---|---|---|
|
|
| B1 | Schema: tambah `Booking`, `Payment`, 3 enum (`BookingStatus`, `PaymentProvider`, `PaymentStatus`) + relasi di `User`, `Trip`, `TripParticipant` | ✅ | [prisma/schema.prisma](prisma/schema.prisma) |
|
|
| B2 | Migration `add_booking_payment` (CreateEnum + CreateTable + Index + FK) | ✅ | [prisma/migrations/20260508150000_add_booking_payment/migration.sql](prisma/migrations/20260508150000_add_booking_payment/migration.sql) |
|
|
| B3 | Backfill script TS (idempotent, skip baris yang sudah punya Booking) | ✅ | [prisma/backfill-bookings.ts](prisma/backfill-bookings.ts) |
|
|
| B4 | `bookingRepo` + `paymentRepo` | ✅ | [server/repositories/booking.repo.ts](server/repositories/booking.repo.ts), [server/repositories/payment.repo.ts](server/repositories/payment.repo.ts) |
|
|
| B5 | `bookingService` — `markPaidManual`, `confirmPaidManual`, `getByTripAndUser`, `getAwaitingManualForTrip` (idempotent, transactional dengan retry serialisasi) | ✅ | [server/services/booking.service.ts](server/services/booking.service.ts) |
|
|
| B6 | `tripService.markParticipantPayment` → delegate ke `bookingService.markPaidManual`. Tetap update `TripParticipant.markedPaidAt` untuk backcompat. | ✅ | [server/services/trip.service.ts](server/services/trip.service.ts) |
|
|
| B7 | `tripService.confirmParticipantPayment` → delegate ke `bookingService.confirmPaidManual`. Tetap update `paymentConfirmedAt`. | ✅ | [server/services/trip.service.ts](server/services/trip.service.ts) |
|
|
| B+ | `tripService.joinTrip` → upsert Booking PENDING (handle re-join dari CANCELLED) | ✅ | [server/services/trip.service.ts](server/services/trip.service.ts) |
|
|
| B+ | `tripService.confirmParticipant` → transition Booking PENDING → AWAITING_PAY (paid) atau PAID (free) | ✅ | [server/services/trip.service.ts](server/services/trip.service.ts) |
|
|
| B+ | `tripService.cancelJoin` & `rejectParticipant` → Booking → CANCELLED | ✅ | [server/services/trip.service.ts](server/services/trip.service.ts) |
|
|
| B8 | Halaman `/trips/[id]/payment` baca dari Booking + Payment (bukan timestamp lama) | ✅ | [app/trips/[id]/payment/page.tsx](app/trips/%5Bid%5D/payment/page.tsx) |
|
|
| B9 | `OrganizerPaymentQueue` di trip detail dapat data dari `bookingService.getAwaitingManualForTrip` | ✅ | [app/trips/[id]/page.tsx](app/trips/%5Bid%5D/page.tsx) |
|
|
| B10 | Deprecate `TripParticipant.markedPaidAt` + `paymentConfirmedAt` (komen `@deprecated`, tetap di-update untuk backcompat) | ✅ | [prisma/schema.prisma](prisma/schema.prisma) |
|
|
| B11 | Index optimization (`@@index([tripId, status])` di Booking, `@@index([provider, status])` di Payment, `@@index([userId])` di Booking) | ✅ | [prisma/schema.prisma](prisma/schema.prisma) |
|
|
|
|
**Tindakan manual (urutan penting):**
|
|
1. `npx prisma migrate deploy` — apply schema migration `20260508150000_add_booking_payment`.
|
|
2. `npx tsx prisma/backfill-bookings.ts` — populate Booking + Payment dari `TripParticipant` lama. Idempotent, aman dijalankan ulang.
|
|
3. Verifikasi: jumlah Booking aktif = jumlah TripParticipant aktif setelah backfill.
|
|
|
|
---
|
|
|
|
## PR C — Midtrans integration (Snap + webhook) ⏳
|
|
|
|
Tambah provider MIDTRANS ke pipeline yang sudah dibuat di PR B. Test di sandbox dulu.
|
|
|
|
### Persiapan akun & env
|
|
|
|
| Env | Keterangan |
|
|
|---|---|
|
|
| `MIDTRANS_SERVER_KEY` | Server key dari dashboard Midtrans (sandbox/production sesuai mode). Rahasia. |
|
|
| `MIDTRANS_CLIENT_KEY` | Client key. Boleh di expose ke frontend (untuk Snap script). |
|
|
| `MIDTRANS_IS_PRODUCTION` | `true`/`false` — pilih endpoint sandbox vs production. |
|
|
| `MIDTRANS_NOTIFICATION_URL` | URL callback publik kita, mis. `https://setrip.id/api/webhooks/midtrans`. Didaftarkan di dashboard Midtrans. |
|
|
|
|
Tambah ke [env.example](env.example) dengan komentar.
|
|
|
|
### Tugas
|
|
|
|
| # | Item | Status | Catatan |
|
|
|---|---|---|---|
|
|
| C1 | Update [env.example](env.example) + dokumentasi env | ⏳ | 4 env baru. |
|
|
| C2 | `lib/midtrans.ts` — client tipis: `createSnapTransaction`, `verifySignature`, `mapStatus` | ⏳ | Pakai `fetch` + `crypto.createHash('sha512')`. Tidak butuh dependency baru. |
|
|
| C3 | Status mapping helper | ⏳ | `transaction_status` + `fraud_status` Midtrans → `PaymentStatus` internal. Tabel mapping ada di README PR ini. |
|
|
| C4 | Service `paymentService.startMidtransPayment(bookingId)` | ⏳ | Bikin Payment row provider=MIDTRANS, kirim ke Midtrans, simpan `snapToken` + `expiresAt`. Kalau Booking sudah PAID → reject. |
|
|
| C5 | Halaman payment: tombol "Bayar online (Midtrans)" untuk trip berbayar | ⏳ | Fallback "Transfer manual" tetap ada (provider MANUAL). User pilih sebelum lanjut. |
|
|
| C6 | Frontend: load Snap script + invoke `window.snap.pay(token)` | ⏳ | Loaded conditional di halaman payment, bukan global. Pakai client key dari env publik. |
|
|
| C7 | Webhook endpoint `app/api/webhooks/midtrans/route.ts` | ⏳ | POST. Verify signature (sha512). Lookup Payment by `externalOrderId`. Update idempotent. Selalu return 200. |
|
|
| C8 | Booking status sync setelah webhook PAID | ⏳ | `Booking.status = PAID`. Sync `TripParticipant.paymentConfirmedAt` untuk kompatibilitas. Concurrency: gunakan DB transaction. |
|
|
| C9 | Cron / scheduled job: expire Payment lama | ⏳ | Midtrans default expire 24 jam, tapi DB-side juga harus bersih supaya UI status akurat. Bisa dijalankan via Vercel cron atau manual scheduler. |
|
|
| C10 | Anti-replay: skip kalau `Payment.status` sudah final (PAID/FAILED/EXPIRED) | ⏳ | Webhook bisa diretry oleh Midtrans. |
|
|
| C11 | Logging callback mentah ke `Payment.rawCallback` (Json) | ⏳ | Audit & dispute. |
|
|
| C12 | Test scenario di sandbox | ⏳ | Settlement BCA VA, gopay, deny (kartu fraud), expire, cancel. |
|
|
| C13 | Status badge di halaman payment | ⏳ | Tampil real-time tanpa polling agresif (refresh manual atau interval longgar 10s). |
|
|
| C14 | Email/in-app notification setelah PAID | ⏳ | Optional Phase ini, bisa Phase berikutnya. |
|
|
|
|
### Mapping `transaction_status` Midtrans → `PaymentStatus`
|
|
|
|
| Midtrans | Trigger lain | `PaymentStatus` |
|
|
|---|---|---|
|
|
| `capture` | `fraud_status === "accept"` | PAID |
|
|
| `capture` | `fraud_status === "challenge"` | AWAITING (review manual di dashboard Midtrans) |
|
|
| `settlement` | — | PAID |
|
|
| `pending` | — | AWAITING |
|
|
| `deny` | — | FAILED |
|
|
| `expire` | — | EXPIRED |
|
|
| `cancel` | — | CANCELLED |
|
|
| `refund` / `partial_refund` | — | REFUNDED |
|
|
|
|
### Webhook checklist (security)
|
|
|
|
1. Verify signature: `sha512(order_id + status_code + gross_amount + SERVER_KEY) === signature_key`. Mismatch → 401, log.
|
|
2. Cek `gross_amount` cocok dengan `payment.amount` — kalau tidak sama, log anomaly, jangan PAID.
|
|
3. Lookup `Payment.externalOrderId === order_id`. Tidak ada → 200 OK + log (jangan biarkan Midtrans retry forever).
|
|
4. Idempotent: kalau status sudah final, skip update tapi tetap return 200.
|
|
5. Pakai DB transaction untuk update Payment + Booking + TripParticipant bersamaan.
|
|
6. Selalu return 200 kalau request valid (mismatch signature → 401, sisanya → 200 + log).
|
|
|
|
### Edge cases yang gampang lupa
|
|
|
|
- **Quota race**: dua user bayar bersamaan untuk slot terakhir → slot harus di-hold saat Booking dibuat (status AWAITING_PAY masih hitung kuota), release otomatis saat Payment EXPIRED.
|
|
- **Trip dibatalkan organizer setelah peserta bayar** → `Booking.status = REFUNDED` setelah dana balik. Implementasi refund Midtrans = PR terpisah (tidak di scope PR C ini).
|
|
- **User retry pembayaran setelah gagal** → bikin Payment baru (bukan reuse), `externalOrderId` baru (`setrip-{bookingId}-{retry}`). Booking status tetap AWAITING_PAY.
|
|
- **Webhook duplicate**: Midtrans bisa kirim notifikasi yang sama 2-3 kali. Idempotency key = `Payment.externalOrderId` + status terkini.
|
|
- **Sandbox vs production**: simulator Midtrans akan kirim callback ke `MIDTRANS_NOTIFICATION_URL`. Pastikan URL sandbox bisa diakses publik (tunneling kalau dev lokal — ngrok / cloudflared).
|
|
|
|
---
|
|
|
|
## ❌ Anti-list (yang harus DITOLAK kalau muncul)
|
|
|
|
- **Halaman payment yang berubah jadi marketplace checkout**: upsell ("trip serupa lebih murah"), pricing comparison, "harga turun" — semua menarik framing OTA. Halaman payment fokus pada satu transaksi: lunas atau belum.
|
|
- **Multi-method abstraksi prematur**: jangan bikin "PaymentProvider" generic untuk Stripe/Xendit/Doku sekaligus sebelum salah satu jalan. Mulai dari MANUAL + MIDTRANS, baru tambah kalau perlu.
|
|
- **Auto-refund logic kompleks** sebelum manual refund di dashboard Midtrans dipakai. Refund jarang, manual cukup di Phase awal.
|
|
- **Payment retry otomatis** dari sisi server. User harus eksplisit klik "bayar lagi" untuk attempt baru — supaya tidak ambigu siapa yang trigger.
|
|
- **Multi-currency** sebelum ada permintaan eksplisit. `currency` di schema sudah default IDR, tapi tidak perlu UI selector.
|
|
- **Saving credit card / tokenization** tanpa kebutuhan jelas. PCI scope naik drastis. Snap sudah handle tanpa simpan kartu di sisi kita.
|
|
|
|
---
|
|
|
|
## Saran phasing
|
|
|
|
PR berurutan. Setiap PR mandiri (siap di-deploy):
|
|
|
|
1. **PR A** — Free trip handling + halaman payment (manual). Cepat, low-risk, no migration. **Mulai dari sini.**
|
|
2. **PR B** — Refactor ke `Booking` + `Payment`. Migration + backfill data lama. UI tetap mirip.
|
|
3. **PR C** — Tambah Midtrans Snap. Test di sandbox dulu sebelum production.
|
|
|
|
Pertanyaan terbuka sebelum mulai:
|
|
|
|
1. **Akses halaman payment**: hanya peserta CONFIRMED, atau juga PENDING (yang belum disetujui organizer)? Saya rekomendasi CONFIRMED only — peserta PENDING belum perlu lihat detail bayar.
|
|
2. **Snapshot amount di Booking**: kalau organizer ubah `trip.price` setelah booking dibuat, booking lama pakai harga lama atau baru? Saya rekomendasi tetap pakai snapshot lama (audit-friendly).
|
|
3. **Manual + Midtrans co-exist**: user pilih satu provider per booking, atau bisa retry dengan provider berbeda? Saya rekomendasi pilih satu — kalau gagal di Midtrans, bisa cancel dan buat Payment baru dengan provider MANUAL.
|