- 
- 
- 
This commit is contained in:
2026-05-18 18:31:16 +07:00
parent b599d01eea
commit c4efe4453b
36 changed files with 3057 additions and 1493 deletions
View File
+4 -1
View File
@@ -31,7 +31,10 @@ yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
.env
.env.production
.env.development
.env.local
# private uploads (KYC: KTP / liveness). Never serve directly.
/uploads/
+109
View File
@@ -0,0 +1,109 @@
# Setrip — Admin Audit & Investigation Roadmap
Admin perlu **mencari** lintas entity (booking/payment/refund/user/trip) dan **export** untuk compliance + investigasi dispute.
> **Skenario nyata:** auditor bertanya "tunjukkan semua refund yang di-approve admin X di bulan Juni 2026 dengan total lebih dari Rp 5 juta". Saat ini admin harus query DB manual atau ambil screenshot satu-satu. Tidak ada cara cari berdasarkan kombinasi reviewer + tanggal + nominal.
---
## Baseline
- ✅ Data audit sudah ada di schema: `Refund.reviewedBy/reviewedAt/adminNote`, `Payout.processedBy/processedAt/adminNote`, `OrganizerVerification.reviewedBy/reviewedAt/rejectionReason`.
- ✅ Existing list pages (`/admin/refunds`, `/admin/payouts`, `/admin/verifications`) sudah grouping by status tab.
- ❌ Tidak ada filter date range / reviewer / amount / reason.
- ❌ Tidak ada kolom "reviewer email" di list — harus klik detail.
- ❌ Tidak ada global search (cari berdasarkan email user, order id, trip id).
- ❌ Tidak ada CSV export.
- ❌ Tidak ada audit log untuk action admin di entity lain (User suspension, Trip force-cancel, Verification reopen).
---
## Phase 1 — Filter & Search Enhancements ⏳
Sebelum bikin audit log baru, perbaiki dulu kemampuan cari & filter di list yang sudah ada.
**Keputusan asumsi:**
- Pakai `searchParams` di Next.js — tidak perlu state client (server-render fast + shareable URL).
- Default date range: 30 hari terakhir, supaya page tidak load semua history.
- Reviewer dropdown sumber dari `ADMIN_EMAILS` env (sudah ada).
| # | Item | Status | File |
|---|---|---|---|
| 1.1 | Filter date range (`from`, `to`) di `/admin/refunds` | ⏳ | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) |
| 1.2 | Filter `reviewedBy` (admin email dropdown) di `/admin/refunds` | ⏳ | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) |
| 1.3 | Filter `reason` di `/admin/refunds` (lihat juga [ADMIN_PAYMENT_OPS_ROADMAP.md](ADMIN_PAYMENT_OPS_ROADMAP.md)) | ⏳ | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) |
| 1.4 | Filter date range + `processedBy` di `/admin/payouts` | ⏳ | [app/admin/payouts/page.tsx](app/admin/payouts/page.tsx) |
| 1.5 | Filter date range + `reviewedBy` di `/admin/verifications` | ⏳ | [app/admin/verifications/page.tsx](app/admin/verifications/page.tsx) |
| 1.6 | Tampilkan kolom "reviewer email" + "reviewed at" di tabel/list (semua admin pages) | ⏳ | semua `app/admin/*/page.tsx` |
| 1.7 | Repo helper: tambah optional filter params di `refundRepo.listByStatus`, `payoutRepo.listByStatus`, `organizerRepo.listByStatus` | ⏳ | `server/repositories/*.ts` |
**Tindakan manual:** tidak ada.
---
## Phase 2 — Global Search ⏳
Satu search box yang resolve ke entity detail page paling relevan.
**Keputusan asumsi:**
- Input string user, prefix-based dispatch:
- Email format (`@`) → user search → redirect ke `/admin/users/[id]`
- Mulai `midtrans-` / `manual-` → payment lookup by `externalOrderId``/admin/bookings/[bookingId]`
- Mulai `cm` (cuid pattern) + length 25 → coba lookup berurutan: trip → booking → user
- Else: full-text search di trip title/destination
- Pakai server action atau route handler `/api/admin/search` — return list hasil + jenis entity.
- UI: searchbar di admin layout (top-right) yang dropdown hasil.
| # | Item | Status | File |
|---|---|---|---|
| 2.1 | `adminSearchService.resolve(query)` — dispatch ke repo lookup yang tepat | ⏳ | `server/services/admin-search.service.ts` |
| 2.2 | Route handler `/api/admin/search?q=...` (GET, guard isAdmin) | ⏳ | `app/api/admin/search/route.ts` |
| 2.3 | Component `AdminSearchBar` di admin layout — debounced, dropdown hasil | ⏳ | `features/admin/components/admin-search-bar.tsx` |
| 2.4 | Page `/admin/search?q=...` untuk full results kalau dropdown limit terlampaui | ⏳ | `app/admin/search/page.tsx` |
**Tindakan manual:** tidak ada.
---
## Phase 3 — CSV Export ⏳
Export untuk laporan keuangan & compliance.
**Keputusan asumsi:**
- Stream CSV via route handler — jangan load semua ke memory.
- Pakai filter yang sama dengan list page — admin pakai URL filter lalu klik "Export".
- Header CSV: human-readable bahasa Indonesia (mis. "Tanggal Approve", "Email Peserta", "Nominal Refund").
- Tidak ada Excel/xlsx — CSV cukup, mudah dibuka di Sheets/Excel.
| # | Item | Status | File |
|---|---|---|---|
| 3.1 | Helper `lib/csv.ts``streamCsv(headers, rows)` return Response | ⏳ | `lib/csv.ts` |
| 3.2 | Route `/api/admin/export/refunds` — pakai filter dari query string | ⏳ | `app/api/admin/export/refunds/route.ts` |
| 3.3 | Route `/api/admin/export/payouts` | ⏳ | `app/api/admin/export/payouts/route.ts` |
| 3.4 | Route `/api/admin/export/verifications` (tanpa NIK / KTP — hanya metadata) | ⏳ | `app/api/admin/export/verifications/route.ts` |
| 3.5 | Tombol "Export CSV" di tiap admin list page | ⏳ | semua `app/admin/*/page.tsx` |
**Tindakan manual:**
1. Test export di staging — pastikan tidak leak data sensitif (NIK harus tetap encrypted/excluded).
2. Update kebijakan privasi: data export hanya untuk internal compliance.
---
## Phase 4 — Generic Admin Audit Log ⏳
Tabel `AdminActionLog` untuk action di entity yang belum punya audit field (User suspend, Trip force-cancel, Verification reopen, dst).
**Keputusan asumsi:**
- Single tabel polymorphic: `AdminActionLog { adminId, action, entityType, entityId, payload Json?, createdAt }`.
- Append-only, never update/delete.
- Service helper `auditLog.record(...)` dipanggil eksplisit di setiap action admin (tidak via Prisma middleware — terlalu magic).
- View page `/admin/audit-log` dengan filter `adminId`, `entityType`, `action`, date range.
| # | Item | Status | File |
|---|---|---|---|
| 4.1 | Model `AdminActionLog` + migration | ⏳ | [prisma/schema.prisma](prisma/schema.prisma) |
| 4.2 | Helper `auditLog.record({ adminId, action, entityType, entityId, payload? })` | ⏳ | `server/services/audit-log.service.ts` |
| 4.3 | Wire `auditLog.record` di semua admin server action existing (refund approve/reject/mark, payout markPaid, verification approve/reject) | ⏳ | `features/*/actions.ts` |
| 4.4 | Page `/admin/audit-log` dengan filter + pagination | ⏳ | `app/admin/audit-log/page.tsx` |
**Tindakan manual:** tidak ada.
+89
View File
@@ -0,0 +1,89 @@
# Setrip — Admin Payment Operations Roadmap
Admin perlu visibilitas + kontrol penuh atas alur uang: payment Midtrans, refund, payout. Saat webhook gagal atau status mismatch, admin harus bisa reconcile tanpa edit DB.
> **Skenario nyata:** webhook Midtrans drop di production. `Booking.status = AWAITING_PAY` padahal user sudah bayar (confirm email dari Midtrans). User komplain via WhatsApp. Saat ini admin harus query DB manual lalu update via Prisma Studio.
---
## Baseline
-`paymentService.reconcileFromGateway(orderId, userId)` di [server/services/payment.service.ts](server/services/payment.service.ts) sudah call Midtrans Core API + apply state-machine. Tapi `userId` check membatasi penggunaan ke user pemilik booking — admin perlu variant.
-`paymentService.handleMidtransWebhook` ada + idempotent via `applyGatewayStatus` helper.
-`Payment.rawCallback` simpan snapshot mentah untuk audit.
-`Refund` + `Payout` model lengkap dengan `reviewedBy`/`processedBy`/`adminNote`.
- ❌ Tidak ada page `/admin/bookings/[id]` untuk drill-down per-booking timeline.
- ❌ Tidak ada admin variant `reconcileFromGateway` (yang tidak butuh userId check).
- ❌ Tidak ada UI yang tampilkan `Payment.rawCallback` JSON.
- ❌ Tidak ada filter refund per `reason` (mis. cari semua DISPUTE_RESOLVED).
- ❌ Tidak ada bulk reconcile untuk stale PENDING/AWAITING payments.
---
## Phase 1 — Booking + Payment Detail View ⏳
Admin perlu satu halaman yang tampilkan **seluruh** event uang untuk satu booking: payment attempts (Midtrans + manual legacy), refund history, payout status, raw callback.
**Keputusan asumsi:**
- Drill-down dari `/admin/trips/[id]`, `/admin/refunds`, `/admin/payouts`, dan global search nanti.
- Tampilkan **timeline chronological** semua event Payment + Refund + Payout untuk booking — bukan tabel terpisah.
- `Payment.rawCallback` ditampilkan sebagai collapsible JSON viewer (tidak default expanded — verbose).
- Show juga `Booking.status` history kalau ada (tidak ada saat ini — `updatedAt` jadi proxy).
| # | Item | Status | File |
|---|---|---|---|
| 1.1 | `bookingRepo.findByIdForAdmin(id)` — include payments (with raw), refunds, payout, trip, user, participant | ⏳ | [server/repositories/booking.repo.ts](server/repositories/booking.repo.ts) |
| 1.2 | Page `/admin/bookings/[id]` — header (trip, user, amount, status), timeline events | ⏳ | `app/admin/bookings/[id]/page.tsx` |
| 1.3 | Component `PaymentTimelineAdmin` — render Payment + Refund + Payout sorted by `createdAt` | ⏳ | `features/booking/components/payment-timeline-admin.tsx` |
| 1.4 | Component `RawCallbackViewer` — collapsible `<details>` block dengan JSON pretty-printed | ⏳ | `features/booking/components/raw-callback-viewer.tsx` |
| 1.5 | Link "Lihat detail" dari `/admin/refunds` ke `/admin/bookings/[id]` | ⏳ | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) |
| 1.6 | Link "Lihat detail" dari `/admin/payouts` ke `/admin/bookings/[id]` | ⏳ | [app/admin/payouts/page.tsx](app/admin/payouts/page.tsx) |
**Tindakan manual:** tidak ada.
---
## Phase 2 — Admin Midtrans Reconciliation UI ⏳
Tombol di booking detail page yang panggil Midtrans Core API + apply update. Admin variant tidak butuh `userId` check.
**Keputusan asumsi:**
- **Reuse internal helper `applyGatewayStatus`** di [server/services/payment.service.ts](server/services/payment.service.ts) — sudah ekstrak.
- Buat `paymentService.adminReconcile(orderId, adminId)` — sama dengan `reconcileFromGateway` tapi:
- Skip ownership check (admin bypass).
- Log `adminId` di `Payment.rawCallback` snapshot (tambah field `_reconciledByAdminId`).
- Server action `adminReconcileMidtransAction(orderId)` guard `isAdmin`.
- UI: tombol per Payment row di timeline. Disable kalau Payment sudah final (PAID/FAILED/EXPIRED/CANCELLED/REFUNDED) tapi tetap show last-reconciled-at.
- Tampilkan toast hasil: "Updated to PAID" / "Already PAID, no change" / "Amount mismatch (audit)".
| # | Item | Status | File |
|---|---|---|---|
| 2.1 | `paymentService.adminReconcile(orderId, adminId)` — variant tanpa ownership check | ⏳ | [server/services/payment.service.ts](server/services/payment.service.ts) |
| 2.2 | Server action `adminReconcileMidtransAction(orderId)` | ⏳ | `features/booking/actions.ts` (atau `features/admin/actions.ts` baru) |
| 2.3 | Tombol "Reconcile dari Midtrans" di tiap Payment Midtrans di timeline | ⏳ | `features/booking/components/payment-timeline-admin.tsx` |
| 2.4 | Tampilkan `Payment.rejectionReason` (untuk amount mismatch log) di card payment | ⏳ | `features/booking/components/payment-timeline-admin.tsx` |
| 2.5 | (Optional) Bulk reconcile: `/admin/payments/stale` — list Payment status PENDING/AWAITING > 6 jam | ⏳ | `app/admin/payments/stale/page.tsx` |
**Tindakan manual:**
1. Brief admin: kapan pakai reconcile (peserta lapor "sudah bayar tapi status belum update"). Jangan dipakai untuk PAID booking (idempotent tapi noise).
---
## Phase 3 — Dispute & Chargeback Tracking ⏳
`RefundReason.DISPUTE_RESOLVED` sudah ada di enum tapi tidak ada flow khusus.
**Keputusan asumsi:**
- Tidak buat tabel baru. Filter di refund list page cukup.
- Tambah "Chargeback note" field di Refund kalau perlu (skip untuk MVP — pakai `adminNote` saja).
- Highlight visual: badge merah untuk DISPUTE_RESOLVED supaya admin treat khusus.
| # | Item | Status | File |
|---|---|---|---|
| 3.1 | Tab/filter `reason` di `/admin/refunds` — dropdown semua nilai `RefundReason` | ⏳ | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) |
| 3.2 | Badge khusus untuk `DISPUTE_RESOLVED` di refund card | ⏳ | [features/refund/components/refund-review-card.tsx](features/refund/components/refund-review-card.tsx) |
| 3.3 | Dokumentasi SOP: kapan pakai `DISPUTE_RESOLVED` vs reason lain | ⏳ | `docs/admin/refund-reasons.md` (baru) |
**Tindakan manual:**
1. Tulis SOP dispute handling (alur bank → admin → refund creation).
2. Brief admin: `DISPUTE_RESOLVED` hanya untuk chargeback yang sudah resolve via bank.
+48
View File
@@ -0,0 +1,48 @@
# Setrip — Admin Roadmap (Index)
Status implementasi kemampuan admin agar admin **dapat mengontrol seluruh aplikasi saat ada insiden**, bukan hanya read-only dashboard.
> **Prinsip:** admin adalah safety net terakhir saat sistem otomatis gagal atau ada bad actor. Setiap action admin harus auditable (siapa, kapan, alasan), idempotent, dan terbatas hanya untuk admin yang terdaftar di `ADMIN_EMAILS`.
---
## Baseline (yang BISA admin lakukan sekarang)
| Area | Fungsi | File |
|---|---|---|
| Dashboard | View count: verifikasi PENDING, refund per status, payout per status | [app/admin/page.tsx](app/admin/page.tsx) |
| Verifikasi KYC | Approve / Reject organizer (KTP, liveness, bank) | [app/admin/verifications/page.tsx](app/admin/verifications/page.tsx) |
| Refund | Create manual, approve, reject, mark SUCCEEDED, mark FAILED | [app/admin/refunds/page.tsx](app/admin/refunds/page.tsx) |
| Payout | View per status, mark PAID setelah transfer manual | [app/admin/payouts/page.tsx](app/admin/payouts/page.tsx) |
Auth admin: env `ADMIN_EMAILS` → cek di [lib/admin.ts](lib/admin.ts), dipassing ke session via [lib/auth.ts](lib/auth.ts).
---
## Roadmap per area
| Roadmap | Prioritas | Status | File |
|---|---|---|---|
| Trip Operations (search, view, cancel manual) | 🔴 HIGH | ⏳ 0% | [ADMIN_TRIP_OPS_ROADMAP.md](ADMIN_TRIP_OPS_ROADMAP.md) |
| Payment Operations (booking detail, reconcile, dispute) | 🔴 HIGH | 🚧 ~15% | [ADMIN_PAYMENT_OPS_ROADMAP.md](ADMIN_PAYMENT_OPS_ROADMAP.md) |
| Audit & Investigation (search, filter, export) | 🔴 HIGH | ⏳ 0% | [ADMIN_AUDIT_ROADMAP.md](ADMIN_AUDIT_ROADMAP.md) |
| User Management (search, suspend/ban) | 🟡 MEDIUM | ⏳ 0% | [ADMIN_USER_MGMT_ROADMAP.md](ADMIN_USER_MGMT_ROADMAP.md) |
| Verification (reopen, re-upload request) | 🟡 MEDIUM | ⏳ 0% | [ADMIN_VERIFICATION_ROADMAP.md](ADMIN_VERIFICATION_ROADMAP.md) |
| System Health (cron monitor, stale state alerts) | 🟡 MEDIUM | ⏳ 0% | [ADMIN_SYSTEM_HEALTH_ROADMAP.md](ADMIN_SYSTEM_HEALTH_ROADMAP.md) |
**Legend status:** ⏳ belum mulai · 🚧 partial · ✅ selesai
---
## Urutan implementasi yang direkomendasikan
Berdasarkan ROI (frekuensi kebutuhan × dampak insiden):
1. **Trip Ops** — paling sering dibutuhkan, infrastruktur service sudah lengkap (`tripService.closeTrip`)
2. **Payment Ops** — kritikal saat webhook gagal; setengah infra sudah ada (`reconcileFromGateway`)
3. **Audit** — compliance + investigasi dispute; data sudah lengkap (`reviewedBy`, `processedBy`, `adminNote`), tinggal UI filter/export
4. **User Management** — moderation; butuh schema change (`User.suspended`)
5. **Verification** — edge case rare; cuma butuh 1 service method + tombol
6. **System Health** — operational visibility; butuh model baru (`CronRun`)
Tiga roadmap pertama menutup ~90% skenario "admin powerless when shit hits the fan".
+103
View File
@@ -0,0 +1,103 @@
# Setrip — Admin System Health Roadmap
Admin perlu visibilitas atas job otomatis (cron) dan deteksi state yang nyangkut (Payment stale, Payout overdue, Refund mandek).
> **Skenario nyata:** cron auto-complete trip crash karena env variable rusak. 50 trip yang sudah lewat tanggalnya tetap `OPEN` selama 3 hari sampai peserta komplain "kenapa belum bisa kasih review". Admin tidak punya cara cek status cron tanpa SSH ke server.
---
## Baseline
- ✅ Cron infra ada (system crontab + `CRON_SECRET`) — lihat [docs/CRON_SETUP.md](docs/CRON_SETUP.md).
- ✅ Cron jobs aktif: `app/api/cron/auto-complete-trips/route.ts`, payout release (lewat `payoutService`), kemungkinan refund timeout.
- ❌ Tidak ada log/audit per cron run (success/fail/error).
- ❌ Tidak ada page `/admin/system` untuk lihat status.
- ❌ Tidak ada alert deteksi state stale (Payment AWAITING > 24h, Payout HELD past `heldUntil`, Refund APPROVED > 7d).
---
## Phase 1 — Cron Run Log ⏳
Tabel `CronRun` yang dicatat setiap kali cron jalan. Foundation untuk semua observability.
**Keputusan asumsi:**
- Append-only model. Retention: keep all (tabel kecil, ~365 rows/year/cron). Cleanup nanti kalau perlu.
- Wrap existing cron handler dengan helper `runCron(name, fn)` yang otomatis log start/finish/error.
- Tidak pakai job library (BullMQ/Inngest) — overkill. Tetap pakai system cron + Next route handler.
| # | Item | Status | File |
|---|---|---|---|
| 1.1 | Model `CronRun { id, jobName, startedAt, finishedAt?, status (RUNNING/SUCCESS/FAILED), errorMessage?, payload? Json }` + migration | ⏳ | [prisma/schema.prisma](prisma/schema.prisma) |
| 1.2 | Helper `runCron(jobName, fn)` — wrap handler, otomatis create RUNNING row → SUCCESS/FAILED | ⏳ | `lib/cron-runner.ts` |
| 1.3 | Wire `runCron` di `app/api/cron/auto-complete-trips/route.ts` | ⏳ | `app/api/cron/auto-complete-trips/route.ts` |
| 1.4 | Wire `runCron` di cron payout release (kalau sudah ada — kalau belum, daftar sebagai gap) | ⏳ | TBD |
| 1.5 | Wire `runCron` di cron lain (refund sweep, dst) | ⏳ | TBD |
**Tindakan manual:** tidak ada.
---
## Phase 2 — System Status Page ⏳
Page `/admin/system` yang tampilkan kondisi terkini.
**Keputusan asumsi:**
- Tabel per cron job: last run, last success, total runs (7d), error count (7d).
- Refresh manual (tombol "Refresh") — bukan auto-poll. Cukup untuk admin.
- Health badge: 🟢 OK (last success < 25 jam untuk daily), 🟡 STALE (> 25 jam), 🔴 FAILED (last run = FAILED).
- Tampilkan 20 cron run terbaru di table bawah untuk drill-down.
| # | Item | Status | File |
|---|---|---|---|
| 2.1 | `cronRepo.getJobSummary(jobName)` — last run, last success, count 7d | ⏳ | `server/repositories/cron.repo.ts` |
| 2.2 | `cronRepo.listRecent(limit)` — 20 run terakhir lintas job | ⏳ | `server/repositories/cron.repo.ts` |
| 2.3 | Page `/admin/system` — tabel job summary + tabel recent runs | ⏳ | `app/admin/system/page.tsx` |
| 2.4 | Health badge logic (helper) | ⏳ | `lib/cron-health.ts` |
| 2.5 | Link "System" di admin navbar | ⏳ | [app/admin/layout.tsx](app/admin/layout.tsx) |
**Tindakan manual:**
1. Set ekspektasi SLA per cron (mis. `auto-complete-trips` harus jalan setiap hari sebelum jam 06:00 WIB).
2. Brief admin: cek `/admin/system` minimal sekali per hari pagi sebelum mulai kerja.
---
## Phase 3 — Stale State Alerts ⏳
Deteksi entity yang nyangkut di state non-final terlalu lama. Tampilkan sebagai banner di `/admin/system`.
**Keputusan asumsi:**
- Stale thresholds (review dengan stakeholder, ini draft):
- Payment status `PENDING` > 1 jam → suspect: gagal create Snap token, perlu manual cleanup
- Payment status `AWAITING` > 25 jam (lebih dari expiresAt) → suspect: webhook gagal, expire belum di-set, perlu reconcile
- Booking status `AWAITING_PAY` + trip date < today → suspect: peserta lupa bayar, butuh cleanup
- Payout status `HELD` + `heldUntil < now` > 1 hari → suspect: cron release tidak jalan, perlu trigger manual
- Refund status `APPROVED` > 7 hari → suspect: admin lupa proses, atau Midtrans refund gagal
- Compute via query parameter pada page load — tidak perlu materialized view.
- Setiap kategori tampilkan jumlah + link ke filtered list page yang relevan.
| # | Item | Status | File |
|---|---|---|---|
| 3.1 | `systemHealthService.detectStale()` return `{ stalePayments, expiredAwaiting, awaitingPayPastDeparture, overduePayouts, stuckRefunds }` | ⏳ | `server/services/system-health.service.ts` |
| 3.2 | Banner alerts di `/admin/system` kalau ada count > 0 | ⏳ | `app/admin/system/page.tsx` |
| 3.3 | Link tiap alert ke filtered list (pakai filter di [ADMIN_AUDIT_ROADMAP.md](ADMIN_AUDIT_ROADMAP.md) Phase 1) | ⏳ | `app/admin/system/page.tsx` |
| 3.4 | Stat card di dashboard utama `/admin` kalau ada alert | ⏳ | [app/admin/page.tsx](app/admin/page.tsx) |
**Tindakan manual:**
1. Tuning threshold setelah jalan 1-2 minggu (false positive vs miss).
2. SOP per alert: action apa yang admin harus ambil saat banner muncul.
---
## Phase 4 — External Alerting (opsional) ⏳
Push notif ke channel eksternal (Discord/Telegram/email) saat ada cron FAILED atau stale state critical. Skip kecuali admin sering miss banner.
| # | Item | Status | File |
|---|---|---|---|
| 4.1 | Helper `notifyAdmins(message)` — POST ke Discord webhook URL dari env | ⏳ | `lib/admin-notify.ts` |
| 4.2 | Trigger notify di `runCron` saat FAILED | ⏳ | `lib/cron-runner.ts` |
| 4.3 | Trigger notify dari `systemHealthService.detectStale` (rate-limited, max 1x/hari per kategori) | ⏳ | `server/services/system-health.service.ts` |
**Tindakan manual:**
1. Buat channel Discord internal + webhook URL → set env `ADMIN_ALERT_WEBHOOK_URL`.
2. Test alert dengan trigger fake fail.
+77
View File
@@ -0,0 +1,77 @@
# Setrip — Admin Trip Operations Roadmap
Admin perlu visibilitas penuh atas trip dan bisa intervensi (cancel + auto-refund) saat organizer unreachable atau ada masalah safety.
> **Skenario nyata:** peserta lapor trip berjalan tidak sesuai itinerary. Organizer tidak responsif. Hari berikutnya peserta minta refund. Saat ini admin harus refund satu-satu manual via `/admin/refunds` tanpa konteks trip atau cara cancel trip-nya.
---
## Baseline
-`tripService.closeTrip(tripId, organizerId)` di [server/services/trip.service.ts](server/services/trip.service.ts) sudah handle cancel + auto-refund semua booking PAID atomically. Hanya menerima `organizerId` — perlu varian admin.
-`tripRepo.findAll()` dan `tripRepo.findById()` ada — siap dipakai untuk admin list/detail.
- ❌ Tidak ada page `/admin/trips`.
- ❌ Tidak ada UI search/filter trip untuk admin.
- ❌ Tidak ada UI view detail trip dari sisi admin (kondisi participant, booking, payment).
---
## Phase 1 — Trip List + Detail View (admin read-only) ⏳
Foundation. Tanpa cara cari & lihat trip, admin tidak tahu apa yang mau di-intervene.
**Keputusan asumsi:**
- Reuse `tripRepo.findAll()` tapi tambah filter param: `status`, `organizerId`, `q` (search title/destination).
- Detail page reuse `tripService.getTripById()` yang sudah include `participants`, `images`, `reviews`, `itineraryItems`.
- Tampilkan **semua participant** (PENDING/CONFIRMED/CANCELLED) — admin perlu konteks lengkap.
- Drill-down ke booking detail (lihat [ADMIN_PAYMENT_OPS_ROADMAP.md](ADMIN_PAYMENT_OPS_ROADMAP.md)) untuk lihat payment timeline.
| # | Item | Status | File |
|---|---|---|---|
| 1.1 | `tripRepo.searchForAdmin({ q?, status?, organizerId?, dateFrom?, dateTo? })` | ⏳ | [server/repositories/trip.repo.ts](server/repositories/trip.repo.ts) |
| 1.2 | Page `/admin/trips` — list + tab status (OPEN/FULL/CLOSED/COMPLETED) + search bar | ⏳ | `app/admin/trips/page.tsx` |
| 1.3 | Filter: tanggal berangkat range, organizer (dropdown), kategori | ⏳ | `app/admin/trips/page.tsx` |
| 1.4 | Page `/admin/trips/[id]` — full detail (trip core + itinerary items + participants + bookings ringkasan) | ⏳ | `app/admin/trips/[id]/page.tsx` |
| 1.5 | Badge metrics di detail: peserta PAID/AWAITING/PENDING, total revenue (sum amount PAID), refund total | ⏳ | `app/admin/trips/[id]/page.tsx` |
| 1.6 | Tambah link "Trips" di admin navbar | ⏳ | [app/admin/layout.tsx](app/admin/layout.tsx) |
**Tindakan manual:** tidak ada.
---
## Phase 2 — Admin Force-Cancel Trip dengan Auto-Refund ⏳
Tombol "Cancel trip" di admin detail page yang setara dengan organizer cancel, tapi dilakukan oleh admin untuk emergency intervention.
**Keputusan asumsi:**
- **Tidak buat method baru di service**. Refactor `tripService.closeTrip` agar terima `actor: { type: "ORGANIZER", id } | { type: "ADMIN", id, reason }`. Atomic dalam satu serializable transaction (sama seperti existing).
- Refund yang dibuat pakai `RefundReason.ORGANIZER_CANCELLED` (tetap, karena dari perspektif peserta sama saja). Tambah `adminNote` di refund record kalau actor ADMIN supaya audit trail jelas.
- Tambah kolom `Trip.cancelledByAdminId` (nullable) + `Trip.cancelledReason` di schema — bukan kolom umum, hanya saat admin yang cancel.
- Modal konfirmasi wajib tampilkan: jumlah booking PAID yang akan auto-refund, total nominal. Kalau organizer yang biasa cancel sudah ada confirm modal di [cancel-trip-button.tsx](features/trip/components/cancel-trip-button.tsx) — reuse pola.
- Idempotent: kalau trip sudah CLOSED, tolak dengan pesan jelas.
| # | Item | Status | File |
|---|---|---|---|
| 2.1 | Migration: tambah `cancelledByAdminId` (FK User) + `cancelledReason` di `Trip` | ⏳ | `prisma/migrations/` |
| 2.2 | Refactor `tripService.closeTrip` terima `actor` discriminated union | ⏳ | [server/services/trip.service.ts](server/services/trip.service.ts) |
| 2.3 | Server action `adminCancelTripAction(tripId, reason)` — guard `isAdmin`, panggil closeTrip dengan actor ADMIN | ⏳ | `features/trip/actions.ts` |
| 2.4 | UI: tombol "Cancel trip (admin)" di `/admin/trips/[id]` dengan modal konfirmasi + textarea reason wajib | ⏳ | `app/admin/trips/[id]/page.tsx` (atau component terpisah) |
| 2.5 | Tampilkan badge "Dibatalkan admin" + reason di trip detail saat `cancelledByAdminId` not null | ⏳ | [app/(public)/trips/[id]/page.tsx](app/(public)/trips/[id]/page.tsx) — opsional, transparansi |
**Tindakan manual:**
1. Setelah deploy, brief admin tentang kapan boleh pakai (kriteria: organizer unreachable >7 hari, dispute peserta tidak terselesaikan, safety issue).
2. Tulis SOP internal: kategori reason yang valid + template komunikasi ke peserta.
---
## Phase 3 — Trip Edit Override (opsional, low priority) ⏳
Admin bisa edit field non-critical (description, meetingPoint, itinerary) atas request organizer saat organizer tidak bisa login. Skip untuk MVP.
| # | Item | Status | File |
|---|---|---|---|
| 3.1 | `tripService.adminUpdateTrip(tripId, partial, adminId, reason)` — whitelist field | ⏳ | [server/services/trip.service.ts](server/services/trip.service.ts) |
| 3.2 | UI form edit di `/admin/trips/[id]/edit` | ⏳ | `app/admin/trips/[id]/edit/page.tsx` |
| 3.3 | Audit log entry untuk setiap edit (siapa, field apa, before/after) | ⏳ | TBD (lihat [ADMIN_AUDIT_ROADMAP.md](ADMIN_AUDIT_ROADMAP.md)) |
**Tindakan manual:** tidak ada (skip phase ini sampai ada keluhan konkret).
+81
View File
@@ -0,0 +1,81 @@
# Setrip — Admin User Management Roadmap
Admin perlu bisa cari user dan **suspend** akun yang melakukan abuse (scam, harassment, fake review).
> **Skenario nyata:** organizer scam berkali-kali bikin trip palsu pakai alias berbeda. Peserta lapor harassment dari user lain di grup WA trip. Saat ini admin cuma bisa refund korban — pelaku tetap bisa lanjut bikin trip baru / join trip lain.
---
## Baseline
-`userRepo.findByEmail()` dan `userRepo.findById()` ada di [server/repositories/user.repo.ts](server/repositories/user.repo.ts).
-`User` model lengkap dengan relasi ke `trips`, `participations`, `bookings`, `tripReviews`, `organizerVerification`, `payouts`.
- ❌ Tidak ada page `/admin/users`.
- ❌ Tidak ada field `suspended` di `User`.
- ❌ Tidak ada guard di auth/server actions yang reject suspended user.
- ❌ Tidak ada stats user (total signups, organizer aktif, peserta aktif).
---
## Phase 1 — User List & Detail View ⏳
Baseline visibility. Sama pola dengan trip ops — list + search + detail.
**Keputusan asumsi:**
- Search by email exact match dulu (paling sering dipakai admin saat ada laporan); kalau perlu, tambah name LIKE search nanti.
- Detail page tampilkan: profile, verification status, booking history (sebagai peserta), trip history (sebagai organizer), payout history (sebagai organizer), review yang dibuat & diterima.
- Sensitive info (password hash, OAuth tokens) **tidak** ditampilkan.
| # | Item | Status | File |
|---|---|---|---|
| 1.1 | `userRepo.searchForAdmin({ q?, role?, suspended? })` — q match email atau name (case insensitive) | ⏳ | [server/repositories/user.repo.ts](server/repositories/user.repo.ts) |
| 1.2 | Page `/admin/users` — list + search bar + filter (organizer/participant/suspended) | ⏳ | `app/admin/users/page.tsx` |
| 1.3 | Page `/admin/users/[id]` — profil + tabs (Bookings, Trips Dibuat, Reviews, Verification) | ⏳ | `app/admin/users/[id]/page.tsx` |
| 1.4 | Stats card di top: total bookings, total spent, total revenue (kalau organizer), verification status | ⏳ | `app/admin/users/[id]/page.tsx` |
| 1.5 | Link "Users" di admin navbar | ⏳ | [app/admin/layout.tsx](app/admin/layout.tsx) |
**Tindakan manual:** tidak ada.
---
## Phase 2 — User Suspension ⏳
Toggle suspend yang mencegah suspended user login + melakukan aksi mutatif.
**Keputusan asumsi:**
- Tambah 4 kolom di `User`: `suspended Boolean @default(false)`, `suspendedAt DateTime?`, `suspendedReason String?`, `suspendedBy String?` (FK User admin).
- **Block sign-in** di NextAuth callbacks (`signIn` callback return false kalau `user.suspended`). Untuk JWT session sudah aktif, cek `suspended` di `session` callback lalu invalidate.
- **Block mutating actions** via helper `requireActiveUser(session)` yang dipanggil di awal setiap server action mutating (joinTrip, createTrip, addReview, dst).
- Suspended user **tetap bisa** lihat data sendiri (refund history, dll) — tidak hard-delete supaya audit trail terjaga.
- Suspended organizer otomatis sembunyikan trip OPEN/FULL miliknya dari public list — tambah filter di `tripRepo.findOpen` (`organizer: { suspended: false }`).
- Unsuspend = toggle false + clear field — tetap simpan history via [ADMIN_AUDIT_ROADMAP.md](ADMIN_AUDIT_ROADMAP.md) Phase 4.
| # | Item | Status | File |
|---|---|---|---|
| 2.1 | Migration: tambah `suspended`, `suspendedAt`, `suspendedReason`, `suspendedBy` di `User` | ⏳ | `prisma/migrations/` |
| 2.2 | `userService.suspendUser(userId, adminId, reason)` + `unsuspendUser(userId, adminId)` | ⏳ | `server/services/user.service.ts` |
| 2.3 | Block sign-in di NextAuth `signIn` callback | ⏳ | [lib/auth.ts](lib/auth.ts) |
| 2.4 | Helper `requireActiveUser(session)` throw kalau suspended | ⏳ | `lib/auth-guards.ts` |
| 2.5 | Wire `requireActiveUser` di semua mutating server action (joinTripAction, createTripAction, createReviewAction, dst) | ⏳ | `features/*/actions.ts` |
| 2.6 | Filter trip public list: organizer tidak suspended | ⏳ | [server/repositories/trip.repo.ts](server/repositories/trip.repo.ts) |
| 2.7 | UI: tombol "Suspend" / "Unsuspend" di `/admin/users/[id]` + modal reason wajib | ⏳ | `app/admin/users/[id]/page.tsx` |
| 2.8 | Badge "SUSPENDED" di user list + detail header (visual jelas) | ⏳ | `app/admin/users/[id]/page.tsx` |
| 2.9 | Server action `suspendUserAction` + `unsuspendUserAction` (guard isAdmin) | ⏳ | `features/admin/actions.ts` (baru) atau `features/user/actions.ts` |
**Tindakan manual:**
1. Brief admin: kriteria suspend (3 kategori: scam, harassment, repeated TOS violation). Hindari subjective suspend.
2. Tulis halaman info "Akun ditangguhkan" yang ditampilkan saat suspended user coba login (jelaskan kenapa & cara appeal via email).
3. Pertimbangkan suspended user di Midtrans webhook — saat ada payment masuk untuk suspended user's booking, tetap di-PAID (uang tetap diterima, refund proses normal).
---
## Phase 3 — User Analytics (low priority, skip MVP) ⏳
Dashboard stats untuk growth tracking. Skip sampai ada kebutuhan konkret.
| # | Item | Status | File |
|---|---|---|---|
| 3.1 | Stats endpoint: total user, signup per minggu (4 minggu terakhir), organizer aktif (yang punya OPEN/FULL trip), peserta aktif (booking PAID) | ⏳ | `app/admin/users/stats/page.tsx` |
| 3.2 | Chart sederhana (HTML/SVG inline, no chart library) | ⏳ | `app/admin/users/stats/page.tsx` |
**Tindakan manual:** tidak ada (skip phase ini).
+88
View File
@@ -0,0 +1,88 @@
# Setrip — Admin Verification Roadmap
Enhancement KYC organizer verification: reopen REJECTED, request re-upload, audit override.
> **Skenario nyata:** organizer terverifikasi dengan KTP buram → admin REJECT. Organizer foto ulang dan kirim via email. Admin sekarang harus edit DB manual karena `OrganizerVerification.status = REJECTED` tidak bisa kembali ke `PENDING` lewat UI.
---
## Baseline
- ✅ Approve / Reject ada di [app/admin/verifications/page.tsx](app/admin/verifications/page.tsx).
-`OrganizerVerification` model lengkap dengan `reviewedBy`, `reviewedAt`, `rejectionReason`.
- ✅ NIK encrypted (decrypt via `organizerService.decryptNik` saat di-render).
- ❌ Tidak ada cara reopen REJECTED → kembali ke PENDING.
- ❌ Tidak ada flow "request re-upload" (admin minta organizer upload ulang field tertentu tanpa harus reject penuh).
- ❌ Tidak ada history per verification — kalau organizer ajukan ulang setelah reject, history sebelumnya hilang (di-overwrite).
---
## Phase 1 — Reopen Rejected Verification ⏳
Tombol di REJECTED detail untuk reset ke PENDING supaya admin/organizer bisa coba lagi tanpa harus drop & recreate.
**Keputusan asumsi:**
- Tidak hapus `rejectionReason` saat reopen — simpan untuk history (rename field jadi `lastRejectionReason`). Sebenarnya rejectionReason cuma string, kalau di-reopen lalu di-reject lagi otomatis di-overwrite. Untuk MVP cukup itu.
- Service method baru `reopenVerification(verifId, adminId, note)` — set `status = PENDING`, clear `reviewedBy/reviewedAt`, log via [ADMIN_AUDIT_ROADMAP.md](ADMIN_AUDIT_ROADMAP.md) Phase 4 `auditLog.record`.
- UI: tombol "Buka kembali" di REJECTED card dengan modal note wajib.
- Tidak otomatis kirim notif ke organizer di MVP — admin coordinate via email/WA.
| # | Item | Status | File |
|---|---|---|---|
| 1.1 | `organizerService.reopenVerification(verifId, adminId, note)` — set PENDING + clear review fields | ⏳ | [server/services/organizer.service.ts](server/services/organizer.service.ts) |
| 1.2 | Server action `reopenVerificationAction(verifId, note)` (guard isAdmin) | ⏳ | [features/organizer/actions.ts](features/organizer/actions.ts) |
| 1.3 | UI: tombol "Buka kembali" di tab REJECTED dengan modal note | ⏳ | [app/admin/verifications/page.tsx](app/admin/verifications/page.tsx) atau [features/organizer/components/review-card.tsx](features/organizer/components/review-card.tsx) |
| 1.4 | Tampilkan `lastRejectionReason` di tab PENDING juga (sebagai konteks "ini submission ke-N") | ⏳ | [features/organizer/components/review-card.tsx](features/organizer/components/review-card.tsx) |
**Tindakan manual:**
1. Brief admin: jangan reopen tanpa konfirmasi organizer sudah siap upload ulang. Note wajib menjelaskan alasan reopen.
---
## Phase 2 — Re-upload Request Flow ⏳
Daripada reject penuh, admin bisa request specific field di-update (KTP buram, foto liveness terlalu gelap).
**Keputusan asumsi:**
- Tambah `OrganizerVerification.reuploadRequested Boolean @default(false)` + `reuploadFields String[]` + `reuploadNote String?`.
- Saat di-request, set `status = PENDING` (atau status baru `NEEDS_REUPLOAD`). Pakai `PENDING` saja supaya tidak nambah enum (organizer-facing copy bedakan via `reuploadRequested` flag).
- Organizer page `/verify` baca flag → tampilkan banner kuning + highlight field yang di-request.
- Setelah organizer submit ulang, flag auto-clear → status tetap PENDING menunggu admin review.
| # | Item | Status | File |
|---|---|---|---|
| 2.1 | Migration: tambah `reuploadRequested`, `reuploadFields`, `reuploadNote` di `OrganizerVerification` | ⏳ | `prisma/migrations/` |
| 2.2 | `organizerService.requestReupload(verifId, adminId, fields[], note)` | ⏳ | [server/services/organizer.service.ts](server/services/organizer.service.ts) |
| 2.3 | Server action + UI tombol "Request re-upload" di admin detail (checkbox per field + textarea note) | ⏳ | [features/organizer/actions.ts](features/organizer/actions.ts) + admin page |
| 2.4 | Banner kuning di `/verify` saat `reuploadRequested = true` + highlight field-nya | ⏳ | [app/(public)/verify/page.tsx](app/(public)/verify/page.tsx) + [features/organizer/components/verify-form.tsx](features/organizer/components/verify-form.tsx) |
| 2.5 | Auto-clear `reuploadRequested` saat organizer submit ulang | ⏳ | [features/organizer/actions.ts](features/organizer/actions.ts) (di `submitVerificationAction`) |
**Tindakan manual:** tidak ada.
---
## Phase 3 — Verification History (opsional) ⏳
Kalau audit butuh trace "berapa kali organizer ini coba verify", tambah tabel history. Skip untuk MVP.
| # | Item | Status | File |
|---|---|---|---|
| 3.1 | Model `OrganizerVerificationHistory` (snapshot per submission) | ⏳ | [prisma/schema.prisma](prisma/schema.prisma) |
| 3.2 | Trigger create snapshot saat submit ulang | ⏳ | [server/services/organizer.service.ts](server/services/organizer.service.ts) |
| 3.3 | Tab "History" di admin verification detail | ⏳ | admin page |
**Tindakan manual:** tidak ada (skip phase).
---
## Phase 4 — Manual Override (super-low priority) ⏳
Admin verifikasi organizer tanpa upload (referral dari partner trusted). Skip kecuali ada use case nyata.
| # | Item | Status | File |
|---|---|---|---|
| 4.1 | `organizerService.adminCreateVerification(userId, adminId, note)` — buat row APPROVED langsung dengan flag `isManualOverride` | ⏳ | [server/services/organizer.service.ts](server/services/organizer.service.ts) |
| 4.2 | Migration: tambah `isManualOverride Boolean @default(false)` | ⏳ | `prisma/migrations/` |
| 4.3 | UI: tombol "Verify manually" di `/admin/users/[id]` (organizer tab) | ⏳ | `app/admin/users/[id]/page.tsx` |
**Tindakan manual:** tidak ada (skip phase, evaluate ulang setelah ada partnership program).
+8 -29
View File
@@ -17,7 +17,6 @@ import { CancelBookingButton } from "@/features/booking/components/cancel-bookin
import { OrganizerJoinRequests } from "@/features/trip/components/organizer-join-requests";
import { OrganizerTrustPanel } from "@/features/trip/components/organizer-trust-panel";
import { TripProgramBlock } from "@/features/trip/components/trip-program-block";
import { OrganizerPaymentQueue } from "@/features/booking/components/organizer-payment-queue";
import { ImageGallery } from "@/features/trip/components/image-gallery";
import { TripReviewSection } from "@/features/review/components/trip-review-section";
import { RefundPolicySection } from "@/features/refund/components/refund-policy-section";
@@ -135,13 +134,6 @@ export default async function TripDetailPage({
const tripIsFree = isFreeTrip(trip);
// Antrian konfirmasi pembayaran: source dari Booking + Payment (B9).
// Hanya organizer yang butuh data ini, dan hanya untuk trip berbayar.
const paymentPendingBookings =
!tripIsFree && isOrganizer
? await bookingService.getAwaitingManualForTrip(trip.id)
: [];
// Booking peserta saat ini — dipakai untuk render CancelBookingButton vs
// tombol "Batal Ikut" biasa. Hanya untuk non-organizer yang ikut trip.
const myBooking =
@@ -447,6 +439,13 @@ export default async function TripDetailPage({
<TripProgramBlock
meetingPoint={trip.meetingPoint}
itinerary={trip.itinerary}
itineraryItems={trip.itineraryItems.map((i) => ({
day: i.day,
startTime: i.startTime,
endTime: i.endTime,
activity: i.activity,
order: i.order,
}))}
whatsIncluded={trip.whatsIncluded}
whatsExcluded={trip.whatsExcluded}
/>
@@ -469,18 +468,6 @@ export default async function TripDetailPage({
pending={pendingParticipants.map((p) => ({
id: p.id,
user: p.user,
markedPaidAt: p.markedPaidAt,
}))}
/>
)}
{isOrganizer && paymentPendingBookings.length > 0 && (
<OrganizerPaymentQueue
tripId={trip.id}
items={paymentPendingBookings.map((b) => ({
id: b.participantId,
user: { name: b.user.name, image: b.user.image },
joinStatus: "CONFIRMED" as const,
}))}
/>
)}
@@ -498,15 +485,7 @@ export default async function TripDetailPage({
? currentParticipation.status
: null
}
participantPayment={
currentParticipation
? {
markedPaidAt: currentParticipation.markedPaidAt,
paymentConfirmedAt:
currentParticipation.paymentConfirmedAt,
}
: null
}
bookingStatus={myBooking?.status ?? null}
isFull={spotsLeft <= 0}
tripStatus={trip.status}
isDeparturePast={isDeparturePast}
+60 -183
View File
@@ -4,15 +4,13 @@ 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 { bookingService } from "@/server/services/booking.service";
import { paymentService } from "@/server/services/payment.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 { MidtransPayButton } from "@/features/booking/components/midtrans-pay-button";
import { CopyButton } from "@/features/booking/components/copy-button";
export const metadata: Metadata = {
title: "Detail Pembayaran",
@@ -21,9 +19,10 @@ export const metadata: Metadata = {
interface PageProps {
params: Promise<{ id: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}
export default async function PaymentPage({ params }: PageProps) {
export default async function PaymentPage({ params, searchParams }: PageProps) {
const { id } = await params;
const session = await getServerSession(authOptions);
if (!session?.user) {
@@ -37,11 +36,26 @@ export default async function PaymentPage({ params }: PageProps) {
notFound();
}
// Organizer trip-nya sendiri tidak butuh halaman pembayaran.
if (trip.organizerId === session.user.id) {
redirect(`/trips/${id}`);
}
// Saat user kembali dari Snap, Midtrans append `order_id` (+ status_code +
// transaction_status) ke finishUrl. Tarik status terkini dari Core API
// sebelum render supaya UI sinkron tanpa menunggu webhook — penting di dev
// (localhost) dan saat webhook tertunda.
const sp = await searchParams;
const orderIdParam = sp.order_id;
const orderId = Array.isArray(orderIdParam) ? orderIdParam[0] : orderIdParam;
if (orderId) {
try {
await paymentService.reconcileFromGateway(orderId, session.user.id);
} catch {
// Jangan blokir render kalau gateway tidak responsif — webhook tetap
// sumber kebenaran jangka panjang. Status di UI akan apa adanya dari DB.
}
}
const booking = await bookingService.getByTripAndUser(
trip.id,
session.user.id
@@ -51,15 +65,10 @@ export default async function PaymentPage({ params }: PageProps) {
return <NotJoinedNotice tripId={trip.id} title={trip.title} />;
}
const latestManualPayment = booking.payments.find(
(p) => p.provider === "MANUAL"
);
const tripIsFree = isFreeTrip(trip);
const catMeta = categoryMeta(trip.category);
const dateRange = formatTripCalendarDateRangeLong(trip.date, trip.endDate);
// Header info — sama untuk free vs paid
const tripHeader = (
<section className="mb-6 rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm sm:p-6">
<div className="flex items-start gap-3">
@@ -104,29 +113,19 @@ export default async function PaymentPage({ params }: PageProps) {
<p className="mb-5 text-sm text-neutral-500">
{tripIsFree
? "Trip ini gratis — kamu tidak perlu transfer apa-apa."
: "Transfer manual ke rekening organizer di bawah, lalu tandai sebagai sudah bayar."}
: "Bayar lewat Midtrans untuk mengamankan slot kamu. Pembayaran akan ter-konfirmasi otomatis."}
</p>
{tripHeader}
{tripIsFree ? (
<FreeTripSection
tripId={trip.id}
bookingStatus={booking.status}
/>
<FreeTripSection tripId={trip.id} bookingStatus={booking.status} />
) : (
<PaidTripSection
tripId={trip.id}
organizerId={trip.organizerId}
organizerName={trip.organizer.name}
price={trip.price}
bookingStatus={booking.status}
paymentMarkedAt={
latestManualPayment?.status === "AWAITING"
? latestManualPayment.updatedAt
: null
}
paymentPaidAt={latestManualPayment?.paidAt ?? null}
/>
)}
</div>
@@ -153,19 +152,21 @@ function NotJoinedNotice({ tripId, title }: { tripId: string; title: string }) {
);
}
type BookingStatus =
| "PENDING"
| "AWAITING_PAY"
| "PAID"
| "CANCELLED"
| "REFUNDED"
| "PARTIALLY_REFUNDED"
| "EXPIRED";
function FreeTripSection({
tripId,
bookingStatus,
}: {
tripId: string;
bookingStatus:
| "PENDING"
| "AWAITING_PAY"
| "PAID"
| "CANCELLED"
| "REFUNDED"
| "PARTIALLY_REFUNDED"
| "EXPIRED";
bookingStatus: BookingStatus;
}) {
return (
<section className="rounded-2xl border border-emerald-200 bg-emerald-50/60 p-6 text-center shadow-sm sm:p-8">
@@ -202,146 +203,59 @@ function FreeTripSection({
);
}
async function PaidTripSection({
function PaidTripSection({
tripId,
organizerId,
organizerName,
price,
bookingStatus,
paymentMarkedAt,
paymentPaidAt,
}: {
tripId: string;
organizerId: string;
organizerName: string;
price: number;
bookingStatus:
| "PENDING"
| "AWAITING_PAY"
| "PAID"
| "CANCELLED"
| "REFUNDED"
| "PARTIALLY_REFUNDED"
| "EXPIRED";
paymentMarkedAt: Date | null;
paymentPaidAt: Date | null;
bookingStatus: BookingStatus;
}) {
const verification = await organizerService.getStatusForUser(organizerId);
const bankAvailable = verification?.status === "APPROVED";
const isApproved = bookingStatus === "AWAITING_PAY" || bookingStatus === "PAID";
const isApproved =
bookingStatus === "AWAITING_PAY" || bookingStatus === "PAID";
const isPendingApproval = bookingStatus === "PENDING";
const hasMarkedPaid = !!paymentMarkedAt || !!paymentPaidAt;
const isFullyPaid = bookingStatus === "PAID";
const canMarkPaid = bookingStatus === "AWAITING_PAY" && !paymentMarkedAt;
const canPay = bookingStatus === "AWAITING_PAY";
return (
<div className="space-y-5">
<PaymentTimeline
approved={isApproved}
markedPaid={hasMarkedPaid}
confirmedPaid={isFullyPaid}
/>
<PaymentTimeline approved={isApproved} confirmedPaid={isFullyPaid} />
{!bankAvailable && (
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
<p className="font-semibold">Rekening organizer belum tersedia</p>
<p className="mt-1 text-amber-800/90">
Organizer trip ini belum melengkapi data verifikasi (bank). Hubungi
organizer langsung lewat profilnya untuk koordinasi pembayaran.
<section className="rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm sm:p-6">
<div className="flex items-baseline justify-between gap-3">
<h3 className="text-sm font-bold text-neutral-900 sm:text-base">
Total Pembayaran
</h3>
<p className="text-lg font-bold text-primary-700 sm:text-xl">
{formatRupiah(price)}
</p>
</div>
)}
{bankAvailable && (
<section className="rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm sm:p-6">
<h3 className="mb-1 text-sm font-bold text-neutral-900 sm:text-base">
Transfer ke rekening organizer
</h3>
<p className="mb-4 text-xs text-neutral-500 sm:text-sm">
Pastikan nominal persis seperti tercantum supaya organizer mudah
mencocokkan.
</p>
<div className="space-y-3 rounded-xl bg-neutral-50 p-4 sm:p-5">
<BankRow
label="Bank"
value={verification.bankName}
copyable
/>
<BankRow
label="Nomor rekening"
value={verification.bankAccountNumber}
copyable
mono
/>
<BankRow
label="Atas nama"
value={verification.bankAccountName}
/>
<div className="mt-2 border-t border-neutral-200 pt-3">
<BankRow
label="Nominal transfer"
value={formatRupiah(price)}
strong
copyable
copyValue={String(price)}
/>
</div>
</div>
<ul className="mt-4 space-y-1.5 text-[11px] text-neutral-500 sm:text-xs">
<li> Transfer dengan nominal pas, jangan dibulatkan.</li>
<li> Simpan bukti transfer untuk jaga-jaga jika ada konfirmasi.</li>
<li>
Setelah transfer, tekan tombol <em>Saya sudah bayar</em> di bawah
supaya organizer tahu dan bisa konfirmasi.
</li>
</ul>
</section>
)}
<p className="mt-1 text-xs text-neutral-500">
Pembayaran diproses oleh Midtrans (BCA VA, GoPay, QRIS, kartu, dll).
Dana ditahan SeTrip sampai trip selesai bukan transfer langsung
ke organizer.
</p>
</section>
{isPendingApproval && (
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
Kamu belum disetujui organizer untuk ikut trip ini. Tunggu persetujuan
dulu sebelum transfer supaya tidak perlu refund kalau ditolak.
dulu sebelum bayar supaya tidak perlu refund kalau ditolak.
</div>
)}
{canMarkPaid && (
<div className="space-y-3">
{bankAvailable && (
<>
<MarkPaidButton tripId={tripId} />
<div className="flex items-center gap-3">
<span className="h-px flex-1 bg-neutral-200" />
<span className="text-[11px] font-semibold uppercase tracking-wide text-neutral-400">
atau
</span>
<span className="h-px flex-1 bg-neutral-200" />
</div>
</>
)}
<MidtransPayButton tripId={tripId} />
</div>
)}
{canPay && <MidtransPayButton tripId={tripId} />}
{hasMarkedPaid && (
<div className="rounded-2xl border border-neutral-200 bg-white p-4 text-sm text-neutral-600 shadow-sm sm:p-5">
{isFullyPaid ? (
<p>
Pembayaran kamu sudah dikonfirmasi oleh{" "}
<span className="font-semibold text-neutral-800">
{organizerName}
</span>
. Sampai jumpa di trip!
</p>
) : (
<p>
Kamu sudah menandai sudah bayar. Menunggu organizer mengecek
dan mengonfirmasi.
</p>
)}
{isFullyPaid && (
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-900 sm:p-5">
<p>
Pembayaran kamu sudah terkonfirmasi. Sampai jumpa di trip
bareng{" "}
<span className="font-semibold">{organizerName}</span>!
</p>
</div>
)}
@@ -359,17 +273,14 @@ async function PaidTripSection({
function PaymentTimeline({
approved,
markedPaid,
confirmedPaid,
}: {
approved: boolean;
markedPaid: boolean;
confirmedPaid: boolean;
}) {
const steps = [
{ label: "Disetujui organizer", done: approved },
{ label: "Kamu menandai sudah bayar", done: markedPaid },
{ label: "Organizer konfirmasi pembayaran", done: confirmedPaid },
{ label: "Pembayaran terkonfirmasi Midtrans", done: confirmedPaid },
];
return (
@@ -404,37 +315,3 @@ function PaymentTimeline({
</section>
);
}
function BankRow({
label,
value,
mono,
strong,
copyable,
copyValue,
}: {
label: string;
value: string;
mono?: boolean;
strong?: boolean;
copyable?: boolean;
copyValue?: string;
}) {
return (
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-wide text-neutral-500">
{label}
</p>
<p
className={`mt-0.5 truncate text-sm text-neutral-800 ${
mono ? "font-mono" : ""
} ${strong ? "text-base font-bold text-primary-700" : ""}`}
>
{value}
</p>
</div>
{copyable && <CopyButton value={copyValue ?? value} />}
</div>
);
}
+3 -2
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
@@ -37,12 +38,12 @@ export default async function AdminLayout({
<p className="mt-1 text-sm text-neutral-500">
Akun kamu tidak punya akses ke panel admin SeTrip.
</p>
<a
<Link
href="/"
className="mt-4 inline-block rounded-xl bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700"
>
Kembali ke beranda
</a>
</Link>
</div>
</div>
);
+245
View File
@@ -0,0 +1,245 @@
# Release Workflow
Panduan rilis: commit perubahan, naikan versi, push. Konsisten dengan pola history repo (single feature commit + version commit terpisah).
---
## Aturan versi (semver untuk 0.x)
Project masih `0.x.y` — API belum stabil, semver yang dipakai:
| Tipe perubahan | Bump | Contoh |
|---|---|---|
| **MAJOR** `0.x.y → 1.0.0` | hanya saat siap rilis publik / API stabil | nanti |
| **MINOR** `0.11.0 → 0.12.0` | fitur baru, breaking change, schema/migration baru, removal API | midtrans-only flow, structured itinerary |
| **PATCH** `0.10.2 → 0.10.3` | bugfix, dependency upgrade, copy/UI tweaks tanpa schema | upgrade lib vulnerability, fix hydration |
**Aturan praktis:** kalau perlu jalankan `prisma migrate deploy` setelah pull → minor. Kalau cuma `git pull && pm2 restart` → patch.
---
## Pre-flight check (wajib sebelum commit)
```bash
# 1. Type check (filter cache stale Next.js)
npx tsc --noEmit 2>&1 | grep -v "\.next"
# 2. Lint
npm run lint
# 3. (Opsional) test seed kalau ubah schema/seed
npm run seed
```
Kalau ada error di TS atau ESLint, **jangan commit**. Fix dulu.
---
## Standard flow (rekomendasi)
Pola dari history repo: **1 commit fitur** + **1 commit versi** terpisah.
### 1. Verify status
```bash
git status
git diff --stat
```
Pastikan tidak ada file sensitif (`.env`, `*.key`, upload KYC, `app/generated/prisma/`) ter-track.
### 2. Stage perubahan
Default — semua perubahan logis:
```bash
git add -A
```
Kalau ada file yang sengaja dipisah commit-nya, pakai selective:
```bash
git add path/to/file1 path/to/file2
```
### 3. Commit fitur
Pakai pesan singkat, lowercase, deskriptif. Pola history:
-`midtrans-only payment + reconcile, structured itinerary items, admin roadmap`
-`add payment and integration with midtrans`
-`create public layout and admin and fix escrow and refund`
-`chore: remove generated prisma client from repository`
Prefix `chore:`, `fix:` boleh dipakai tapi tidak wajib. Yang penting: deskriptif & ringkas.
```bash
git commit -m "deskripsi singkat perubahan utama"
```
### 4. Bump versi
Edit manual `package.json` di field `"version"`, atau pakai npm:
```bash
# Bump tanpa auto-commit & tag (kita commit manual)
npm version 0.12.0 --no-git-tag-version
```
`--no-git-tag-version` penting — repo ini **tidak pakai git tag**, cuma commit dengan pesan = nomor versi.
### 5. Commit versi (terpisah)
```bash
git add package.json
git commit -m "0.12.0"
```
Pesan = nomor versi saja, tanpa prefix/kata lain. Konsisten dengan history (`0.11.0`, `0.10.3`, `0.10.2`, ...).
### 6. Push
```bash
git push origin main
```
---
## Post-deploy actions
Setelah merge ke main + auto-deploy / `git pull` di server:
### Wajib kalau ada migration baru
```bash
# Cek dulu migration belum applied
npx prisma migrate status
# Apply
npx prisma migrate deploy
# Restart PM2 supaya Prisma client re-load
pm2 restart setrip --update-env
```
### Wajib kalau ubah field di env
```bash
# Edit .env di server, lalu
pm2 restart setrip --update-env
```
### Opsional — seed (hanya untuk dev/staging, JANGAN production)
```bash
npm run seed
```
⚠️ **Production**: seed wipe seluruh data. Jangan dijalankan di production.
---
## Skenario umum
### A. Bug fix kecil (patch)
```bash
npx tsc --noEmit 2>&1 | grep -v "\.next" && npm run lint
git add path/to/fix
git commit -m "fix: deskripsi bug"
npm version patch --no-git-tag-version # 0.11.0 → 0.11.1
git add package.json
git commit -m "0.11.1"
git push origin main
```
### B. Fitur baru tanpa schema change (minor)
Sama dengan A, ganti `patch` jadi `minor`:
```bash
npm version minor --no-git-tag-version # 0.11.0 → 0.12.0
```
### C. Fitur baru DENGAN schema/migration (minor)
```bash
# 1. Buat migration
npx prisma migrate dev --name nama_migration
# 2. Smoke test
npm run seed
npx tsc --noEmit 2>&1 | grep -v "\.next"
# 3. Commit fitur + migration sekaligus
git add -A
git commit -m "deskripsi fitur"
# 4. Bump versi minor
npm version minor --no-git-tag-version
git add package.json
git commit -m "$(node -p "require('./package.json').version")"
git push origin main
# 5. Di production, setelah git pull:
npx prisma migrate deploy
pm2 restart setrip --update-env
```
### D. Multiple perubahan logis di branch yang sama (split commits)
Pisahkan jadi commit kecil per topik supaya history bersih:
```bash
# Commit 1: foundation (mis. schema + service)
git add prisma/ server/
git commit -m "add X service"
# Commit 2: UI yang konsumsi
git add features/ app/
git commit -m "wire X to UI"
# Commit 3: docs
git add docs/ *.md
git commit -m "docs: X usage guide"
# Commit 4: version bump
npm version minor --no-git-tag-version
git add package.json
git commit -m "$(node -p "require('./package.json').version")"
git push origin main
```
---
## Kesalahan umum & cara recovery
| Kesalahan | Recovery |
|---|---|
| Commit pesan typo, **belum push** | `git commit --amend -m "pesan baru"` |
| Commit pesan typo, **sudah push** | jangan amend (force-push hilangin history kolaborator). Bikin commit baru `git commit --allow-empty -m "fix: pesan sebelumnya typo"` atau biarkan |
| Lupa bump versi sebelum push | bikin commit versi baru di atasnya — bukan amend |
| Bump versi salah angka (mis. 0.12.0 padahal harusnya patch 0.11.1) | revisi `package.json`, bikin commit baru `chore: revert version to 0.11.1` |
| Commit termasuk file sensitif (`.env`, upload) | jangan push. `git reset --soft HEAD~1`, un-stage file sensitif, tambah ke `.gitignore`, commit ulang |
| Sudah push dengan file sensitif | rotate secret yang ke-leak, lalu pakai `git filter-repo` atau hubungi maintainer git history |
---
## Cheatsheet (one-liner)
Untuk update biasa (fitur kecil tanpa schema):
```bash
npx tsc --noEmit 2>&1 | grep -v "\.next" && npm run lint && \
git add -A && git commit -m "deskripsi" && \
npm version minor --no-git-tag-version && \
git add package.json && git commit -m "$(node -p "require('./package.json').version")" && \
git push origin main
```
Ganti `minor``patch` untuk bug fix. Jangan jalankan kalau ada step yang minta keputusan manual (mis. conflict, migration baru).
+27 -32
View File
@@ -2,30 +2,12 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { tripService } from "@/server/services/trip.service";
import { paymentService } from "@/server/services/payment.service";
import { bookingService } from "@/server/services/booking.service";
import { refundService } from "@/server/services/refund.service";
import { absoluteUrl } from "@/lib/site";
import { revalidatePath } from "next/cache";
export async function markParticipantPaidAction(tripId: string) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return { error: "Kamu harus login terlebih dahulu" };
}
try {
await tripService.markParticipantPayment(tripId, session.user.id);
revalidatePath(`/trips/${tripId}`);
revalidatePath("/trips");
revalidatePath("/");
revalidatePath("/profile");
return { success: true };
} catch (err) {
return { error: (err as Error).message };
}
}
export type StartMidtransResponse =
| { error: string }
| {
@@ -33,6 +15,7 @@ export type StartMidtransResponse =
snapToken: string;
snapJsUrl: string;
clientKey: string;
orderId: string;
};
/**
@@ -58,39 +41,51 @@ export async function startMidtransPaymentAction(
const result = await paymentService.startMidtransPayment(
booking.id,
session.user.id
session.user.id,
{ finishUrl: absoluteUrl(`/trips/${tripId}/payment`) }
);
return {
success: true,
snapToken: result.snapToken,
snapJsUrl: result.snapJsUrl,
clientKey: result.clientKey,
orderId: result.orderId,
};
} catch (err) {
return { error: (err as Error).message };
}
}
export async function confirmParticipantPaymentAction(
tripId: string,
participantId: string
) {
/**
* Tarik status terkini dari Midtrans untuk satu order, lalu sinkron ke DB.
* Dipakai oleh payment page saat user kembali dari Snap (redirect bawa
* `?order_id=...`), dan oleh `MidtransPayButton` di callback `onSuccess`/
* `onPending`/`onClose` agar UI ter-update tanpa menunggu webhook.
*/
export async function reconcileMidtransPaymentAction(orderId: string) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return { error: "Kamu harus login terlebih dahulu" };
}
if (!orderId || typeof orderId !== "string") {
return { error: "order_id tidak valid" };
}
try {
await tripService.confirmParticipantPayment(
tripId,
participantId,
const result = await paymentService.reconcileFromGateway(
orderId,
session.user.id
);
revalidatePath(`/trips/${tripId}`);
revalidatePath("/trips");
revalidatePath("/");
revalidatePath("/profile");
return { success: true };
if (!result.ok) {
if (result.reason === "forbidden") {
return { error: "Order ini bukan milikmu" };
}
if (result.reason === "not_found") {
return { error: "Order tidak ditemukan" };
}
return { error: "Status pembayaran tidak cocok dengan tagihan" };
}
return { success: true as const, status: result.status };
} catch (err) {
return { error: (err as Error).message };
}
@@ -1,50 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { markParticipantPaidAction } from "@/features/booking/actions";
interface MarkPaidButtonProps {
tripId: string;
disabled?: boolean;
}
export function MarkPaidButton({ tripId, disabled }: MarkPaidButtonProps) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
async function handleClick() {
setLoading(true);
setError("");
const result = await markParticipantPaidAction(tripId);
setLoading(false);
if (result.error) {
setError(result.error);
return;
}
router.refresh();
}
return (
<div>
{error && (
<div className="mb-3 rounded-xl bg-red-50 px-4 py-3 text-sm font-medium text-red-600">
{error}
</div>
)}
<button
type="button"
onClick={handleClick}
disabled={loading || disabled}
className="w-full rounded-xl bg-primary-600 py-3 text-sm font-bold text-white shadow-lg shadow-primary-600/20 transition-colors hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{loading ? "Memproses..." : "Saya sudah bayar"}
</button>
<p className="mt-2 text-center text-[11px] text-neutral-500">
Tekan setelah kamu transfer ke rekening di atas. Organizer akan cek &
konfirmasi pembayaran kamu.
</p>
</div>
);
}
@@ -2,7 +2,10 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { startMidtransPaymentAction } from "@/features/booking/actions";
import {
reconcileMidtransPaymentAction,
startMidtransPaymentAction,
} from "@/features/booking/actions";
interface SnapCallbacks {
onSuccess?: (result: unknown) => void;
@@ -86,23 +89,25 @@ export function MidtransPayButton({ tripId }: MidtransPayButtonProps) {
return;
}
const orderId = result.orderId;
// Tarik status terkini dari Midtrans server-side, lalu refresh halaman.
// Tidak menunggu webhook supaya UI ter-update saat webhook belum sampai
// (mis. di localhost) atau redirect flow di mana popup tidak dipakai.
async function reconcileAndRefresh() {
await reconcileMidtransPaymentAction(orderId);
router.refresh();
}
window.snap.pay(result.snapToken, {
onSuccess: () => {
// Webhook server akan tetap jadi sumber kebenaran. Refresh page untuk pull state baru.
router.refresh();
},
onPending: () => router.refresh(),
onSuccess: reconcileAndRefresh,
onPending: reconcileAndRefresh,
onError: () => {
setError(
"Pembayaran gagal diproses. Coba lagi atau pakai metode lain."
);
router.refresh();
},
onClose: () => {
// User menutup popup tanpa menyelesaikan. Refresh saja, kalau status berubah
// (mis. user sudah bayar VA) callback dari Midtrans akan datang ke webhook.
router.refresh();
void reconcileAndRefresh();
},
onClose: reconcileAndRefresh,
});
setLoading(false);
@@ -1,102 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Image from "next/image";
import { confirmParticipantPaymentAction } from "@/features/booking/actions";
export interface PaymentPendingParticipant {
id: string;
user: { name: string; image: string | null };
/** PENDING atau CONFIRMED (join) — keduanya bisa sudah tandai bayar */
joinStatus: "PENDING" | "CONFIRMED";
}
interface OrganizerPaymentQueueProps {
tripId: string;
items: PaymentPendingParticipant[];
}
export function OrganizerPaymentQueue({
tripId,
items,
}: OrganizerPaymentQueueProps) {
const router = useRouter();
const [loadingId, setLoadingId] = useState<string | null>(null);
const [error, setError] = useState("");
async function confirm(participantId: string) {
setLoadingId(participantId);
setError("");
const result = await confirmParticipantPaymentAction(tripId, participantId);
setLoadingId(null);
if (result.error) {
setError(result.error);
return;
}
router.refresh();
}
return (
<div className="rounded-xl border border-primary-200 bg-primary-50/60 p-4 sm:p-5">
<h2 className="text-sm font-bold text-primary-950 sm:text-base">
Konfirmasi pembayaran ({items.length})
</h2>
<p className="mt-1 text-xs text-primary-900/85 sm:text-sm">
Peserta sudah menandai pembayaran. Cek rekening atau bukti transfer,
lalu konfirmasi.
</p>
{error && (
<p className="mt-3 rounded-lg bg-red-50 px-3 py-2 text-xs font-medium text-red-700 sm:text-sm">
{error}
</p>
)}
<ul className="mt-4 space-y-3">
{items.map((p) => (
<li
key={p.id}
className="flex flex-col gap-3 rounded-xl border border-primary-100 bg-white/95 p-3 sm:flex-row sm:items-center sm:justify-between"
>
<div className="flex min-w-0 items-center gap-3">
{p.user.image ? (
<Image
src={p.user.image}
alt=""
width={40}
height={40}
className="h-10 w-10 shrink-0 rounded-full object-cover"
/>
) : (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary-600 text-sm font-bold text-white">
{p.user.name.charAt(0).toUpperCase()}
</div>
)}
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-neutral-800">
{p.user.name}
</p>
<p className="text-xs text-primary-800/90">
Menunggu konfirmasi pembayaran
{p.joinStatus === "PENDING" && (
<span className="text-neutral-500">
{" "}
· belum disetujui ikut trip
</span>
)}
</p>
</div>
</div>
<button
type="button"
disabled={loadingId !== null}
onClick={() => confirm(p.id)}
className="shrink-0 rounded-lg bg-primary-600 px-4 py-2 text-xs font-semibold text-white shadow-sm hover:bg-primary-700 disabled:opacity-50 sm:text-sm"
>
{loadingId === p.id ? "Memproses…" : "Konfirmasi pembayaran"}
</button>
</li>
))}
</ul>
</div>
);
}
@@ -20,20 +20,17 @@ export function ProfileTripRow({
rightSlot,
}: ProfileTripRowProps) {
return (
<Link
href={href}
className="flex items-center justify-between gap-3 rounded-xl border border-neutral-200 bg-white px-3 py-2.5 transition-colors hover:border-primary-200 hover:bg-primary-50/40 sm:px-4 sm:py-3"
>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3 rounded-xl border border-neutral-200 bg-white px-3 py-2.5 transition-colors hover:border-primary-200 hover:bg-primary-50/40 sm:px-4 sm:py-3">
<Link href={href} className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-neutral-800">{title}</p>
<p className="truncate text-xs text-neutral-500">{destination}</p>
<p className="mt-0.5 text-[11px] text-neutral-400 sm:text-xs">
{formatTripCalendarDateRangeLong(date, endDate)}
</p>
</div>
</Link>
{rightSlot && (
<div className="shrink-0 text-right text-xs font-medium">{rightSlot}</div>
)}
</Link>
</div>
);
}
+22 -4
View File
@@ -2,7 +2,11 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { createTripSchema, tripImageUrlsSchema } from "./schemas";
import {
createTripSchema,
itineraryItemsSchema,
tripImageUrlsSchema,
} from "./schemas";
import { tripService } from "@/server/services/trip.service";
import { organizerService } from "@/server/services/organizer.service";
import { revalidatePath } from "next/cache";
@@ -21,7 +25,6 @@ export async function createTripAction(formData: FormData) {
destination: formData.get("destination") as string,
location: formData.get("location") as string,
meetingPoint: formData.get("meetingPoint") as string,
itinerary: formData.get("itinerary") as string,
whatsIncluded: formData.get("whatsIncluded") as string,
whatsExcluded: formData.get("whatsExcluded") as string,
date: formData.get("date") as string,
@@ -36,6 +39,22 @@ export async function createTripAction(formData: FormData) {
return { error: result.error.issues[0].message };
}
const itineraryJson = formData.get("itineraryItems");
let itineraryItems: ReturnType<typeof itineraryItemsSchema.parse> = [];
if (typeof itineraryJson === "string" && itineraryJson.trim().length > 0) {
let parsed: unknown;
try {
parsed = JSON.parse(itineraryJson);
} catch {
return { error: "Format itinerary tidak valid" };
}
const itineraryParsed = itineraryItemsSchema.safeParse(parsed);
if (!itineraryParsed.success) {
return { error: itineraryParsed.error.issues[0].message };
}
itineraryItems = itineraryParsed.data;
}
if (result.data.price > 0) {
const approved = await organizerService.isApproved(session.user.id);
if (!approved) {
@@ -69,7 +88,6 @@ export async function createTripAction(formData: FormData) {
try {
const {
meetingPoint,
itinerary,
whatsIncluded,
whatsExcluded,
...tripCore
@@ -78,13 +96,13 @@ export async function createTripAction(formData: FormData) {
const trip = await tripService.createTrip({
...tripCore,
meetingPoint,
itinerary,
whatsIncluded,
whatsExcluded,
date,
endDate,
organizerId: session.user.id,
imageUrls: imageUrls.length > 0 ? imageUrls : undefined,
itineraryItems: itineraryItems.length > 0 ? itineraryItems : undefined,
});
revalidatePath("/trips");
revalidatePath("/");
File diff suppressed because it is too large Load Diff
+22 -13
View File
@@ -1,24 +1,31 @@
"use client";
import { useState } from "react";
import { LIMITS } from "@/lib/limits";
export function ImageUrlInput() {
const [urls, setUrls] = useState<string[]>([""]);
interface ImageUrlInputProps {
value: string[];
onChange: (urls: string[]) => void;
}
export function ImageUrlInput({ value, onChange }: ImageUrlInputProps) {
const urls = value.length > 0 ? value : [""];
const max = LIMITS.MAX_IMAGE_URLS;
function addField() {
if (urls.length < 5) {
setUrls([...urls, ""]);
if (urls.length < max) {
onChange([...urls, ""]);
}
}
function removeField(index: number) {
setUrls(urls.filter((_, i) => i !== index));
const next = urls.filter((_, i) => i !== index);
onChange(next.length > 0 ? next : [""]);
}
function updateField(index: number, value: string) {
function updateField(index: number, next: string) {
const updated = [...urls];
updated[index] = value;
setUrls(updated);
updated[index] = next;
onChange(updated);
}
return (
@@ -27,13 +34,14 @@ export function ImageUrlInput() {
<span className="text-sm font-semibold text-neutral-700">
Foto Trip (URL)
</span>
<span className="text-xs text-neutral-400">{urls.length}/5</span>
<span className="text-xs text-neutral-400">
{urls.length}/{max}
</span>
</label>
<div className="space-y-2">
{urls.map((url, i) => (
<div key={i} className="flex gap-2">
<input
name="imageUrls"
type="url"
value={url}
onChange={(e) => updateField(i, e.target.value)}
@@ -48,6 +56,7 @@ export function ImageUrlInput() {
<button
type="button"
onClick={() => removeField(i)}
aria-label={`Hapus foto ${i + 1}`}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-neutral-200 text-neutral-400 hover:bg-red-50 hover:text-red-500"
>
@@ -56,7 +65,7 @@ export function ImageUrlInput() {
</div>
))}
</div>
{urls.length < 5 && (
{urls.length < max && (
<button
type="button"
onClick={addField}
@@ -66,7 +75,7 @@ export function ImageUrlInput() {
</button>
)}
<p className="mt-1.5 text-xs text-neutral-400">
Upload foto ke hosting (imgur, imgbb, dll) lalu paste URL-nya di sini
Upload foto ke hosting (imgur, imgbb, dll) lalu paste URL-nya di sini.
</p>
</div>
);
+22 -21
View File
@@ -5,6 +5,15 @@ import { useRouter } from "next/navigation";
import Link from "next/link";
import { joinTripAction, cancelJoinAction } from "@/features/trip/actions";
type BookingStatus =
| "PENDING"
| "AWAITING_PAY"
| "PAID"
| "CANCELLED"
| "REFUNDED"
| "PARTIALLY_REFUNDED"
| "EXPIRED";
interface JoinTripButtonProps {
tripId: string;
isLoggedIn: boolean;
@@ -14,11 +23,8 @@ interface JoinTripButtonProps {
isFree: boolean;
/** Status partisipasi user saat isJoined (bukan organizer) */
participationStatus?: "PENDING" | "CONFIRMED" | null;
/** Status pembayaran manual (peserta). Hanya relevan untuk trip berbayar. */
participantPayment?: {
markedPaidAt: string | Date | null;
paymentConfirmedAt: string | Date | null;
} | null;
/** Status booking peserta (hanya relevan untuk trip berbayar). */
bookingStatus?: BookingStatus | null;
isFull: boolean;
tripStatus: string;
/** Tanggal berangkat trip sudah lewat */
@@ -35,7 +41,7 @@ export function JoinTripButton({
isJoined,
isFree,
participationStatus,
participantPayment,
bookingStatus,
isFull,
tripStatus,
isDeparturePast,
@@ -114,11 +120,9 @@ export function JoinTripButton({
}
}
const pay = participantPayment;
const showPaymentLink = !isFree && isJoined && !isDeparturePast;
const waitingPaymentConfirm =
!isFree && isJoined && pay && pay.markedPaidAt && !pay.paymentConfirmedAt;
const paymentDone = !isFree && isJoined && pay && pay.paymentConfirmedAt;
const needsPayment = !isFree && isJoined && bookingStatus === "AWAITING_PAY";
const paymentDone = !isFree && isJoined && bookingStatus === "PAID";
const showPaymentLink = (needsPayment || paymentDone) && !isDeparturePast;
return (
<div>
@@ -142,16 +146,17 @@ export function JoinTripButton({
{isFree && <span> trip gratis, tidak ada pembayaran 🎉</span>}.
</div>
)}
{waitingPaymentConfirm && (
<div className="mb-3 rounded-xl border border-primary-200 bg-primary-50 px-4 py-3 text-sm font-medium leading-relaxed text-primary-950">
Kamu sudah menandai <span className="font-semibold">sudah bayar</span>.
Tunggu organizer mengonfirmasi pembayaran.
{needsPayment && (
<div className="mb-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-medium leading-relaxed text-amber-900">
Selesaikan pembayaran lewat{" "}
<span className="font-semibold">Midtrans</span> untuk mengamankan slot
kamu.
</div>
)}
{paymentDone && (
<div className="mb-3 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-900">
Pembayaran kamu sudah{" "}
<span className="font-semibold">dikonfirmasi organizer</span>.
<span className="font-semibold">terkonfirmasi</span>.
</div>
)}
{showPaymentLink && (
@@ -159,11 +164,7 @@ export function JoinTripButton({
href={`/trips/${tripId}/payment`}
className="mb-3 block w-full rounded-xl border-2 border-primary-500 bg-white py-3 text-center text-sm font-bold text-primary-700 transition-colors hover:bg-primary-50"
>
{paymentDone
? "Lihat detail pembayaran"
: pay?.markedPaidAt
? "Lihat status pembayaran"
: "Buka detail pembayaran"}
{paymentDone ? "Lihat detail pembayaran" : "Bayar sekarang"}
</Link>
)}
{isJoined ? (
@@ -11,8 +11,6 @@ import {
export interface PendingJoinRequest {
id: string;
user: { name: string; image: string | null };
/** Peserta sudah menekan &quot;Saya sudah bayar&quot; */
markedPaidAt?: string | Date | null;
}
interface OrganizerJoinRequestsProps {
@@ -83,14 +81,7 @@ export function OrganizerJoinRequests({
<p className="truncate text-sm font-semibold text-neutral-800">
{p.user.name}
</p>
<div className="flex flex-wrap items-center gap-1.5">
<p className="text-xs text-amber-800/80">Menunggu persetujuan</p>
{p.markedPaidAt ? (
<span className="rounded-full bg-primary-100 px-2 py-0.5 text-[10px] font-bold text-primary-800">
Sudah tandai bayar
</span>
) : null}
</div>
<p className="text-xs text-amber-800/80">Menunggu persetujuan</p>
</div>
</div>
<div className="flex shrink-0 gap-2">
@@ -1,6 +1,17 @@
import { groupItineraryByDay } from "@/lib/itinerary";
interface ItineraryItem {
day: number;
startTime: string;
endTime: string | null;
activity: string;
order: number;
}
interface TripProgramBlockProps {
meetingPoint: string | null;
itinerary: string | null;
itineraryItems: ItineraryItem[];
whatsIncluded: string | null;
whatsExcluded: string | null;
}
@@ -8,13 +19,24 @@ interface TripProgramBlockProps {
export function TripProgramBlock({
meetingPoint,
itinerary,
itineraryItems,
whatsIncluded,
whatsExcluded,
}: TripProgramBlockProps) {
const hasStructuredItinerary = itineraryItems.length > 0;
const hasLegacyItinerary = !hasStructuredItinerary && !!itinerary;
const hasAny =
meetingPoint || itinerary || whatsIncluded || whatsExcluded;
meetingPoint ||
hasStructuredItinerary ||
hasLegacyItinerary ||
whatsIncluded ||
whatsExcluded;
if (!hasAny) return null;
const grouped = hasStructuredItinerary
? groupItineraryByDay(itineraryItems)
: null;
return (
<div className="space-y-4 rounded-xl border border-neutral-200 bg-neutral-50/50 p-4 sm:p-5">
<h2 className="text-xs font-bold text-neutral-800 sm:text-sm">
@@ -32,7 +54,41 @@ export function TripProgramBlock({
</div>
)}
{itinerary && (
{grouped && (
<div>
<h3 className="mb-2 text-[11px] font-bold uppercase tracking-wide text-primary-700 sm:text-xs">
Itinerary
</h3>
<div className="space-y-3">
{[...grouped.entries()].map(([day, items]) => (
<div
key={day}
className="rounded-lg border border-primary-100 bg-white p-3 sm:p-4"
>
<p className="mb-2 text-xs font-bold text-primary-800 sm:text-sm">
Hari {day}
</p>
<ol className="space-y-2">
{items.map((item) => (
<li
key={item.order}
className="flex gap-3 text-xs leading-relaxed text-neutral-700 sm:text-sm"
>
<span className="shrink-0 font-mono text-[11px] font-semibold text-primary-700 sm:text-xs">
{item.startTime}
{item.endTime ? `${item.endTime}` : ""}
</span>
<span className="min-w-0 flex-1">{item.activity}</span>
</li>
))}
</ol>
</div>
))}
</div>
</div>
)}
{hasLegacyItinerary && (
<div>
<h3 className="mb-1 text-[11px] font-bold uppercase tracking-wide text-primary-700 sm:text-xs">
Itinerary
+70 -14
View File
@@ -7,6 +7,7 @@ import {
isTripDepartureDayPast,
tripStoredInstantFromYmd,
} from "@/lib/trip-dates";
import { isValidTimeFormat, timeToMinutes } from "@/lib/itinerary";
export const tripImageUrlsSchema = z
.array(
@@ -18,6 +19,75 @@ export const tripImageUrlsSchema = z
)
.max(LIMITS.MAX_IMAGE_URLS, `Maksimal ${LIMITS.MAX_IMAGE_URLS} foto`);
export const itineraryItemSchema = z
.object({
day: z.coerce
.number()
.int("Nomor hari tidak valid")
.min(1, "Nomor hari minimal 1")
.max(
LIMITS.MAX_ITINERARY_DAYS,
`Maksimal ${LIMITS.MAX_ITINERARY_DAYS} hari`
),
startTime: z
.string()
.trim()
.refine(isValidTimeFormat, "Format jam mulai harus HH:mm"),
endTime: z.preprocess(
(val) => {
if (val == null) return undefined;
const s = String(val).trim();
return s === "" ? undefined : s;
},
z
.string()
.refine(isValidTimeFormat, "Format jam selesai harus HH:mm")
.optional()
),
activity: z
.string()
.trim()
.min(1, "Deskripsi aktivitas harus diisi")
.max(
LIMITS.MAX_ITINERARY_ACTIVITY_LENGTH,
`Aktivitas maksimal ${LIMITS.MAX_ITINERARY_ACTIVITY_LENGTH} karakter`
),
})
.superRefine((data, ctx) => {
if (
data.endTime &&
timeToMinutes(data.endTime) < timeToMinutes(data.startTime)
) {
ctx.addIssue({
code: "custom",
message: "Jam selesai tidak boleh sebelum jam mulai",
path: ["endTime"],
});
}
});
export const itineraryItemsSchema = z
.array(itineraryItemSchema)
.max(
LIMITS.MAX_ITINERARY_ITEMS,
`Maksimal ${LIMITS.MAX_ITINERARY_ITEMS} aktivitas total`
)
.superRefine((items, ctx) => {
if (items.length === 0) return;
const days = [...new Set(items.map((i) => i.day))].sort((a, b) => a - b);
for (let i = 0; i < days.length; i++) {
if (days[i] !== i + 1) {
ctx.addIssue({
code: "custom",
message:
"Nomor hari harus berurutan mulai dari Hari 1 (tidak boleh lompat)",
path: [0, "day"],
});
return;
}
}
});
export const createTripSchema = z
.object({
category: z.enum(
@@ -105,20 +175,6 @@ export const createTripSchema = z
)
.optional()
),
itinerary: z.preprocess(
(val) => {
if (val == null) return undefined;
const s = String(val).trim();
return s === "" ? undefined : s;
},
z
.string()
.max(
LIMITS.MAX_TRIP_ITINERARY_LENGTH,
`Itinerary maksimal ${LIMITS.MAX_TRIP_ITINERARY_LENGTH} karakter`
)
.optional()
),
whatsIncluded: z.preprocess(
(val) => {
if (val == null) return undefined;
+114
View File
@@ -0,0 +1,114 @@
import { LIMITS } from "@/lib/limits";
/**
* Item itinerary terstruktur. Format jam: "HH:mm" 24-jam.
* Bentuk ini dipakai di form, action, dan render — DB-nya pakai
* `TripItineraryItem` (lihat schema.prisma).
*/
export interface ItineraryItemInput {
day: number;
startTime: string;
endTime?: string | null;
activity: string;
}
const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
export function isValidTimeFormat(value: string): boolean {
return TIME_RE.test(value);
}
/**
* Konversi "HH:mm" ke total menit sejak 00:00. Pakai untuk perbandingan
* jam mulai vs jam selesai. Mengembalikan NaN kalau format invalid.
*/
export function timeToMinutes(value: string): number {
if (!isValidTimeFormat(value)) return Number.NaN;
const [h, m] = value.split(":").map(Number);
return h * 60 + m;
}
/**
* Format display jam (sudah HH:mm di DB, sekedar pass-through dengan
* trimming defensif).
*/
export function formatItineraryTime(value: string): string {
return value.trim();
}
/**
* Kelompokkan items per hari, urut ascending. Item dalam satu hari diurut
* berdasarkan `order` lalu `startTime`.
*/
export function groupItineraryByDay<
T extends { day: number; order: number; startTime: string },
>(items: T[]): Map<number, T[]> {
const grouped = new Map<number, T[]>();
for (const item of items) {
const list = grouped.get(item.day) ?? [];
list.push(item);
grouped.set(item.day, list);
}
for (const list of grouped.values()) {
list.sort((a, b) => {
if (a.order !== b.order) return a.order - b.order;
return timeToMinutes(a.startTime) - timeToMinutes(b.startTime);
});
}
return new Map(
[...grouped.entries()].sort(([a], [b]) => a - b)
);
}
/**
* Validasi semantik (selain Zod): jam selesai harus >= jam mulai (kalau diisi),
* dan jumlah day harus rapat (1..N tanpa lompat) untuk menjaga UX builder
* tetap deterministik. Return error pertama yang ditemui, atau null kalau OK.
*/
export function validateItineraryItems(
items: ItineraryItemInput[]
): string | null {
if (items.length === 0) return null;
if (items.length > LIMITS.MAX_ITINERARY_ITEMS) {
return `Maksimal ${LIMITS.MAX_ITINERARY_ITEMS} aktivitas total`;
}
const days = new Set<number>();
for (const item of items) {
if (!Number.isInteger(item.day) || item.day < 1) {
return "Nomor hari tidak valid";
}
if (item.day > LIMITS.MAX_ITINERARY_DAYS) {
return `Maksimal ${LIMITS.MAX_ITINERARY_DAYS} hari`;
}
if (!isValidTimeFormat(item.startTime)) {
return "Format jam mulai harus HH:mm (00:00 23:59)";
}
if (item.endTime && !isValidTimeFormat(item.endTime)) {
return "Format jam selesai harus HH:mm (00:00 23:59)";
}
if (
item.endTime &&
timeToMinutes(item.endTime) < timeToMinutes(item.startTime)
) {
return "Jam selesai tidak boleh sebelum jam mulai";
}
const trimmedActivity = item.activity.trim();
if (trimmedActivity.length === 0) {
return "Deskripsi aktivitas harus diisi";
}
if (trimmedActivity.length > LIMITS.MAX_ITINERARY_ACTIVITY_LENGTH) {
return `Aktivitas maksimal ${LIMITS.MAX_ITINERARY_ACTIVITY_LENGTH} karakter`;
}
days.add(item.day);
}
const sortedDays = [...days].sort((a, b) => a - b);
for (let i = 0; i < sortedDays.length; i++) {
if (sortedDays[i] !== i + 1) {
return "Nomor hari harus berurutan mulai dari Hari 1 (tidak boleh lompat)";
}
}
return null;
}
+6
View File
@@ -13,6 +13,12 @@ export const LIMITS = {
/** Meeting point & tiap blok include/exclude */
MAX_MEETING_POINT_LENGTH: 500,
MAX_TRIP_ITINERARY_LENGTH: 8000,
/** Itinerary baru terstruktur: maksimal hari dalam satu trip. */
MAX_ITINERARY_DAYS: 14,
/** Maksimal item itinerary per trip (lintas hari). */
MAX_ITINERARY_ITEMS: 60,
/** Maksimal panjang deskripsi satu aktivitas. */
MAX_ITINERARY_ACTIVITY_LENGTH: 200,
MAX_TRIP_BULLET_SECTION_LENGTH: 4000,
MAX_REVIEW_COMMENT: 500,
MAX_IMAGE_URLS: 5,
+73
View File
@@ -42,6 +42,11 @@ export const MIDTRANS = {
isProduction()
? "https://app.midtrans.com/snap/snap.js"
: "https://app.sandbox.midtrans.com/snap/snap.js",
/** Core API base — dipakai untuk GET /v2/{order_id}/status (rekonsiliasi). */
coreApiBase: () =>
isProduction()
? "https://api.midtrans.com/v2"
: "https://api.sandbox.midtrans.com/v2",
};
function requireServerKey(): string {
@@ -70,6 +75,9 @@ interface SnapTransactionPayload {
itemName: string;
/// Berapa detik sampai expire. Default Midtrans 24 jam, kita pakai itu kalau undefined.
expirySeconds?: number;
/// URL absolut untuk redirect user setelah selesai bayar (success / pending / error).
/// Tanpa ini, Midtrans pakai default `example.com`.
finishUrl?: string;
}
export interface SnapTransactionResult {
@@ -110,6 +118,10 @@ export async function createSnapTransaction(
};
}
if (payload.finishUrl) {
body.callbacks = { finish: payload.finishUrl };
}
const res = await fetch(`${MIDTRANS.snapApiBase()}/transactions`, {
method: "POST",
headers: {
@@ -137,6 +149,67 @@ export async function createSnapTransaction(
};
}
/**
* Bentuk minimal response dari Midtrans Core API GET /v2/{order_id}/status.
* Sub-set field yang kita pakai untuk rekonsiliasi (sama dengan field webhook).
* https://docs.midtrans.com/reference/get-transaction-status
*/
export interface MidtransTransactionStatus {
order_id: string;
status_code: string;
transaction_status: string;
gross_amount: string;
transaction_id?: string;
payment_type?: string;
fraud_status?: string | null;
}
/**
* Fetch status transaksi langsung dari Midtrans untuk rekonsiliasi server-side.
* Dipakai saat kita tidak bisa mengandalkan webhook (mis. dev di localhost,
* atau webhook tertunda). Auth pakai server key — response sudah terpercaya
* karena datang dari Midtrans atas request kita, jadi tidak perlu verifikasi
* signature.
*
* Return null kalau Midtrans tidak menemukan order (404).
*/
export async function fetchMidtransTransactionStatus(
orderId: string
): Promise<MidtransTransactionStatus | null> {
const res = await fetch(
`${MIDTRANS.coreApiBase()}/${encodeURIComponent(orderId)}/status`,
{
method: "GET",
headers: {
Accept: "application/json",
Authorization: basicAuthHeader(),
},
cache: "no-store",
}
);
if (res.status === 404) return null;
const json = (await res.json().catch(() => null)) as
| (Partial<MidtransTransactionStatus> & { status_message?: string })
| null;
if (!res.ok || !json?.order_id || !json.transaction_status) {
const reason = json?.status_message ?? `HTTP ${res.status}`;
throw new Error(`Midtrans status fetch gagal: ${reason}`);
}
return {
order_id: json.order_id,
status_code: json.status_code ?? String(res.status),
transaction_status: json.transaction_status,
gross_amount: json.gross_amount ?? "0",
transaction_id: json.transaction_id,
payment_type: json.payment_type,
fraud_status: json.fraud_status ?? null,
};
}
/**
* Verifikasi signature webhook Midtrans.
* Formula: SHA512(order_id + status_code + gross_amount + serverKey).
@@ -0,0 +1,20 @@
-- CreateTable
CREATE TABLE "TripItineraryItem" (
"id" TEXT NOT NULL,
"tripId" TEXT NOT NULL,
"day" INTEGER NOT NULL,
"startTime" TEXT NOT NULL,
"endTime" TEXT,
"activity" TEXT NOT NULL,
"order" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TripItineraryItem_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "TripItineraryItem_tripId_day_order_idx" ON "TripItineraryItem"("tripId", "day", "order");
-- AddForeignKey
ALTER TABLE "TripItineraryItem" ADD CONSTRAINT "TripItineraryItem_tripId_fkey" FOREIGN KEY ("tripId") REFERENCES "Trip"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+32 -6
View File
@@ -143,7 +143,8 @@ model Trip {
location String
/// Titik kumpul / meeting point (teks bebas)
meetingPoint String?
/// Itinerary hari per hari (teks bebas, bullet OK)
/// @deprecated — itinerary lama bentuk teks bebas, backward-compat untuk data
/// lama. Trip baru pakai `itineraryItems` (struktural per hari + jam).
itinerary String?
/// Yang termasuk harga (teks bebas)
whatsIncluded String?
@@ -162,16 +163,41 @@ model Trip {
organizerId String
organizer User @relation(fields: [organizerId], references: [id])
participants TripParticipant[]
images TripImage[]
reviews TripReview[]
bookings Booking[]
payouts Payout[]
participants TripParticipant[]
images TripImage[]
reviews TripReview[]
bookings Booking[]
payouts Payout[]
itineraryItems TripItineraryItem[]
@@index([category, status, date])
@@index([vibe, status, date])
}
/// Itinerary item terstruktur per hari + jam. Satu Trip punya banyak item;
/// dikelompokkan per `day` lalu diurutkan `order`. Format jam: "HH:mm" 24-jam.
model TripItineraryItem {
id String @id @default(cuid())
tripId String
trip Trip @relation(fields: [tripId], references: [id], onDelete: Cascade)
/// Hari ke-berapa, mulai dari 1.
day Int
/// Waktu mulai aktivitas, format "HH:mm".
startTime String
/// Waktu selesai (opsional), format "HH:mm".
endTime String?
/// Deskripsi aktivitas singkat.
activity String
/// Urutan dalam hari, untuk preserve order saat render.
order Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tripId, day, order])
}
model TripReview {
id String @id @default(cuid())
rating Int
+238 -228
View File
@@ -27,231 +27,226 @@ const img = (id: string) =>
const SEED_PASSWORD = "password123";
// ============================================================================
// Itineraries — disimpan sebagai const supaya data trip tetap padat.
// Format: header `Hari N — <hari>` lalu bullet `• HH:MMHH:MM aktivitas`.
// Lokasi, pos, dan spot diambil dari kasus nyata (Papandayan via Cisurupan,
// Ciremai via Apuy, USS Liberty Tulamben, Karimun Jawa hopping, dst).
// Itineraries — array terstruktur per hari/jam. Setiap item: {day, startTime,
// endTime, activity}. Disimpan ke TripItineraryItem (lihat schema.prisma).
// ============================================================================
const ITIN_PAPANDAYAN = `Hari 1 — Sabtu
• 05:0005:30 Meeting & briefing di Alun-alun Garut
• 05:3007:00 Perjalanan menuju basecamp Cisurupan
• 07:0008:00 Sarapan + repacking + pemanasan
• 08:0011:00 Trekking Camp David — Hutan Mati — Pondok Salada
• 11:0012:30 Setup tenda di Pondok Salada
• 12:3014:00 ISHOMA + games kenalan grup
• 14:0017:00 Eksplor Tegal Alun & Hutan Mati (golden hour foto)
• 17:0019:00 Masak bareng + makan malam
• 19:0021:00 Api unggun, kopi, sharing rencana summit
• 21:00 Istirahat
interface SeedItineraryItem {
day: number;
startTime: string;
endTime?: string;
activity: string;
}
Hari 2 — Minggu
• 03:3004:00 Bangun + sarapan ringan + air panas
• 04:0005:30 Summit attack ke puncak Papandayan
• 05:3007:00 Sunrise + foto bareng di puncak
• 07:0009:00 Turun ke camp + sarapan utama
• 09:0011:00 Beres-beres tenda + repacking
• 11:0013:30 Turun ke basecamp Cisurupan
• 13:3014:30 Bersih-bersih + makan siang
• 14:3016:30 Perjalanan kembali ke Garut
• 16:30 Sampai Garut, bubar grup`;
const ITIN_PAPANDAYAN: SeedItineraryItem[] = [
{ day: 1, startTime: "05:00", endTime: "05:30", activity: "Meeting & briefing di Alun-alun Garut" },
{ day: 1, startTime: "05:30", endTime: "07:00", activity: "Perjalanan menuju basecamp Cisurupan" },
{ day: 1, startTime: "07:00", endTime: "08:00", activity: "Sarapan + repacking + pemanasan" },
{ day: 1, startTime: "08:00", endTime: "11:00", activity: "Trekking Camp David — Hutan Mati — Pondok Salada" },
{ day: 1, startTime: "11:00", endTime: "12:30", activity: "Setup tenda di Pondok Salada" },
{ day: 1, startTime: "12:30", endTime: "14:00", activity: "ISHOMA + games kenalan grup" },
{ day: 1, startTime: "14:00", endTime: "17:00", activity: "Eksplor Tegal Alun & Hutan Mati (golden hour foto)" },
{ day: 1, startTime: "17:00", endTime: "19:00", activity: "Masak bareng + makan malam" },
{ day: 1, startTime: "19:00", endTime: "21:00", activity: "Api unggun, kopi, sharing rencana summit" },
{ day: 1, startTime: "21:00", activity: "Istirahat" },
{ day: 2, startTime: "03:30", endTime: "04:00", activity: "Bangun + sarapan ringan + air panas" },
{ day: 2, startTime: "04:00", endTime: "05:30", activity: "Summit attack ke puncak Papandayan" },
{ day: 2, startTime: "05:30", endTime: "07:00", activity: "Sunrise + foto bareng di puncak" },
{ day: 2, startTime: "07:00", endTime: "09:00", activity: "Turun ke camp + sarapan utama" },
{ day: 2, startTime: "09:00", endTime: "11:00", activity: "Beres-beres tenda + repacking" },
{ day: 2, startTime: "11:00", endTime: "13:30", activity: "Turun ke basecamp Cisurupan" },
{ day: 2, startTime: "13:30", endTime: "14:30", activity: "Bersih-bersih + makan siang" },
{ day: 2, startTime: "14:30", endTime: "16:30", activity: "Perjalanan kembali ke Garut" },
{ day: 2, startTime: "16:30", activity: "Sampai Garut, bubar grup" },
];
const ITIN_CIREMAI = `Hari 1 — Sabtu
• 04:0004:30 Meeting & briefing di Stasiun Kuningan
• 04:3006:30 Perjalanan ke basecamp Apuy via Maja & Argapura
• 06:3007:30 Sarapan + registrasi simaksi + repacking
• 07:3010:30 Trek Pos 1 (Berod) → Pos 2 (Arban) → Pos 3 (Tegal Masawa)
• 10:3011:30 ISHOMA di Pos 4 (Tegal Jamuju)
• 11:3014:30 Lanjut Pos 5 (Sanghyang Rangkah) → Pos 6 (Goa Walet)
• 14:3016:00 Setup tenda di Pos 6
• 16:0018:00 Acara bebas + makan sore + persiapan summit
• 18:0020:00 Briefing summit + early dinner
• 20:00 Istirahat (bangun dini hari)
const ITIN_CIREMAI: SeedItineraryItem[] = [
{ day: 1, startTime: "04:00", endTime: "04:30", activity: "Meeting & briefing di Stasiun Kuningan" },
{ day: 1, startTime: "04:30", endTime: "06:30", activity: "Perjalanan ke basecamp Apuy via Maja & Argapura" },
{ day: 1, startTime: "06:30", endTime: "07:30", activity: "Sarapan + registrasi simaksi + repacking" },
{ day: 1, startTime: "07:30", endTime: "10:30", activity: "Trek Pos 1 (Berod) → Pos 2 (Arban) → Pos 3 (Tegal Masawa)" },
{ day: 1, startTime: "10:30", endTime: "11:30", activity: "ISHOMA di Pos 4 (Tegal Jamuju)" },
{ day: 1, startTime: "11:30", endTime: "14:30", activity: "Lanjut Pos 5 (Sanghyang Rangkah) → Pos 6 (Goa Walet)" },
{ day: 1, startTime: "14:30", endTime: "16:00", activity: "Setup tenda di Pos 6" },
{ day: 1, startTime: "16:00", endTime: "18:00", activity: "Acara bebas + makan sore + persiapan summit" },
{ day: 1, startTime: "18:00", endTime: "20:00", activity: "Briefing summit + early dinner" },
{ day: 1, startTime: "20:00", activity: "Istirahat (bangun dini hari)" },
{ day: 2, startTime: "02:00", endTime: "02:30", activity: "Bangun + cemilan + minuman hangat" },
{ day: 2, startTime: "02:30", endTime: "05:00", activity: "Summit attack ke puncak Sunan Cirebon (3.078 mdpl)" },
{ day: 2, startTime: "05:00", endTime: "06:30", activity: "Sunrise di puncak Ciremai" },
{ day: 2, startTime: "06:30", endTime: "08:30", activity: "Turun ke Pos 6 + sarapan" },
{ day: 2, startTime: "08:30", endTime: "10:30", activity: "Beres tenda + repacking" },
{ day: 2, startTime: "10:30", endTime: "14:00", activity: "Turun ke basecamp Apuy (track curam — hati-hati lutut)" },
{ day: 2, startTime: "14:00", endTime: "15:00", activity: "Bersih-bersih + makan siang di basecamp" },
{ day: 2, startTime: "15:00", endTime: "17:00", activity: "Kembali ke Stasiun Kuningan" },
{ day: 2, startTime: "17:00", activity: "Bubar grup" },
];
Hari 2 — Minggu
• 02:0002:30 Bangun + cemilan + minuman hangat
• 02:3005:00 Summit attack ke puncak Sunan Cirebon (3.078 mdpl)
• 05:0006:30 Sunrise di puncak Ciremai
• 06:3008:30 Turun ke Pos 6 + sarapan
• 08:3010:30 Beres tenda + repacking
• 10:3014:00 Turun ke basecamp Apuy (track curam — hati-hati lutut)
• 14:0015:00 Bersih-bersih + makan siang di basecamp
• 15:0017:00 Kembali ke Stasiun Kuningan
• 17:00 Bubar grup`;
const ITIN_CAMPING: SeedItineraryItem[] = [
{ day: 1, startTime: "13:00", endTime: "13:30", activity: "Meeting di Pertigaan Pasar Lembang" },
{ day: 1, startTime: "13:30", endTime: "15:00", activity: "Perjalanan ke Ranca Upas via Ciwidey" },
{ day: 1, startTime: "15:00", endTime: "16:00", activity: "Check-in + setup tenda dome (sudah disiapkan tim)" },
{ day: 1, startTime: "16:00", endTime: "17:30", activity: "Tour camp area + ketemu rusa-rusa" },
{ day: 1, startTime: "17:30", endTime: "19:00", activity: "Persiapan BBQ + nyalakan api unggun" },
{ day: 1, startTime: "19:00", endTime: "21:00", activity: "Makan malam BBQ" },
{ day: 1, startTime: "21:00", endTime: "23:00", activity: "Live music akustik + games" },
{ day: 1, startTime: "23:00", activity: "Istirahat" },
{ day: 2, startTime: "06:00", endTime: "07:00", activity: "Sunrise + foto di hutan pinus" },
{ day: 2, startTime: "07:00", endTime: "08:30", activity: "Sarapan (nasi goreng + roti bakar + kopi)" },
{ day: 2, startTime: "08:30", endTime: "10:00", activity: "Memberi makan rusa + sesi foto" },
{ day: 2, startTime: "10:00", endTime: "11:00", activity: "Beres tenda + bersih-bersih" },
{ day: 2, startTime: "11:00", endTime: "11:30", activity: "Pulang menuju Lembang" },
{ day: 2, startTime: "12:00", activity: "Sampai Lembang, bubar grup" },
];
const ITIN_CAMPING = `Hari 1 — Sabtu
• 13:0013:30 Meeting di Pertigaan Pasar Lembang
• 13:3015:00 Perjalanan ke Ranca Upas via Ciwidey
• 15:0016:00 Check-in + setup tenda dome (sudah disiapkan tim)
• 16:0017:30 Tour camp area + ketemu rusa-rusa
• 17:3019:00 Persiapan BBQ + nyalakan api unggun
• 19:0021:00 Makan malam BBQ
• 21:0023:00 Live music akustik + games
• 23:00 Istirahat
const ITIN_PAHAWANG: SeedItineraryItem[] = [
{ day: 1, startTime: "07:00", endTime: "07:30", activity: "Meeting di Dermaga Ketapang, Lampung Selatan" },
{ day: 1, startTime: "07:30", endTime: "08:30", activity: "Briefing safety + fitting alat snorkel" },
{ day: 1, startTime: "08:30", endTime: "09:30", activity: "Sailing menuju Pulau Pahawang Kecil" },
{ day: 1, startTime: "09:30", endTime: "11:30", activity: "Snorkeling spot Cukuh Bedil — terumbu warna-warni" },
{ day: 1, startTime: "11:30", endTime: "12:30", activity: "Pindah spot ke Pulau Kelagian Kecil" },
{ day: 1, startTime: "12:30", endTime: "14:00", activity: "Makan siang + istirahat di pasir putih" },
{ day: 1, startTime: "14:00", endTime: "15:30", activity: "Snorkeling Tanjung Putus + sesi foto underwater" },
{ day: 1, startTime: "15:30", endTime: "16:30", activity: "Sailing kembali ke dermaga" },
{ day: 1, startTime: "16:30", endTime: "17:00", activity: "Bersih-bersih + bubar grup" },
];
Hari 2 — Minggu
• 06:0007:00 Sunrise + foto di hutan pinus
• 07:0008:30 Sarapan (nasi goreng + roti bakar + kopi)
• 08:3010:00 Memberi makan rusa + sesi foto
• 10:0011:00 Beres tenda + bersih-bersih
• 11:0011:30 Pulang menuju Lembang
• 12:00 Sampai Lembang, bubar grup`;
const ITIN_DIVING: SeedItineraryItem[] = [
{ day: 1, startTime: "06:30", endTime: "07:00", activity: "Meeting di dive shop Tulamben + welcome coffee" },
{ day: 1, startTime: "07:00", endTime: "08:00", activity: "Briefing dive plan + cek sertifikasi + fitting gear" },
{ day: 1, startTime: "08:00", endTime: "09:00", activity: "Surface interval + pengecekan tank/regulator" },
{ day: 1, startTime: "09:00", endTime: "10:30", activity: "Dive #1 — USS Liberty Wreck (528m, ~50 menit bottom time)" },
{ day: 1, startTime: "10:30", endTime: "12:00", activity: "Surface interval + brunch + log dive" },
{ day: 1, startTime: "12:00", endTime: "13:30", activity: "Dive #2 — Coral Garden / Drop Off (~50 menit)" },
{ day: 1, startTime: "13:30", endTime: "15:00", activity: "Debrief + makan siang" },
{ day: 1, startTime: "15:00", endTime: "17:00", activity: "Acara bebas (rest atau eksplor desa Tulamben)" },
{ day: 1, startTime: "17:00", endTime: "19:00", activity: "Sunset di pantai Tulamben + dinner" },
{ day: 1, startTime: "19:00", activity: "Istirahat di homestay (mandiri)" },
{ day: 2, startTime: "06:00", endTime: "06:30", activity: "Bangun + kopi" },
{ day: 2, startTime: "06:30", endTime: "07:30", activity: "Briefing dive #3 (early morning visibility tinggi)" },
{ day: 2, startTime: "07:30", endTime: "09:00", activity: "Dive #3 — Liberty Wreck pagi" },
{ day: 2, startTime: "09:00", endTime: "10:30", activity: "Surface interval + sarapan + log" },
{ day: 2, startTime: "10:30", endTime: "12:00", activity: "Dive #4 (opsional, fun dive shallow reef)" },
{ day: 2, startTime: "12:00", endTime: "13:00", activity: "Bersih gear + debrief akhir" },
{ day: 2, startTime: "13:00", endTime: "14:00", activity: "Makan siang penutupan" },
{ day: 2, startTime: "14:00", activity: "Bubar grup" },
];
const ITIN_PAHAWANG = `Hari 1 — Sabtu (one-day trip)
• 07:0007:30 Meeting di Dermaga Ketapang, Lampung Selatan
• 07:3008:30 Briefing safety + fitting alat snorkel
• 08:3009:30 Sailing menuju Pulau Pahawang Kecil
• 09:3011:30 Snorkeling spot Cukuh Bedil — terumbu warna-warni
• 11:3012:30 Pindah spot ke Pulau Kelagian Kecil
• 12:3014:00 Makan siang + istirahat di pasir putih
• 14:0015:30 Snorkeling Tanjung Putus + sesi foto underwater
• 15:3016:30 Sailing kembali ke dermaga
• 16:3017:00 Bersih-bersih + bubar grup`;
const ITIN_ISLANDHOP: SeedItineraryItem[] = [
{ day: 1, startTime: "07:00", endTime: "07:30", activity: "Meeting di Pelabuhan Kartini Jepara" },
{ day: 1, startTime: "07:30", endTime: "13:00", activity: "Penyeberangan kapal feri Jepara → Karimun Jawa" },
{ day: 1, startTime: "13:00", endTime: "14:00", activity: "Tiba di Pelabuhan Karimun + transfer homestay" },
{ day: 1, startTime: "14:00", endTime: "15:00", activity: "Check-in homestay + ISHOMA" },
{ day: 1, startTime: "15:00", endTime: "17:30", activity: "Sunset di Bukit Love + foto-foto" },
{ day: 1, startTime: "17:30", endTime: "19:00", activity: "Bersih-bersih + makan malam" },
{ day: 1, startTime: "19:00", endTime: "21:00", activity: "Alun-alun Karimun + jajan kuliner" },
{ day: 1, startTime: "21:00", activity: "Istirahat" },
{ day: 2, startTime: "06:30", endTime: "07:30", activity: "Sarapan + briefing hopping" },
{ day: 2, startTime: "07:30", endTime: "09:30", activity: "Boat ke Pulau Menjangan Kecil — snorkeling spot terumbu" },
{ day: 2, startTime: "09:30", endTime: "11:30", activity: "Pulau Menjangan Besar — interaksi hiu (penangkaran)" },
{ day: 2, startTime: "11:30", endTime: "13:00", activity: "Makan siang BBQ ikan di Pulau Cemara Besar" },
{ day: 2, startTime: "13:00", endTime: "15:00", activity: "Pulau Cemara Kecil + foto pasir putih" },
{ day: 2, startTime: "15:00", endTime: "17:00", activity: "Pulau Cilik — sunset + snorkel terakhir" },
{ day: 2, startTime: "17:00", endTime: "18:30", activity: "Kembali ke homestay + bersih-bersih" },
{ day: 2, startTime: "18:30", endTime: "20:00", activity: "Makan malam seafood" },
{ day: 2, startTime: "20:00", activity: "Acara bebas" },
{ day: 3, startTime: "06:00", endTime: "07:00", activity: "Sunrise di Tanjung Gelam" },
{ day: 3, startTime: "07:00", endTime: "09:00", activity: "Sarapan + pack-up" },
{ day: 3, startTime: "09:00", endTime: "10:00", activity: "Belanja oleh-oleh di pelabuhan" },
{ day: 3, startTime: "10:00", endTime: "16:00", activity: "Penyeberangan kapal feri Karimun → Jepara" },
{ day: 3, startTime: "16:00", endTime: "17:00", activity: "Tiba di Pelabuhan Kartini, bubar grup" },
];
const ITIN_DIVING = `Hari 1 — Sabtu
• 06:3007:00 Meeting di dive shop Tulamben + welcome coffee
• 07:0008:00 Briefing dive plan + cek sertifikasi + fitting gear
• 08:0009:00 Surface interval + pengecekan tank/regulator
• 09:0010:30 Dive #1 — USS Liberty Wreck (528m, ~50 menit bottom time)
• 10:3012:00 Surface interval + brunch + log dive
• 12:0013:30 Dive #2 — Coral Garden / Drop Off (~50 menit)
• 13:3015:00 Debrief + makan siang
• 15:0017:00 Acara bebas (rest atau eksplor desa Tulamben)
• 17:0019:00 Sunset di pantai Tulamben + dinner
• 19:00 Istirahat di homestay (mandiri)
const ITIN_CITYTRIP: SeedItineraryItem[] = [
{ day: 1, startTime: "08:00", endTime: "08:30", activity: "Meeting di Stasiun Tugu Yogyakarta" },
{ day: 1, startTime: "08:30", endTime: "10:00", activity: "Sarapan Gudeg Yu Djum + briefing rute" },
{ day: 1, startTime: "10:00", endTime: "12:00", activity: "Kotagede — kerajinan perak + Masjid Mataram" },
{ day: 1, startTime: "12:00", endTime: "13:30", activity: "Makan siang Sate Klathak Pak Pong" },
{ day: 1, startTime: "13:30", endTime: "16:00", activity: "Tamansari — pemandian Sultan + sumur Gumuling" },
{ day: 1, startTime: "16:00", endTime: "17:30", activity: "Coffee break di kedai lokal Prawirotaman" },
{ day: 1, startTime: "17:30", endTime: "19:30", activity: "Sunset di Bukit Bintang (Jl. Imogiri)" },
{ day: 1, startTime: "19:30", endTime: "21:30", activity: "Angkringan Lik Man — kopi joss + nasi kucing" },
{ day: 1, startTime: "21:30", activity: "Drop ke penginapan masing-masing" },
{ day: 2, startTime: "06:00", endTime: "07:00", activity: "Pickup dari penginapan" },
{ day: 2, startTime: "07:00", endTime: "09:30", activity: "Perjalanan ke Kalibiru, Kulon Progo" },
{ day: 2, startTime: "09:30", endTime: "11:30", activity: "Kalibiru — spot foto rumah pohon di tebing" },
{ day: 2, startTime: "11:30", endTime: "13:00", activity: "Makan siang pecel di warung lokal" },
{ day: 2, startTime: "13:00", endTime: "15:00", activity: "Pinus Pengger — instalasi seni alam" },
{ day: 2, startTime: "15:00", endTime: "16:30", activity: "Heha Sky View (opsional, cek cuaca)" },
{ day: 2, startTime: "16:30", endTime: "18:00", activity: "Kembali ke kota — drop di Stasiun Tugu / Bandara" },
{ day: 2, startTime: "18:00", activity: "Bubar grup" },
];
Hari 2 — Minggu
• 06:0006:30 Bangun + kopi
• 06:3007:30 Briefing dive #3 (early morning visibility tinggi)
• 07:3009:00 Dive #3 — Liberty Wreck pagi
• 09:0010:30 Surface interval + sarapan + log
• 10:3012:00 Dive #4 (opsional, fun dive shallow reef)
• 12:0013:00 Bersih gear + debrief akhir
• 13:0014:00 Makan siang penutupan
• 14:00 Bubar grup`;
const ITIN_CULINARY: SeedItineraryItem[] = [
{ day: 1, startTime: "09:00", endTime: "09:30", activity: "Meeting di Stasiun Bandung pintu utara + briefing rute" },
{ day: 1, startTime: "09:30", endTime: "10:15", activity: "Stop 1: Surabi Enhaii (sarapan tradisional)" },
{ day: 1, startTime: "10:15", endTime: "11:00", activity: "Stop 2: Lotek Kalipah Apo" },
{ day: 1, startTime: "11:00", endTime: "11:45", activity: "Stop 3: Mie Kocok Mang Dadeng (Kebon Jukut)" },
{ day: 1, startTime: "11:45", endTime: "12:30", activity: "Stop 4: Bakso Akung (cabang Burangrang)" },
{ day: 1, startTime: "12:30", endTime: "13:30", activity: "Istirahat + jalan santai di Cihampelas" },
{ day: 1, startTime: "13:30", endTime: "14:15", activity: "Stop 5: Batagor Kingsley" },
{ day: 1, startTime: "14:15", endTime: "15:00", activity: "Stop 6: Cuanki Serayu" },
{ day: 1, startTime: "15:00", endTime: "15:45", activity: "Stop 7: Es Cendol Elizabeth" },
{ day: 1, startTime: "15:45", endTime: "16:30", activity: "Stop 8: Roti Gempol & Kopi Anjis (penutup)" },
{ day: 1, startTime: "16:30", endTime: "17:00", activity: "Closing + foto bareng di Braga" },
];
const ITIN_ISLANDHOP = `Hari 1 — Jumat
• 07:0007:30 Meeting di Pelabuhan Kartini Jepara
• 07:3013:00 Penyeberangan kapal feri Jepara → Karimun Jawa
• 13:0014:00 Tiba di Pelabuhan Karimun + transfer homestay
• 14:0015:00 Check-in homestay + ISHOMA
• 15:0017:30 Sunset di Bukit Love + foto-foto
• 17:3019:00 Bersih-bersih + makan malam
• 19:0021:00 Alun-alun Karimun + jajan kuliner
• 21:00 Istirahat
const ITIN_CONCERT: SeedItineraryItem[] = [
{ day: 1, startTime: "17:00", endTime: "17:30", activity: "Meeting di Plaza GBK, depan loket Cat 1" },
{ day: 1, startTime: "17:30", endTime: "18:30", activity: "Foto bareng pre-show + obrolan singkat" },
{ day: 1, startTime: "18:30", endTime: "19:00", activity: "Masuk venue bareng (kategori tetap masing-masing)" },
{ day: 1, startTime: "19:00", endTime: "22:30", activity: "Konser Coldplay — Music of the Spheres" },
{ day: 1, startTime: "22:30", endTime: "23:00", activity: "Berkumpul lagi di luar gerbang utama GBK" },
{ day: 1, startTime: "23:00", activity: "After-party dinner di Senayan (resto TBA via grup WA)" },
];
Hari 2 — Sabtu
• 06:3007:30 Sarapan + briefing hopping
• 07:3009:30 Boat ke Pulau Menjangan Kecil — snorkeling spot terumbu
• 09:3011:30 Pulau Menjangan Besar — interaksi hiu (penangkaran)
• 11:3013:00 Makan siang BBQ ikan di Pulau Cemara Besar
• 13:0015:00 Pulau Cemara Kecil + foto pasir putih
• 15:0017:00 Pulau Cilik — sunset + snorkel terakhir
• 17:0018:30 Kembali ke homestay + bersih-bersih
• 18:3020:00 Makan malam seafood
• 20:00 Acara bebas
const ITIN_WORKSHOP: SeedItineraryItem[] = [
{ day: 1, startTime: "04:00", endTime: "04:30", activity: "Meeting di Alun-alun Pangalengan" },
{ day: 1, startTime: "04:30", endTime: "05:30", activity: "Briefing teknis + setup peralatan" },
{ day: 1, startTime: "05:30", endTime: "07:30", activity: "Sunrise shoot di Perkebunan Teh Malabar" },
{ day: 1, startTime: "07:30", endTime: "09:00", activity: "Sarapan + sesi review foto bareng mentor" },
{ day: 1, startTime: "09:00", endTime: "11:00", activity: "Materi indoor: long exposure & filter ND" },
{ day: 1, startTime: "11:00", endTime: "13:00", activity: "ISHOMA + transfer ke Situ Cileunca" },
{ day: 1, startTime: "13:00", endTime: "16:00", activity: "Workshop on-field di Situ Cileunca (panorama, refleksi)" },
{ day: 1, startTime: "16:00", endTime: "18:00", activity: "Golden hour shoot di Bukit Nini" },
{ day: 1, startTime: "18:00", endTime: "19:30", activity: "Makan malam di villa" },
{ day: 1, startTime: "19:30", endTime: "22:00", activity: "Sesi malam — milky way / star trail (cuaca permitting)" },
{ day: 1, startTime: "22:00", activity: "Istirahat di villa" },
{ day: 2, startTime: "05:00", endTime: "07:00", activity: "Sunrise shoot di Pangalengan + foto siluet" },
{ day: 2, startTime: "07:00", endTime: "08:30", activity: "Sarapan + diskusi hasil" },
{ day: 2, startTime: "08:30", endTime: "11:00", activity: "Sesi editing Lightroom (laptop pribadi)" },
{ day: 2, startTime: "11:00", endTime: "12:00", activity: "Review akhir + sertifikat" },
{ day: 2, startTime: "12:00", endTime: "13:00", activity: "Makan siang penutupan" },
{ day: 2, startTime: "13:00", endTime: "15:00", activity: "Kembali ke Alun-alun Pangalengan" },
{ day: 2, startTime: "15:00", activity: "Bubar grup" },
];
Hari 3 — Minggu
• 06:0007:00 Sunrise di Tanjung Gelam
• 07:0009:00 Sarapan + pack-up
• 09:0010:00 Belanja oleh-oleh di pelabuhan
• 10:0016:00 Penyeberangan kapal feri Karimun → Jepara
• 16:0017:00 Tiba di Pelabuhan Kartini, bubar grup`;
const ITIN_CITYTRIP = `Hari 1 — Sabtu
• 08:0008:30 Meeting di Stasiun Tugu Yogyakarta
• 08:3010:00 Sarapan Gudeg Yu Djum + briefing rute
• 10:0012:00 Kotagede — kerajinan perak + Masjid Mataram
• 12:0013:30 Makan siang Sate Klathak Pak Pong
• 13:3016:00 Tamansari — pemandian Sultan + sumur Gumuling
• 16:0017:30 Coffee break di kedai lokal Prawirotaman
• 17:3019:30 Sunset di Bukit Bintang (Jl. Imogiri)
• 19:3021:30 Angkringan Lik Man — kopi joss + nasi kucing
• 21:30 Drop ke penginapan masing-masing
Hari 2 — Minggu
• 06:0007:00 Pickup dari penginapan
• 07:0009:30 Perjalanan ke Kalibiru, Kulon Progo
• 09:3011:30 Kalibiru — spot foto rumah pohon di tebing
• 11:3013:00 Makan siang pecel di warung lokal
• 13:0015:00 Pinus Pengger — instalasi seni alam
• 15:0016:30 Heha Sky View (opsional, cek cuaca)
• 16:3018:00 Kembali ke kota — drop di Stasiun Tugu / Bandara
• 18:00 Bubar grup`;
const ITIN_CULINARY = `Hari 1 — Sabtu (one-day food tour)
• 09:0009:30 Meeting di Stasiun Bandung pintu utara + briefing rute
• 09:3010:15 Stop 1: Surabi Enhaii (sarapan tradisional)
• 10:1511:00 Stop 2: Lotek Kalipah Apo
• 11:0011:45 Stop 3: Mie Kocok Mang Dadeng (Kebon Jukut)
• 11:4512:30 Stop 4: Bakso Akung (cabang Burangrang)
• 12:3013:30 Istirahat + jalan santai di Cihampelas
• 13:3014:15 Stop 5: Batagor Kingsley
• 14:1515:00 Stop 6: Cuanki Serayu
• 15:0015:45 Stop 7: Es Cendol Elizabeth
• 15:4516:30 Stop 8: Roti Gempol & Kopi Anjis (penutup)
• 16:3017:00 Closing + foto bareng di Braga`;
const ITIN_CONCERT = `Hari 1 — Sabtu (showtime)
• 17:0017:30 Meeting di Plaza GBK, depan loket Cat 1
• 17:3018:30 Foto bareng pre-show + obrolan singkat
• 18:3019:00 Masuk venue bareng (kategori tetap masing-masing)
• 19:0022:30 Konser Coldplay — Music of the Spheres
• 22:3023:00 Berkumpul lagi di luar gerbang utama GBK
• 23:0000:30 After-party dinner di Senayan (resto TBA via grup WA)
• 00:30 Bubar`;
const ITIN_WORKSHOP = `Hari 1 — Sabtu
• 04:0004:30 Meeting di Alun-alun Pangalengan
• 04:3005:30 Briefing teknis + setup peralatan
• 05:3007:30 Sunrise shoot di Perkebunan Teh Malabar
• 07:3009:00 Sarapan + sesi review foto bareng mentor
• 09:0011:00 Materi indoor: long exposure & filter ND
• 11:0013:00 ISHOMA + transfer ke Situ Cileunca
• 13:0016:00 Workshop on-field di Situ Cileunca (panorama, refleksi)
• 16:0018:00 Golden hour shoot di Bukit Nini
• 18:0019:30 Makan malam di villa
• 19:3022:00 Sesi malam — milky way / star trail (cuaca permitting)
• 22:00 Istirahat di villa
Hari 2 — Minggu
• 05:0007:00 Sunrise shoot di Pangalengan + foto siluet
• 07:0008:30 Sarapan + diskusi hasil
• 08:3011:00 Sesi editing Lightroom (laptop pribadi)
• 11:0012:00 Review akhir + sertifikat
• 12:0013:00 Makan siang penutupan
• 13:0015:00 Kembali ke Alun-alun Pangalengan
• 15:00 Bubar grup`;
const ITIN_RETREAT = `Hari 1 — Jumat
• 14:0015:00 Check-in Villa Sawah Ubud + welcome drink (jamu)
• 15:0016:00 Tour fasilitas + pembagian welcome kit
• 16:0017:30 Yin Yoga pembuka — release perjalanan
• 17:3018:30 Journaling: niat & ekspektasi retreat
• 18:3020:00 Dinner vegan (set menu)
• 20:0021:00 Circle pengenalan + meditasi singkat
• 21:00 Lights-out
Hari 2 — Sabtu
• 06:0007:30 Hatha Yoga matahari terbit
• 07:3009:00 Sarapan vegan + tea ceremony
• 09:0010:30 Meditasi guided: body scan
• 10:3012:00 Pranayama (latihan napas)
• 12:0013:30 Lunch + acara bebas (sawah walk)
• 13:3015:00 Sound healing dengan singing bowl
• 15:0016:30 Workshop: mindful eating + jamu making
• 16:3018:00 Yin Yoga sore + savasana panjang
• 18:0019:30 Dinner vegan
• 19:3021:00 Sharing circle + meditasi malam
• 21:00 Lights-out
Hari 3 — Minggu
• 06:0007:30 Vinyasa Flow penutupan
• 07:3009:00 Sarapan + closing journaling
• 09:0010:30 Closing circle + tukar pesan
• 10:3011:30 Check-out + pelukan perpisahan
• 11:3014:00 Acara bebas (rekomendasi spa/cafe sekitar)
• 14:00 Trip resmi ditutup`;
const ITIN_RETREAT: SeedItineraryItem[] = [
{ day: 1, startTime: "14:00", endTime: "15:00", activity: "Check-in Villa Sawah Ubud + welcome drink (jamu)" },
{ day: 1, startTime: "15:00", endTime: "16:00", activity: "Tour fasilitas + pembagian welcome kit" },
{ day: 1, startTime: "16:00", endTime: "17:30", activity: "Yin Yoga pembuka — release perjalanan" },
{ day: 1, startTime: "17:30", endTime: "18:30", activity: "Journaling: niat & ekspektasi retreat" },
{ day: 1, startTime: "18:30", endTime: "20:00", activity: "Dinner vegan (set menu)" },
{ day: 1, startTime: "20:00", endTime: "21:00", activity: "Circle pengenalan + meditasi singkat" },
{ day: 1, startTime: "21:00", activity: "Lights-out" },
{ day: 2, startTime: "06:00", endTime: "07:30", activity: "Hatha Yoga matahari terbit" },
{ day: 2, startTime: "07:30", endTime: "09:00", activity: "Sarapan vegan + tea ceremony" },
{ day: 2, startTime: "09:00", endTime: "10:30", activity: "Meditasi guided: body scan" },
{ day: 2, startTime: "10:30", endTime: "12:00", activity: "Pranayama (latihan napas)" },
{ day: 2, startTime: "12:00", endTime: "13:30", activity: "Lunch + acara bebas (sawah walk)" },
{ day: 2, startTime: "13:30", endTime: "15:00", activity: "Sound healing dengan singing bowl" },
{ day: 2, startTime: "15:00", endTime: "16:30", activity: "Workshop: mindful eating + jamu making" },
{ day: 2, startTime: "16:30", endTime: "18:00", activity: "Yin Yoga sore + savasana panjang" },
{ day: 2, startTime: "18:00", endTime: "19:30", activity: "Dinner vegan" },
{ day: 2, startTime: "19:30", endTime: "21:00", activity: "Sharing circle + meditasi malam" },
{ day: 2, startTime: "21:00", activity: "Lights-out" },
{ day: 3, startTime: "06:00", endTime: "07:30", activity: "Vinyasa Flow penutupan" },
{ day: 3, startTime: "07:30", endTime: "09:00", activity: "Sarapan + closing journaling" },
{ day: 3, startTime: "09:00", endTime: "10:30", activity: "Closing circle + tukar pesan" },
{ day: 3, startTime: "10:30", endTime: "11:30", activity: "Check-out + pelukan perpisahan" },
{ day: 3, startTime: "11:30", endTime: "14:00", activity: "Acara bebas (rekomendasi spa/cafe sekitar)" },
{ day: 3, startTime: "14:00", activity: "Trip resmi ditutup" },
];
// ============================================================================
// 1. Cleanup — urutan FK aware (Payment → Booking → Review → Image →
@@ -260,10 +255,15 @@ Hari 3 — Minggu
// ============================================================================
async function cleanup() {
// Refund & Payout pakai onDelete: Restrict ke Booking/Payment, jadi mereka
// wajib dihapus duluan sebelum Booking/Payment bisa dibersihkan.
await prisma.refund.deleteMany();
await prisma.payout.deleteMany();
await prisma.payment.deleteMany();
await prisma.booking.deleteMany();
await prisma.tripReview.deleteMany();
await prisma.tripImage.deleteMany();
await prisma.tripItineraryItem.deleteMany();
await prisma.tripParticipant.deleteMany();
await prisma.trip.deleteMany();
await prisma.organizerVerification.deleteMany();
@@ -556,7 +556,7 @@ interface SeedTrip {
title: string;
description: string;
meetingPoint?: string;
itinerary?: string;
itineraryItems?: SeedItineraryItem[];
whatsIncluded?: string;
whatsExcluded?: string;
destination: string;
@@ -580,7 +580,7 @@ const SEED_TRIPS: SeedTrip[] = [
title: "Open Trip Papandayan — Maret 2026",
description: `Pendakian santai ke Gunung Papandayan, batch Maret. Cocok untuk pemula.`,
meetingPoint: "Alun-alun Garut, 05:00 WIB",
itinerary: ITIN_PAPANDAYAN,
itineraryItems: ITIN_PAPANDAYAN,
destination: "Gunung Papandayan",
location: "Garut, Jawa Barat",
date: utc(2026, 2, 14, 22, 0),
@@ -601,7 +601,7 @@ const SEED_TRIPS: SeedTrip[] = [
title: "Snorkeling Pulau Pahawang — Februari 2026",
description: `Trip snorkeling batch Februari. Cuaca bersahabat, visibility 10m+.`,
meetingPoint: "Dermaga Ketapang, Lampung Selatan, 07:00 WIB",
itinerary: ITIN_PAHAWANG,
itineraryItems: ITIN_PAHAWANG,
destination: "Pulau Pahawang Kecil",
location: "Lampung Selatan, Lampung",
date: utc(2026, 1, 21, 0, 0),
@@ -621,7 +621,7 @@ const SEED_TRIPS: SeedTrip[] = [
title: "Kulineran Street Food Bandung — April 2026",
description: `Food tour batch April. 8 spot legend dilibas dalam satu hari.`,
meetingPoint: "Stasiun Bandung pintu utara, 09:00 WIB",
itinerary: ITIN_CULINARY,
itineraryItems: ITIN_CULINARY,
destination: "Street Food Tour Bandung",
location: "Bandung, Jawa Barat",
date: utc(2026, 3, 12, 0, 0),
@@ -662,7 +662,7 @@ const SEED_TRIPS: SeedTrip[] = [
⚠️ Bawa: Sleeping bag, jaket, headlamp, air 2L`,
meetingPoint: "Alun-alun Garut, Sabtu 05:00 WIB — detail grup WA.",
itinerary: ITIN_PAPANDAYAN,
itineraryItems: ITIN_PAPANDAYAN,
whatsIncluded: `• Transport PP Garutbasecamp
• Guide lokal
• Tenda tim
@@ -693,7 +693,7 @@ const SEED_TRIPS: SeedTrip[] = [
📍 Meeting Point: Stasiun Kuningan, 04:00 WIB
⚠️ Level: Menengah — perlu stamina baik`,
meetingPoint: "Stasiun Kuningan, Sabtu 04:00 WIB",
itinerary: ITIN_CIREMAI,
itineraryItems: ITIN_CIREMAI,
destination: "Gunung Ciremai",
location: "Kuningan, Jawa Barat",
date: utc(2026, 5, 23, 4, 0),
@@ -718,7 +718,7 @@ const SEED_TRIPS: SeedTrip[] = [
🎒 Fasilitas: Tenda dome, sleeping bag, BBQ, api unggun
🔥 Bonus: Live music akustik malam hari`,
meetingPoint: "Pertigaan Pasar Lembang, Sabtu 13:00 WIB",
itinerary: ITIN_CAMPING,
itineraryItems: ITIN_CAMPING,
whatsIncluded: `• Tenda + sleeping bag + matras
• Logistik camp
• Makan malam BBQ + sarapan
@@ -748,7 +748,7 @@ const SEED_TRIPS: SeedTrip[] = [
🤿 Pemula friendly — guide profesional
📷 Underwater photo session included`,
meetingPoint: "Dermaga Ketapang, Lampung Selatan, 07:00 WIB",
itinerary: ITIN_PAHAWANG,
itineraryItems: ITIN_PAHAWANG,
whatsIncluded: `• Boat PP
• Alat snorkel (masker, fin, life vest)
• Guide & pemandu underwater
@@ -777,7 +777,7 @@ const SEED_TRIPS: SeedTrip[] = [
⚠️ Sertifikasi minimal: Open Water (PADI/SSI)`,
meetingPoint: "Dive shop Tulamben, 06:30 WITA",
itinerary: ITIN_DIVING,
itineraryItems: ITIN_DIVING,
whatsIncluded: `• 2x dive guided
• Full gear rental
• Tank & weight
@@ -807,7 +807,7 @@ const SEED_TRIPS: SeedTrip[] = [
🏝️ Cocok untuk solo traveler & couple`,
meetingPoint: "Pelabuhan Kartini Jepara, Jumat 07:00 WIB",
itinerary: ITIN_ISLANDHOP,
itineraryItems: ITIN_ISLANDHOP,
whatsIncluded: `• Tiket kapal feri PP JeparaKarimun
• Homestay 2 malam (twin sharing)
• Boat hopping 2 hari
@@ -837,7 +837,7 @@ const SEED_TRIPS: SeedTrip[] = [
🚐 Mobil grup, bukan tour bus`,
meetingPoint: "Stasiun Tugu Yogyakarta, Sabtu 08:00 WIB",
itinerary: ITIN_CITYTRIP,
itineraryItems: ITIN_CITYTRIP,
whatsIncluded: `• Transport mobil grup 2 hari
• Tour leader lokal
• Makan 3x (kuliner lokal)
@@ -866,7 +866,7 @@ const SEED_TRIPS: SeedTrip[] = [
🍜 Cocok buat foodie & first-timer`,
meetingPoint: "Stasiun Bandung pintu utara, 09:00 WIB",
itinerary: ITIN_CULINARY,
itineraryItems: ITIN_CULINARY,
whatsIncluded: `• Transport angkot/grup
• Tour leader food explorer
• Sample setiap spot (8 tempat)`,
@@ -893,7 +893,7 @@ const SEED_TRIPS: SeedTrip[] = [
🎤 Tiket BUKAN termasuk — peserta bawa tiket masing-masing
🤝 Grup hanya untuk koordinasi meet-up & after-party`,
meetingPoint: "Plaza GBK, depan loket Cat 1, 17:00 WIB",
itinerary: ITIN_CONCERT,
itineraryItems: ITIN_CONCERT,
whatsIncluded: `• Koordinasi grup
• Foto bareng pre-show
• After-party dinner di Senayan`,
@@ -921,7 +921,7 @@ const SEED_TRIPS: SeedTrip[] = [
📷 Bawa kamera DSLR/mirrorless + tripod
👨‍🏫 Mentor: fotografer pro (10+ tahun pengalaman)`,
meetingPoint: "Alun-alun Pangalengan, Sabtu 04:00 WIB",
itinerary: ITIN_WORKSHOP,
itineraryItems: ITIN_WORKSHOP,
whatsIncluded: `• Materi workshop (briefing + on-field)
• Tour leader & mentor
• Penginapan villa 1 malam
@@ -951,7 +951,7 @@ const SEED_TRIPS: SeedTrip[] = [
🧘 Untuk yang lagi burnout & butuh reset
👥 Grup kecil (max 8) — pengalaman akrab`,
meetingPoint: "Villa Sawah Ubud (alamat dikirim H-3 via WA)",
itinerary: ITIN_RETREAT,
itineraryItems: ITIN_RETREAT,
whatsIncluded: `• Penginapan villa 2 malam
• Yoga 4 sesi + meditasi 6 sesi
• Sound healing (1 sesi)
@@ -990,7 +990,6 @@ async function seedTrips(users: UserMap): Promise<TripMap> {
title: t.title,
description: t.description,
meetingPoint: t.meetingPoint,
itinerary: t.itinerary,
whatsIncluded: t.whatsIncluded,
whatsExcluded: t.whatsExcluded,
destination: t.destination,
@@ -1011,6 +1010,17 @@ async function seedTrips(users: UserMap): Promise<TripMap> {
})),
}
: undefined,
itineraryItems: t.itineraryItems?.length
? {
create: t.itineraryItems.map((item, order) => ({
day: item.day,
startTime: item.startTime,
endTime: item.endTime ?? null,
activity: item.activity,
order,
})),
}
: undefined,
},
});
map[t.key] = { id: created.id, price: created.price, status: t.status };
-26
View File
@@ -20,32 +20,6 @@ export const bookingRepo = {
});
},
/**
* Daftar booking di trip ini yang masih menunggu konfirmasi pembayaran
* dari organizer (Payment MANUAL status AWAITING).
*/
async findAwaitingManualConfirmation(tripId: string) {
return prisma.booking.findMany({
where: {
tripId,
status: "AWAITING_PAY",
payments: {
some: { provider: "MANUAL", status: "AWAITING" },
},
},
include: {
participant: true,
user: { select: { id: true, name: true, image: true } },
payments: {
where: { provider: "MANUAL", status: "AWAITING" },
orderBy: { createdAt: "desc" },
take: 1,
},
},
orderBy: { updatedAt: "asc" },
});
},
async create(
data: Pick<
Prisma.BookingUncheckedCreateInput,
+3
View File
@@ -160,6 +160,9 @@ export const tripRepo = {
},
},
images: { orderBy: { order: "asc" } },
itineraryItems: {
orderBy: [{ day: "asc" }, { order: "asc" }],
},
participants: {
include: {
user: {
-215
View File
@@ -1,24 +1,4 @@
import { Prisma } from "@/app/generated/prisma/client";
import { prisma } from "@/lib/prisma";
import { bookingRepo } from "@/server/repositories/booking.repo";
import { paymentRepo } from "@/server/repositories/payment.repo";
import { isTripDepartureDayPast } from "@/lib/trip-dates";
import { payoutService } from "@/server/services/payout.service";
const SERIAL_TX_ATTEMPTS = 6;
function isSerializationConflict(err: unknown): boolean {
return (
typeof err === "object" &&
err !== null &&
"code" in err &&
(err as { code: string }).code === "P2034"
);
}
function manualOrderId(bookingId: string): string {
return `manual-${bookingId}`;
}
export const bookingService = {
async getByParticipantId(participantId: string) {
@@ -28,199 +8,4 @@ export const bookingService = {
async getByTripAndUser(tripId: string, userId: string) {
return bookingRepo.findByTripAndUser(tripId, userId);
},
/**
* Peserta tandai sudah transfer manual. Idempotent: kalau sudah ada Payment
* MANUAL aktif, biarkan; kalau Booking sudah PAID, tolak.
*/
async markPaidManual(bookingId: string, userId: string) {
let lastErr: unknown;
for (let attempt = 0; attempt < SERIAL_TX_ATTEMPTS; attempt++) {
try {
return await prisma.$transaction(
async (tx) => {
const booking = await tx.booking.findUnique({
where: { id: bookingId },
include: { trip: { select: { price: true, date: true } } },
});
if (!booking) {
throw new Error("Booking tidak ditemukan");
}
if (booking.userId !== userId) {
throw new Error("Booking ini bukan milikmu");
}
if (booking.amount <= 0) {
throw new Error(
"Booking ini tidak butuh pembayaran (gratis)"
);
}
if (booking.status === "PAID") {
throw new Error("Pembayaran sudah dikonfirmasi");
}
if (booking.status !== "AWAITING_PAY") {
throw new Error(
"Booking belum siap menerima pembayaran (tunggu approve organizer)"
);
}
if (isTripDepartureDayPast(booking.trip.date)) {
throw new Error(
"Trip sudah lewat tanggal berangkat — pembayaran ditutup"
);
}
const existing = await tx.payment.findFirst({
where: {
bookingId,
provider: "MANUAL",
status: { in: ["PENDING", "AWAITING"] },
},
orderBy: { createdAt: "desc" },
});
if (existing && existing.status === "AWAITING") {
return existing;
}
const payment = existing
? await tx.payment.update({
where: { id: existing.id },
data: { status: "AWAITING" },
})
: await tx.payment.create({
data: {
bookingId,
provider: "MANUAL",
externalOrderId: manualOrderId(bookingId),
amount: booking.amount,
status: "AWAITING",
},
});
// Backward-compat: tetap update timestamp di TripParticipant
// selama UI lama masih membaca kolom ini.
await tx.tripParticipant.update({
where: { id: booking.participantId },
data: { markedPaidAt: new Date() },
});
return payment;
},
{
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
maxWait: 5000,
timeout: 15000,
}
);
} catch (e) {
lastErr = e;
if (isSerializationConflict(e) && attempt < SERIAL_TX_ATTEMPTS - 1) {
continue;
}
throw e;
}
}
throw lastErr instanceof Error
? lastErr
: new Error("Gagal menandai pembayaran. Coba lagi sebentar.");
},
/**
* Organizer konfirmasi pembayaran manual masuk.
* Idempotent: kalau sudah PAID, tolak (UI lama bisa muncul tombol dua kali).
*/
async confirmPaidManual(bookingId: string, organizerId: string) {
let lastErr: unknown;
for (let attempt = 0; attempt < SERIAL_TX_ATTEMPTS; attempt++) {
try {
return await prisma.$transaction(
async (tx) => {
const booking = await tx.booking.findUnique({
where: { id: bookingId },
include: { trip: { select: { organizerId: true, price: true } } },
});
if (!booking) {
throw new Error("Booking tidak ditemukan");
}
if (booking.trip.organizerId !== organizerId) {
throw new Error(
"Hanya organizer trip ini yang bisa mengonfirmasi pembayaran"
);
}
if (booking.amount <= 0) {
throw new Error(
"Booking ini gratis — tidak ada pembayaran yang perlu dikonfirmasi"
);
}
if (booking.status === "PAID") {
throw new Error("Pembayaran sudah dikonfirmasi sebelumnya");
}
const awaitingPayment = await tx.payment.findFirst({
where: {
bookingId,
provider: "MANUAL",
status: "AWAITING",
},
orderBy: { createdAt: "desc" },
});
if (!awaitingPayment) {
throw new Error(
"Peserta belum menandai sudah membayar"
);
}
const now = new Date();
await tx.payment.update({
where: { id: awaitingPayment.id },
data: {
status: "PAID",
paidAt: now,
method: "manual_transfer",
},
});
await tx.booking.update({
where: { id: bookingId },
data: { status: "PAID" },
});
// Backward-compat: tetap update timestamp di TripParticipant.
await tx.tripParticipant.update({
where: { id: booking.participantId },
data: { paymentConfirmedAt: now },
});
// Escrow: tahan uang di Payout HELD sampai trip selesai + buffer.
await payoutService.createForPaidBooking(tx, { bookingId });
return { ok: true as const };
},
{
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
maxWait: 5000,
timeout: 15000,
}
);
} catch (e) {
lastErr = e;
if (isSerializationConflict(e) && attempt < SERIAL_TX_ATTEMPTS - 1) {
continue;
}
throw e;
}
}
throw lastErr instanceof Error
? lastErr
: new Error("Gagal mengonfirmasi pembayaran. Coba lagi sebentar.");
},
/**
* Daftar booking yang masih menunggu konfirmasi organizer di trip tertentu.
* Dipakai OrganizerPaymentQueue.
*/
async getAwaitingManualForTrip(tripId: string) {
return bookingRepo.findAwaitingManualConfirmation(tripId);
},
};
+234 -143
View File
@@ -3,8 +3,10 @@ import { prisma } from "@/lib/prisma";
import {
MIDTRANS,
createSnapTransaction,
fetchMidtransTransactionStatus,
mapMidtransStatus,
verifyMidtransSignature,
type MidtransTransactionStatus,
type MidtransWebhookBody,
} from "@/lib/midtrans";
import { isTripDepartureDayPast } from "@/lib/trip-dates";
@@ -31,11 +33,166 @@ export interface StartMidtransResult {
snapJsUrl: string;
clientKey: string;
expiresAt: Date;
orderId: string;
}
export type WebhookOutcome =
export type ApplyOutcome =
| { ok: true; status: "updated" | "skipped" | "ignored" | "booking_conflict" }
| { ok: false; reason: "signature_mismatch" | "amount_mismatch" };
| { ok: false; reason: "amount_mismatch" };
export type WebhookOutcome =
| ApplyOutcome
| { ok: false; reason: "signature_mismatch" };
/**
* Bentuk minimum yang dibutuhkan oleh `applyGatewayStatus` — bisa berasal dari
* webhook callback (Midtrans → kita) atau dari hasil GET /v2/{order_id}/status
* (kita → Midtrans saat rekonsiliasi). Bedanya cuma asal dan apakah signature
* perlu dicek.
*/
interface GatewayUpdatePayload {
order_id: string;
gross_amount: string;
transaction_status: string;
fraud_status?: string | null;
transaction_id?: string;
payment_type?: string;
/** Snapshot mentah untuk audit trail di `Payment.rawCallback`. */
rawSource: Prisma.InputJsonValue;
}
/**
* State machine: terjemahkan status dari gateway ke perubahan Payment + Booking
* di DB. Idempotent: kalau payment sudah final, hanya update rawCallback.
* Tidak melakukan auth — caller wajib pastikan source-nya terpercaya
* (signature webhook valid, atau response Midtrans Core API).
*/
async function applyGatewayStatus(
update: GatewayUpdatePayload
): Promise<ApplyOutcome> {
const payment = await prisma.payment.findUnique({
where: { externalOrderId: update.order_id },
include: { booking: true },
});
if (!payment) {
return { ok: true, status: "ignored" };
}
const amountFromGateway = Math.round(Number(update.gross_amount));
if (
Number.isNaN(amountFromGateway) ||
amountFromGateway !== payment.amount
) {
await prisma.payment.update({
where: { id: payment.id },
data: {
rawCallback: update.rawSource,
rejectionReason: `Amount mismatch: gateway=${update.gross_amount}, expected=${payment.amount}`,
},
});
return { ok: false, reason: "amount_mismatch" };
}
const finalStatuses = new Set([
"PAID",
"FAILED",
"EXPIRED",
"CANCELLED",
"REFUNDED",
]);
if (finalStatuses.has(payment.status)) {
await prisma.payment.update({
where: { id: payment.id },
data: { rawCallback: update.rawSource },
});
return { ok: true, status: "skipped" };
}
const newStatus = mapMidtransStatus(
update.transaction_status,
update.fraud_status ?? null
);
let lastErr: unknown;
for (let attempt = 0; attempt < SERIAL_TX_ATTEMPTS; attempt++) {
try {
await prisma.$transaction(
async (tx) => {
const now = new Date();
const currentBooking = await tx.booking.findUnique({
where: { id: payment.bookingId },
select: { status: true, participantId: true },
});
const bookingInConflictState =
currentBooking?.status === "CANCELLED" ||
currentBooking?.status === "REFUNDED" ||
currentBooking?.status === "EXPIRED";
const conflictNote =
bookingInConflictState && newStatus === "PAID"
? `Gateway PAID but Booking is ${currentBooking.status}. Manual review required (potential refund).`
: null;
await tx.payment.update({
where: { id: payment.id },
data: {
status: newStatus,
externalTxId: update.transaction_id ?? null,
method: update.payment_type ?? null,
rawCallback: update.rawSource,
paidAt: newStatus === "PAID" ? now : null,
failedAt:
newStatus === "FAILED" || newStatus === "EXPIRED" ? now : null,
rejectionReason: conflictNote,
},
});
if (newStatus === "PAID" && !bookingInConflictState) {
await tx.booking.update({
where: { id: payment.bookingId },
data: { status: "PAID" },
});
await tx.tripParticipant.update({
where: { id: payment.booking.participantId },
data: { paymentConfirmedAt: now, markedPaidAt: now },
});
await payoutService.createForPaidBooking(tx, {
bookingId: payment.bookingId,
});
}
},
{
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
maxWait: 5000,
timeout: 15000,
}
);
const finalBooking = await prisma.booking.findUnique({
where: { id: payment.bookingId },
select: { status: true },
});
const isConflict =
newStatus === "PAID" && finalBooking?.status !== "PAID";
return {
ok: true,
status: isConflict ? "booking_conflict" : "updated",
};
} catch (e) {
lastErr = e;
if (isSerializationConflict(e) && attempt < SERIAL_TX_ATTEMPTS - 1) {
continue;
}
throw e;
}
}
throw lastErr instanceof Error
? lastErr
: new Error("Gagal apply status gateway karena konflik transaksi");
}
export const paymentService = {
/**
@@ -43,7 +200,11 @@ export const paymentService = {
* MIDTRANS aktif (status PENDING/AWAITING), reuse token yang sudah ada
* selama belum expired.
*/
async startMidtransPayment(bookingId: string, userId: string): Promise<StartMidtransResult> {
async startMidtransPayment(
bookingId: string,
userId: string,
options?: { finishUrl?: string }
): Promise<StartMidtransResult> {
const clientKey = process.env.NEXT_PUBLIC_MIDTRANS_CLIENT_KEY;
if (!clientKey) {
throw new Error("NEXT_PUBLIC_MIDTRANS_CLIENT_KEY belum di-set");
@@ -102,6 +263,7 @@ export const paymentService = {
snapJsUrl,
clientKey,
expiresAt: reusable.expiresAt ?? new Date(now.getTime() + 24 * 3600 * 1000),
orderId: reusable.externalOrderId,
};
}
@@ -149,6 +311,7 @@ export const paymentService = {
},
itemName: booking.trip.title,
expirySeconds,
finishUrl: options?.finishUrl,
});
} catch (err) {
// Roll back Payment ke FAILED supaya orderId tidak nyangkut PENDING selamanya.
@@ -177,164 +340,92 @@ export const paymentService = {
snapJsUrl,
clientKey,
expiresAt,
orderId,
};
},
/**
* Handle webhook callback dari Midtrans. Idempotent — boleh dipanggil berulang.
* Selalu return result yang webhook handler bisa terjemahkan ke HTTP status.
* Verifikasi signature dulu, lalu delegate ke `applyGatewayStatus`.
*/
async handleMidtransWebhook(
payload: MidtransWebhookBody
): Promise<WebhookOutcome> {
const { order_id: orderId, status_code: statusCode, gross_amount: grossAmount, signature_key: signatureKey } = payload;
const signatureValid = verifyMidtransSignature(
orderId,
statusCode,
grossAmount,
signatureKey
payload.order_id,
payload.status_code,
payload.gross_amount,
payload.signature_key
);
if (!signatureValid) {
return { ok: false, reason: "signature_mismatch" };
}
return applyGatewayStatus({
order_id: payload.order_id,
gross_amount: payload.gross_amount,
transaction_status: payload.transaction_status,
fraud_status: payload.fraud_status ?? null,
transaction_id: payload.transaction_id,
payment_type: payload.payment_type,
rawSource: payload as unknown as Prisma.InputJsonValue,
});
},
/**
* Rekonsiliasi server-side: tarik status terkini dari Midtrans Core API,
* lalu apply ke DB. Dipakai saat user kembali dari Snap (redirect flow) atau
* saat webhook belum sampai (mis. dev di localhost). Aman dipanggil
* berulang — idempotent via `applyGatewayStatus`.
*
* Auth: caller harus pastikan `userId` adalah owner booking; kita verifikasi
* di sini lewat lookup payment → booking.userId.
*/
async reconcileFromGateway(
orderId: string,
userId: string
): Promise<
| {
ok: true;
status:
| "updated"
| "skipped"
| "ignored"
| "booking_conflict"
| "not_found";
}
| { ok: false; reason: "amount_mismatch" | "forbidden" | "not_found" }
> {
const payment = await prisma.payment.findUnique({
where: { externalOrderId: orderId },
include: { booking: true },
include: { booking: { select: { userId: true } } },
});
if (!payment) {
return { ok: false, reason: "not_found" };
}
if (payment.booking.userId !== userId) {
return { ok: false, reason: "forbidden" };
}
const status = await fetchMidtransTransactionStatus(orderId);
if (!status) {
return { ok: true, status: "not_found" };
}
const result = await applyGatewayStatus({
order_id: status.order_id,
gross_amount: status.gross_amount,
transaction_status: status.transaction_status,
fraud_status: status.fraud_status ?? null,
transaction_id: status.transaction_id,
payment_type: status.payment_type,
rawSource: status as unknown as Prisma.InputJsonValue,
});
if (!payment) {
// Order tidak dikenal — return ok supaya Midtrans tidak retry forever.
return { ok: true, status: "ignored" };
}
// Cek amount cocok. gross_amount dari Midtrans format "100000.00".
const amountFromGateway = Math.round(Number(grossAmount));
if (
Number.isNaN(amountFromGateway) ||
amountFromGateway !== payment.amount
) {
// Tetap simpan callback mentah untuk audit, tapi jangan ubah status.
await prisma.payment.update({
where: { id: payment.id },
data: {
rawCallback: payload as unknown as Prisma.InputJsonValue,
rejectionReason: `Amount mismatch: gateway=${grossAmount}, expected=${payment.amount}`,
},
});
return { ok: false, reason: "amount_mismatch" };
}
const finalStatuses = new Set([
"PAID",
"FAILED",
"EXPIRED",
"CANCELLED",
"REFUNDED",
]);
if (finalStatuses.has(payment.status)) {
// Idempotent: skip update tapi tetap log callback baru.
await prisma.payment.update({
where: { id: payment.id },
data: {
rawCallback: payload as unknown as Prisma.InputJsonValue,
},
});
return { ok: true, status: "skipped" };
}
const newStatus = mapMidtransStatus(
payload.transaction_status,
payload.fraud_status ?? null
);
let lastErr: unknown;
for (let attempt = 0; attempt < SERIAL_TX_ATTEMPTS; attempt++) {
try {
await prisma.$transaction(
async (tx) => {
const now = new Date();
// Re-baca booking di dalam transaksi — bisa berubah (mis. user cancel) sejak
// outer fetch. Lock-free read tetap aman karena isolasi serializable.
const currentBooking = await tx.booking.findUnique({
where: { id: payment.bookingId },
select: { status: true, participantId: true },
});
const bookingInConflictState =
currentBooking?.status === "CANCELLED" ||
currentBooking?.status === "REFUNDED" ||
currentBooking?.status === "EXPIRED";
// Selalu simpan callback Payment (truth dari gateway). Kalau booking
// di state konflik dan webhook mau set PAID, payment tetap PAID
// (uang masuk) tapi tidak propagate ke booking — flag untuk manual review/refund.
const conflictNote =
bookingInConflictState && newStatus === "PAID"
? `Webhook PAID arrived but Booking is ${currentBooking.status}. Manual review required (potential refund).`
: null;
await tx.payment.update({
where: { id: payment.id },
data: {
status: newStatus,
externalTxId: payload.transaction_id ?? null,
method: payload.payment_type ?? null,
rawCallback: payload as unknown as Prisma.InputJsonValue,
paidAt: newStatus === "PAID" ? now : null,
failedAt:
newStatus === "FAILED" || newStatus === "EXPIRED" ? now : null,
rejectionReason: conflictNote,
},
});
if (newStatus === "PAID" && !bookingInConflictState) {
await tx.booking.update({
where: { id: payment.bookingId },
data: { status: "PAID" },
});
// Backward-compat: sync timestamp di TripParticipant.
await tx.tripParticipant.update({
where: { id: payment.booking.participantId },
data: { paymentConfirmedAt: now, markedPaidAt: now },
});
// Escrow: tahan uang di Payout HELD sampai trip selesai + buffer.
await payoutService.createForPaidBooking(tx, {
bookingId: payment.bookingId,
});
}
},
{
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
maxWait: 5000,
timeout: 15000,
}
);
// Recheck post-tx untuk reporting outcome.
const finalBooking = await prisma.booking.findUnique({
where: { id: payment.bookingId },
select: { status: true },
});
const isConflict =
newStatus === "PAID" &&
finalBooking?.status !== "PAID";
return {
ok: true,
status: isConflict ? "booking_conflict" : "updated",
};
} catch (e) {
lastErr = e;
if (isSerializationConflict(e) && attempt < SERIAL_TX_ATTEMPTS - 1) {
continue;
}
throw e;
}
}
throw lastErr instanceof Error
? lastErr
: new Error("Webhook gagal diproses karena konflik transaksi");
return result;
},
};
// Re-export untuk testing langsung kalau perlu (tetap private dari modul lain).
export const _internal = { applyGatewayStatus };
export type { MidtransTransactionStatus };
+15 -51
View File
@@ -4,13 +4,13 @@ import { prisma } from "@/lib/prisma";
import { tripRepo, type TripFilters } from "@/server/repositories/trip.repo";
import { participantRepo } from "@/server/repositories/participant.repo";
import { bookingRepo } from "@/server/repositories/booking.repo";
import { bookingService } from "@/server/services/booking.service";
import { refundService } from "@/server/services/refund.service";
import { payoutService } from "@/server/services/payout.service";
import { payoutRepo } from "@/server/repositories/payout.repo";
import { LIMITS } from "@/lib/limits";
import { utcStartOfDay, isTripDepartureDayPast } from "@/lib/trip-dates";
import { isFreeTrip } from "@/lib/trip-pricing";
import type { ItineraryItemInput } from "@/lib/itinerary";
const SERIAL_TX_ATTEMPTS = 6;
@@ -30,7 +30,6 @@ interface CreateTripInput {
destination: string;
location: string;
meetingPoint?: string;
itinerary?: string;
whatsIncluded?: string;
whatsExcluded?: string;
date: Date;
@@ -40,6 +39,7 @@ interface CreateTripInput {
vibe?: Vibe;
organizerId: string;
imageUrls?: string[];
itineraryItems?: ItineraryItemInput[];
}
export const tripService = {
@@ -75,6 +75,18 @@ export const tripService = {
}
: undefined;
const itineraryItems = input.itineraryItems?.length
? {
create: input.itineraryItems.map((item, i) => ({
day: item.day,
startTime: item.startTime,
endTime: item.endTime ?? null,
activity: item.activity.trim(),
order: i,
})),
}
: undefined;
const tripData = {
category: input.category,
title: input.title,
@@ -82,7 +94,6 @@ export const tripService = {
destination: input.destination,
location: input.location,
meetingPoint: input.meetingPoint,
itinerary: input.itinerary,
whatsIncluded: input.whatsIncluded,
whatsExcluded: input.whatsExcluded,
date: input.date,
@@ -92,6 +103,7 @@ export const tripService = {
vibe: input.vibe,
organizer: { connect: { id: input.organizerId } },
images,
itineraryItems,
} satisfies Prisma.TripCreateInput;
let lastErr: unknown;
@@ -377,30 +389,6 @@ export const tripService = {
return { ok: true as const };
},
async markParticipantPayment(tripId: string, userId: string) {
const trip = await tripRepo.findById(tripId);
if (!trip) {
throw new Error("Trip tidak ditemukan");
}
if (isFreeTrip(trip)) {
throw new Error(
"Trip ini gratis — tidak ada pembayaran yang perlu ditandai"
);
}
if (isTripDepartureDayPast(trip.date)) {
throw new Error("Trip sudah lewat — pembayaran tidak perlu ditandai");
}
const booking = await bookingRepo.findByTripAndUser(tripId, userId);
if (!booking || booking.status === "CANCELLED") {
throw new Error("Kamu tidak terdaftar di trip ini");
}
return bookingService.markPaidManual(booking.id, userId);
},
/**
* Auto-complete trip yang sudah lewat. Dipakai cron harian.
*
@@ -415,30 +403,6 @@ export const tripService = {
return tripRepo.bulkCompletePastTrips(cutoff);
},
async confirmParticipantPayment(
tripId: string,
participantId: string,
organizerId: string
) {
const trip = await tripRepo.findById(tripId);
if (!trip) {
throw new Error("Trip tidak ditemukan");
}
if (trip.organizerId !== organizerId) {
throw new Error("Hanya organizer yang bisa mengonfirmasi pembayaran");
}
if (isFreeTrip(trip)) {
throw new Error("Trip ini gratis — tidak ada pembayaran yang perlu dikonfirmasi");
}
const booking = await bookingRepo.findByParticipantId(participantId);
if (!booking || booking.tripId !== tripId) {
throw new Error("Booking tidak ditemukan");
}
return bookingService.confirmPaidManual(booking.id, organizerId);
},
/**
* Organizer batalkan trip (Trip.status = CLOSED). Atomic dalam satu
* serializable transaction: