Files
setrip/PAYMENT_ROADMAP.md
T

19 KiB

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 (markParticipantPayment, confirmParticipantPayment), features/booking/actions.ts, features/booking/components/organizer-payment-queue.tsx, features/trip/components/join-trip-button.tsx, 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
A2 Service guard: markParticipantPayment & confirmParticipantPayment reject trip gratis server/services/trip.service.ts
A3 UI gate di JoinTripButton: hide flow pembayaran kalau gratis features/trip/components/join-trip-button.tsx
A4 UI gate di trip detail: skip OrganizerPaymentQueue kalau gratis app/trips/[id]/page.tsx
A5 Halaman /trips/[id]/payment (server component dengan akses kontrol) app/trips/[id]/payment/page.tsx
A6 Konten halaman trip gratis: banner 🎉 + status keikutsertaan + CTA FreeTripSection di page.tsx
A7 Konten halaman trip berbayar: timeline + rekening organizer + tombol "Saya sudah bayar" + status PaidTripSection, PaymentTimeline, BankRow di page.tsx
A8 Link dari trip detail → /trips/[id]/payment (replace tombol inline) showPaymentLink di join-trip-button.tsx
A9 Metadata + robots: noindex halaman payment page.tsxmetadata.robots: { index: false, follow: false }
A+ MarkPaidButton (client component) untuk action "Saya sudah bayar" features/booking/components/mark-paid-button.tsx
A+ CopyButton (client component) untuk copy nomor rekening / nominal 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)

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
B2 Migration add_booking_payment (CreateEnum + CreateTable + Index + FK) prisma/migrations/20260508150000_add_booking_payment/migration.sql
B3 Backfill script TS (idempotent, skip baris yang sudah punya Booking) prisma/backfill-bookings.ts
B4 bookingRepo + paymentRepo server/repositories/booking.repo.ts, server/repositories/payment.repo.ts
B5 bookingServicemarkPaidManual, confirmPaidManual, getByTripAndUser, getAwaitingManualForTrip (idempotent, transactional dengan retry serialisasi) server/services/booking.service.ts
B6 tripService.markParticipantPayment → delegate ke bookingService.markPaidManual. Tetap update TripParticipant.markedPaidAt untuk backcompat. server/services/trip.service.ts
B7 tripService.confirmParticipantPayment → delegate ke bookingService.confirmPaidManual. Tetap update paymentConfirmedAt. server/services/trip.service.ts
B+ tripService.joinTrip → upsert Booking PENDING (handle re-join dari CANCELLED) server/services/trip.service.ts
B+ tripService.confirmParticipant → transition Booking PENDING → AWAITING_PAY (paid) atau PAID (free) server/services/trip.service.ts
B+ tripService.cancelJoin & rejectParticipant → Booking → CANCELLED server/services/trip.service.ts
B8 Halaman /trips/[id]/payment baca dari Booking + Payment (bukan timestamp lama) app/trips/[id]/payment/page.tsx
B9 OrganizerPaymentQueue di trip detail dapat data dari bookingService.getAwaitingManualForTrip app/trips/[id]/page.tsx
B10 Deprecate TripParticipant.markedPaidAt + paymentConfirmedAt (komen @deprecated, tetap di-update untuk backcompat) prisma/schema.prisma
B11 Index optimization (@@index([tripId, status]) di Booking, @@index([provider, status]) di Payment, @@index([userId]) di Booking) 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 <NEXT_PUBLIC_SITE_URL>/api/webhooks/midtrans.

Sudah ditambah ke env.example.

Tugas

# Item Status File
C1 Update env.example + 3 env baru + komentar webhook URL env.example
C2 lib/midtrans.tscreateSnapTransaction, verifyMidtransSignature (timing-safe compare), MIDTRANS config helper lib/midtrans.ts
C3 Status mapping mapMidtransStatus(transaction_status, fraud_status)PaymentStatus 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
C5 Halaman payment: tombol "Bayar online via Midtrans" + divider "atau" + tombol manual lama app/trips/[id]/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
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
C8 paymentService.handleMidtransWebhook — verifikasi signature, amount check, transaction (Payment + Booking + TripParticipant.paymentConfirmedAt backcompat) server/services/payment.service.ts
C10 Anti-replay: skip update kalau Payment sudah final (PAID/FAILED/EXPIRED/CANCELLED/REFUNDED) payment.service.ts
C11 Simpan callback mentah ke Payment.rawCallback (audit & dispute), termasuk untuk callback yang di-skip payment.service.ts
C+ Server action startMidtransPaymentAction (resolve booking dari tripId, bridge ke client) 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
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, route.ts
2 startMidtransPayment lupa cek trip departure date Tambah isTripDepartureDayPast guard, juga di bookingService.markPaidManual untuk konsistensi payment.service.ts, 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, migration, 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, route.ts, 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 bayarBooking.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 = <NEXT_PUBLIC_SITE_URL>/api/webhooks/midtrans. Dev lokal perlu tunneling (ngrok / cloudflared) supaya endpoint bisa di-reach Midtrans.
  • Booking belum approved (PENDING) tapi user coba bayarpaymentService.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.