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.tsx — metadata.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 | bookingService — markPaidManual, 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):
npx prisma migrate deploy— apply schema migration20260508150000_add_booking_payment.npx tsx prisma/backfill-bookings.ts— populate Booking + Payment dariTripParticipantlama. Idempotent, aman dijalankan ulang.- 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.ts — createSnapTransaction, 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)
- Verify signature:
sha512(order_id + status_code + gross_amount + SERVER_KEY) === signature_key. Mismatch → 401, log. - Cek
gross_amountcocok denganpayment.amount— kalau tidak sama, log anomaly, jangan PAID. - Lookup
Payment.externalOrderId === order_id. Tidak ada → 200 OK + log (jangan biarkan Midtrans retry forever). - Idempotent: kalau status sudah final, skip update tapi tetap return 200.
- Pakai DB transaction untuk update Payment + Booking + TripParticipant bersamaan.
- 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 bayar →
Booking.status = REFUNDEDsetelah dana balik. Implementasi refund Midtrans = PR terpisah (tidak di scope PR C ini). - User retry pembayaran setelah gagal → bikin Payment baru,
externalOrderIdbaru (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
rawCallbackuntuk 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 bayar —paymentService.startMidtransPaymentreject 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.
currencydi 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):
- PR A — Free trip handling + halaman payment (manual). Cepat, low-risk, no migration. Mulai dari sini.
- PR B — Refactor ke
Booking+Payment. Migration + backfill data lama. UI tetap mirip. - PR C — Tambah Midtrans Snap. Test di sandbox dulu sebelum production.
Pertanyaan terbuka sebelum mulai:
- Akses halaman payment: hanya peserta CONFIRMED, atau juga PENDING (yang belum disetujui organizer)? Saya rekomendasi CONFIRMED only — peserta PENDING belum perlu lihat detail bayar.
- Snapshot amount di Booking: kalau organizer ubah
trip.pricesetelah booking dibuat, booking lama pakai harga lama atau baru? Saya rekomendasi tetap pakai snapshot lama (audit-friendly). - 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.