diff --git a/PAYMENT_ROADMAP.md b/PAYMENT_ROADMAP.md
new file mode 100644
index 0000000..432f817
--- /dev/null
+++ b/PAYMENT_ROADMAP.md
@@ -0,0 +1,244 @@
+# 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) ⏳
+
+Pondasi untuk Midtrans tanpa lompat ke gateway dulu. UI tetap, sumber kebenaran pindah dari timestamp di `TripParticipant` ke tabel `Booking`/`Payment`.
+
+### 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 | Catatan |
+|---|---|---|---|
+| B1 | Update [prisma/schema.prisma](prisma/schema.prisma) — tambah `Booking`, `Payment`, 3 enum | ⏳ | Relasi 1-1 ke `TripParticipant` via `participantId` unique. |
+| B2 | Migration baru `add_booking_payment` | ⏳ | CreateTable Booking & Payment + CreateEnum + index. |
+| B3 | Data backfill migration: bikin `Booking` + `Payment` untuk semua `TripParticipant` aktif yang sudah punya `markedPaidAt` atau `paymentConfirmedAt` | ⏳ | Provider `MANUAL`. Status mapping: `paymentConfirmedAt → PAID`, `markedPaidAt only → AWAITING`, none → PENDING. Trip gratis → Booking PAID, tanpa Payment row. |
+| B4 | `server/repositories/booking.repo.ts` + `payment.repo.ts` | ⏳ | findByTrip, findByUser, findByExternalOrderId. |
+| B5 | `server/services/booking.service.ts` | ⏳ | `createForParticipant`, `markPaid (manual)`, `confirmPaid (organizer)`, `expireOldPending`. Idempotent. |
+| B6 | Refactor `tripService.markParticipantPayment` → delegate ke `bookingService` | ⏳ | Backward compat: tetap update `TripParticipant` timestamp untuk transisi UI. |
+| B7 | Refactor `confirmParticipantPayment` → delegate ke `bookingService` | ⏳ | Sama. |
+| B8 | Update halaman payment (PR A) untuk baca dari `Booking`/`Payment` | ⏳ | Status timeline lebih kaya: dibuat → menunggu bayar → menunggu konfirmasi → lunas. |
+| B9 | Update `OrganizerPaymentQueue` query | ⏳ | List Booking dengan Payment status AWAITING (manual) vs status sebelumnya. |
+| B10 | Deprecate `TripParticipant.markedPaidAt` + `paymentConfirmedAt` | ⏳ | Tetap ada di DB untuk transisi. Hapus di PR berikutnya setelah UI fully cutover. |
+| B11 | Index optimization | ⏳ | `@@index([tripId, status])` di Booking, `@@index([provider, status])` di Payment. |
+
+**Tindakan manual:** `npx prisma migrate deploy` + jalankan backfill script (B3 bisa dimasukkan ke migration SQL atau script TS terpisah).
+
+---
+
+## 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.
diff --git a/app/trips/[id]/page.tsx b/app/trips/[id]/page.tsx
index 077ec11..7dc307f 100644
--- a/app/trips/[id]/page.tsx
+++ b/app/trips/[id]/page.tsx
@@ -18,6 +18,7 @@ import { ImageGallery } from "@/features/trip/components/image-gallery";
import { TripReviewSection } from "@/features/review/components/trip-review-section";
import { categoryMeta } from "@/lib/activity-category";
import { vibeMeta } from "@/lib/vibe";
+import { isFreeTrip } from "@/lib/trip-pricing";
import {
isPastTripLastDayForReview,
isTripDepartureDayPast,
@@ -126,9 +127,12 @@ export default async function TripDetailPage({
) / 10
: null;
- const paymentPendingParticipants = activeParticipants.filter(
- (p) => p.markedPaidAt && !p.paymentConfirmedAt
- );
+ const tripIsFree = isFreeTrip(trip);
+ const paymentPendingParticipants = tripIsFree
+ ? []
+ : activeParticipants.filter(
+ (p) => p.markedPaidAt && !p.paymentConfirmedAt
+ );
const catMeta = categoryMeta(trip.category);
@@ -426,6 +430,7 @@ export default async function TripDetailPage({
isLoggedIn={!!session?.user}
isOrganizer={isOrganizer}
isJoined={!!currentParticipation}
+ isFree={tripIsFree}
participationStatus={
currentParticipation?.status === "PENDING" ||
currentParticipation?.status === "CONFIRMED"
diff --git a/app/trips/[id]/payment/page.tsx b/app/trips/[id]/payment/page.tsx
new file mode 100644
index 0000000..5fc4d73
--- /dev/null
+++ b/app/trips/[id]/payment/page.tsx
@@ -0,0 +1,411 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { notFound, redirect } from "next/navigation";
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/lib/auth";
+import { tripService } from "@/server/services/trip.service";
+import { organizerService } from "@/server/services/organizer.service";
+import { formatRupiah } from "@/lib/utils";
+import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
+import { isFreeTrip } from "@/lib/trip-pricing";
+import { categoryMeta } from "@/lib/activity-category";
+import { MarkPaidButton } from "@/features/booking/components/mark-paid-button";
+import { CopyButton } from "@/features/booking/components/copy-button";
+
+export const metadata: Metadata = {
+ title: "Detail Pembayaran",
+ robots: { index: false, follow: false },
+};
+
+interface PageProps {
+ params: Promise<{ id: string }>;
+}
+
+export default async function PaymentPage({ params }: PageProps) {
+ const { id } = await params;
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ redirect(`/login?callbackUrl=/trips/${id}/payment`);
+ }
+
+ let trip;
+ try {
+ trip = await tripService.getTripById(id);
+ } catch {
+ notFound();
+ }
+
+ // Organizer trip-nya sendiri tidak butuh halaman pembayaran.
+ if (trip.organizerId === session.user.id) {
+ redirect(`/trips/${id}`);
+ }
+
+ const myParticipation = trip.participants.find(
+ (p) => p.userId === session.user.id && p.status !== "CANCELLED"
+ );
+
+ if (!myParticipation || myParticipation.status === "CANCELLED") {
+ return (
+
+ {catMeta.label}
+
+ 📅 {dateRange} · 📍 {trip.location}
+
+ Organizer:{" "}
+
+ {trip.organizer.name}
+
+
+ {trip.title}
+
+
+ {tripIsFree + ? "Trip ini gratis — kamu tidak perlu transfer apa-apa." + : "Transfer manual ke rekening organizer di bawah, lalu tandai sebagai sudah bayar."} +
+ + {tripHeader} + + {tripIsFree ? ( ++ Halaman pembayaran hanya tersedia untuk peserta trip{" "} + {title}. +
+ + Lihat detail trip + ++ Tidak ada biaya yang perlu kamu transfer. +
+ ++ Status keikutsertaan +
++ {participationStatus === "CONFIRMED" + ? "✅ Terkonfirmasi sebagai peserta" + : "⏳ Menunggu persetujuan organizer"} +
+Rekening organizer belum tersedia
++ Organizer trip ini belum melengkapi data verifikasi (bank). Hubungi + organizer langsung lewat profilnya untuk koordinasi pembayaran. +
++ Pastikan nominal persis seperti tercantum supaya organizer mudah + mencocokkan. +
+ ++ ✅ Pembayaran kamu sudah dikonfirmasi oleh{" "} + + {organizerName} + + . Sampai jumpa di trip! +
+ ) : ( ++ ⏳ Kamu sudah menandai sudah bayar. Menunggu organizer mengecek + dan mengonfirmasi. +
+ )} ++ {label} +
++ {value} +
+