# 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) ✅ Selesai. `tsc --noEmit` lulus. Belum test live ke sandbox Midtrans — perlu env diisi + tunneling kalau dev lokal. ### Persiapan akun & env | Env | Keterangan | |---|---| | `MIDTRANS_SERVER_KEY` | Server key dari dashboard Midtrans (sandbox/production sesuai mode). Rahasia. Server-side only. | | `NEXT_PUBLIC_MIDTRANS_CLIENT_KEY` | Client key untuk Snap.js. Aman di-expose ke frontend (NEXT_PUBLIC_). | | `NEXT_PUBLIC_MIDTRANS_IS_PRODUCTION` | `true` untuk production, `false` (atau kosong) untuk sandbox. NEXT_PUBLIC_ supaya client tahu URL Snap.js yang benar. | `MIDTRANS_NOTIFICATION_URL` **tidak** di env — diset langsung di dashboard Midtrans ke `/api/webhooks/midtrans`. Sudah ditambah ke [env.example](env.example). ### Tugas | # | Item | Status | File | |---|---|---|---| | C1 | Update [env.example](env.example) + 3 env baru + komentar webhook URL | ✅ | [env.example](env.example) | | C2 | `lib/midtrans.ts` — `createSnapTransaction`, `verifyMidtransSignature` (timing-safe compare), `MIDTRANS` config helper | ✅ | [lib/midtrans.ts](lib/midtrans.ts) | | C3 | Status mapping `mapMidtransStatus(transaction_status, fraud_status)` → `PaymentStatus` | ✅ | [lib/midtrans.ts](lib/midtrans.ts) | | C4 | `paymentService.startMidtransPayment(bookingId, userId)` — validate, reuse Payment AWAITING aktif (idempotent re-attempt), atau buat Payment baru + call Snap API + simpan token + expiresAt | ✅ | [server/services/payment.service.ts](server/services/payment.service.ts) | | C5 | Halaman payment: tombol "Bayar online via Midtrans" + divider "atau" + tombol manual lama | ✅ | [app/trips/[id]/payment/page.tsx](app/trips/%5Bid%5D/payment/page.tsx) | | C6 | `MidtransPayButton` client component — load Snap.js dengan `data-client-key` dinamis, `window.snap.pay(token, callbacks)`, refresh page setelah Snap close | ✅ | [features/booking/components/midtrans-pay-button.tsx](features/booking/components/midtrans-pay-button.tsx) | | C7 | Webhook endpoint `app/api/webhooks/midtrans/route.ts` — POST, verify signature, lookup, idempotent, return 200/401 sesuai outcome | ✅ | [app/api/webhooks/midtrans/route.ts](app/api/webhooks/midtrans/route.ts) | | C8 | `paymentService.handleMidtransWebhook` — verifikasi signature, amount check, transaction (`Payment` + `Booking` + `TripParticipant.paymentConfirmedAt` backcompat) | ✅ | [server/services/payment.service.ts](server/services/payment.service.ts) | | C10 | Anti-replay: skip update kalau Payment sudah final (PAID/FAILED/EXPIRED/CANCELLED/REFUNDED) | ✅ | [payment.service.ts](server/services/payment.service.ts) | | C11 | Simpan callback mentah ke `Payment.rawCallback` (audit & dispute), termasuk untuk callback yang di-skip | ✅ | [payment.service.ts](server/services/payment.service.ts) | | C+ | Server action `startMidtransPaymentAction` (resolve booking dari tripId, bridge ke client) | ✅ | [features/booking/actions.ts](features/booking/actions.ts) | | C+ | Retry handling: Payment row baru dengan `midtrans-{bookingId}-{retryN}` kalau attempt lama expired/failed; idempotent reuse kalau masih AWAITING | ✅ | [payment.service.ts](server/services/payment.service.ts) | | C9 | Cron expire Payment lama | ⏸️ skipped | Housekeeping di-handle saat user start payment (auto-expire attempt yang lewat `expiresAt`). Cron formal bisa ditambah kalau perlu cleanup massal. | | C12 | Test scenario sandbox (settlement, deny, expire) | ⏸️ manual | Perlu env Midtrans diisi + tunneling untuk dev lokal (ngrok/cloudflared). Tidak bisa otomatis dari sini. | | C13 | Status badge real-time | ⏸️ partial | Page refresh setelah Snap close + halaman SSR pull state baru tiap reload. Polling otomatis belum diimplementasi. | | C14 | Email/in-app notification setelah PAID | ⏳ pending | Diluar scope PR C — masuk 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). ### Hardening pasca-audit (sebelum Midtrans live) ✅ Empat fix tambahan dari audit security/correctness: | Fix | Issue | Solusi | File | |---|---|---|---| | 1 | Webhook bisa overwrite Booking CANCELLED/REFUNDED/EXPIRED jadi PAID | Re-fetch Booking di dalam serializable transaction; kalau state konflik, Payment tetap PAID (uang masuk) tapi Booking tidak di-update + `Payment.rejectionReason` di-flag untuk manual review/refund. Webhook outcome `booking_conflict` di-log warning. | [payment.service.ts](server/services/payment.service.ts), [route.ts](app/api/webhooks/midtrans/route.ts) | | 2 | `startMidtransPayment` lupa cek trip departure date | Tambah `isTripDepartureDayPast` guard, juga di `bookingService.markPaidManual` untuk konsistensi | [payment.service.ts](server/services/payment.service.ts), [booking.service.ts](server/services/booking.service.ts) | | 3 | `Booking` tidak punya constraint `(tripId, userId)` unique | Tambah `@@unique([tripId, userId])` + migration `20260508160000_booking_unique_trip_user`. `findByTripAndUser` switch dari `findFirst` ke `findUnique` (lebih efisien) | [schema.prisma](prisma/schema.prisma), [migration](prisma/migrations/20260508160000_booking_unique_trip_user/migration.sql), [booking.repo.ts](server/repositories/booking.repo.ts) | | 4 | Webhook payload tidak schema-validated | Zod `midtransWebhookSchema` (passthrough untuk forward-compat). Webhook route `safeParse` → 400 kalau shape invalid. Service signature pakai type yang inferred dari schema. | [lib/midtrans.ts](lib/midtrans.ts), [route.ts](app/api/webhooks/midtrans/route.ts), [payment.service.ts](server/services/payment.service.ts) | ### Edge cases yang gampang lupa - **Quota race**: dua user bayar bersamaan untuk slot terakhir → slot di-hold saat Booking dibuat (status AWAITING_PAY masih hitung kuota via `TripParticipant.status`). Release belum otomatis saat Payment EXPIRED — kalau perlu, tambah cron (lihat C9 yang di-skip). - **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, `externalOrderId` baru (`midtrans-{bookingId}-{retryN}`). Reuse kalau masih AWAITING & belum expired. - **Webhook duplicate**: Midtrans bisa kirim notifikasi yang sama 2-3 kali. Idempotent: skip update kalau Payment sudah final, tapi tetap simpan callback ke `rawCallback` untuk audit. - **Sandbox vs production**: webhook URL diset di dashboard Midtrans = `/api/webhooks/midtrans`. Dev lokal perlu tunneling (ngrok / cloudflared) supaya endpoint bisa di-reach Midtrans. - **Booking belum approved (`PENDING`) tapi user coba bayar** — `paymentService.startMidtransPayment` reject dengan pesan jelas. UI sudah hide tombol di state ini. --- ## ❌ 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.