Compare commits

..

4 Commits

Author SHA1 Message Date
arifal e1966b69f1 0.12.0 2026-05-18 18:32:39 +07:00
arifal c4efe4453b -
- 
- 
- 
2026-05-18 18:31:16 +07:00
arifal b599d01eea 0.11.0 2026-05-12 00:05:42 +07:00
arifal 958514d575 create public layout and admin and fix escrow and refund 2026-05-12 00:05:30 +07:00
79 changed files with 4977 additions and 1503 deletions
View File
+4 -1
View File
@@ -31,7 +31,10 @@ yarn-error.log*
.pnpm-debug.log* .pnpm-debug.log*
# env files (can opt-in for committing if needed) # 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. # private uploads (KYC: KTP / liveness). Never serve directly.
/uploads/ /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).
+16 -7
View File
@@ -59,19 +59,28 @@ Dengan menggunakan SeTrip, Anda menyatakan bahwa:
--- ---
# 6. Pembayaran # 6. Pembayaran & Escrow
- Pembayaran dilakukan sesuai metode yang tersedia di platform - Pembayaran dilakukan melalui metode yang tersedia di platform (Midtrans atau transfer manual yang dikonfirmasi organizer)
- Dalam fase awal, pembayaran dapat dilakukan langsung kepada organizer - **Uang peserta ditahan oleh SeTrip (escrow)** sejak pembayaran berhasil hingga trip selesai + 3 hari, baru kemudian diteruskan ke organizer
- SeTrip tidak menjamin keamanan transaksi yang dilakukan di luar platform - Buffer 3 hari memberi waktu peserta dan organizer melaporkan masalah trip sebelum uang cair
- Pembayaran di luar platform tidak dijamin keamanannya oleh SeTrip — kami tidak dapat memediasi sengketa untuk transaksi off-platform
--- ---
# 7. Pembatalan & Refund # 7. Pembatalan & Refund
- Kebijakan pembatalan ditentukan oleh organizer **Saat peserta membatalkan booking sendiri** (kebijakan default platform):
- SeTrip tidak bertanggung jawab atas refund yang tidak diberikan oleh organizer
- Pengguna disarankan untuk memahami kebijakan sebelum melakukan pembayaran - **≥ 7 hari** sebelum tanggal berangkat → refund **80%** dari nominal booking
- **36 hari** sebelum tanggal berangkat → refund **50%** dari nominal booking
- **< 3 hari** sebelum tanggal berangkat / setelah berangkat → **tidak ada refund**
**Saat organizer membatalkan trip:** peserta yang sudah bayar mendapat refund **100%**.
**Pengembalian dana** diproses manual oleh admin SeTrip — perlu 13 hari kerja sejak refund disetujui untuk uang masuk ke rekening kamu. Setiap pengajuan refund tercatat (tidak pernah dihapus) untuk audit trail.
Kebijakan di atas berlaku platform-wide; organizer tidak dapat menetapkan policy yang lebih ketat tanpa persetujuan tertulis dari SeTrip.
--- ---
+25
View File
@@ -0,0 +1,25 @@
import { Navbar } from "@/components/shared/navbar";
import { ProfileNudgeBanner } from "@/components/shared/profile-nudge-banner";
import { Footer } from "@/components/shared/footer";
/**
* Layout user-facing (semua halaman publik + dashboard organizer/peserta).
* Berisi navbar global, profile-nudge banner, dan footer.
*
* Tidak berlaku untuk halaman admin — admin punya layout sendiri di
* app/admin/layout.tsx dengan sidebar khusus.
*/
export default function PublicLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
<Navbar />
<ProfileNudgeBanner />
<main className="flex-1">{children}</main>
<Footer />
</>
);
}
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState, Suspense } from "react"; import { useState, Suspense } from "react";
import { signIn } from "next-auth/react"; import { signIn, getSession } from "next-auth/react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
@@ -38,7 +38,13 @@ function LoginForm() {
if (result?.error) { if (result?.error) {
setError(result.error); setError(result.error);
} else { } else {
const next = safeInternalPath(searchParams.get("callbackUrl")); const rawCallback = searchParams.get("callbackUrl");
let next = safeInternalPath(rawCallback);
// Tanpa callbackUrl eksplisit, arahkan admin ke dashboard /admin.
if (!rawCallback) {
const session = await getSession();
if (session?.user?.isAdmin) next = "/admin";
}
router.push(next); router.push(next);
router.refresh(); router.refresh();
} }
@@ -5,9 +5,11 @@ import Link from "next/link";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { profileService } from "@/server/services/profile.service"; import { profileService } from "@/server/services/profile.service";
import { payoutRepo } from "@/server/repositories/payout.repo";
import { TripCard } from "@/features/trip/components/trip-card"; import { TripCard } from "@/features/trip/components/trip-card";
import { ProfileTripRow } from "@/features/profile/components/profile-trip-row"; import { ProfileTripRow } from "@/features/profile/components/profile-trip-row";
import { ProfileEditor } from "@/features/profile/components/profile-editor"; import { ProfileEditor } from "@/features/profile/components/profile-editor";
import { EarningsSection } from "@/features/payout/components/earnings-section";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Profil Saya", title: "Profil Saya",
@@ -20,9 +22,10 @@ export default async function ProfilePage() {
redirect("/login?callbackUrl=/profile"); redirect("/login?callbackUrl=/profile");
} }
const [data, ownProfile] = await Promise.all([ const [data, ownProfile, payouts] = await Promise.all([
profileService.getProfileDashboard(session.user.id), profileService.getProfileDashboard(session.user.id),
profileService.getOwnProfile(session.user.id), profileService.getOwnProfile(session.user.id),
payoutRepo.listForOrganizer(session.user.id),
]); ]);
const { const {
user, user,
@@ -84,6 +87,9 @@ export default async function ProfilePage() {
</Link> </Link>
</div> </div>
{/* Pendapatan dari peserta (escrow payout) */}
<EarningsSection payouts={payouts} />
{/* Profil sosial publik */} {/* Profil sosial publik */}
<div className="mb-6"> <div className="mb-6">
<ProfileEditor <ProfileEditor
@@ -17,7 +17,6 @@ import { CancelBookingButton } from "@/features/booking/components/cancel-bookin
import { OrganizerJoinRequests } from "@/features/trip/components/organizer-join-requests"; import { OrganizerJoinRequests } from "@/features/trip/components/organizer-join-requests";
import { OrganizerTrustPanel } from "@/features/trip/components/organizer-trust-panel"; import { OrganizerTrustPanel } from "@/features/trip/components/organizer-trust-panel";
import { TripProgramBlock } from "@/features/trip/components/trip-program-block"; 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 { ImageGallery } from "@/features/trip/components/image-gallery";
import { TripReviewSection } from "@/features/review/components/trip-review-section"; import { TripReviewSection } from "@/features/review/components/trip-review-section";
import { RefundPolicySection } from "@/features/refund/components/refund-policy-section"; import { RefundPolicySection } from "@/features/refund/components/refund-policy-section";
@@ -135,13 +134,6 @@ export default async function TripDetailPage({
const tripIsFree = isFreeTrip(trip); 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 // Booking peserta saat ini — dipakai untuk render CancelBookingButton vs
// tombol "Batal Ikut" biasa. Hanya untuk non-organizer yang ikut trip. // tombol "Batal Ikut" biasa. Hanya untuk non-organizer yang ikut trip.
const myBooking = const myBooking =
@@ -447,6 +439,13 @@ export default async function TripDetailPage({
<TripProgramBlock <TripProgramBlock
meetingPoint={trip.meetingPoint} meetingPoint={trip.meetingPoint}
itinerary={trip.itinerary} 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} whatsIncluded={trip.whatsIncluded}
whatsExcluded={trip.whatsExcluded} whatsExcluded={trip.whatsExcluded}
/> />
@@ -469,18 +468,6 @@ export default async function TripDetailPage({
pending={pendingParticipants.map((p) => ({ pending={pendingParticipants.map((p) => ({
id: p.id, id: p.id,
user: p.user, 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 ? currentParticipation.status
: null : null
} }
participantPayment={ bookingStatus={myBooking?.status ?? null}
currentParticipation
? {
markedPaidAt: currentParticipation.markedPaidAt,
paymentConfirmedAt:
currentParticipation.paymentConfirmedAt,
}
: null
}
isFull={spotsLeft <= 0} isFull={spotsLeft <= 0}
tripStatus={trip.status} tripStatus={trip.status}
isDeparturePast={isDeparturePast} isDeparturePast={isDeparturePast}
@@ -4,15 +4,13 @@ import { notFound, redirect } from "next/navigation";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { tripService } from "@/server/services/trip.service"; import { tripService } from "@/server/services/trip.service";
import { organizerService } from "@/server/services/organizer.service";
import { bookingService } from "@/server/services/booking.service"; import { bookingService } from "@/server/services/booking.service";
import { paymentService } from "@/server/services/payment.service";
import { formatRupiah } from "@/lib/utils"; import { formatRupiah } from "@/lib/utils";
import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates"; import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
import { isFreeTrip } from "@/lib/trip-pricing"; import { isFreeTrip } from "@/lib/trip-pricing";
import { categoryMeta } from "@/lib/activity-category"; 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 { MidtransPayButton } from "@/features/booking/components/midtrans-pay-button";
import { CopyButton } from "@/features/booking/components/copy-button";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Detail Pembayaran", title: "Detail Pembayaran",
@@ -21,9 +19,10 @@ export const metadata: Metadata = {
interface PageProps { interface PageProps {
params: Promise<{ id: string }>; 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 { id } = await params;
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session?.user) { if (!session?.user) {
@@ -37,11 +36,26 @@ export default async function PaymentPage({ params }: PageProps) {
notFound(); notFound();
} }
// Organizer trip-nya sendiri tidak butuh halaman pembayaran.
if (trip.organizerId === session.user.id) { if (trip.organizerId === session.user.id) {
redirect(`/trips/${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( const booking = await bookingService.getByTripAndUser(
trip.id, trip.id,
session.user.id session.user.id
@@ -51,15 +65,10 @@ export default async function PaymentPage({ params }: PageProps) {
return <NotJoinedNotice tripId={trip.id} title={trip.title} />; return <NotJoinedNotice tripId={trip.id} title={trip.title} />;
} }
const latestManualPayment = booking.payments.find(
(p) => p.provider === "MANUAL"
);
const tripIsFree = isFreeTrip(trip); const tripIsFree = isFreeTrip(trip);
const catMeta = categoryMeta(trip.category); const catMeta = categoryMeta(trip.category);
const dateRange = formatTripCalendarDateRangeLong(trip.date, trip.endDate); const dateRange = formatTripCalendarDateRangeLong(trip.date, trip.endDate);
// Header info — sama untuk free vs paid
const tripHeader = ( const tripHeader = (
<section className="mb-6 rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm sm:p-6"> <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"> <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"> <p className="mb-5 text-sm text-neutral-500">
{tripIsFree {tripIsFree
? "Trip ini gratis — kamu tidak perlu transfer apa-apa." ? "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> </p>
{tripHeader} {tripHeader}
{tripIsFree ? ( {tripIsFree ? (
<FreeTripSection <FreeTripSection tripId={trip.id} bookingStatus={booking.status} />
tripId={trip.id}
bookingStatus={booking.status}
/>
) : ( ) : (
<PaidTripSection <PaidTripSection
tripId={trip.id} tripId={trip.id}
organizerId={trip.organizerId}
organizerName={trip.organizer.name} organizerName={trip.organizer.name}
price={trip.price} price={trip.price}
bookingStatus={booking.status} bookingStatus={booking.status}
paymentMarkedAt={
latestManualPayment?.status === "AWAITING"
? latestManualPayment.updatedAt
: null
}
paymentPaidAt={latestManualPayment?.paidAt ?? null}
/> />
)} )}
</div> </div>
@@ -153,12 +152,7 @@ function NotJoinedNotice({ tripId, title }: { tripId: string; title: string }) {
); );
} }
function FreeTripSection({ type BookingStatus =
tripId,
bookingStatus,
}: {
tripId: string;
bookingStatus:
| "PENDING" | "PENDING"
| "AWAITING_PAY" | "AWAITING_PAY"
| "PAID" | "PAID"
@@ -166,6 +160,13 @@ function FreeTripSection({
| "REFUNDED" | "REFUNDED"
| "PARTIALLY_REFUNDED" | "PARTIALLY_REFUNDED"
| "EXPIRED"; | "EXPIRED";
function FreeTripSection({
tripId,
bookingStatus,
}: {
tripId: string;
bookingStatus: BookingStatus;
}) { }) {
return ( return (
<section className="rounded-2xl border border-emerald-200 bg-emerald-50/60 p-6 text-center shadow-sm sm:p-8"> <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, tripId,
organizerId,
organizerName, organizerName,
price, price,
bookingStatus, bookingStatus,
paymentMarkedAt,
paymentPaidAt,
}: { }: {
tripId: string; tripId: string;
organizerId: string;
organizerName: string; organizerName: string;
price: number; price: number;
bookingStatus: bookingStatus: BookingStatus;
| "PENDING"
| "AWAITING_PAY"
| "PAID"
| "CANCELLED"
| "REFUNDED"
| "PARTIALLY_REFUNDED"
| "EXPIRED";
paymentMarkedAt: Date | null;
paymentPaidAt: Date | null;
}) { }) {
const verification = await organizerService.getStatusForUser(organizerId); const isApproved =
const bankAvailable = verification?.status === "APPROVED"; bookingStatus === "AWAITING_PAY" || bookingStatus === "PAID";
const isApproved = bookingStatus === "AWAITING_PAY" || bookingStatus === "PAID";
const isPendingApproval = bookingStatus === "PENDING"; const isPendingApproval = bookingStatus === "PENDING";
const hasMarkedPaid = !!paymentMarkedAt || !!paymentPaidAt;
const isFullyPaid = bookingStatus === "PAID"; const isFullyPaid = bookingStatus === "PAID";
const canMarkPaid = bookingStatus === "AWAITING_PAY" && !paymentMarkedAt; const canPay = bookingStatus === "AWAITING_PAY";
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<PaymentTimeline <PaymentTimeline approved={isApproved} confirmedPaid={isFullyPaid} />
approved={isApproved}
markedPaid={hasMarkedPaid}
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.
</p>
</div>
)}
{bankAvailable && (
<section className="rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm sm:p-6"> <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"> <div className="flex items-baseline justify-between gap-3">
Transfer ke rekening organizer <h3 className="text-sm font-bold text-neutral-900 sm:text-base">
Total Pembayaran
</h3> </h3>
<p className="mb-4 text-xs text-neutral-500 sm:text-sm"> <p className="text-lg font-bold text-primary-700 sm:text-xl">
Pastikan nominal persis seperti tercantum supaya organizer mudah {formatRupiah(price)}
mencocokkan.
</p> </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>
</div> <p className="mt-1 text-xs text-neutral-500">
Pembayaran diproses oleh Midtrans (BCA VA, GoPay, QRIS, kartu, dll).
<ul className="mt-4 space-y-1.5 text-[11px] text-neutral-500 sm:text-xs"> Dana ditahan SeTrip sampai trip selesai bukan transfer langsung
<li> Transfer dengan nominal pas, jangan dibulatkan.</li> ke organizer.
<li> Simpan bukti transfer untuk jaga-jaga jika ada konfirmasi.</li> </p>
<li>
Setelah transfer, tekan tombol <em>Saya sudah bayar</em> di bawah
supaya organizer tahu dan bisa konfirmasi.
</li>
</ul>
</section> </section>
)}
{isPendingApproval && ( {isPendingApproval && (
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900"> <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 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> </div>
)} )}
{canMarkPaid && ( {canPay && <MidtransPayButton tripId={tripId} />}
<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>
)}
{hasMarkedPaid && ( {isFullyPaid && (
<div className="rounded-2xl border border-neutral-200 bg-white p-4 text-sm text-neutral-600 shadow-sm sm:p-5"> <div className="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-900 sm:p-5">
{isFullyPaid ? (
<p> <p>
Pembayaran kamu sudah dikonfirmasi oleh{" "} Pembayaran kamu sudah terkonfirmasi. Sampai jumpa di trip
<span className="font-semibold text-neutral-800"> bareng{" "}
{organizerName} <span className="font-semibold">{organizerName}</span>!
</span>
. Sampai jumpa di trip!
</p> </p>
) : (
<p>
Kamu sudah menandai sudah bayar. Menunggu organizer mengecek
dan mengonfirmasi.
</p>
)}
</div> </div>
)} )}
@@ -359,17 +273,14 @@ async function PaidTripSection({
function PaymentTimeline({ function PaymentTimeline({
approved, approved,
markedPaid,
confirmedPaid, confirmedPaid,
}: { }: {
approved: boolean; approved: boolean;
markedPaid: boolean;
confirmedPaid: boolean; confirmedPaid: boolean;
}) { }) {
const steps = [ const steps = [
{ label: "Disetujui organizer", done: approved }, { label: "Disetujui organizer", done: approved },
{ label: "Kamu menandai sudah bayar", done: markedPaid }, { label: "Pembayaran terkonfirmasi Midtrans", done: confirmedPaid },
{ label: "Organizer konfirmasi pembayaran", done: confirmedPaid },
]; ];
return ( return (
@@ -404,37 +315,3 @@ function PaymentTimeline({
</section> </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>
);
}
+60
View File
@@ -0,0 +1,60 @@
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";
import { AdminSidebar } from "@/components/admin/admin-sidebar";
export const metadata: Metadata = {
title: "Admin · SeTrip",
alternates: { canonical: "/admin" },
robots: { index: false, follow: false },
};
/**
* Layout admin — terpisah penuh dari layout user (navbar/footer publik tidak
* dipakai). Sidebar kiri jadi shell global untuk semua /admin/*.
*
* Auth gate di layout ini berlaku ke seluruh sub-page admin sehingga
* sub-page tidak perlu re-check (boleh disederhanakan di iterasi berikutnya).
*/
export default async function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await getServerSession(authOptions);
if (!session?.user) {
redirect("/login?callbackUrl=/admin");
}
if (!session.user.isAdmin) {
return (
<div className="flex min-h-screen items-center justify-center bg-neutral-50 px-4">
<div className="max-w-md rounded-2xl border border-neutral-200 bg-white p-8 text-center shadow-sm">
<p className="text-2xl">🔒</p>
<h1 className="mt-2 text-base font-bold text-neutral-900">
Halaman khusus admin
</h1>
<p className="mt-1 text-sm text-neutral-500">
Akun kamu tidak punya akses ke panel admin SeTrip.
</p>
<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
</Link>
</div>
</div>
);
}
return (
<div className="flex min-h-screen flex-col bg-neutral-50 lg:flex-row">
<AdminSidebar
user={{ name: session.user.name, email: session.user.email }}
/>
<main className="flex-1 min-w-0">{children}</main>
</div>
);
}
+371
View File
@@ -0,0 +1,371 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { organizerRepo } from "@/server/repositories/organizer.repo";
import { refundRepo } from "@/server/repositories/refund.repo";
import { payoutRepo } from "@/server/repositories/payout.repo";
const REFUND_REASON_LABEL: Record<string, string> = {
USER_CANCELLATION: "Peserta cancel",
ORGANIZER_CANCELLED: "Organizer cancel",
TRIP_ISSUE: "Masalah trip",
ADMIN_ADJUSTMENT: "Penyesuaian admin",
DISPUTE_RESOLVED: "Dispute selesai",
OTHER: "Lainnya",
};
function formatIDR(n: number) {
return `Rp${n.toLocaleString("id-ID")}`;
}
function timeAgo(d: Date) {
const diff = Date.now() - new Date(d).getTime();
const h = Math.floor(diff / 3600000);
if (h < 1) return `${Math.max(1, Math.floor(diff / 60000))} mnt lalu`;
if (h < 24) return `${h} jam lalu`;
const days = Math.floor(h / 24);
return `${days} hari lalu`;
}
export default async function AdminDashboardPage() {
const session = await getServerSession(authOptions);
if (!session?.user) redirect("/login?callbackUrl=/admin");
if (!session.user.isAdmin) {
return (
<div className="mx-auto max-w-2xl px-4 py-12 text-center">
<p className="text-sm text-neutral-600">
Halaman ini hanya untuk admin SeTrip.
</p>
</div>
);
}
const [
pendingVerif,
approvedVerif,
rejectedVerif,
pendingRefund,
approvedRefund,
succeededRefund,
heldPayout,
releasedPayout,
paidPayout,
recentPendingVerif,
recentPendingRefund,
recentApprovedRefund,
recentReleasedPayout,
] = await Promise.all([
organizerRepo.countByStatus("PENDING"),
organizerRepo.countByStatus("APPROVED"),
organizerRepo.countByStatus("REJECTED"),
refundRepo.countByStatus("PENDING"),
refundRepo.countByStatus("APPROVED"),
refundRepo.countByStatus("SUCCEEDED"),
payoutRepo.countByStatus("HELD"),
payoutRepo.countByStatus("RELEASED"),
payoutRepo.countByStatus("PAID"),
organizerRepo.listRecent("PENDING", 3),
refundRepo.listRecent("PENDING", 3),
refundRepo.listRecent("APPROVED", 3),
payoutRepo.listRecent("RELEASED", 3),
]);
const stats: Array<{
label: string;
value: number;
hint: string;
href: string;
accent: "amber" | "blue" | "primary";
}> = [
{
label: "Verifikasi menunggu",
value: pendingVerif,
hint: "KYC organizer perlu ditinjau",
href: "/admin/verifications?tab=PENDING",
accent: "amber",
},
{
label: "Refund baru",
value: pendingRefund,
hint: "Perlu disetujui / ditolak",
href: "/admin/refunds?tab=PENDING",
accent: "amber",
},
{
label: "Refund siap transfer",
value: approvedRefund,
hint: "Refund APPROVED — transfer ke peserta lalu mark SUCCEEDED",
href: "/admin/refunds?tab=APPROVED",
accent: "blue",
},
{
label: "Payout siap transfer",
value: releasedPayout,
hint: "Escrow lepas — transfer ke organizer",
href: "/admin/payouts?tab=RELEASED",
accent: "blue",
},
];
const accentClasses: Record<typeof stats[number]["accent"], string> = {
amber: "bg-amber-50 text-amber-900 ring-amber-200",
blue: "bg-blue-50 text-blue-900 ring-blue-200",
primary: "bg-primary-50 text-primary-900 ring-primary-200",
};
const totalAttention =
pendingVerif + pendingRefund + approvedRefund + releasedPayout;
return (
<div className="mx-auto max-w-5xl px-4 py-8 sm:py-12">
<header className="mb-8">
<p className="text-xs font-semibold uppercase tracking-wide text-primary-600">
Admin
</p>
<h1 className="mt-1 text-2xl font-bold text-neutral-900 sm:text-3xl">
Dashboard
</h1>
<p className="mt-1 text-sm text-neutral-500">
Halo {session.user.name}.{" "}
{totalAttention > 0 ? (
<>
Ada <strong className="text-neutral-800">{totalAttention}</strong>{" "}
hal yang menunggu tindakan kamu.
</>
) : (
<>Tidak ada antrian pending semua sudah beres </>
)}
</p>
</header>
{/* Stats row */}
<section className="mb-8 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{stats.map((s) => (
<Link
key={s.label}
href={s.href}
className={`group rounded-2xl bg-white p-5 ring-1 transition-shadow hover:shadow-md ${
s.value > 0 ? "ring-neutral-200" : "ring-neutral-100"
}`}
>
<div className="flex items-center justify-between">
<span
className={`inline-flex rounded-full px-2.5 py-0.5 text-[11px] font-semibold ring-1 ${accentClasses[s.accent]}`}
>
{s.label}
</span>
<span className="text-xs text-neutral-400 group-hover:text-primary-600">
Buka
</span>
</div>
<p className="mt-3 text-3xl font-bold text-neutral-900">{s.value}</p>
<p className="mt-1 text-xs text-neutral-500">{s.hint}</p>
</Link>
))}
</section>
{/* Pending Verifikasi */}
<section className="mb-8 rounded-2xl border border-neutral-200 bg-white">
<div className="flex items-center justify-between border-b border-neutral-100 px-5 py-4">
<div>
<h2 className="text-base font-bold text-neutral-800">
Verifikasi Organizer
</h2>
<p className="text-xs text-neutral-500">
{approvedVerif} disetujui · {rejectedVerif} ditolak ·{" "}
{pendingVerif} menunggu
</p>
</div>
<Link
href="/admin/verifications?tab=PENDING"
className="rounded-lg bg-primary-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-primary-700"
>
Tinjau pending ({pendingVerif})
</Link>
</div>
{recentPendingVerif.length === 0 ? (
<p className="px-5 py-6 text-sm text-neutral-500">
Tidak ada pengajuan menunggu review.
</p>
) : (
<ul className="divide-y divide-neutral-100">
{recentPendingVerif.map((v) => (
<li
key={v.id}
className="flex items-center justify-between gap-3 px-5 py-3"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-neutral-800">
{v.fullName}
</p>
<p className="truncate text-xs text-neutral-500">
{v.user.name} · {v.user.email} · {timeAgo(v.createdAt)}
</p>
</div>
<Link
href="/admin/verifications?tab=PENDING"
className="shrink-0 rounded-lg border border-neutral-200 px-3 py-1.5 text-xs font-medium text-neutral-700 hover:bg-neutral-50"
>
Buka
</Link>
</li>
))}
</ul>
)}
</section>
{/* Refund — Pending & Siap Transfer */}
<section className="mb-8 rounded-2xl border border-neutral-200 bg-white">
<div className="flex items-center justify-between border-b border-neutral-100 px-5 py-4">
<div>
<h2 className="text-base font-bold text-neutral-800">Refund</h2>
<p className="text-xs text-neutral-500">
{succeededRefund} selesai · {approvedRefund} siap transfer ·{" "}
{pendingRefund} baru
</p>
</div>
<Link
href="/admin/refunds?tab=PENDING"
className="rounded-lg bg-primary-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-primary-700"
>
Tinjau refund
</Link>
</div>
<div className="grid divide-neutral-100 sm:grid-cols-2 sm:divide-x">
<div className="px-5 py-4">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-amber-700">
Pending ({pendingRefund})
</p>
{recentPendingRefund.length === 0 ? (
<p className="text-sm text-neutral-500">
Tidak ada refund baru.
</p>
) : (
<ul className="space-y-2">
{recentPendingRefund.map((r) => (
<li key={r.id} className="text-sm">
<Link
href="/admin/refunds?tab=PENDING"
className="block rounded-lg p-2 -m-2 hover:bg-neutral-50"
>
<p className="font-semibold text-neutral-800">
{formatIDR(r.amount)} ·{" "}
<span className="font-normal text-neutral-500">
{REFUND_REASON_LABEL[r.reason] ?? r.reason}
</span>
</p>
<p className="truncate text-xs text-neutral-500">
{r.booking.user.name} · {r.booking.trip.title} ·{" "}
{timeAgo(r.createdAt)}
</p>
</Link>
</li>
))}
</ul>
)}
</div>
<div className="px-5 py-4">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-blue-700">
Siap transfer ({approvedRefund})
</p>
{recentApprovedRefund.length === 0 ? (
<p className="text-sm text-neutral-500">
Tidak ada refund siap transfer.
</p>
) : (
<ul className="space-y-2">
{recentApprovedRefund.map((r) => (
<li key={r.id} className="text-sm">
<Link
href="/admin/refunds?tab=APPROVED"
className="block rounded-lg p-2 -m-2 hover:bg-neutral-50"
>
<p className="font-semibold text-neutral-800">
{formatIDR(r.amount)} ·{" "}
<span className="font-normal text-neutral-500">
{REFUND_REASON_LABEL[r.reason] ?? r.reason}
</span>
</p>
<p className="truncate text-xs text-neutral-500">
{r.booking.user.name} · {r.booking.trip.title} ·{" "}
{timeAgo(r.createdAt)}
</p>
</Link>
</li>
))}
</ul>
)}
</div>
</div>
</section>
{/* Payout — escrow ke organizer */}
<section className="mb-8 rounded-2xl border border-neutral-200 bg-white">
<div className="flex items-center justify-between border-b border-neutral-100 px-5 py-4">
<div>
<h2 className="text-base font-bold text-neutral-800">
Payout Organizer (Escrow)
</h2>
<p className="text-xs text-neutral-500">
{paidPayout} dibayar · {releasedPayout} siap transfer ·{" "}
{heldPayout} ditahan
</p>
</div>
<Link
href="/admin/payouts?tab=RELEASED"
className="rounded-lg bg-primary-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-primary-700"
>
Transfer payout ({releasedPayout})
</Link>
</div>
{recentReleasedPayout.length === 0 ? (
<p className="px-5 py-6 text-sm text-neutral-500">
Tidak ada payout siap transfer.
</p>
) : (
<ul className="divide-y divide-neutral-100">
{recentReleasedPayout.map((p) => (
<li
key={p.id}
className="flex items-center justify-between gap-3 px-5 py-3"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-neutral-800">
{formatIDR(p.amount)} · {p.organizer.name}
</p>
<p className="truncate text-xs text-neutral-500">
{p.trip.title} ·{" "}
{p.releasedAt
? `release ${timeAgo(p.releasedAt)}`
: `hold sampai ${new Date(p.heldUntil).toLocaleDateString("id-ID")}`}
</p>
</div>
<Link
href="/admin/payouts?tab=RELEASED"
className="shrink-0 rounded-lg border border-neutral-200 px-3 py-1.5 text-xs font-medium text-neutral-700 hover:bg-neutral-50"
>
Buka
</Link>
</li>
))}
</ul>
)}
</section>
<section className="rounded-2xl border border-dashed border-neutral-300 bg-neutral-50 px-5 py-4 text-xs text-neutral-500">
<p className="mb-1">
<span className="font-semibold">Refund APPROVED:</span> admin transfer
manual ke peserta lalu tandai <span className="font-semibold">SUCCEEDED</span>.
</p>
<p>
<span className="font-semibold">Payout RELEASED:</span> escrow dilepas
karena trip sudah selesai + 3 hari. Admin transfer ke organizer lalu
tandai <span className="font-semibold">PAID</span>.
</p>
</section>
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Admin · Payout Organizer",
description:
"Halaman admin untuk meneruskan uang escrow ke rekening organizer setelah trip selesai.",
alternates: { canonical: "/admin/payouts" },
robots: { index: false, follow: false },
};
export default function AdminPayoutsLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}
+110
View File
@@ -0,0 +1,110 @@
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { payoutRepo } from "@/server/repositories/payout.repo";
import {
PayoutReviewCard,
type PayoutCardData,
} from "@/features/payout/components/payout-review-card";
type Tab = "RELEASED" | "HELD" | "PAID" | "CANCELLED";
const TABS: { key: Tab; label: string }[] = [
{ key: "RELEASED", label: "Siap transfer" },
{ key: "HELD", label: "Ditahan (escrow)" },
{ key: "PAID", label: "Selesai" },
{ key: "CANCELLED", label: "Dibatalkan" },
];
interface PageProps {
searchParams: Promise<{ tab?: string }>;
}
export default async function AdminPayoutsPage({ searchParams }: PageProps) {
const session = await getServerSession(authOptions);
if (!session?.user) redirect("/login?callbackUrl=/admin/payouts");
if (!isAdminEmail(session.user.email)) {
return (
<div className="mx-auto max-w-2xl px-4 py-12 text-center">
<p className="text-sm text-neutral-600">
Halaman ini hanya untuk admin SeTrip.
</p>
</div>
);
}
const params = await searchParams;
const tab: Tab = TABS.some((t) => t.key === params.tab)
? (params.tab as Tab)
: "RELEASED";
const rows = await payoutRepo.listByStatus(tab);
const items: PayoutCardData[] = rows.map((p) => ({
id: p.id,
amount: p.amount,
currency: p.currency,
status: p.status,
heldUntil: p.heldUntil,
releasedAt: p.releasedAt,
paidAt: p.paidAt,
cancelledAt: p.cancelledAt,
bankName: p.bankName,
bankAccountNumber: p.bankAccountNumber,
bankAccountName: p.bankAccountName,
adminNote: p.adminNote,
createdAt: p.createdAt,
trip: p.trip,
organizer: p.organizer,
booking: {
id: p.booking.id,
amount: p.booking.amount,
status: p.booking.status,
user: p.booking.user,
},
processedBy: p.processedBy,
}));
return (
<div className="mx-auto max-w-4xl px-4 py-8 sm:py-12">
<header className="mb-6">
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
Payout Organizer
</h1>
<p className="mt-1 text-sm text-neutral-500">
Uang peserta ditahan (escrow) sampai trip selesai + 3 hari. Setelah
status <strong>Siap transfer</strong>, admin transfer manual ke
rekening organizer lalu tandai sudah dibayar.
</p>
</header>
<div className="mb-6 flex flex-wrap gap-2">
{TABS.map((t) => (
<a
key={t.key}
href={`/admin/payouts?tab=${t.key}`}
className={`rounded-full px-4 py-1.5 text-sm font-semibold transition-colors ${
tab === t.key
? "bg-primary-600 text-white"
: "bg-neutral-100 text-neutral-600 hover:bg-neutral-200"
}`}
>
{t.label}
</a>
))}
</div>
{items.length === 0 ? (
<div className="rounded-2xl border border-dashed border-neutral-300 bg-white p-10 text-center">
<p className="text-sm text-neutral-500">Tidak ada payout pada status ini.</p>
</div>
) : (
<div className="space-y-4">
{items.map((p) => (
<PayoutReviewCard key={p.id} payout={p} />
))}
</div>
)}
</div>
);
}
+7 -1
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { tripService } from "@/server/services/trip.service"; import { tripService } from "@/server/services/trip.service";
import { payoutService } from "@/server/services/payout.service";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -31,14 +32,19 @@ export async function GET(req: NextRequest) {
try { try {
const result = await tripService.autoCompletePastTrips(); const result = await tripService.autoCompletePastTrips();
// Setelah trip COMPLETED, payout yang sudah lewat heldUntil di-release
// supaya admin bisa langsung transfer ke organizer. Idempotent.
const releaseResult = await payoutService.releaseEligible();
console.log("[cron/auto-complete-trips] selesai", { console.log("[cron/auto-complete-trips] selesai", {
count: result.count, completed: result.count,
ids: result.ids, ids: result.ids,
payoutsReleased: releaseResult.releasedIds.length,
}); });
return NextResponse.json({ return NextResponse.json({
ok: true, ok: true,
completed: result.count, completed: result.count,
ids: result.ids, ids: result.ids,
payoutsReleased: releaseResult.releasedIds,
}); });
} catch (err) { } catch (err) {
console.error("[cron/auto-complete-trips] gagal", err); console.error("[cron/auto-complete-trips] gagal", err);
+1 -7
View File
@@ -1,8 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import { SessionProvider } from "@/components/providers/session-provider"; import { SessionProvider } from "@/components/providers/session-provider";
import { Navbar } from "@/components/shared/navbar";
import { ProfileNudgeBanner } from "@/components/shared/profile-nudge-banner";
import { siteConfig, siteUrl } from "@/lib/site"; import { siteConfig, siteUrl } from "@/lib/site";
import "./globals.css"; import "./globals.css";
@@ -79,11 +77,7 @@ export default function RootLayout({
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
> >
<body className="flex min-h-full flex-col bg-neutral-50"> <body className="flex min-h-full flex-col bg-neutral-50">
<SessionProvider> <SessionProvider>{children}</SessionProvider>
<Navbar />
<ProfileNudgeBanner />
<main className="flex-1">{children}</main>
</SessionProvider>
</body> </body>
</html> </html>
); );
+162
View File
@@ -0,0 +1,162 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import Image from "next/image";
import { signOut } from "next-auth/react";
const NAV_ITEMS: { href: string; label: string; icon: string }[] = [
{ href: "/admin", label: "Dashboard", icon: "📊" },
{ href: "/admin/verifications", label: "Verifikasi", icon: "🪪" },
{ href: "/admin/refunds", label: "Refund", icon: "↩️" },
{ href: "/admin/payouts", label: "Payout", icon: "💸" },
];
interface AdminSidebarProps {
user: { name: string; email: string };
}
export function AdminSidebar({ user }: AdminSidebarProps) {
const pathname = usePathname();
const [open, setOpen] = useState(false);
return (
<>
{/* Mobile top bar */}
<header className="sticky top-0 z-30 flex h-14 items-center justify-between border-b border-neutral-200 bg-white px-4 lg:hidden">
<Link href="/admin" className="flex items-center gap-2">
<Image
src="/images/SeTrip.png"
alt=""
width={28}
height={28}
className="h-7 w-7 object-contain"
/>
<span className="text-sm font-bold text-neutral-800">
SeTrip <span className="text-primary-600">Admin</span>
</span>
</Link>
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex h-9 w-9 items-center justify-center rounded-lg text-neutral-600 hover:bg-neutral-100"
aria-label="Toggle menu"
aria-expanded={open}
>
{open ? (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M5 5l10 10M15 5L5 15" />
</svg>
) : (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M3 5h14M3 10h14M3 15h14" />
</svg>
)}
</button>
</header>
{/* Mobile drawer backdrop */}
{open && (
<button
type="button"
aria-label="Tutup menu"
onClick={() => setOpen(false)}
className="fixed inset-0 z-30 bg-neutral-900/30 lg:hidden"
/>
)}
{/* Sidebar */}
<aside
className={`fixed inset-y-0 left-0 z-40 flex w-64 flex-col border-r border-neutral-200 bg-white transition-transform lg:sticky lg:top-0 lg:h-screen lg:translate-x-0 ${
open ? "translate-x-0" : "-translate-x-full"
}`}
>
<div className="flex h-16 items-center gap-2.5 border-b border-neutral-200 px-5">
<Image
src="/images/SeTrip.png"
alt=""
width={32}
height={32}
className="h-8 w-8 object-contain"
/>
<div className="leading-tight">
<p className="text-base font-bold text-neutral-800">
SeTrip
</p>
<p className="text-[10px] font-semibold uppercase tracking-wider text-primary-600">
Admin Panel
</p>
</div>
</div>
<nav className="flex-1 overflow-y-auto p-3">
<ul className="space-y-1">
{NAV_ITEMS.map((item) => {
const isActive =
pathname === item.href ||
(item.href !== "/admin" && pathname?.startsWith(item.href));
return (
<li key={item.href}>
<Link
href={item.href}
onClick={() => setOpen(false)}
className={`flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
isActive
? "bg-primary-600 text-white"
: "text-neutral-700 hover:bg-neutral-100"
}`}
>
<span aria-hidden className="text-base">
{item.icon}
</span>
<span>{item.label}</span>
</Link>
</li>
);
})}
</ul>
<div className="my-4 border-t border-neutral-100" />
<ul className="space-y-1">
<li>
<Link
href="/"
onClick={() => setOpen(false)}
className="flex items-center gap-3 rounded-lg px-3 py-2 text-xs font-medium text-neutral-500 hover:bg-neutral-100 hover:text-neutral-700"
>
<span aria-hidden></span>
<span>Lihat situs publik</span>
</Link>
</li>
</ul>
</nav>
<div className="border-t border-neutral-100 p-3">
<div className="flex items-center gap-2 rounded-lg px-2 py-2">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary-600 text-xs font-bold text-white">
{user.name.charAt(0).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-semibold text-neutral-800">
{user.name}
</p>
<p className="truncate text-[10px] text-neutral-500">
{user.email}
</p>
</div>
<button
type="button"
onClick={() => signOut({ callbackUrl: "/" })}
className="rounded-lg px-2 py-1 text-[11px] font-medium text-neutral-500 hover:bg-red-50 hover:text-red-600"
title="Keluar"
>
Keluar
</button>
</div>
</div>
</aside>
</>
);
}
+82
View File
@@ -0,0 +1,82 @@
import Link from "next/link";
import { siteConfig } from "@/lib/site";
const LEGAL_LINKS = [
{ href: "/terms", label: "Syarat & Ketentuan" },
{ href: "/privacy", label: "Kebijakan Privasi" },
] as const;
const EXPLORE_LINKS = [
{ href: "/trips", label: "Open Trip" },
{ href: "/people", label: "Cari Teman" },
{ href: "/create-trip", label: "Buat Trip" },
] as const;
export function Footer() {
const year = new Date().getFullYear();
return (
<footer className="mt-12 border-t border-neutral-200 bg-white">
<div className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
<div className="grid gap-8 sm:grid-cols-3">
<div>
<Link href="/" className="inline-flex items-center gap-2">
<span className="text-lg font-bold text-neutral-800">
Se<span className="text-primary-600">Trip</span>
</span>
</Link>
<p className="mt-2 max-w-xs text-xs text-neutral-500">
{siteConfig.slogan} Gabung trip & aktivitas, kenal stranger jadi
travel buddies.
</p>
</div>
<div>
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-neutral-500">
Jelajah
</p>
<ul className="space-y-2 text-sm">
{EXPLORE_LINKS.map((l) => (
<li key={l.href}>
<Link
href={l.href}
className="text-neutral-600 hover:text-primary-700"
>
{l.label}
</Link>
</li>
))}
</ul>
</div>
<div>
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-neutral-500">
Kebijakan
</p>
<ul className="space-y-2 text-sm">
{LEGAL_LINKS.map((l) => (
<li key={l.href}>
<Link
href={l.href}
className="text-neutral-600 hover:text-primary-700"
>
{l.label}
</Link>
</li>
))}
</ul>
</div>
</div>
<div className="mt-8 flex flex-col items-start justify-between gap-2 border-t border-neutral-100 pt-4 text-xs text-neutral-500 sm:flex-row sm:items-center">
<p>
© {year} {siteConfig.name}. Pergi bareng, bukan sendiri.
</p>
<p className="text-[11px] text-neutral-400">
Pembayaran ditahan (escrow) sampai trip selesai · refund manual oleh
admin
</p>
</div>
</div>
</footer>
);
}
+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 { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { tripService } from "@/server/services/trip.service";
import { paymentService } from "@/server/services/payment.service"; import { paymentService } from "@/server/services/payment.service";
import { bookingService } from "@/server/services/booking.service"; import { bookingService } from "@/server/services/booking.service";
import { refundService } from "@/server/services/refund.service"; import { refundService } from "@/server/services/refund.service";
import { absoluteUrl } from "@/lib/site";
import { revalidatePath } from "next/cache"; 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 = export type StartMidtransResponse =
| { error: string } | { error: string }
| { | {
@@ -33,6 +15,7 @@ export type StartMidtransResponse =
snapToken: string; snapToken: string;
snapJsUrl: string; snapJsUrl: string;
clientKey: string; clientKey: string;
orderId: string;
}; };
/** /**
@@ -58,39 +41,51 @@ export async function startMidtransPaymentAction(
const result = await paymentService.startMidtransPayment( const result = await paymentService.startMidtransPayment(
booking.id, booking.id,
session.user.id session.user.id,
{ finishUrl: absoluteUrl(`/trips/${tripId}/payment`) }
); );
return { return {
success: true, success: true,
snapToken: result.snapToken, snapToken: result.snapToken,
snapJsUrl: result.snapJsUrl, snapJsUrl: result.snapJsUrl,
clientKey: result.clientKey, clientKey: result.clientKey,
orderId: result.orderId,
}; };
} catch (err) { } catch (err) {
return { error: (err as Error).message }; return { error: (err as Error).message };
} }
} }
export async function confirmParticipantPaymentAction( /**
tripId: string, * Tarik status terkini dari Midtrans untuk satu order, lalu sinkron ke DB.
participantId: string * 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); const session = await getServerSession(authOptions);
if (!session?.user) { if (!session?.user) {
return { error: "Kamu harus login terlebih dahulu" }; return { error: "Kamu harus login terlebih dahulu" };
} }
if (!orderId || typeof orderId !== "string") {
return { error: "order_id tidak valid" };
}
try { try {
await tripService.confirmParticipantPayment( const result = await paymentService.reconcileFromGateway(
tripId, orderId,
participantId,
session.user.id session.user.id
); );
revalidatePath(`/trips/${tripId}`); if (!result.ok) {
revalidatePath("/trips"); if (result.reason === "forbidden") {
revalidatePath("/"); return { error: "Order ini bukan milikmu" };
revalidatePath("/profile"); }
return { success: true }; 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) { } catch (err) {
return { error: (err as Error).message }; 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 { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { startMidtransPaymentAction } from "@/features/booking/actions"; import {
reconcileMidtransPaymentAction,
startMidtransPaymentAction,
} from "@/features/booking/actions";
interface SnapCallbacks { interface SnapCallbacks {
onSuccess?: (result: unknown) => void; onSuccess?: (result: unknown) => void;
@@ -86,23 +89,25 @@ export function MidtransPayButton({ tripId }: MidtransPayButtonProps) {
return; return;
} }
window.snap.pay(result.snapToken, { const orderId = result.orderId;
onSuccess: () => { // Tarik status terkini dari Midtrans server-side, lalu refresh halaman.
// Webhook server akan tetap jadi sumber kebenaran. Refresh page untuk pull state baru. // 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(); router.refresh();
}, }
onPending: () => router.refresh(),
window.snap.pay(result.snapToken, {
onSuccess: reconcileAndRefresh,
onPending: reconcileAndRefresh,
onError: () => { onError: () => {
setError( setError(
"Pembayaran gagal diproses. Coba lagi atau pakai metode lain." "Pembayaran gagal diproses. Coba lagi atau pakai metode lain."
); );
router.refresh(); void reconcileAndRefresh();
},
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();
}, },
onClose: reconcileAndRefresh,
}); });
setLoading(false); 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>
);
}
+43
View File
@@ -0,0 +1,43 @@
"use server";
import { getServerSession } from "next-auth";
import { revalidatePath } from "next/cache";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { payoutService } from "@/server/services/payout.service";
import { payoutMarkPaidSchema } from "./schemas";
async function requireAdmin() {
const session = await getServerSession(authOptions);
if (!session?.user || !isAdminEmail(session.user.email)) {
return null;
}
return session.user;
}
export async function markPayoutPaidAction(formData: FormData) {
const admin = await requireAdmin();
if (!admin) return { error: "Tidak memiliki akses admin" };
const parsed = payoutMarkPaidSchema.safeParse({
payoutId: formData.get("payoutId") as string,
adminNote: (formData.get("adminNote") as string) ?? "",
});
if (!parsed.success) {
return { error: parsed.error.issues[0].message };
}
try {
await payoutService.markPaid({
payoutId: parsed.data.payoutId,
adminId: admin.id,
adminNote: parsed.data.adminNote,
});
revalidatePath("/admin/payouts");
revalidatePath("/admin");
revalidatePath("/profile");
return { success: true };
} catch (err) {
return { error: (err as Error).message };
}
}
@@ -0,0 +1,161 @@
import Link from "next/link";
import { formatRupiah } from "@/lib/utils";
type PayoutStatus = "HELD" | "RELEASED" | "PAID" | "CANCELLED";
interface EarningRow {
id: string;
amount: number;
status: PayoutStatus;
heldUntil: Date;
releasedAt: Date | null;
paidAt: Date | null;
cancelledAt: Date | null;
trip: { id: string; title: string; date: Date; endDate: Date | null };
booking: {
id: string;
amount: number;
user: { id: string; name: string };
};
}
interface EarningsSectionProps {
payouts: EarningRow[];
}
function formatDay(d: Date | null | string): string {
if (!d) return "—";
return new Date(d).toLocaleDateString("id-ID", {
day: "2-digit",
month: "short",
year: "numeric",
});
}
const STATUS_META: Record<
PayoutStatus,
{ label: string; cls: string; hint: (row: EarningRow) => string }
> = {
HELD: {
label: "Ditahan",
cls: "bg-amber-50 text-amber-700 ring-amber-200",
hint: (r) => `Cair setelah ${formatDay(r.heldUntil)}`,
},
RELEASED: {
label: "Siap transfer",
cls: "bg-blue-50 text-blue-700 ring-blue-200",
hint: (r) =>
r.releasedAt
? `Antri admin transfer sejak ${formatDay(r.releasedAt)}`
: "Antri admin transfer",
},
PAID: {
label: "Diterima",
cls: "bg-primary-50 text-primary-700 ring-primary-200",
hint: (r) =>
r.paidAt ? `Ditransfer ${formatDay(r.paidAt)}` : "Sudah ditransfer admin",
},
CANCELLED: {
label: "Dibatalkan",
cls: "bg-neutral-100 text-neutral-600 ring-neutral-200",
hint: () => "Trip dibatalkan / di-refund — tidak ada payout",
},
};
export function EarningsSection({ payouts }: EarningsSectionProps) {
if (payouts.length === 0) return null;
const totals = {
held: 0,
released: 0,
paid: 0,
};
for (const p of payouts) {
if (p.status === "HELD") totals.held += p.amount;
else if (p.status === "RELEASED") totals.released += p.amount;
else if (p.status === "PAID") totals.paid += p.amount;
}
return (
<section className="mb-8 rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm sm:p-6">
<header className="mb-4">
<h2 className="text-base font-bold text-neutral-800 sm:text-lg">
Pendapatan dari peserta
</h2>
<p className="mt-0.5 text-xs text-neutral-500">
Uang peserta ditahan oleh SeTrip (escrow) sampai trip selesai + 3 hari,
lalu admin transfer ke rekening bank yang kamu daftarkan.
</p>
</header>
<div className="mb-5 grid grid-cols-3 gap-2 text-xs sm:text-sm">
<Stat label="Ditahan" value={totals.held} accent="amber" />
<Stat label="Siap transfer" value={totals.released} accent="blue" />
<Stat label="Sudah diterima" value={totals.paid} accent="primary" />
</div>
<ul className="divide-y divide-neutral-100">
{payouts.map((p) => {
const meta = STATUS_META[p.status];
return (
<li
key={p.id}
className="flex flex-wrap items-start justify-between gap-3 py-3"
>
<div className="min-w-0 flex-1">
<Link
href={`/trips/${p.trip.id}`}
className="block truncate text-sm font-semibold text-neutral-800 hover:text-primary-700"
>
{p.trip.title}
</Link>
<p className="truncate text-xs text-neutral-500">
{p.booking.user.name} · trip {formatDay(p.trip.date)}
</p>
<p className="mt-1 text-[11px] text-neutral-500">
{meta.hint(p)}
</p>
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<span className="text-sm font-bold text-neutral-900">
{formatRupiah(p.amount)}
</span>
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1 ${meta.cls}`}
>
{meta.label}
</span>
</div>
</li>
);
})}
</ul>
</section>
);
}
function Stat({
label,
value,
accent,
}: {
label: string;
value: number;
accent: "amber" | "blue" | "primary";
}) {
const cls = {
amber: "bg-amber-50 text-amber-900",
blue: "bg-blue-50 text-blue-900",
primary: "bg-primary-50 text-primary-900",
}[accent];
return (
<div className={`rounded-xl px-3 py-2 ${cls}`}>
<p className="text-[10px] font-semibold uppercase tracking-wide opacity-70">
{label}
</p>
<p className="mt-0.5 text-sm font-bold sm:text-base">
{formatRupiah(value)}
</p>
</div>
);
}
@@ -0,0 +1,271 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { markPayoutPaidAction } from "@/features/payout/actions";
import { formatRupiah } from "@/lib/utils";
type PayoutStatus = "HELD" | "RELEASED" | "PAID" | "CANCELLED";
export type PayoutCardData = {
id: string;
amount: number;
currency: string;
status: PayoutStatus;
heldUntil: Date;
releasedAt: Date | null;
paidAt: Date | null;
cancelledAt: Date | null;
bankName: string | null;
bankAccountNumber: string | null;
bankAccountName: string | null;
adminNote: string | null;
createdAt: Date;
trip: { id: string; title: string; date: Date; endDate: Date | null; status: string };
organizer: { id: string; name: string; email: string };
booking: {
id: string;
amount: number;
status: string;
user: { id: string; name: string; email: string };
};
processedBy: { id: string; name: string; email: string } | null;
};
function formatDate(d: Date | null | string): string {
if (!d) return "—";
return new Date(d).toLocaleString("id-ID", {
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function formatDay(d: Date | null | string): string {
if (!d) return "—";
return new Date(d).toLocaleDateString("id-ID", {
day: "2-digit",
month: "short",
year: "numeric",
});
}
export function PayoutReviewCard({ payout }: { payout: PayoutCardData }) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [note, setNote] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function submit() {
setError("");
setLoading(true);
const fd = new FormData();
fd.set("payoutId", payout.id);
fd.set("adminNote", note);
const result = await markPayoutPaidAction(fd);
setLoading(false);
if (result.error) {
setError(result.error);
return;
}
setOpen(false);
setNote("");
router.refresh();
}
const bankAvailable =
payout.bankName && payout.bankAccountNumber && payout.bankAccountName;
return (
<article className="rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm sm:p-6">
<header className="flex flex-wrap items-start justify-between gap-3 border-b border-neutral-100 pb-4">
<div className="min-w-0">
<h3 className="truncate text-base font-bold text-neutral-900">
{payout.trip.title}
</h3>
<p className="mt-0.5 text-xs text-neutral-500">
Booking {payout.booking.user.name} · trip{" "}
{formatDay(payout.trip.date)}
{" · "}
<span className="font-mono">{payout.id.slice(0, 8)}</span>
</p>
</div>
<StatusPill status={payout.status} />
</header>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<Field
label="Organizer"
value={`${payout.organizer.name} · ${payout.organizer.email}`}
/>
<Field
label="Nominal payout"
value={`${formatRupiah(payout.amount)} ${payout.currency !== "IDR" ? `(${payout.currency})` : ""}`}
highlight
/>
<Field label="Booking" value={`${formatRupiah(payout.booking.amount)} · ${payout.booking.status}`} />
<Field
label="Hold sampai"
value={formatDay(payout.heldUntil)}
/>
{payout.releasedAt && (
<Field label="Direlease" value={formatDate(payout.releasedAt)} />
)}
{payout.paidAt && (
<Field label="Dibayar" value={formatDate(payout.paidAt)} />
)}
{payout.cancelledAt && (
<Field label="Dibatalkan" value={formatDate(payout.cancelledAt)} />
)}
</div>
<div className="mt-4 rounded-xl bg-neutral-50 p-3 text-sm">
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-neutral-500">
Rekening tujuan transfer
</p>
{bankAvailable ? (
<div className="space-y-0.5 text-neutral-800">
<p className="font-semibold">{payout.bankName}</p>
<p className="font-mono text-sm">{payout.bankAccountNumber}</p>
<p className="text-xs text-neutral-600">
a/n {payout.bankAccountName}
</p>
</div>
) : (
<p className="text-amber-700">
Organizer belum menyelesaikan verifikasi (KYC) tidak ada rekening
snapshot. Hubungi organizer untuk konfirmasi rekening sebelum transfer.
</p>
)}
</div>
{payout.adminNote && (
<div className="mt-3 rounded-xl bg-blue-50 p-3 text-sm text-blue-800">
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-blue-600">
Catatan admin
</p>
<p className="whitespace-pre-wrap">{payout.adminNote}</p>
</div>
)}
{payout.processedBy && (
<p className="mt-3 text-xs text-neutral-500">
Diproses oleh {payout.processedBy.name}
{payout.paidAt && ` · transfer ${formatDate(payout.paidAt)}`}
</p>
)}
{payout.status === "RELEASED" && (
<div className="mt-5 border-t border-neutral-100 pt-4">
{error && (
<div className="mb-3 rounded-lg bg-red-50 px-3 py-2 text-xs text-red-600">
{error}
</div>
)}
{open ? (
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-600">
Tandai Sudah Transfer
</p>
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
rows={2}
placeholder="Referensi transfer / nomor mutasi bank (wajib)"
className="w-full rounded-xl border border-neutral-200 bg-neutral-50 px-3 py-2 text-sm focus:bg-white"
/>
<div className="flex gap-2">
<button
type="button"
onClick={submit}
disabled={loading || note.trim().length < 3}
className="rounded-xl bg-primary-600 px-4 py-2 text-sm font-bold text-white hover:bg-primary-700 disabled:opacity-50"
>
{loading ? "Memproses…" : "Tandai PAID"}
</button>
<button
type="button"
onClick={() => {
setOpen(false);
setNote("");
setError("");
}}
disabled={loading}
className="rounded-xl border border-neutral-200 bg-white px-4 py-2 text-sm font-medium text-neutral-600 hover:bg-neutral-50 disabled:opacity-50"
>
Batal
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setOpen(true)}
disabled={loading}
className="rounded-xl bg-primary-600 px-4 py-2 text-sm font-bold text-white hover:bg-primary-700 disabled:opacity-50"
>
💸 Tandai sudah ditransfer ke organizer
</button>
)}
</div>
)}
</article>
);
}
function Field({
label,
value,
highlight,
}: {
label: string;
value: string;
highlight?: boolean;
}) {
return (
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
{label}
</p>
<p
className={`mt-0.5 text-sm ${
highlight ? "font-bold text-primary-700" : "text-neutral-800"
}`}
>
{value}
</p>
</div>
);
}
function StatusPill({ status }: { status: PayoutStatus }) {
const cfg: Record<PayoutStatus, { label: string; cls: string }> = {
HELD: {
label: "Ditahan (escrow)",
cls: "bg-amber-50 text-amber-700 ring-amber-200",
},
RELEASED: {
label: "Siap transfer",
cls: "bg-blue-50 text-blue-700 ring-blue-200",
},
PAID: {
label: "Selesai",
cls: "bg-primary-50 text-primary-700 ring-primary-200",
},
CANCELLED: {
label: "Dibatalkan",
cls: "bg-neutral-100 text-neutral-600 ring-neutral-200",
},
};
const c = cfg[status];
return (
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ring-1 ${c.cls}`}
>
{c.label}
</span>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { z } from "zod/v4";
import { LIMITS } from "@/lib/limits";
export const payoutMarkPaidSchema = z.object({
payoutId: z.string().trim().min(1, "Payout ID wajib"),
adminNote: z
.string()
.trim()
.min(3, "Catatan/referensi transfer minimal 3 karakter")
.max(
LIMITS.MAX_REFUND_NOTE_LENGTH,
`Catatan maksimal ${LIMITS.MAX_REFUND_NOTE_LENGTH} karakter`
),
});
export type PayoutMarkPaidInput = z.infer<typeof payoutMarkPaidSchema>;
@@ -20,20 +20,17 @@ export function ProfileTripRow({
rightSlot, rightSlot,
}: ProfileTripRowProps) { }: ProfileTripRowProps) {
return ( return (
<Link <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">
href={href} <Link href={href} className="min-w-0 flex-1">
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">
<p className="truncate text-sm font-semibold text-neutral-800">{title}</p> <p className="truncate text-sm font-semibold text-neutral-800">{title}</p>
<p className="truncate text-xs text-neutral-500">{destination}</p> <p className="truncate text-xs text-neutral-500">{destination}</p>
<p className="mt-0.5 text-[11px] text-neutral-400 sm:text-xs"> <p className="mt-0.5 text-[11px] text-neutral-400 sm:text-xs">
{formatTripCalendarDateRangeLong(date, endDate)} {formatTripCalendarDateRangeLong(date, endDate)}
</p> </p>
</div> </Link>
{rightSlot && ( {rightSlot && (
<div className="shrink-0 text-right text-xs font-medium">{rightSlot}</div> <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 { getServerSession } from "next-auth";
import { authOptions } from "@/lib/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 { tripService } from "@/server/services/trip.service";
import { organizerService } from "@/server/services/organizer.service"; import { organizerService } from "@/server/services/organizer.service";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
@@ -21,7 +25,6 @@ export async function createTripAction(formData: FormData) {
destination: formData.get("destination") as string, destination: formData.get("destination") as string,
location: formData.get("location") as string, location: formData.get("location") as string,
meetingPoint: formData.get("meetingPoint") as string, meetingPoint: formData.get("meetingPoint") as string,
itinerary: formData.get("itinerary") as string,
whatsIncluded: formData.get("whatsIncluded") as string, whatsIncluded: formData.get("whatsIncluded") as string,
whatsExcluded: formData.get("whatsExcluded") as string, whatsExcluded: formData.get("whatsExcluded") as string,
date: formData.get("date") 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 }; 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) { if (result.data.price > 0) {
const approved = await organizerService.isApproved(session.user.id); const approved = await organizerService.isApproved(session.user.id);
if (!approved) { if (!approved) {
@@ -69,7 +88,6 @@ export async function createTripAction(formData: FormData) {
try { try {
const { const {
meetingPoint, meetingPoint,
itinerary,
whatsIncluded, whatsIncluded,
whatsExcluded, whatsExcluded,
...tripCore ...tripCore
@@ -78,13 +96,13 @@ export async function createTripAction(formData: FormData) {
const trip = await tripService.createTrip({ const trip = await tripService.createTrip({
...tripCore, ...tripCore,
meetingPoint, meetingPoint,
itinerary,
whatsIncluded, whatsIncluded,
whatsExcluded, whatsExcluded,
date, date,
endDate, endDate,
organizerId: session.user.id, organizerId: session.user.id,
imageUrls: imageUrls.length > 0 ? imageUrls : undefined, imageUrls: imageUrls.length > 0 ? imageUrls : undefined,
itineraryItems: itineraryItems.length > 0 ? itineraryItems : undefined,
}); });
revalidatePath("/trips"); revalidatePath("/trips");
revalidatePath("/"); revalidatePath("/");
File diff suppressed because it is too large Load Diff
+22 -13
View File
@@ -1,24 +1,31 @@
"use client"; "use client";
import { useState } from "react"; import { LIMITS } from "@/lib/limits";
export function ImageUrlInput() { interface ImageUrlInputProps {
const [urls, setUrls] = useState<string[]>([""]); 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() { function addField() {
if (urls.length < 5) { if (urls.length < max) {
setUrls([...urls, ""]); onChange([...urls, ""]);
} }
} }
function removeField(index: number) { 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]; const updated = [...urls];
updated[index] = value; updated[index] = next;
setUrls(updated); onChange(updated);
} }
return ( return (
@@ -27,13 +34,14 @@ export function ImageUrlInput() {
<span className="text-sm font-semibold text-neutral-700"> <span className="text-sm font-semibold text-neutral-700">
Foto Trip (URL) Foto Trip (URL)
</span> </span>
<span className="text-xs text-neutral-400">{urls.length}/5</span> <span className="text-xs text-neutral-400">
{urls.length}/{max}
</span>
</label> </label>
<div className="space-y-2"> <div className="space-y-2">
{urls.map((url, i) => ( {urls.map((url, i) => (
<div key={i} className="flex gap-2"> <div key={i} className="flex gap-2">
<input <input
name="imageUrls"
type="url" type="url"
value={url} value={url}
onChange={(e) => updateField(i, e.target.value)} onChange={(e) => updateField(i, e.target.value)}
@@ -48,6 +56,7 @@ export function ImageUrlInput() {
<button <button
type="button" type="button"
onClick={() => removeField(i)} 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" 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>
))} ))}
</div> </div>
{urls.length < 5 && ( {urls.length < max && (
<button <button
type="button" type="button"
onClick={addField} onClick={addField}
@@ -66,7 +75,7 @@ export function ImageUrlInput() {
</button> </button>
)} )}
<p className="mt-1.5 text-xs text-neutral-400"> <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> </p>
</div> </div>
); );
+22 -21
View File
@@ -5,6 +5,15 @@ import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { joinTripAction, cancelJoinAction } from "@/features/trip/actions"; import { joinTripAction, cancelJoinAction } from "@/features/trip/actions";
type BookingStatus =
| "PENDING"
| "AWAITING_PAY"
| "PAID"
| "CANCELLED"
| "REFUNDED"
| "PARTIALLY_REFUNDED"
| "EXPIRED";
interface JoinTripButtonProps { interface JoinTripButtonProps {
tripId: string; tripId: string;
isLoggedIn: boolean; isLoggedIn: boolean;
@@ -14,11 +23,8 @@ interface JoinTripButtonProps {
isFree: boolean; isFree: boolean;
/** Status partisipasi user saat isJoined (bukan organizer) */ /** Status partisipasi user saat isJoined (bukan organizer) */
participationStatus?: "PENDING" | "CONFIRMED" | null; participationStatus?: "PENDING" | "CONFIRMED" | null;
/** Status pembayaran manual (peserta). Hanya relevan untuk trip berbayar. */ /** Status booking peserta (hanya relevan untuk trip berbayar). */
participantPayment?: { bookingStatus?: BookingStatus | null;
markedPaidAt: string | Date | null;
paymentConfirmedAt: string | Date | null;
} | null;
isFull: boolean; isFull: boolean;
tripStatus: string; tripStatus: string;
/** Tanggal berangkat trip sudah lewat */ /** Tanggal berangkat trip sudah lewat */
@@ -35,7 +41,7 @@ export function JoinTripButton({
isJoined, isJoined,
isFree, isFree,
participationStatus, participationStatus,
participantPayment, bookingStatus,
isFull, isFull,
tripStatus, tripStatus,
isDeparturePast, isDeparturePast,
@@ -114,11 +120,9 @@ export function JoinTripButton({
} }
} }
const pay = participantPayment; const needsPayment = !isFree && isJoined && bookingStatus === "AWAITING_PAY";
const showPaymentLink = !isFree && isJoined && !isDeparturePast; const paymentDone = !isFree && isJoined && bookingStatus === "PAID";
const waitingPaymentConfirm = const showPaymentLink = (needsPayment || paymentDone) && !isDeparturePast;
!isFree && isJoined && pay && pay.markedPaidAt && !pay.paymentConfirmedAt;
const paymentDone = !isFree && isJoined && pay && pay.paymentConfirmedAt;
return ( return (
<div> <div>
@@ -142,16 +146,17 @@ export function JoinTripButton({
{isFree && <span> trip gratis, tidak ada pembayaran 🎉</span>}. {isFree && <span> trip gratis, tidak ada pembayaran 🎉</span>}.
</div> </div>
)} )}
{waitingPaymentConfirm && ( {needsPayment && (
<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"> <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">
Kamu sudah menandai <span className="font-semibold">sudah bayar</span>. Selesaikan pembayaran lewat{" "}
Tunggu organizer mengonfirmasi pembayaran. <span className="font-semibold">Midtrans</span> untuk mengamankan slot
kamu.
</div> </div>
)} )}
{paymentDone && ( {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"> <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{" "} Pembayaran kamu sudah{" "}
<span className="font-semibold">dikonfirmasi organizer</span>. <span className="font-semibold">terkonfirmasi</span>.
</div> </div>
)} )}
{showPaymentLink && ( {showPaymentLink && (
@@ -159,11 +164,7 @@ export function JoinTripButton({
href={`/trips/${tripId}/payment`} 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" 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 {paymentDone ? "Lihat detail pembayaran" : "Bayar sekarang"}
? "Lihat detail pembayaran"
: pay?.markedPaidAt
? "Lihat status pembayaran"
: "Buka detail pembayaran"}
</Link> </Link>
)} )}
{isJoined ? ( {isJoined ? (
@@ -11,8 +11,6 @@ import {
export interface PendingJoinRequest { export interface PendingJoinRequest {
id: string; id: string;
user: { name: string; image: string | null }; user: { name: string; image: string | null };
/** Peserta sudah menekan &quot;Saya sudah bayar&quot; */
markedPaidAt?: string | Date | null;
} }
interface OrganizerJoinRequestsProps { interface OrganizerJoinRequestsProps {
@@ -83,14 +81,7 @@ export function OrganizerJoinRequests({
<p className="truncate text-sm font-semibold text-neutral-800"> <p className="truncate text-sm font-semibold text-neutral-800">
{p.user.name} {p.user.name}
</p> </p>
<div className="flex flex-wrap items-center gap-1.5">
<p className="text-xs text-amber-800/80">Menunggu persetujuan</p> <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>
</div> </div>
</div> </div>
<div className="flex shrink-0 gap-2"> <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 { interface TripProgramBlockProps {
meetingPoint: string | null; meetingPoint: string | null;
itinerary: string | null; itinerary: string | null;
itineraryItems: ItineraryItem[];
whatsIncluded: string | null; whatsIncluded: string | null;
whatsExcluded: string | null; whatsExcluded: string | null;
} }
@@ -8,13 +19,24 @@ interface TripProgramBlockProps {
export function TripProgramBlock({ export function TripProgramBlock({
meetingPoint, meetingPoint,
itinerary, itinerary,
itineraryItems,
whatsIncluded, whatsIncluded,
whatsExcluded, whatsExcluded,
}: TripProgramBlockProps) { }: TripProgramBlockProps) {
const hasStructuredItinerary = itineraryItems.length > 0;
const hasLegacyItinerary = !hasStructuredItinerary && !!itinerary;
const hasAny = const hasAny =
meetingPoint || itinerary || whatsIncluded || whatsExcluded; meetingPoint ||
hasStructuredItinerary ||
hasLegacyItinerary ||
whatsIncluded ||
whatsExcluded;
if (!hasAny) return null; if (!hasAny) return null;
const grouped = hasStructuredItinerary
? groupItineraryByDay(itineraryItems)
: null;
return ( return (
<div className="space-y-4 rounded-xl border border-neutral-200 bg-neutral-50/50 p-4 sm:p-5"> <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"> <h2 className="text-xs font-bold text-neutral-800 sm:text-sm">
@@ -32,7 +54,41 @@ export function TripProgramBlock({
</div> </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> <div>
<h3 className="mb-1 text-[11px] font-bold uppercase tracking-wide text-primary-700 sm:text-xs"> <h3 className="mb-1 text-[11px] font-bold uppercase tracking-wide text-primary-700 sm:text-xs">
Itinerary Itinerary
+70 -14
View File
@@ -7,6 +7,7 @@ import {
isTripDepartureDayPast, isTripDepartureDayPast,
tripStoredInstantFromYmd, tripStoredInstantFromYmd,
} from "@/lib/trip-dates"; } from "@/lib/trip-dates";
import { isValidTimeFormat, timeToMinutes } from "@/lib/itinerary";
export const tripImageUrlsSchema = z export const tripImageUrlsSchema = z
.array( .array(
@@ -18,6 +19,75 @@ export const tripImageUrlsSchema = z
) )
.max(LIMITS.MAX_IMAGE_URLS, `Maksimal ${LIMITS.MAX_IMAGE_URLS} foto`); .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 export const createTripSchema = z
.object({ .object({
category: z.enum( category: z.enum(
@@ -105,20 +175,6 @@ export const createTripSchema = z
) )
.optional() .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( whatsIncluded: z.preprocess(
(val) => { (val) => {
if (val == null) return undefined; if (val == null) return undefined;
+2
View File
@@ -4,6 +4,7 @@ import GoogleProvider from "next-auth/providers/google";
import { PrismaAdapter } from "@next-auth/prisma-adapter"; import { PrismaAdapter } from "@next-auth/prisma-adapter";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { isAdminEmail } from "@/lib/admin";
// Adapter dipakai untuk persist User + Account saat OAuth (Google). // Adapter dipakai untuk persist User + Account saat OAuth (Google).
// Session tetap pakai JWT supaya kompatibel dengan CredentialsProvider. // Session tetap pakai JWT supaya kompatibel dengan CredentialsProvider.
@@ -89,6 +90,7 @@ export const authOptions: AuthOptions = {
if (session.user) { if (session.user) {
session.user.id = token.id as string; session.user.id = token.id as string;
session.user.acceptedTermsAndPrivacy = token.acceptedTermsAndPrivacy ?? false; session.user.acceptedTermsAndPrivacy = token.acceptedTermsAndPrivacy ?? false;
session.user.isAdmin = isAdminEmail(session.user.email);
} }
return session; return session;
}, },
+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 */ /** Meeting point & tiap blok include/exclude */
MAX_MEETING_POINT_LENGTH: 500, MAX_MEETING_POINT_LENGTH: 500,
MAX_TRIP_ITINERARY_LENGTH: 8000, 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_TRIP_BULLET_SECTION_LENGTH: 4000,
MAX_REVIEW_COMMENT: 500, MAX_REVIEW_COMMENT: 500,
MAX_IMAGE_URLS: 5, MAX_IMAGE_URLS: 5,
+73
View File
@@ -42,6 +42,11 @@ export const MIDTRANS = {
isProduction() isProduction()
? "https://app.midtrans.com/snap/snap.js" ? "https://app.midtrans.com/snap/snap.js"
: "https://app.sandbox.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 { function requireServerKey(): string {
@@ -70,6 +75,9 @@ interface SnapTransactionPayload {
itemName: string; itemName: string;
/// Berapa detik sampai expire. Default Midtrans 24 jam, kita pakai itu kalau undefined. /// Berapa detik sampai expire. Default Midtrans 24 jam, kita pakai itu kalau undefined.
expirySeconds?: number; 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 { 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`, { const res = await fetch(`${MIDTRANS.snapApiBase()}/transactions`, {
method: "POST", method: "POST",
headers: { 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. * Verifikasi signature webhook Midtrans.
* Formula: SHA512(order_id + status_code + gross_amount + serverKey). * Formula: SHA512(order_id + status_code + gross_amount + serverKey).
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "setrip", "name": "setrip",
"version": "0.10.3", "version": "0.12.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "setrip", "name": "setrip",
"version": "0.10.3", "version": "0.12.0",
"dependencies": { "dependencies": {
"@next-auth/prisma-adapter": "^1.0.7", "@next-auth/prisma-adapter": "^1.0.7",
"@prisma/adapter-pg": "^7.7.0", "@prisma/adapter-pg": "^7.7.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "setrip", "name": "setrip",
"version": "0.10.3", "version": "0.12.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -0,0 +1,47 @@
-- CreateEnum
CREATE TYPE "PayoutStatus" AS ENUM ('HELD', 'RELEASED', 'PAID', 'CANCELLED');
-- CreateTable
CREATE TABLE "Payout" (
"id" TEXT NOT NULL,
"bookingId" TEXT NOT NULL,
"tripId" TEXT NOT NULL,
"organizerId" TEXT NOT NULL,
"amount" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'IDR',
"status" "PayoutStatus" NOT NULL DEFAULT 'HELD',
"heldUntil" TIMESTAMP(3) NOT NULL,
"releasedAt" TIMESTAMP(3),
"paidAt" TIMESTAMP(3),
"cancelledAt" TIMESTAMP(3),
"bankName" TEXT,
"bankAccountNumber" TEXT,
"bankAccountName" TEXT,
"adminNote" TEXT,
"processedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Payout_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Payout_bookingId_key" ON "Payout"("bookingId");
-- CreateIndex
CREATE INDEX "Payout_organizerId_status_idx" ON "Payout"("organizerId", "status");
-- CreateIndex
CREATE INDEX "Payout_status_heldUntil_idx" ON "Payout"("status", "heldUntil");
-- AddForeignKey
ALTER TABLE "Payout" ADD CONSTRAINT "Payout_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Payout" ADD CONSTRAINT "Payout_tripId_fkey" FOREIGN KEY ("tripId") REFERENCES "Trip"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Payout" ADD CONSTRAINT "Payout_organizerId_fkey" FOREIGN KEY ("organizerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Payout" ADD CONSTRAINT "Payout_processedById_fkey" FOREIGN KEY ("processedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -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;
+102 -1
View File
@@ -34,6 +34,11 @@ model User {
reviewedRefunds Refund[] @relation("RefundReviewer") reviewedRefunds Refund[] @relation("RefundReviewer")
/// Payout yang diterima user ini sebagai organizer (escrow trip selesai).
payouts Payout[] @relation("PayoutOrganizer")
/// Payout yang ditandai admin sebagai PAID/CANCELLED oleh user ini.
processedPayouts Payout[] @relation("PayoutProcessor")
profile UserProfile? profile UserProfile?
} }
@@ -138,7 +143,8 @@ model Trip {
location String location String
/// Titik kumpul / meeting point (teks bebas) /// Titik kumpul / meeting point (teks bebas)
meetingPoint String? 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? itinerary String?
/// Yang termasuk harga (teks bebas) /// Yang termasuk harga (teks bebas)
whatsIncluded String? whatsIncluded String?
@@ -161,11 +167,37 @@ model Trip {
images TripImage[] images TripImage[]
reviews TripReview[] reviews TripReview[]
bookings Booking[] bookings Booking[]
payouts Payout[]
itineraryItems TripItineraryItem[]
@@index([category, status, date]) @@index([category, status, date])
@@index([vibe, 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 { model TripReview {
id String @id @default(cuid()) id String @id @default(cuid())
rating Int rating Int
@@ -261,6 +293,7 @@ model Booking {
payments Payment[] payments Payment[]
refunds Refund[] refunds Refund[]
payout Payout?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -436,3 +469,71 @@ enum RefundReporter {
PARTICIPANT PARTICIPANT
ORGANIZER ORGANIZER
} }
/// Escrow payout ke organizer. Uang peserta ditahan sejak Booking → PAID sampai
/// trip selesai + buffer beberapa hari, baru di-release untuk ditransfer admin.
///
/// State machine:
/// HELD → diciptakan saat booking PAID, heldUntil = endDate/date + 3 hari
/// RELEASED → cron flip setelah heldUntil lewat + trip COMPLETED
/// PAID → admin sudah transfer manual ke rekening organizer
/// CANCELLED → booking di-refund / trip dibatalkan; payout tidak jadi
///
/// Audit: 1-1 dengan Booking (unique). Refund SUCCEEDED mengurangi amount
/// (partial) atau membatalkan payout (full).
model Payout {
id String @id @default(cuid())
bookingId String @unique
booking Booking @relation(fields: [bookingId], references: [id], onDelete: Restrict)
tripId String
trip Trip @relation(fields: [tripId], references: [id])
organizerId String
organizer User @relation("PayoutOrganizer", fields: [organizerId], references: [id])
/// Nominal yg organizer terima (IDR integer). Default = booking.amount saat
/// payout dibuat. Refund SUCCEEDED memotong nilai ini supaya total payout +
/// total refund = uang yang dibayar peserta.
amount Int
currency String @default("IDR")
status PayoutStatus @default(HELD)
/// Tanggal payout boleh di-release ke organizer
/// (= trip.endDate ?? trip.date + buffer days).
heldUntil DateTime
releasedAt DateTime?
paidAt DateTime?
cancelledAt DateTime?
/// Snapshot bank info organizer dari OrganizerVerification saat payout dibuat.
/// Disimpan inline supaya audit-friendly walau organizer ganti bank nanti.
bankName String?
bankAccountNumber String?
bankAccountName String?
/// Catatan admin: referensi transfer manual, alasan cancel, dst.
adminNote String?
/// Admin yang menandai PAID/CANCELLED.
processedById String?
processedBy User? @relation("PayoutProcessor", fields: [processedById], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizerId, status])
@@index([status, heldUntil])
}
enum PayoutStatus {
/// Menunggu trip selesai + buffer beberapa hari sebelum boleh ditransfer.
HELD
/// Buffer lewat & trip COMPLETED, siap di-transfer admin ke rekening organizer.
RELEASED
/// Admin sudah transfer ke rekening organizer.
PAID
/// Booking di-refund penuh / trip dibatalkan — uang tidak jadi ke organizer.
CANCELLED
}
+238 -228
View File
@@ -27,231 +27,226 @@ const img = (id: string) =>
const SEED_PASSWORD = "password123"; const SEED_PASSWORD = "password123";
// ============================================================================ // ============================================================================
// Itineraries — disimpan sebagai const supaya data trip tetap padat. // Itineraries — array terstruktur per hari/jam. Setiap item: {day, startTime,
// Format: header `Hari N — <hari>` lalu bullet `• HH:MMHH:MM aktivitas`. // endTime, activity}. Disimpan ke TripItineraryItem (lihat schema.prisma).
// Lokasi, pos, dan spot diambil dari kasus nyata (Papandayan via Cisurupan,
// Ciremai via Apuy, USS Liberty Tulamben, Karimun Jawa hopping, dst).
// ============================================================================ // ============================================================================
const ITIN_PAPANDAYAN = `Hari 1 — Sabtu interface SeedItineraryItem {
05:0005:30 Meeting & briefing di Alun-alun Garut day: number;
05:3007:00 Perjalanan menuju basecamp Cisurupan startTime: string;
07:0008:00 Sarapan + repacking + pemanasan endTime?: string;
08:0011:00 Trekking Camp David Hutan Mati Pondok Salada activity: string;
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
Hari 2 Minggu const ITIN_PAPANDAYAN: SeedItineraryItem[] = [
03:3004:00 Bangun + sarapan ringan + air panas { day: 1, startTime: "05:00", endTime: "05:30", activity: "Meeting & briefing di Alun-alun Garut" },
04:0005:30 Summit attack ke puncak Papandayan { day: 1, startTime: "05:30", endTime: "07:00", activity: "Perjalanan menuju basecamp Cisurupan" },
05:3007:00 Sunrise + foto bareng di puncak { day: 1, startTime: "07:00", endTime: "08:00", activity: "Sarapan + repacking + pemanasan" },
07:0009:00 Turun ke camp + sarapan utama { day: 1, startTime: "08:00", endTime: "11:00", activity: "Trekking Camp David — Hutan Mati — Pondok Salada" },
09:0011:00 Beres-beres tenda + repacking { day: 1, startTime: "11:00", endTime: "12:30", activity: "Setup tenda di Pondok Salada" },
11:0013:30 Turun ke basecamp Cisurupan { day: 1, startTime: "12:30", endTime: "14:00", activity: "ISHOMA + games kenalan grup" },
13:3014:30 Bersih-bersih + makan siang { day: 1, startTime: "14:00", endTime: "17:00", activity: "Eksplor Tegal Alun & Hutan Mati (golden hour foto)" },
14:3016:30 Perjalanan kembali ke Garut { day: 1, startTime: "17:00", endTime: "19:00", activity: "Masak bareng + makan malam" },
16:30 Sampai Garut, bubar grup`; { 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 const ITIN_CIREMAI: SeedItineraryItem[] = [
04:0004:30 Meeting & briefing di Stasiun Kuningan { day: 1, startTime: "04:00", endTime: "04:30", activity: "Meeting & briefing di Stasiun Kuningan" },
04:3006:30 Perjalanan ke basecamp Apuy via Maja & Argapura { day: 1, startTime: "04:30", endTime: "06:30", activity: "Perjalanan ke basecamp Apuy via Maja & Argapura" },
06:3007:30 Sarapan + registrasi simaksi + repacking { day: 1, startTime: "06:30", endTime: "07:30", activity: "Sarapan + registrasi simaksi + repacking" },
07:3010:30 Trek Pos 1 (Berod) Pos 2 (Arban) Pos 3 (Tegal Masawa) { day: 1, startTime: "07:30", endTime: "10:30", activity: "Trek Pos 1 (Berod) → Pos 2 (Arban) → Pos 3 (Tegal Masawa)" },
10:3011:30 ISHOMA di Pos 4 (Tegal Jamuju) { day: 1, startTime: "10:30", endTime: "11:30", activity: "ISHOMA di Pos 4 (Tegal Jamuju)" },
11:3014:30 Lanjut Pos 5 (Sanghyang Rangkah) Pos 6 (Goa Walet) { day: 1, startTime: "11:30", endTime: "14:30", activity: "Lanjut Pos 5 (Sanghyang Rangkah) → Pos 6 (Goa Walet)" },
14:3016:00 Setup tenda di Pos 6 { day: 1, startTime: "14:30", endTime: "16:00", activity: "Setup tenda di Pos 6" },
16:0018:00 Acara bebas + makan sore + persiapan summit { day: 1, startTime: "16:00", endTime: "18:00", activity: "Acara bebas + makan sore + persiapan summit" },
18:0020:00 Briefing summit + early dinner { day: 1, startTime: "18:00", endTime: "20:00", activity: "Briefing summit + early dinner" },
20:00 Istirahat (bangun dini hari) { 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 const ITIN_CAMPING: SeedItineraryItem[] = [
02:0002:30 Bangun + cemilan + minuman hangat { day: 1, startTime: "13:00", endTime: "13:30", activity: "Meeting di Pertigaan Pasar Lembang" },
02:3005:00 Summit attack ke puncak Sunan Cirebon (3.078 mdpl) { day: 1, startTime: "13:30", endTime: "15:00", activity: "Perjalanan ke Ranca Upas via Ciwidey" },
05:0006:30 Sunrise di puncak Ciremai { day: 1, startTime: "15:00", endTime: "16:00", activity: "Check-in + setup tenda dome (sudah disiapkan tim)" },
06:3008:30 Turun ke Pos 6 + sarapan { day: 1, startTime: "16:00", endTime: "17:30", activity: "Tour camp area + ketemu rusa-rusa" },
08:3010:30 Beres tenda + repacking { day: 1, startTime: "17:30", endTime: "19:00", activity: "Persiapan BBQ + nyalakan api unggun" },
10:3014:00 Turun ke basecamp Apuy (track curam hati-hati lutut) { day: 1, startTime: "19:00", endTime: "21:00", activity: "Makan malam BBQ" },
14:0015:00 Bersih-bersih + makan siang di basecamp { day: 1, startTime: "21:00", endTime: "23:00", activity: "Live music akustik + games" },
15:0017:00 Kembali ke Stasiun Kuningan { day: 1, startTime: "23:00", activity: "Istirahat" },
17:00 Bubar grup`; { 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 const ITIN_PAHAWANG: SeedItineraryItem[] = [
13:0013:30 Meeting di Pertigaan Pasar Lembang { day: 1, startTime: "07:00", endTime: "07:30", activity: "Meeting di Dermaga Ketapang, Lampung Selatan" },
13:3015:00 Perjalanan ke Ranca Upas via Ciwidey { day: 1, startTime: "07:30", endTime: "08:30", activity: "Briefing safety + fitting alat snorkel" },
15:0016:00 Check-in + setup tenda dome (sudah disiapkan tim) { day: 1, startTime: "08:30", endTime: "09:30", activity: "Sailing menuju Pulau Pahawang Kecil" },
16:0017:30 Tour camp area + ketemu rusa-rusa { day: 1, startTime: "09:30", endTime: "11:30", activity: "Snorkeling spot Cukuh Bedil — terumbu warna-warni" },
17:3019:00 Persiapan BBQ + nyalakan api unggun { day: 1, startTime: "11:30", endTime: "12:30", activity: "Pindah spot ke Pulau Kelagian Kecil" },
19:0021:00 Makan malam BBQ { day: 1, startTime: "12:30", endTime: "14:00", activity: "Makan siang + istirahat di pasir putih" },
21:0023:00 Live music akustik + games { day: 1, startTime: "14:00", endTime: "15:30", activity: "Snorkeling Tanjung Putus + sesi foto underwater" },
23:00 Istirahat { 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 const ITIN_DIVING: SeedItineraryItem[] = [
06:0007:00 Sunrise + foto di hutan pinus { day: 1, startTime: "06:30", endTime: "07:00", activity: "Meeting di dive shop Tulamben + welcome coffee" },
07:0008:30 Sarapan (nasi goreng + roti bakar + kopi) { day: 1, startTime: "07:00", endTime: "08:00", activity: "Briefing dive plan + cek sertifikasi + fitting gear" },
08:3010:00 Memberi makan rusa + sesi foto { day: 1, startTime: "08:00", endTime: "09:00", activity: "Surface interval + pengecekan tank/regulator" },
10:0011:00 Beres tenda + bersih-bersih { day: 1, startTime: "09:00", endTime: "10:30", activity: "Dive #1 — USS Liberty Wreck (528m, ~50 menit bottom time)" },
11:0011:30 Pulang menuju Lembang { day: 1, startTime: "10:30", endTime: "12:00", activity: "Surface interval + brunch + log dive" },
12:00 Sampai Lembang, bubar grup`; { 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) const ITIN_ISLANDHOP: SeedItineraryItem[] = [
07:0007:30 Meeting di Dermaga Ketapang, Lampung Selatan { day: 1, startTime: "07:00", endTime: "07:30", activity: "Meeting di Pelabuhan Kartini Jepara" },
07:3008:30 Briefing safety + fitting alat snorkel { day: 1, startTime: "07:30", endTime: "13:00", activity: "Penyeberangan kapal feri Jepara → Karimun Jawa" },
08:3009:30 Sailing menuju Pulau Pahawang Kecil { day: 1, startTime: "13:00", endTime: "14:00", activity: "Tiba di Pelabuhan Karimun + transfer homestay" },
09:3011:30 Snorkeling spot Cukuh Bedil terumbu warna-warni { day: 1, startTime: "14:00", endTime: "15:00", activity: "Check-in homestay + ISHOMA" },
11:3012:30 Pindah spot ke Pulau Kelagian Kecil { day: 1, startTime: "15:00", endTime: "17:30", activity: "Sunset di Bukit Love + foto-foto" },
12:3014:00 Makan siang + istirahat di pasir putih { day: 1, startTime: "17:30", endTime: "19:00", activity: "Bersih-bersih + makan malam" },
14:0015:30 Snorkeling Tanjung Putus + sesi foto underwater { day: 1, startTime: "19:00", endTime: "21:00", activity: "Alun-alun Karimun + jajan kuliner" },
15:3016:30 Sailing kembali ke dermaga { day: 1, startTime: "21:00", activity: "Istirahat" },
16:3017:00 Bersih-bersih + bubar grup`; { 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 const ITIN_CITYTRIP: SeedItineraryItem[] = [
06:3007:00 Meeting di dive shop Tulamben + welcome coffee { day: 1, startTime: "08:00", endTime: "08:30", activity: "Meeting di Stasiun Tugu Yogyakarta" },
07:0008:00 Briefing dive plan + cek sertifikasi + fitting gear { day: 1, startTime: "08:30", endTime: "10:00", activity: "Sarapan Gudeg Yu Djum + briefing rute" },
08:0009:00 Surface interval + pengecekan tank/regulator { day: 1, startTime: "10:00", endTime: "12:00", activity: "Kotagede — kerajinan perak + Masjid Mataram" },
09:0010:30 Dive #1 USS Liberty Wreck (528m, ~50 menit bottom time) { day: 1, startTime: "12:00", endTime: "13:30", activity: "Makan siang Sate Klathak Pak Pong" },
10:3012:00 Surface interval + brunch + log dive { day: 1, startTime: "13:30", endTime: "16:00", activity: "Tamansari — pemandian Sultan + sumur Gumuling" },
12:0013:30 Dive #2 Coral Garden / Drop Off (~50 menit) { day: 1, startTime: "16:00", endTime: "17:30", activity: "Coffee break di kedai lokal Prawirotaman" },
13:3015:00 Debrief + makan siang { day: 1, startTime: "17:30", endTime: "19:30", activity: "Sunset di Bukit Bintang (Jl. Imogiri)" },
15:0017:00 Acara bebas (rest atau eksplor desa Tulamben) { day: 1, startTime: "19:30", endTime: "21:30", activity: "Angkringan Lik Man — kopi joss + nasi kucing" },
17:0019:00 Sunset di pantai Tulamben + dinner { day: 1, startTime: "21:30", activity: "Drop ke penginapan masing-masing" },
19:00 Istirahat di homestay (mandiri) { 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 const ITIN_CULINARY: SeedItineraryItem[] = [
06:0006:30 Bangun + kopi { day: 1, startTime: "09:00", endTime: "09:30", activity: "Meeting di Stasiun Bandung pintu utara + briefing rute" },
06:3007:30 Briefing dive #3 (early morning visibility tinggi) { day: 1, startTime: "09:30", endTime: "10:15", activity: "Stop 1: Surabi Enhaii (sarapan tradisional)" },
07:3009:00 Dive #3 Liberty Wreck pagi { day: 1, startTime: "10:15", endTime: "11:00", activity: "Stop 2: Lotek Kalipah Apo" },
09:0010:30 Surface interval + sarapan + log { day: 1, startTime: "11:00", endTime: "11:45", activity: "Stop 3: Mie Kocok Mang Dadeng (Kebon Jukut)" },
10:3012:00 Dive #4 (opsional, fun dive shallow reef) { day: 1, startTime: "11:45", endTime: "12:30", activity: "Stop 4: Bakso Akung (cabang Burangrang)" },
12:0013:00 Bersih gear + debrief akhir { day: 1, startTime: "12:30", endTime: "13:30", activity: "Istirahat + jalan santai di Cihampelas" },
13:0014:00 Makan siang penutupan { day: 1, startTime: "13:30", endTime: "14:15", activity: "Stop 5: Batagor Kingsley" },
14:00 Bubar grup`; { 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 const ITIN_CONCERT: SeedItineraryItem[] = [
07:0007:30 Meeting di Pelabuhan Kartini Jepara { day: 1, startTime: "17:00", endTime: "17:30", activity: "Meeting di Plaza GBK, depan loket Cat 1" },
07:3013:00 Penyeberangan kapal feri Jepara Karimun Jawa { day: 1, startTime: "17:30", endTime: "18:30", activity: "Foto bareng pre-show + obrolan singkat" },
13:0014:00 Tiba di Pelabuhan Karimun + transfer homestay { day: 1, startTime: "18:30", endTime: "19:00", activity: "Masuk venue bareng (kategori tetap masing-masing)" },
14:0015:00 Check-in homestay + ISHOMA { day: 1, startTime: "19:00", endTime: "22:30", activity: "Konser Coldplay — Music of the Spheres" },
15:0017:30 Sunset di Bukit Love + foto-foto { day: 1, startTime: "22:30", endTime: "23:00", activity: "Berkumpul lagi di luar gerbang utama GBK" },
17:3019:00 Bersih-bersih + makan malam { day: 1, startTime: "23:00", activity: "After-party dinner di Senayan (resto TBA via grup WA)" },
19:0021:00 Alun-alun Karimun + jajan kuliner ];
21:00 Istirahat
Hari 2 Sabtu const ITIN_WORKSHOP: SeedItineraryItem[] = [
06:3007:30 Sarapan + briefing hopping { day: 1, startTime: "04:00", endTime: "04:30", activity: "Meeting di Alun-alun Pangalengan" },
07:3009:30 Boat ke Pulau Menjangan Kecil snorkeling spot terumbu { day: 1, startTime: "04:30", endTime: "05:30", activity: "Briefing teknis + setup peralatan" },
09:3011:30 Pulau Menjangan Besar interaksi hiu (penangkaran) { day: 1, startTime: "05:30", endTime: "07:30", activity: "Sunrise shoot di Perkebunan Teh Malabar" },
11:3013:00 Makan siang BBQ ikan di Pulau Cemara Besar { day: 1, startTime: "07:30", endTime: "09:00", activity: "Sarapan + sesi review foto bareng mentor" },
13:0015:00 Pulau Cemara Kecil + foto pasir putih { day: 1, startTime: "09:00", endTime: "11:00", activity: "Materi indoor: long exposure & filter ND" },
15:0017:00 Pulau Cilik sunset + snorkel terakhir { day: 1, startTime: "11:00", endTime: "13:00", activity: "ISHOMA + transfer ke Situ Cileunca" },
17:0018:30 Kembali ke homestay + bersih-bersih { day: 1, startTime: "13:00", endTime: "16:00", activity: "Workshop on-field di Situ Cileunca (panorama, refleksi)" },
18:3020:00 Makan malam seafood { day: 1, startTime: "16:00", endTime: "18:00", activity: "Golden hour shoot di Bukit Nini" },
20:00 Acara bebas { 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 const ITIN_RETREAT: SeedItineraryItem[] = [
06:0007:00 Sunrise di Tanjung Gelam { day: 1, startTime: "14:00", endTime: "15:00", activity: "Check-in Villa Sawah Ubud + welcome drink (jamu)" },
07:0009:00 Sarapan + pack-up { day: 1, startTime: "15:00", endTime: "16:00", activity: "Tour fasilitas + pembagian welcome kit" },
09:0010:00 Belanja oleh-oleh di pelabuhan { day: 1, startTime: "16:00", endTime: "17:30", activity: "Yin Yoga pembuka — release perjalanan" },
10:0016:00 Penyeberangan kapal feri Karimun Jepara { day: 1, startTime: "17:30", endTime: "18:30", activity: "Journaling: niat & ekspektasi retreat" },
16:0017:00 Tiba di Pelabuhan Kartini, bubar grup`; { 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" },
const ITIN_CITYTRIP = `Hari 1 — Sabtu { day: 1, startTime: "21:00", activity: "Lights-out" },
08:0008:30 Meeting di Stasiun Tugu Yogyakarta { day: 2, startTime: "06:00", endTime: "07:30", activity: "Hatha Yoga matahari terbit" },
08:3010:00 Sarapan Gudeg Yu Djum + briefing rute { day: 2, startTime: "07:30", endTime: "09:00", activity: "Sarapan vegan + tea ceremony" },
10:0012:00 Kotagede kerajinan perak + Masjid Mataram { day: 2, startTime: "09:00", endTime: "10:30", activity: "Meditasi guided: body scan" },
12:0013:30 Makan siang Sate Klathak Pak Pong { day: 2, startTime: "10:30", endTime: "12:00", activity: "Pranayama (latihan napas)" },
13:3016:00 Tamansari pemandian Sultan + sumur Gumuling { day: 2, startTime: "12:00", endTime: "13:30", activity: "Lunch + acara bebas (sawah walk)" },
16:0017:30 Coffee break di kedai lokal Prawirotaman { day: 2, startTime: "13:30", endTime: "15:00", activity: "Sound healing dengan singing bowl" },
17:3019:30 Sunset di Bukit Bintang (Jl. Imogiri) { day: 2, startTime: "15:00", endTime: "16:30", activity: "Workshop: mindful eating + jamu making" },
19:3021:30 Angkringan Lik Man kopi joss + nasi kucing { day: 2, startTime: "16:30", endTime: "18:00", activity: "Yin Yoga sore + savasana panjang" },
21:30 Drop ke penginapan masing-masing { day: 2, startTime: "18:00", endTime: "19:30", activity: "Dinner vegan" },
{ day: 2, startTime: "19:30", endTime: "21:00", activity: "Sharing circle + meditasi malam" },
Hari 2 Minggu { day: 2, startTime: "21:00", activity: "Lights-out" },
06:0007:00 Pickup dari penginapan { day: 3, startTime: "06:00", endTime: "07:30", activity: "Vinyasa Flow penutupan" },
07:0009:30 Perjalanan ke Kalibiru, Kulon Progo { day: 3, startTime: "07:30", endTime: "09:00", activity: "Sarapan + closing journaling" },
09:3011:30 Kalibiru spot foto rumah pohon di tebing { day: 3, startTime: "09:00", endTime: "10:30", activity: "Closing circle + tukar pesan" },
11:3013:00 Makan siang pecel di warung lokal { day: 3, startTime: "10:30", endTime: "11:30", activity: "Check-out + pelukan perpisahan" },
13:0015:00 Pinus Pengger instalasi seni alam { day: 3, startTime: "11:30", endTime: "14:00", activity: "Acara bebas (rekomendasi spa/cafe sekitar)" },
15:0016:30 Heha Sky View (opsional, cek cuaca) { day: 3, startTime: "14:00", activity: "Trip resmi ditutup" },
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`;
// ============================================================================ // ============================================================================
// 1. Cleanup — urutan FK aware (Payment → Booking → Review → Image → // 1. Cleanup — urutan FK aware (Payment → Booking → Review → Image →
@@ -260,10 +255,15 @@ Hari 3 — Minggu
// ============================================================================ // ============================================================================
async function cleanup() { 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.payment.deleteMany();
await prisma.booking.deleteMany(); await prisma.booking.deleteMany();
await prisma.tripReview.deleteMany(); await prisma.tripReview.deleteMany();
await prisma.tripImage.deleteMany(); await prisma.tripImage.deleteMany();
await prisma.tripItineraryItem.deleteMany();
await prisma.tripParticipant.deleteMany(); await prisma.tripParticipant.deleteMany();
await prisma.trip.deleteMany(); await prisma.trip.deleteMany();
await prisma.organizerVerification.deleteMany(); await prisma.organizerVerification.deleteMany();
@@ -556,7 +556,7 @@ interface SeedTrip {
title: string; title: string;
description: string; description: string;
meetingPoint?: string; meetingPoint?: string;
itinerary?: string; itineraryItems?: SeedItineraryItem[];
whatsIncluded?: string; whatsIncluded?: string;
whatsExcluded?: string; whatsExcluded?: string;
destination: string; destination: string;
@@ -580,7 +580,7 @@ const SEED_TRIPS: SeedTrip[] = [
title: "Open Trip Papandayan — Maret 2026", title: "Open Trip Papandayan — Maret 2026",
description: `Pendakian santai ke Gunung Papandayan, batch Maret. Cocok untuk pemula.`, description: `Pendakian santai ke Gunung Papandayan, batch Maret. Cocok untuk pemula.`,
meetingPoint: "Alun-alun Garut, 05:00 WIB", meetingPoint: "Alun-alun Garut, 05:00 WIB",
itinerary: ITIN_PAPANDAYAN, itineraryItems: ITIN_PAPANDAYAN,
destination: "Gunung Papandayan", destination: "Gunung Papandayan",
location: "Garut, Jawa Barat", location: "Garut, Jawa Barat",
date: utc(2026, 2, 14, 22, 0), date: utc(2026, 2, 14, 22, 0),
@@ -601,7 +601,7 @@ const SEED_TRIPS: SeedTrip[] = [
title: "Snorkeling Pulau Pahawang — Februari 2026", title: "Snorkeling Pulau Pahawang — Februari 2026",
description: `Trip snorkeling batch Februari. Cuaca bersahabat, visibility 10m+.`, description: `Trip snorkeling batch Februari. Cuaca bersahabat, visibility 10m+.`,
meetingPoint: "Dermaga Ketapang, Lampung Selatan, 07:00 WIB", meetingPoint: "Dermaga Ketapang, Lampung Selatan, 07:00 WIB",
itinerary: ITIN_PAHAWANG, itineraryItems: ITIN_PAHAWANG,
destination: "Pulau Pahawang Kecil", destination: "Pulau Pahawang Kecil",
location: "Lampung Selatan, Lampung", location: "Lampung Selatan, Lampung",
date: utc(2026, 1, 21, 0, 0), date: utc(2026, 1, 21, 0, 0),
@@ -621,7 +621,7 @@ const SEED_TRIPS: SeedTrip[] = [
title: "Kulineran Street Food Bandung — April 2026", title: "Kulineran Street Food Bandung — April 2026",
description: `Food tour batch April. 8 spot legend dilibas dalam satu hari.`, description: `Food tour batch April. 8 spot legend dilibas dalam satu hari.`,
meetingPoint: "Stasiun Bandung pintu utara, 09:00 WIB", meetingPoint: "Stasiun Bandung pintu utara, 09:00 WIB",
itinerary: ITIN_CULINARY, itineraryItems: ITIN_CULINARY,
destination: "Street Food Tour Bandung", destination: "Street Food Tour Bandung",
location: "Bandung, Jawa Barat", location: "Bandung, Jawa Barat",
date: utc(2026, 3, 12, 0, 0), date: utc(2026, 3, 12, 0, 0),
@@ -662,7 +662,7 @@ const SEED_TRIPS: SeedTrip[] = [
Bawa: Sleeping bag, jaket, headlamp, air 2L`, Bawa: Sleeping bag, jaket, headlamp, air 2L`,
meetingPoint: "Alun-alun Garut, Sabtu 05:00 WIB — detail grup WA.", meetingPoint: "Alun-alun Garut, Sabtu 05:00 WIB — detail grup WA.",
itinerary: ITIN_PAPANDAYAN, itineraryItems: ITIN_PAPANDAYAN,
whatsIncluded: `• Transport PP Garutbasecamp whatsIncluded: `• Transport PP Garutbasecamp
Guide lokal Guide lokal
Tenda tim Tenda tim
@@ -693,7 +693,7 @@ const SEED_TRIPS: SeedTrip[] = [
📍 Meeting Point: Stasiun Kuningan, 04:00 WIB 📍 Meeting Point: Stasiun Kuningan, 04:00 WIB
Level: Menengah perlu stamina baik`, Level: Menengah perlu stamina baik`,
meetingPoint: "Stasiun Kuningan, Sabtu 04:00 WIB", meetingPoint: "Stasiun Kuningan, Sabtu 04:00 WIB",
itinerary: ITIN_CIREMAI, itineraryItems: ITIN_CIREMAI,
destination: "Gunung Ciremai", destination: "Gunung Ciremai",
location: "Kuningan, Jawa Barat", location: "Kuningan, Jawa Barat",
date: utc(2026, 5, 23, 4, 0), date: utc(2026, 5, 23, 4, 0),
@@ -718,7 +718,7 @@ const SEED_TRIPS: SeedTrip[] = [
🎒 Fasilitas: Tenda dome, sleeping bag, BBQ, api unggun 🎒 Fasilitas: Tenda dome, sleeping bag, BBQ, api unggun
🔥 Bonus: Live music akustik malam hari`, 🔥 Bonus: Live music akustik malam hari`,
meetingPoint: "Pertigaan Pasar Lembang, Sabtu 13:00 WIB", meetingPoint: "Pertigaan Pasar Lembang, Sabtu 13:00 WIB",
itinerary: ITIN_CAMPING, itineraryItems: ITIN_CAMPING,
whatsIncluded: `• Tenda + sleeping bag + matras whatsIncluded: `• Tenda + sleeping bag + matras
Logistik camp Logistik camp
Makan malam BBQ + sarapan Makan malam BBQ + sarapan
@@ -748,7 +748,7 @@ const SEED_TRIPS: SeedTrip[] = [
🤿 Pemula friendly guide profesional 🤿 Pemula friendly guide profesional
📷 Underwater photo session included`, 📷 Underwater photo session included`,
meetingPoint: "Dermaga Ketapang, Lampung Selatan, 07:00 WIB", meetingPoint: "Dermaga Ketapang, Lampung Selatan, 07:00 WIB",
itinerary: ITIN_PAHAWANG, itineraryItems: ITIN_PAHAWANG,
whatsIncluded: `• Boat PP whatsIncluded: `• Boat PP
Alat snorkel (masker, fin, life vest) Alat snorkel (masker, fin, life vest)
Guide & pemandu underwater Guide & pemandu underwater
@@ -777,7 +777,7 @@ const SEED_TRIPS: SeedTrip[] = [
Sertifikasi minimal: Open Water (PADI/SSI)`, Sertifikasi minimal: Open Water (PADI/SSI)`,
meetingPoint: "Dive shop Tulamben, 06:30 WITA", meetingPoint: "Dive shop Tulamben, 06:30 WITA",
itinerary: ITIN_DIVING, itineraryItems: ITIN_DIVING,
whatsIncluded: `• 2x dive guided whatsIncluded: `• 2x dive guided
Full gear rental Full gear rental
Tank & weight Tank & weight
@@ -807,7 +807,7 @@ const SEED_TRIPS: SeedTrip[] = [
🏝 Cocok untuk solo traveler & couple`, 🏝 Cocok untuk solo traveler & couple`,
meetingPoint: "Pelabuhan Kartini Jepara, Jumat 07:00 WIB", meetingPoint: "Pelabuhan Kartini Jepara, Jumat 07:00 WIB",
itinerary: ITIN_ISLANDHOP, itineraryItems: ITIN_ISLANDHOP,
whatsIncluded: `• Tiket kapal feri PP JeparaKarimun whatsIncluded: `• Tiket kapal feri PP JeparaKarimun
Homestay 2 malam (twin sharing) Homestay 2 malam (twin sharing)
Boat hopping 2 hari Boat hopping 2 hari
@@ -837,7 +837,7 @@ const SEED_TRIPS: SeedTrip[] = [
🚐 Mobil grup, bukan tour bus`, 🚐 Mobil grup, bukan tour bus`,
meetingPoint: "Stasiun Tugu Yogyakarta, Sabtu 08:00 WIB", meetingPoint: "Stasiun Tugu Yogyakarta, Sabtu 08:00 WIB",
itinerary: ITIN_CITYTRIP, itineraryItems: ITIN_CITYTRIP,
whatsIncluded: `• Transport mobil grup 2 hari whatsIncluded: `• Transport mobil grup 2 hari
Tour leader lokal Tour leader lokal
Makan 3x (kuliner lokal) Makan 3x (kuliner lokal)
@@ -866,7 +866,7 @@ const SEED_TRIPS: SeedTrip[] = [
🍜 Cocok buat foodie & first-timer`, 🍜 Cocok buat foodie & first-timer`,
meetingPoint: "Stasiun Bandung pintu utara, 09:00 WIB", meetingPoint: "Stasiun Bandung pintu utara, 09:00 WIB",
itinerary: ITIN_CULINARY, itineraryItems: ITIN_CULINARY,
whatsIncluded: `• Transport angkot/grup whatsIncluded: `• Transport angkot/grup
Tour leader food explorer Tour leader food explorer
Sample setiap spot (8 tempat)`, Sample setiap spot (8 tempat)`,
@@ -893,7 +893,7 @@ const SEED_TRIPS: SeedTrip[] = [
🎤 Tiket BUKAN termasuk peserta bawa tiket masing-masing 🎤 Tiket BUKAN termasuk peserta bawa tiket masing-masing
🤝 Grup hanya untuk koordinasi meet-up & after-party`, 🤝 Grup hanya untuk koordinasi meet-up & after-party`,
meetingPoint: "Plaza GBK, depan loket Cat 1, 17:00 WIB", meetingPoint: "Plaza GBK, depan loket Cat 1, 17:00 WIB",
itinerary: ITIN_CONCERT, itineraryItems: ITIN_CONCERT,
whatsIncluded: `• Koordinasi grup whatsIncluded: `• Koordinasi grup
Foto bareng pre-show Foto bareng pre-show
After-party dinner di Senayan`, After-party dinner di Senayan`,
@@ -921,7 +921,7 @@ const SEED_TRIPS: SeedTrip[] = [
📷 Bawa kamera DSLR/mirrorless + tripod 📷 Bawa kamera DSLR/mirrorless + tripod
👨🏫 Mentor: fotografer pro (10+ tahun pengalaman)`, 👨🏫 Mentor: fotografer pro (10+ tahun pengalaman)`,
meetingPoint: "Alun-alun Pangalengan, Sabtu 04:00 WIB", meetingPoint: "Alun-alun Pangalengan, Sabtu 04:00 WIB",
itinerary: ITIN_WORKSHOP, itineraryItems: ITIN_WORKSHOP,
whatsIncluded: `• Materi workshop (briefing + on-field) whatsIncluded: `• Materi workshop (briefing + on-field)
Tour leader & mentor Tour leader & mentor
Penginapan villa 1 malam Penginapan villa 1 malam
@@ -951,7 +951,7 @@ const SEED_TRIPS: SeedTrip[] = [
🧘 Untuk yang lagi burnout & butuh reset 🧘 Untuk yang lagi burnout & butuh reset
👥 Grup kecil (max 8) pengalaman akrab`, 👥 Grup kecil (max 8) pengalaman akrab`,
meetingPoint: "Villa Sawah Ubud (alamat dikirim H-3 via WA)", meetingPoint: "Villa Sawah Ubud (alamat dikirim H-3 via WA)",
itinerary: ITIN_RETREAT, itineraryItems: ITIN_RETREAT,
whatsIncluded: `• Penginapan villa 2 malam whatsIncluded: `• Penginapan villa 2 malam
Yoga 4 sesi + meditasi 6 sesi Yoga 4 sesi + meditasi 6 sesi
Sound healing (1 sesi) Sound healing (1 sesi)
@@ -990,7 +990,6 @@ async function seedTrips(users: UserMap): Promise<TripMap> {
title: t.title, title: t.title,
description: t.description, description: t.description,
meetingPoint: t.meetingPoint, meetingPoint: t.meetingPoint,
itinerary: t.itinerary,
whatsIncluded: t.whatsIncluded, whatsIncluded: t.whatsIncluded,
whatsExcluded: t.whatsExcluded, whatsExcluded: t.whatsExcluded,
destination: t.destination, destination: t.destination,
@@ -1011,6 +1010,17 @@ async function seedTrips(users: UserMap): Promise<TripMap> {
})), })),
} }
: undefined, : 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 }; 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( async create(
data: Pick< data: Pick<
Prisma.BookingUncheckedCreateInput, Prisma.BookingUncheckedCreateInput,
+19
View File
@@ -36,6 +36,25 @@ export const organizerRepo = {
}); });
}, },
async countByStatus(status: "PENDING" | "APPROVED" | "REJECTED") {
return prisma.organizerVerification.count({ where: { status } });
},
/** Verifikasi terbaru (default PENDING) untuk preview di dashboard admin. */
async listRecent(status: "PENDING" | "APPROVED" | "REJECTED", limit = 3) {
return prisma.organizerVerification.findMany({
where: { status },
orderBy: { createdAt: "desc" },
take: limit,
select: {
id: true,
fullName: true,
createdAt: true,
user: { select: { id: true, name: true, email: true } },
},
});
},
async updateReview( async updateReview(
id: string, id: string,
data: { data: {
+122
View File
@@ -0,0 +1,122 @@
import { prisma } from "@/lib/prisma";
import { Prisma } from "@/app/generated/prisma/client";
import type { PayoutStatus } from "@/app/generated/prisma/enums";
const payoutListInclude = {
booking: {
select: {
id: true,
amount: true,
status: true,
user: { select: { id: true, name: true, email: true } },
},
},
trip: {
select: { id: true, title: true, date: true, endDate: true, status: true },
},
organizer: { select: { id: true, name: true, email: true } },
processedBy: { select: { id: true, name: true, email: true } },
} satisfies Prisma.PayoutInclude;
export const payoutRepo = {
async findById(id: string) {
return prisma.payout.findUnique({
where: { id },
include: payoutListInclude,
});
},
async findByBookingId(bookingId: string, tx?: Prisma.TransactionClient) {
const client = tx ?? prisma;
return client.payout.findUnique({ where: { bookingId } });
},
async listByStatus(status: PayoutStatus) {
return prisma.payout.findMany({
where: { status },
orderBy: { heldUntil: "asc" },
include: payoutListInclude,
});
},
async listForOrganizer(organizerId: string) {
return prisma.payout.findMany({
where: { organizerId },
orderBy: { createdAt: "desc" },
include: {
trip: {
select: { id: true, title: true, date: true, endDate: true, status: true },
},
booking: {
select: {
id: true,
amount: true,
user: { select: { id: true, name: true } },
},
},
},
});
},
async countByStatus(status: PayoutStatus) {
return prisma.payout.count({ where: { status } });
},
/** Payout terbaru untuk satu status — dipakai dashboard admin. */
async listRecent(status: PayoutStatus, limit = 3) {
return prisma.payout.findMany({
where: { status },
orderBy: status === "HELD" ? { heldUntil: "asc" } : { updatedAt: "desc" },
take: limit,
select: {
id: true,
amount: true,
heldUntil: true,
releasedAt: true,
organizer: { select: { id: true, name: true } },
trip: { select: { id: true, title: true } },
},
});
},
async create(
data: Pick<
Prisma.PayoutUncheckedCreateInput,
| "bookingId"
| "tripId"
| "organizerId"
| "amount"
| "heldUntil"
| "bankName"
| "bankAccountNumber"
| "bankAccountName"
>,
tx?: Prisma.TransactionClient
) {
const client = tx ?? prisma;
return client.payout.create({ data });
},
async update(
id: string,
data: Prisma.PayoutUncheckedUpdateInput,
tx?: Prisma.TransactionClient
) {
const client = tx ?? prisma;
return client.payout.update({ where: { id }, data });
},
/** Cari semua HELD payout yang sudah lewat heldUntil & trip-nya COMPLETED. */
async findEligibleForRelease(now: Date) {
return prisma.payout.findMany({
where: {
status: "HELD",
heldUntil: { lte: now },
trip: { status: "COMPLETED" },
},
select: { id: true },
});
},
};
export type PayoutWithRelations = Awaited<ReturnType<typeof payoutRepo.findById>>;
+30
View File
@@ -49,6 +49,36 @@ export const refundRepo = {
}); });
}, },
async countByStatus(
status: "PENDING" | "APPROVED" | "REJECTED" | "PROCESSING" | "SUCCEEDED" | "FAILED"
) {
return prisma.refund.count({ where: { status } });
},
/** Refund terbaru untuk satu status — dipakai dashboard admin. */
async listRecent(
status: "PENDING" | "APPROVED" | "REJECTED" | "PROCESSING" | "SUCCEEDED" | "FAILED",
limit = 3
) {
return prisma.refund.findMany({
where: { status },
orderBy: { createdAt: "desc" },
take: limit,
select: {
id: true,
amount: true,
reason: true,
createdAt: true,
booking: {
select: {
user: { select: { id: true, name: true } },
trip: { select: { id: true, title: true } },
},
},
},
});
},
async listByBooking(bookingId: string) { async listByBooking(bookingId: string) {
return prisma.refund.findMany({ return prisma.refund.findMany({
where: { bookingId }, where: { bookingId },
+3
View File
@@ -160,6 +160,9 @@ export const tripRepo = {
}, },
}, },
images: { orderBy: { order: "asc" } }, images: { orderBy: { order: "asc" } },
itineraryItems: {
orderBy: [{ day: "asc" }, { order: "asc" }],
},
participants: { participants: {
include: { include: {
user: { user: {
-211
View File
@@ -1,23 +1,4 @@
import { Prisma } from "@/app/generated/prisma/client";
import { prisma } from "@/lib/prisma";
import { bookingRepo } from "@/server/repositories/booking.repo"; import { bookingRepo } from "@/server/repositories/booking.repo";
import { paymentRepo } from "@/server/repositories/payment.repo";
import { isTripDepartureDayPast } from "@/lib/trip-dates";
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 = { export const bookingService = {
async getByParticipantId(participantId: string) { async getByParticipantId(participantId: string) {
@@ -27,196 +8,4 @@ export const bookingService = {
async getByTripAndUser(tripId: string, userId: string) { async getByTripAndUser(tripId: string, userId: string) {
return bookingRepo.findByTripAndUser(tripId, userId); 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 },
});
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);
},
}; };
+229 -133
View File
@@ -3,11 +3,14 @@ import { prisma } from "@/lib/prisma";
import { import {
MIDTRANS, MIDTRANS,
createSnapTransaction, createSnapTransaction,
fetchMidtransTransactionStatus,
mapMidtransStatus, mapMidtransStatus,
verifyMidtransSignature, verifyMidtransSignature,
type MidtransTransactionStatus,
type MidtransWebhookBody, type MidtransWebhookBody,
} from "@/lib/midtrans"; } from "@/lib/midtrans";
import { isTripDepartureDayPast } from "@/lib/trip-dates"; import { isTripDepartureDayPast } from "@/lib/trip-dates";
import { payoutService } from "@/server/services/payout.service";
const SERIAL_TX_ATTEMPTS = 6; const SERIAL_TX_ATTEMPTS = 6;
@@ -30,11 +33,166 @@ export interface StartMidtransResult {
snapJsUrl: string; snapJsUrl: string;
clientKey: string; clientKey: string;
expiresAt: Date; expiresAt: Date;
orderId: string;
} }
export type WebhookOutcome = export type ApplyOutcome =
| { ok: true; status: "updated" | "skipped" | "ignored" | "booking_conflict" } | { 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 = { export const paymentService = {
/** /**
@@ -42,7 +200,11 @@ export const paymentService = {
* MIDTRANS aktif (status PENDING/AWAITING), reuse token yang sudah ada * MIDTRANS aktif (status PENDING/AWAITING), reuse token yang sudah ada
* selama belum expired. * 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; const clientKey = process.env.NEXT_PUBLIC_MIDTRANS_CLIENT_KEY;
if (!clientKey) { if (!clientKey) {
throw new Error("NEXT_PUBLIC_MIDTRANS_CLIENT_KEY belum di-set"); throw new Error("NEXT_PUBLIC_MIDTRANS_CLIENT_KEY belum di-set");
@@ -101,6 +263,7 @@ export const paymentService = {
snapJsUrl, snapJsUrl,
clientKey, clientKey,
expiresAt: reusable.expiresAt ?? new Date(now.getTime() + 24 * 3600 * 1000), expiresAt: reusable.expiresAt ?? new Date(now.getTime() + 24 * 3600 * 1000),
orderId: reusable.externalOrderId,
}; };
} }
@@ -148,6 +311,7 @@ export const paymentService = {
}, },
itemName: booking.trip.title, itemName: booking.trip.title,
expirySeconds, expirySeconds,
finishUrl: options?.finishUrl,
}); });
} catch (err) { } catch (err) {
// Roll back Payment ke FAILED supaya orderId tidak nyangkut PENDING selamanya. // Roll back Payment ke FAILED supaya orderId tidak nyangkut PENDING selamanya.
@@ -176,160 +340,92 @@ export const paymentService = {
snapJsUrl, snapJsUrl,
clientKey, clientKey,
expiresAt, expiresAt,
orderId,
}; };
}, },
/** /**
* Handle webhook callback dari Midtrans. Idempotent boleh dipanggil berulang. * 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( async handleMidtransWebhook(
payload: MidtransWebhookBody payload: MidtransWebhookBody
): Promise<WebhookOutcome> { ): Promise<WebhookOutcome> {
const { order_id: orderId, status_code: statusCode, gross_amount: grossAmount, signature_key: signatureKey } = payload;
const signatureValid = verifyMidtransSignature( const signatureValid = verifyMidtransSignature(
orderId, payload.order_id,
statusCode, payload.status_code,
grossAmount, payload.gross_amount,
signatureKey payload.signature_key
); );
if (!signatureValid) { if (!signatureValid) {
return { ok: false, reason: "signature_mismatch" }; 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({ const payment = await prisma.payment.findUnique({
where: { externalOrderId: orderId }, where: { externalOrderId: orderId },
include: { booking: true }, include: { booking: { select: { userId: true } } },
}); });
if (!payment) { if (!payment) {
// Order tidak dikenal — return ok supaya Midtrans tidak retry forever. return { ok: false, reason: "not_found" };
return { ok: true, status: "ignored" }; }
if (payment.booking.userId !== userId) {
return { ok: false, reason: "forbidden" };
} }
// Cek amount cocok. gross_amount dari Midtrans format "100000.00". const status = await fetchMidtransTransactionStatus(orderId);
const amountFromGateway = Math.round(Number(grossAmount)); if (!status) {
if ( return { ok: true, status: "not_found" };
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([ const result = await applyGatewayStatus({
"PAID", order_id: status.order_id,
"FAILED", gross_amount: status.gross_amount,
"EXPIRED", transaction_status: status.transaction_status,
"CANCELLED", fraud_status: status.fraud_status ?? null,
"REFUNDED", transaction_id: status.transaction_id,
]); payment_type: status.payment_type,
if (finalStatuses.has(payment.status)) { rawSource: status as unknown as Prisma.InputJsonValue,
// 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 = return result;
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 },
});
}
},
{
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");
}, },
}; };
// Re-export untuk testing langsung kalau perlu (tetap private dari modul lain).
export const _internal = { applyGatewayStatus };
export type { MidtransTransactionStatus };
+248
View File
@@ -0,0 +1,248 @@
import { Prisma } from "@/app/generated/prisma/client";
import { prisma } from "@/lib/prisma";
import { payoutRepo } from "@/server/repositories/payout.repo";
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"
);
}
async function runSerializable<T>(
fn: (tx: Prisma.TransactionClient) => Promise<T>
): Promise<T> {
let lastErr: unknown;
for (let attempt = 0; attempt < SERIAL_TX_ATTEMPTS; attempt++) {
try {
return await prisma.$transaction(fn, {
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 memproses payout. Coba lagi sebentar.");
}
/** Buffer hari setelah trip selesai sebelum payout boleh ditransfer. */
export const PAYOUT_HOLD_BUFFER_DAYS = 3;
/** Hitung heldUntil dari trip date. Pakai endDate kalau ada, kalau tidak pakai date. */
function computeHeldUntil(tripDate: Date, tripEndDate: Date | null): Date {
const baseDate = tripEndDate ?? tripDate;
const result = new Date(baseDate);
result.setUTCDate(result.getUTCDate() + PAYOUT_HOLD_BUFFER_DAYS);
return result;
}
export const payoutService = {
/**
* Dipanggil saat Booking PAID (webhook Midtrans atau organizer confirm manual).
* Idempotent: kalau Payout untuk booking ini sudah ada, no-op (return existing).
*
* Snapshot bank info dari OrganizerVerification (kalau ada) supaya audit-friendly
* walau organizer ganti bank nanti.
*/
async createForPaidBooking(
tx: Prisma.TransactionClient,
input: { bookingId: string }
) {
const booking = await tx.booking.findUnique({
where: { id: input.bookingId },
select: {
id: true,
amount: true,
status: true,
userId: true,
trip: {
select: {
id: true,
organizerId: true,
date: true,
endDate: true,
},
},
},
});
if (!booking) {
throw new Error("Booking tidak ditemukan saat membuat payout");
}
if (booking.amount <= 0) {
// Trip gratis — tidak ada uang yang perlu di-payout.
return null;
}
const existing = await payoutRepo.findByBookingId(booking.id, tx);
if (existing) {
return existing;
}
const bankInfo = await tx.organizerVerification.findUnique({
where: { userId: booking.trip.organizerId },
select: {
status: true,
bankName: true,
bankAccountNumber: true,
bankAccountName: true,
},
});
const heldUntil = computeHeldUntil(booking.trip.date, booking.trip.endDate);
return payoutRepo.create(
{
bookingId: booking.id,
tripId: booking.trip.id,
organizerId: booking.trip.organizerId,
amount: booking.amount,
heldUntil,
bankName:
bankInfo?.status === "APPROVED" ? bankInfo.bankName : null,
bankAccountNumber:
bankInfo?.status === "APPROVED" ? bankInfo.bankAccountNumber : null,
bankAccountName:
bankInfo?.status === "APPROVED" ? bankInfo.bankAccountName : null,
},
tx
);
},
/**
* Cron-callable: cari semua HELD payout yang sudah lewat heldUntil & trip-nya
* COMPLETED, lalu flip ke RELEASED. Idempotent.
*/
async releaseEligible() {
const now = new Date();
const eligible = await payoutRepo.findEligibleForRelease(now);
if (eligible.length === 0) {
return { releasedIds: [] as string[] };
}
const ids = eligible.map((p) => p.id);
await prisma.payout.updateMany({
where: { id: { in: ids }, status: "HELD" },
data: { status: "RELEASED", releasedAt: now },
});
return { releasedIds: ids };
},
/** RELEASED → PAID. Catatan/referensi transfer wajib (audit trail). */
async markPaid(input: { payoutId: string; adminId: string; adminNote: string }) {
if (!input.adminNote.trim()) {
throw new Error("Catatan/referensi transfer wajib diisi");
}
return runSerializable(async (tx) => {
const payout = await tx.payout.findUnique({
where: { id: input.payoutId },
});
if (!payout) {
throw new Error("Payout tidak ditemukan");
}
if (payout.status !== "RELEASED") {
throw new Error(
"Hanya payout RELEASED yang bisa ditandai PAID. Tunggu trip selesai + buffer."
);
}
return payoutRepo.update(
input.payoutId,
{
status: "PAID",
paidAt: new Date(),
processedById: input.adminId,
adminNote: input.adminNote.trim(),
},
tx
);
});
},
/**
* Cancel payout biasanya dipanggil internal saat refund SUCCEEDED penuh
* atau trip dibatalkan. Tidak boleh untuk payout yang sudah PAID (uang sudah
* keluar ke organizer; admin perlu clawback manual).
*/
async cancel(
tx: Prisma.TransactionClient,
input: { payoutId: string; reason: string; adminId?: string | null }
) {
const payout = await tx.payout.findUnique({
where: { id: input.payoutId },
});
if (!payout) return null;
if (payout.status === "CANCELLED") return payout;
if (payout.status === "PAID") {
// Uang sudah ditransfer — tidak bisa undo otomatis. Catat note saja.
return payoutRepo.update(
input.payoutId,
{
adminNote:
(payout.adminNote ? `${payout.adminNote}\n---\n` : "") +
`[!] ${input.reason} setelah PAID — perlu clawback manual.`,
},
tx
);
}
return payoutRepo.update(
input.payoutId,
{
status: "CANCELLED",
cancelledAt: new Date(),
processedById: input.adminId ?? null,
adminNote: input.reason,
},
tx
);
},
/**
* Refund SUCCEEDED kurangi nominal payout sesuai nominal refund. Kalau
* jatuh ke 0 atau lebih, cancel payout. Dipanggil dari refund.service.
*/
async applyRefundDelta(
tx: Prisma.TransactionClient,
input: { bookingId: string; refundAmount: number }
) {
const payout = await payoutRepo.findByBookingId(input.bookingId, tx);
if (!payout) return null;
if (payout.status === "CANCELLED") return payout;
if (payout.status === "PAID") {
// Uang sudah ditransfer ke organizer — flag untuk clawback manual.
return payoutRepo.update(
payout.id,
{
adminNote:
(payout.adminNote ? `${payout.adminNote}\n---\n` : "") +
`[!] Refund Rp${input.refundAmount.toLocaleString("id-ID")} terjadi setelah payout PAID. Perlu clawback manual dari organizer.`,
},
tx
);
}
const nextAmount = payout.amount - input.refundAmount;
if (nextAmount <= 0) {
return payoutRepo.update(
payout.id,
{
status: "CANCELLED",
cancelledAt: new Date(),
adminNote:
(payout.adminNote ? `${payout.adminNote}\n---\n` : "") +
"Dibatalkan otomatis karena refund penuh.",
},
tx
);
}
return payoutRepo.update(payout.id, { amount: nextAmount }, tx);
},
};
+7
View File
@@ -4,6 +4,7 @@ import { prisma } from "@/lib/prisma";
import { refundRepo } from "@/server/repositories/refund.repo"; import { refundRepo } from "@/server/repositories/refund.repo";
import { calculateRefundAmount, daysUntilDeparture } from "@/lib/refund-policy"; import { calculateRefundAmount, daysUntilDeparture } from "@/lib/refund-policy";
import { isTripDepartureDayPast } from "@/lib/trip-dates"; import { isTripDepartureDayPast } from "@/lib/trip-dates";
import { payoutService } from "@/server/services/payout.service";
const SERIAL_TX_ATTEMPTS = 6; const SERIAL_TX_ATTEMPTS = 6;
@@ -236,6 +237,12 @@ export const refundService = {
tx tx
); );
// Escrow: kurangi (atau cancel) payout organizer sesuai nominal refund.
await payoutService.applyRefundDelta(tx, {
bookingId: refund.bookingId,
refundAmount: refund.amount,
});
const totalRefunded = await refundRepo.sumSucceededAmount( const totalRefunded = await refundRepo.sumSucceededAmount(
refund.bookingId, refund.bookingId,
tx tx
+27 -51
View File
@@ -4,11 +4,13 @@ import { prisma } from "@/lib/prisma";
import { tripRepo, type TripFilters } from "@/server/repositories/trip.repo"; import { tripRepo, type TripFilters } from "@/server/repositories/trip.repo";
import { participantRepo } from "@/server/repositories/participant.repo"; import { participantRepo } from "@/server/repositories/participant.repo";
import { bookingRepo } from "@/server/repositories/booking.repo"; import { bookingRepo } from "@/server/repositories/booking.repo";
import { bookingService } from "@/server/services/booking.service";
import { refundService } from "@/server/services/refund.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 { LIMITS } from "@/lib/limits";
import { utcStartOfDay, isTripDepartureDayPast } from "@/lib/trip-dates"; import { utcStartOfDay, isTripDepartureDayPast } from "@/lib/trip-dates";
import { isFreeTrip } from "@/lib/trip-pricing"; import { isFreeTrip } from "@/lib/trip-pricing";
import type { ItineraryItemInput } from "@/lib/itinerary";
const SERIAL_TX_ATTEMPTS = 6; const SERIAL_TX_ATTEMPTS = 6;
@@ -28,7 +30,6 @@ interface CreateTripInput {
destination: string; destination: string;
location: string; location: string;
meetingPoint?: string; meetingPoint?: string;
itinerary?: string;
whatsIncluded?: string; whatsIncluded?: string;
whatsExcluded?: string; whatsExcluded?: string;
date: Date; date: Date;
@@ -38,6 +39,7 @@ interface CreateTripInput {
vibe?: Vibe; vibe?: Vibe;
organizerId: string; organizerId: string;
imageUrls?: string[]; imageUrls?: string[];
itineraryItems?: ItineraryItemInput[];
} }
export const tripService = { export const tripService = {
@@ -73,6 +75,18 @@ export const tripService = {
} }
: undefined; : 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 = { const tripData = {
category: input.category, category: input.category,
title: input.title, title: input.title,
@@ -80,7 +94,6 @@ export const tripService = {
destination: input.destination, destination: input.destination,
location: input.location, location: input.location,
meetingPoint: input.meetingPoint, meetingPoint: input.meetingPoint,
itinerary: input.itinerary,
whatsIncluded: input.whatsIncluded, whatsIncluded: input.whatsIncluded,
whatsExcluded: input.whatsExcluded, whatsExcluded: input.whatsExcluded,
date: input.date, date: input.date,
@@ -90,6 +103,7 @@ export const tripService = {
vibe: input.vibe, vibe: input.vibe,
organizer: { connect: { id: input.organizerId } }, organizer: { connect: { id: input.organizerId } },
images, images,
itineraryItems,
} satisfies Prisma.TripCreateInput; } satisfies Prisma.TripCreateInput;
let lastErr: unknown; let lastErr: unknown;
@@ -375,30 +389,6 @@ export const tripService = {
return { ok: true as const }; 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. * Auto-complete trip yang sudah lewat. Dipakai cron harian.
* *
@@ -413,30 +403,6 @@ export const tripService = {
return tripRepo.bulkCompletePastTrips(cutoff); 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 * Organizer batalkan trip (Trip.status = CLOSED). Atomic dalam satu
* serializable transaction: * serializable transaction:
@@ -546,6 +512,16 @@ export const tripService = {
} }
); );
refundsCreated.push(refund.id); refundsCreated.push(refund.id);
// Trip dibatalkan → cancel payout HELD/RELEASED organizer untuk
// booking ini. Payout PAID di-flag clawback otomatis.
const payout = await payoutRepo.findByBookingId(b.id, tx);
if (payout) {
await payoutService.cancel(tx, {
payoutId: payout.id,
reason: "Trip dibatalkan organizer.",
});
}
} else { } else {
// PENDING / AWAITING_PAY → uang belum masuk → langsung CANCELLED. // PENDING / AWAITING_PAY → uang belum masuk → langsung CANCELLED.
await tx.booking.update({ await tx.booking.update({
+1
View File
@@ -8,6 +8,7 @@ declare module "next-auth" {
email: string; email: string;
image?: string | null; image?: string | null;
acceptedTermsAndPrivacy: boolean; acceptedTermsAndPrivacy: boolean;
isAdmin: boolean;
}; };
} }
} }