Compare commits

..

8 Commits

Author SHA1 Message Date
arifal 427bfc0447 0.10.3 2026-05-11 13:04:32 +07:00
arifal 54f4569107 refund roadmap pr-1 and pr-2 2026-05-11 13:04:20 +07:00
arifal d2b0a780d5 0.10.2 2026-05-11 08:15:54 +07:00
arifal 22e1e8fbea update lib high vurnalibity 2026-05-11 08:15:48 +07:00
arifal 11b2d45d20 partial update itinerary 2026-05-10 22:48:12 +07:00
arifal 744ee3446b add cron and partial update refund schema 2026-05-10 22:27:21 +07:00
arifal 9a163c4f13 0.10.1 2026-05-09 00:55:57 +07:00
arifal 54cd984a7e trust roadmap 2026-05-09 00:55:40 +07:00
56 changed files with 8130 additions and 781 deletions
+9 -1
View File
@@ -2,7 +2,15 @@
"permissions": { "permissions": {
"allow": [ "allow": [
"WebFetch(domain:unsplash.com)", "WebFetch(domain:unsplash.com)",
"Bash(npx prisma *)" "Bash(npx prisma *)",
"Bash(Get-ChildItem -Path \"c:\\\\development\\\\DIOS\\\\weekly-project\\\\setrip\" -Force)",
"Bash(Select-Object Name, PSIsContainer)",
"Bash(npx tsc *)",
"Bash(echo \"exitcode=$?\")",
"PowerShell(npx prisma generate 2>&1)",
"PowerShell(npx tsc --noEmit 2>&1)",
"PowerShell(npx eslint server/services/refund.service.ts server/repositories/refund.repo.ts features/refund app/admin/refunds 2>&1)",
"PowerShell(npx eslint server lib features app 2>&1)"
] ]
} }
} }
+2 -2
View File
@@ -16,8 +16,8 @@
## Forbidden ## Forbidden
- Jangan query database langsung di component - Jangan query database langsung di component
- Jangan buat arsitektur over-engineered - Jangan buat arsitektur over-engineered, tidak apa apa jika lebih baik untuk performance dan struktur yang baik
- Jangan menambahkan dependency tanpa kebutuhan jelas - Jangan menambahkan dependency tanpa kebutuhan jelas, tambahkan jika memang dibutuhkan dan gunakan dependency yang aman
## Output Style ## Output Style
-265
View File
@@ -1,265 +0,0 @@
# Setrip — Payment Roadmap
Rencana implementasi flow pembayaran: handle trip gratis, halaman detail payment, dan integrasi Midtrans.
> **Prinsip:** Sumber kebenaran pembayaran = record di tabel khusus, bukan timestamp di `TripParticipant`. Manual transfer & Midtrans = dua provider dari pipeline yang sama. Trip gratis = first-class case (bukan paid trip dengan amount 0).
---
## Audit state sekarang (baseline)
- Field `TripParticipant.markedPaidAt` + `paymentConfirmedAt` — dual-timestamp manual.
- Flow: user klik "Saya sudah bayar" → organizer klik "Konfirmasi pembayaran". Tidak ada record transaksi terpisah, tidak ada bukti transfer, tidak ada amount snapshot.
- Tidak ada halaman `/trips/[id]/payment`. Tombol bayar inline di trip detail.
- **Bug**: trip gratis (`price === 0`) tetap melewati flow yang sama — peserta tetap harus klik "Saya sudah bayar" dan organizer tetap harus konfirmasi.
File terkait: [server/services/trip.service.ts](server/services/trip.service.ts) (`markParticipantPayment`, `confirmParticipantPayment`), [features/booking/actions.ts](features/booking/actions.ts), [features/booking/components/organizer-payment-queue.tsx](features/booking/components/organizer-payment-queue.tsx), [features/trip/components/join-trip-button.tsx](features/trip/components/join-trip-button.tsx), [server/repositories/participant.repo.ts](server/repositories/participant.repo.ts).
---
## PR A — Free trip handling + halaman detail payment (manual flow) ✅
Selesai. `tsc --noEmit` lulus. Tanpa schema baru, tanpa migration.
**Keputusan akses control:** halaman `/trips/[id]/payment` boleh diakses peserta apapun yang aktif (PENDING/CONFIRMED). Peserta PENDING bisa lihat nominal + rekening untuk persiapan, tapi diberi notice "tunggu approve dulu sebelum transfer". Organizer trip-nya sendiri di-redirect ke trip detail.
| # | Item | Status | File |
|---|---|---|---|
| A1 | Helper `lib/trip-pricing.ts` dengan `isFreeTrip` & `isPaidTrip` | ✅ | [lib/trip-pricing.ts](lib/trip-pricing.ts) |
| A2 | Service guard: `markParticipantPayment` & `confirmParticipantPayment` reject trip gratis | ✅ | [server/services/trip.service.ts](server/services/trip.service.ts) |
| A3 | UI gate di JoinTripButton: hide flow pembayaran kalau gratis | ✅ | [features/trip/components/join-trip-button.tsx](features/trip/components/join-trip-button.tsx) |
| A4 | UI gate di trip detail: skip `OrganizerPaymentQueue` kalau gratis | ✅ | [app/trips/[id]/page.tsx](app/trips/[id]/page.tsx) |
| A5 | Halaman `/trips/[id]/payment` (server component dengan akses kontrol) | ✅ | [app/trips/[id]/payment/page.tsx](app/trips/%5Bid%5D/payment/page.tsx) |
| A6 | Konten halaman trip gratis: banner 🎉 + status keikutsertaan + CTA | ✅ | `FreeTripSection` di [page.tsx](app/trips/%5Bid%5D/payment/page.tsx) |
| A7 | Konten halaman trip berbayar: timeline + rekening organizer + tombol "Saya sudah bayar" + status | ✅ | `PaidTripSection`, `PaymentTimeline`, `BankRow` di [page.tsx](app/trips/%5Bid%5D/payment/page.tsx) |
| A8 | Link dari trip detail → `/trips/[id]/payment` (replace tombol inline) | ✅ | `showPaymentLink` di [join-trip-button.tsx](features/trip/components/join-trip-button.tsx) |
| A9 | Metadata + `robots: noindex` halaman payment | ✅ | [page.tsx](app/trips/%5Bid%5D/payment/page.tsx) — `metadata.robots: { index: false, follow: false }` |
| A+ | `MarkPaidButton` (client component) untuk action "Saya sudah bayar" | ✅ | [features/booking/components/mark-paid-button.tsx](features/booking/components/mark-paid-button.tsx) |
| A+ | `CopyButton` (client component) untuk copy nomor rekening / nominal | ✅ | [features/booking/components/copy-button.tsx](features/booking/components/copy-button.tsx) |
**Tindakan manual:** tidak ada. Tidak ada migration di PR A.
**Catatan edge case yang sudah dihandle:**
- Organizer trip-nya sendiri buka halaman → redirect ke trip detail.
- User belum login → redirect ke login dengan `callbackUrl`.
- User belum join / sudah cancel → tampil notice "kamu belum terdaftar".
- Trip dengan organizer yang belum APPROVED verifikasinya → tampil notice "rekening belum tersedia, hubungi organizer langsung" (tidak crash).
- Peserta PENDING di-warning "tunggu approve dulu sebelum transfer".
- Tombol "Saya sudah bayar" hanya muncul untuk CONFIRMED + belum mark + bank tersedia.
---
## PR B — Refactor schema ke `Booking` + `Payment` (provider MANUAL only) ✅
Selesai. `tsc --noEmit` lulus. Pondasi untuk Midtrans sudah siap.
### Schema (target)
```prisma
model Booking {
id String @id @default(cuid())
tripId String
trip Trip @relation(fields: [tripId], references: [id])
userId String
user User @relation(fields: [userId], references: [id])
participantId String @unique
participant TripParticipant @relation(fields: [participantId], references: [id])
/// Snapshot harga saat booking dibuat — protect dari perubahan trip.price.
amount Int
currency String @default("IDR")
status BookingStatus @default(PENDING)
payments Payment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tripId, status])
@@index([userId])
}
enum BookingStatus {
PENDING // join belum approve atau belum bayar
AWAITING_PAY // approved, tinggal bayar
PAID // lunas (manual confirm atau gateway settlement)
CANCELLED
REFUNDED
EXPIRED
}
model Payment {
id String @id @default(cuid())
bookingId String
booking Booking @relation(fields: [bookingId], references: [id])
provider PaymentProvider
/// order_id eksternal — untuk MANUAL referensi internal, untuk MIDTRANS dikirim ke gateway. Unik per attempt.
externalOrderId String @unique
externalTxId String?
method String? // bca_va, gopay, qris, manual_transfer, dst
amount Int
status PaymentStatus @default(PENDING)
rawCallback Json?
snapToken String?
expiresAt DateTime?
paidAt DateTime?
failedAt DateTime?
rejectionReason String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([bookingId, status])
@@index([provider, status])
}
enum PaymentProvider {
MANUAL
MIDTRANS
}
enum PaymentStatus {
PENDING
AWAITING
PAID
FAILED
EXPIRED
CANCELLED
REFUNDED
}
```
### Tugas
| # | Item | Status | File |
|---|---|---|---|
| B1 | Schema: tambah `Booking`, `Payment`, 3 enum (`BookingStatus`, `PaymentProvider`, `PaymentStatus`) + relasi di `User`, `Trip`, `TripParticipant` | ✅ | [prisma/schema.prisma](prisma/schema.prisma) |
| B2 | Migration `add_booking_payment` (CreateEnum + CreateTable + Index + FK) | ✅ | [prisma/migrations/20260508150000_add_booking_payment/migration.sql](prisma/migrations/20260508150000_add_booking_payment/migration.sql) |
| B3 | Backfill script TS (idempotent, skip baris yang sudah punya Booking) | ✅ | [prisma/backfill-bookings.ts](prisma/backfill-bookings.ts) |
| B4 | `bookingRepo` + `paymentRepo` | ✅ | [server/repositories/booking.repo.ts](server/repositories/booking.repo.ts), [server/repositories/payment.repo.ts](server/repositories/payment.repo.ts) |
| B5 | `bookingService``markPaidManual`, `confirmPaidManual`, `getByTripAndUser`, `getAwaitingManualForTrip` (idempotent, transactional dengan retry serialisasi) | ✅ | [server/services/booking.service.ts](server/services/booking.service.ts) |
| B6 | `tripService.markParticipantPayment` → delegate ke `bookingService.markPaidManual`. Tetap update `TripParticipant.markedPaidAt` untuk backcompat. | ✅ | [server/services/trip.service.ts](server/services/trip.service.ts) |
| B7 | `tripService.confirmParticipantPayment` → delegate ke `bookingService.confirmPaidManual`. Tetap update `paymentConfirmedAt`. | ✅ | [server/services/trip.service.ts](server/services/trip.service.ts) |
| B+ | `tripService.joinTrip` → upsert Booking PENDING (handle re-join dari CANCELLED) | ✅ | [server/services/trip.service.ts](server/services/trip.service.ts) |
| B+ | `tripService.confirmParticipant` → transition Booking PENDING → AWAITING_PAY (paid) atau PAID (free) | ✅ | [server/services/trip.service.ts](server/services/trip.service.ts) |
| B+ | `tripService.cancelJoin` & `rejectParticipant` → Booking → CANCELLED | ✅ | [server/services/trip.service.ts](server/services/trip.service.ts) |
| B8 | Halaman `/trips/[id]/payment` baca dari Booking + Payment (bukan timestamp lama) | ✅ | [app/trips/[id]/payment/page.tsx](app/trips/%5Bid%5D/payment/page.tsx) |
| B9 | `OrganizerPaymentQueue` di trip detail dapat data dari `bookingService.getAwaitingManualForTrip` | ✅ | [app/trips/[id]/page.tsx](app/trips/%5Bid%5D/page.tsx) |
| B10 | Deprecate `TripParticipant.markedPaidAt` + `paymentConfirmedAt` (komen `@deprecated`, tetap di-update untuk backcompat) | ✅ | [prisma/schema.prisma](prisma/schema.prisma) |
| B11 | Index optimization (`@@index([tripId, status])` di Booking, `@@index([provider, status])` di Payment, `@@index([userId])` di Booking) | ✅ | [prisma/schema.prisma](prisma/schema.prisma) |
**Tindakan manual (urutan penting):**
1. `npx prisma migrate deploy` — apply schema migration `20260508150000_add_booking_payment`.
2. `npx tsx prisma/backfill-bookings.ts` — populate Booking + Payment dari `TripParticipant` lama. Idempotent, aman dijalankan ulang.
3. Verifikasi: jumlah Booking aktif = jumlah TripParticipant aktif setelah backfill.
---
## PR C — Midtrans integration (Snap + webhook) ✅
Selesai. `tsc --noEmit` lulus. Belum test live ke sandbox Midtrans — perlu env diisi + tunneling kalau dev lokal.
### Persiapan akun & env
| Env | Keterangan |
|---|---|
| `MIDTRANS_SERVER_KEY` | Server key dari dashboard Midtrans (sandbox/production sesuai mode). Rahasia. Server-side only. |
| `NEXT_PUBLIC_MIDTRANS_CLIENT_KEY` | Client key untuk Snap.js. Aman di-expose ke frontend (NEXT_PUBLIC_). |
| `NEXT_PUBLIC_MIDTRANS_IS_PRODUCTION` | `true` untuk production, `false` (atau kosong) untuk sandbox. NEXT_PUBLIC_ supaya client tahu URL Snap.js yang benar. |
`MIDTRANS_NOTIFICATION_URL` **tidak** di env — diset langsung di dashboard Midtrans ke `<NEXT_PUBLIC_SITE_URL>/api/webhooks/midtrans`.
Sudah ditambah ke [env.example](env.example).
### Tugas
| # | Item | Status | File |
|---|---|---|---|
| C1 | Update [env.example](env.example) + 3 env baru + komentar webhook URL | ✅ | [env.example](env.example) |
| C2 | `lib/midtrans.ts``createSnapTransaction`, `verifyMidtransSignature` (timing-safe compare), `MIDTRANS` config helper | ✅ | [lib/midtrans.ts](lib/midtrans.ts) |
| C3 | Status mapping `mapMidtransStatus(transaction_status, fraud_status)``PaymentStatus` | ✅ | [lib/midtrans.ts](lib/midtrans.ts) |
| C4 | `paymentService.startMidtransPayment(bookingId, userId)` — validate, reuse Payment AWAITING aktif (idempotent re-attempt), atau buat Payment baru + call Snap API + simpan token + expiresAt | ✅ | [server/services/payment.service.ts](server/services/payment.service.ts) |
| C5 | Halaman payment: tombol "Bayar online via Midtrans" + divider "atau" + tombol manual lama | ✅ | [app/trips/[id]/payment/page.tsx](app/trips/%5Bid%5D/payment/page.tsx) |
| C6 | `MidtransPayButton` client component — load Snap.js dengan `data-client-key` dinamis, `window.snap.pay(token, callbacks)`, refresh page setelah Snap close | ✅ | [features/booking/components/midtrans-pay-button.tsx](features/booking/components/midtrans-pay-button.tsx) |
| C7 | Webhook endpoint `app/api/webhooks/midtrans/route.ts` — POST, verify signature, lookup, idempotent, return 200/401 sesuai outcome | ✅ | [app/api/webhooks/midtrans/route.ts](app/api/webhooks/midtrans/route.ts) |
| C8 | `paymentService.handleMidtransWebhook` — verifikasi signature, amount check, transaction (`Payment` + `Booking` + `TripParticipant.paymentConfirmedAt` backcompat) | ✅ | [server/services/payment.service.ts](server/services/payment.service.ts) |
| C10 | Anti-replay: skip update kalau Payment sudah final (PAID/FAILED/EXPIRED/CANCELLED/REFUNDED) | ✅ | [payment.service.ts](server/services/payment.service.ts) |
| C11 | Simpan callback mentah ke `Payment.rawCallback` (audit & dispute), termasuk untuk callback yang di-skip | ✅ | [payment.service.ts](server/services/payment.service.ts) |
| C+ | Server action `startMidtransPaymentAction` (resolve booking dari tripId, bridge ke client) | ✅ | [features/booking/actions.ts](features/booking/actions.ts) |
| C+ | Retry handling: Payment row baru dengan `midtrans-{bookingId}-{retryN}` kalau attempt lama expired/failed; idempotent reuse kalau masih AWAITING | ✅ | [payment.service.ts](server/services/payment.service.ts) |
| C9 | Cron expire Payment lama | ⏸️ skipped | Housekeeping di-handle saat user start payment (auto-expire attempt yang lewat `expiresAt`). Cron formal bisa ditambah kalau perlu cleanup massal. |
| C12 | Test scenario sandbox (settlement, deny, expire) | ⏸️ manual | Perlu env Midtrans diisi + tunneling untuk dev lokal (ngrok/cloudflared). Tidak bisa otomatis dari sini. |
| C13 | Status badge real-time | ⏸️ partial | Page refresh setelah Snap close + halaman SSR pull state baru tiap reload. Polling otomatis belum diimplementasi. |
| C14 | Email/in-app notification setelah PAID | ⏳ pending | Diluar scope PR C — masuk Phase berikutnya. |
### Mapping `transaction_status` Midtrans → `PaymentStatus`
| Midtrans | Trigger lain | `PaymentStatus` |
|---|---|---|
| `capture` | `fraud_status === "accept"` | PAID |
| `capture` | `fraud_status === "challenge"` | AWAITING (review manual di dashboard Midtrans) |
| `settlement` | — | PAID |
| `pending` | — | AWAITING |
| `deny` | — | FAILED |
| `expire` | — | EXPIRED |
| `cancel` | — | CANCELLED |
| `refund` / `partial_refund` | — | REFUNDED |
### Webhook checklist (security)
1. Verify signature: `sha512(order_id + status_code + gross_amount + SERVER_KEY) === signature_key`. Mismatch → 401, log.
2. Cek `gross_amount` cocok dengan `payment.amount` — kalau tidak sama, log anomaly, jangan PAID.
3. Lookup `Payment.externalOrderId === order_id`. Tidak ada → 200 OK + log (jangan biarkan Midtrans retry forever).
4. Idempotent: kalau status sudah final, skip update tapi tetap return 200.
5. Pakai DB transaction untuk update Payment + Booking + TripParticipant bersamaan.
6. Selalu return 200 kalau request valid (mismatch signature → 401, sisanya → 200 + log).
### Hardening pasca-audit (sebelum Midtrans live) ✅
Empat fix tambahan dari audit security/correctness:
| Fix | Issue | Solusi | File |
|---|---|---|---|
| 1 | Webhook bisa overwrite Booking CANCELLED/REFUNDED/EXPIRED jadi PAID | Re-fetch Booking di dalam serializable transaction; kalau state konflik, Payment tetap PAID (uang masuk) tapi Booking tidak di-update + `Payment.rejectionReason` di-flag untuk manual review/refund. Webhook outcome `booking_conflict` di-log warning. | [payment.service.ts](server/services/payment.service.ts), [route.ts](app/api/webhooks/midtrans/route.ts) |
| 2 | `startMidtransPayment` lupa cek trip departure date | Tambah `isTripDepartureDayPast` guard, juga di `bookingService.markPaidManual` untuk konsistensi | [payment.service.ts](server/services/payment.service.ts), [booking.service.ts](server/services/booking.service.ts) |
| 3 | `Booking` tidak punya constraint `(tripId, userId)` unique | Tambah `@@unique([tripId, userId])` + migration `20260508160000_booking_unique_trip_user`. `findByTripAndUser` switch dari `findFirst` ke `findUnique` (lebih efisien) | [schema.prisma](prisma/schema.prisma), [migration](prisma/migrations/20260508160000_booking_unique_trip_user/migration.sql), [booking.repo.ts](server/repositories/booking.repo.ts) |
| 4 | Webhook payload tidak schema-validated | Zod `midtransWebhookSchema` (passthrough untuk forward-compat). Webhook route `safeParse` → 400 kalau shape invalid. Service signature pakai type yang inferred dari schema. | [lib/midtrans.ts](lib/midtrans.ts), [route.ts](app/api/webhooks/midtrans/route.ts), [payment.service.ts](server/services/payment.service.ts) |
### Edge cases yang gampang lupa
- **Quota race**: dua user bayar bersamaan untuk slot terakhir → slot di-hold saat Booking dibuat (status AWAITING_PAY masih hitung kuota via `TripParticipant.status`). Release belum otomatis saat Payment EXPIRED — kalau perlu, tambah cron (lihat C9 yang di-skip).
- **Trip dibatalkan organizer setelah peserta bayar** → `Booking.status = REFUNDED` setelah dana balik. Implementasi refund Midtrans = PR terpisah (tidak di scope PR C ini).
- **User retry pembayaran setelah gagal** → bikin Payment baru, `externalOrderId` baru (`midtrans-{bookingId}-{retryN}`). Reuse kalau masih AWAITING & belum expired.
- **Webhook duplicate**: Midtrans bisa kirim notifikasi yang sama 2-3 kali. Idempotent: skip update kalau Payment sudah final, tapi tetap simpan callback ke `rawCallback` untuk audit.
- **Sandbox vs production**: webhook URL diset di dashboard Midtrans = `<NEXT_PUBLIC_SITE_URL>/api/webhooks/midtrans`. Dev lokal perlu tunneling (ngrok / cloudflared) supaya endpoint bisa di-reach Midtrans.
- **Booking belum approved (`PENDING`) tapi user coba bayar** — `paymentService.startMidtransPayment` reject dengan pesan jelas. UI sudah hide tombol di state ini.
---
## ❌ Anti-list (yang harus DITOLAK kalau muncul)
- **Halaman payment yang berubah jadi marketplace checkout**: upsell ("trip serupa lebih murah"), pricing comparison, "harga turun" — semua menarik framing OTA. Halaman payment fokus pada satu transaksi: lunas atau belum.
- **Multi-method abstraksi prematur**: jangan bikin "PaymentProvider" generic untuk Stripe/Xendit/Doku sekaligus sebelum salah satu jalan. Mulai dari MANUAL + MIDTRANS, baru tambah kalau perlu.
- **Auto-refund logic kompleks** sebelum manual refund di dashboard Midtrans dipakai. Refund jarang, manual cukup di Phase awal.
- **Payment retry otomatis** dari sisi server. User harus eksplisit klik "bayar lagi" untuk attempt baru — supaya tidak ambigu siapa yang trigger.
- **Multi-currency** sebelum ada permintaan eksplisit. `currency` di schema sudah default IDR, tapi tidak perlu UI selector.
- **Saving credit card / tokenization** tanpa kebutuhan jelas. PCI scope naik drastis. Snap sudah handle tanpa simpan kartu di sisi kita.
---
## Saran phasing
PR berurutan. Setiap PR mandiri (siap di-deploy):
1. **PR A** — Free trip handling + halaman payment (manual). Cepat, low-risk, no migration. **Mulai dari sini.**
2. **PR B** — Refactor ke `Booking` + `Payment`. Migration + backfill data lama. UI tetap mirip.
3. **PR C** — Tambah Midtrans Snap. Test di sandbox dulu sebelum production.
Pertanyaan terbuka sebelum mulai:
1. **Akses halaman payment**: hanya peserta CONFIRMED, atau juga PENDING (yang belum disetujui organizer)? Saya rekomendasi CONFIRMED only — peserta PENDING belum perlu lihat detail bayar.
2. **Snapshot amount di Booking**: kalau organizer ubah `trip.price` setelah booking dibuat, booking lama pakai harga lama atau baru? Saya rekomendasi tetap pakai snapshot lama (audit-friendly).
3. **Manual + Midtrans co-exist**: user pilih satu provider per booking, atau bisa retry dengan provider berbeda? Saya rekomendasi pilih satu — kalau gagal di Midtrans, bisa cancel dan buat Payment baru dengan provider MANUAL.
+226
View File
@@ -0,0 +1,226 @@
# Setrip — Refund Roadmap
Status implementasi sistem refund yang dapat dipercaya dan auditable — dari schema, policy, sampai integrasi gateway.
> **Prinsip:** refund = financial event, bukan flag boolean. Setiap rupiah yang keluar harus auditable, idempotent, dan punya state machine eksplisit. Untuk MVP: tetap simple, tapi anticipate partial refund + async gateway dari hari pertama.
---
## Audit state sekarang (baseline)
**Schema (~10% sudah ada):**
-`BookingStatus.REFUNDED` & `PaymentStatus.REFUNDED` — enum value ada di [prisma/schema.prisma](prisma/schema.prisma).
- ❌ Model `Refund` belum ada — refund saat ini cuma flag, tanpa entity tersendiri.
- ❌ Tidak ada audit trail (siapa, kapan, alasan, approval).
- ❌ Tidak bisa partial refund.
- ❌ Tidak bisa multiple refund per booking (mis. refund deposit lalu sisa).
- ❌ Tidak bisa bedakan refund vs chargeback (dispute bank).
**Service/UI (~0% sudah ada):**
- ❌ Tidak ada `refundService`.
- ❌ Tidak ada flow "organizer cancel trip → auto refund peserta PAID".
- ❌ Tidak ada UI peserta untuk request cancel + refund.
- ❌ Tidak ada UI admin untuk approve/eksekusi refund.
- ❌ Tidak ada integrasi Midtrans Refund API.
- ❌ Tidak ada reconciliation harian.
**Konteks pendukung yang sudah ada:**
-`Booking` + `Payment` model dengan `amount` (Int, IDR — money math safe).
- ✅ Midtrans webhook handler ([app/api/webhooks/midtrans/route.ts](app/api/webhooks/midtrans/route.ts)) — pola untuk refund webhook bisa di-mirror.
-`bookingService` dengan transaksi serializable + retry — pola untuk `refundService`.
- ✅ Cron infra (system crontab + `CRON_SECRET`, lihat [docs/CRON_SETUP.md](docs/CRON_SETUP.md)) — siap untuk reconciliation job.
File baseline: [prisma/schema.prisma](prisma/schema.prisma), [server/services/booking.service.ts](server/services/booking.service.ts), [app/api/webhooks/midtrans/route.ts](app/api/webhooks/midtrans/route.ts).
---
## PR-R1 — Refund Schema + Service Stub (foundation) ⏳
Foundation. Bikin entity `Refund` + service stub yang cuma create record `PENDING` (belum panggil gateway). UI admin minimal: list + manual mark succeeded. Tanpa ini, semua PR berikutnya tidak bisa jalan.
**Keputusan asumsi yang diusulkan:**
- `Refund.amount` Int (IDR) — bisa < `payment.amount` untuk partial. Constraint: `SUM(refunds.amount WHERE status=SUCCEEDED) <= payment.amount` di-enforce di service layer.
- `idempotencyKey` di-generate sekali saat Refund dibuat — dipakai saat panggil gateway nanti (R-4) supaya retry tidak double-refund.
- `BookingStatus.REFUNDED` di-set sebagai **derived state** saat full refund SUCCEEDED. Untuk partial: tambah `BookingStatus.PARTIALLY_REFUNDED` (enum baru) — atau biarkan PAID + lihat Booking.refunds[]. **Saran: tambah PARTIALLY_REFUNDED** supaya filter list "refunded bookings" bisa pakai status saja, tidak perlu join.
- `RefundReason` enum lengkap dari hari pertama — supaya laporan finance tidak butuh string parsing.
- Approval admin **wajib** untuk semua refund di MVP (4-eyes principle). Auto-approve bisa di-relax di R-2 untuk SYSTEM refund.
- Admin UI sederhana — list refund PENDING + tombol approve / mark-succeeded. Reuse pattern KYC verification UI yang sudah ada.
| # | Item | Status | File |
|---|---|---|---|
| R1.1 | Model `Refund` + enum `RefundReason`, `RefundStatus`, `RefundInitiator` | ⏳ | [prisma/schema.prisma](prisma/schema.prisma) |
| R1.2 | Tambah `BookingStatus.PARTIALLY_REFUNDED` | ⏳ | [prisma/schema.prisma](prisma/schema.prisma) |
| R1.3 | Migration `add_refund_model` | ⏳ | `prisma/migrations/` |
| R1.4 | `refundRepo` (create, findById, findByBooking, listPending, updateStatus) | ⏳ | `server/repositories/refund.repo.ts` |
| R1.5 | `refundService.requestRefund(input)` — create Refund PENDING + validasi `amount <= payment.amount - totalRefunded` | ⏳ | `server/services/refund.service.ts` |
| R1.6 | `refundService.approveRefund(refundId, adminId)` — PENDING → APPROVED | ⏳ | `server/services/refund.service.ts` |
| R1.7 | `refundService.markSucceededManual(refundId, adminId)` — APPROVED → SUCCEEDED, update Booking/Payment status. Untuk MVP refund manual transfer (non-Midtrans channel). | ⏳ | `server/services/refund.service.ts` |
| R1.8 | UI admin `/admin/refunds` — list PENDING + tombol approve & mark-succeeded | ⏳ | `app/admin/refunds/page.tsx` |
**Tindakan manual:**
1. Run migration di staging → smoke test → run di production.
2. Tambah `/admin/refunds` ke admin nav.
---
## PR-R2 — Auto-Trigger Refund saat Trip CLOSED (organizer cancel) ⏳
Saat organizer batalkan trip (`status = CLOSED`), semua peserta dengan `Booking.status = PAID` otomatis di-create `Refund` record dengan `reason = ORGANIZER_CANCELLED`, `initiatedBy = SYSTEM`, full amount.
**Keputusan asumsi yang diusulkan:**
- Trigger di service `tripService.closeTrip()` (yang nge-set `status = CLOSED`). Pakai serializable transaction — close trip + create refunds atomic.
- SYSTEM refund bisa **auto-approve** (skip admin approval) — karena policy clear: organizer cancel = 100% refund. Tapi eksekusi (mark succeeded) tetap manual atau via gateway (R-4), tidak skip.
- Notifikasi peserta: kirim email/notif "Trip dibatalkan, refund Rp X sedang diproses". (Notification system di luar scope PR ini — assume sudah ada atau di-deferred.)
- Edge case: peserta yang `AWAITING_PAY` (belum bayar) tidak perlu refund — cuma update `Booking.status = CANCELLED`. Yang `PAID` saja yang dapat refund.
| # | Item | Status | File |
|---|---|---|---|
| R2.1 | `tripService.closeTrip(tripId, organizerId)` — set status CLOSED + auto-create refunds | ⏳ | [server/services/trip.service.ts](server/services/trip.service.ts) |
| R2.2 | Auto-approve untuk SYSTEM refund dengan reason ORGANIZER_CANCELLED | ⏳ | `server/services/refund.service.ts` |
| R2.3 | UI organizer: tombol "Batalkan trip" di dashboard organizer dengan confirmation modal | ⏳ | `features/trip/components/cancel-trip-button.tsx` |
| R2.4 | Server action `cancelTripAction` | ⏳ | `features/trip/actions.ts` |
**Tindakan manual:** tidak ada.
---
## PR-R3 — Self-Service User Cancel dengan Refund Window ⏳
User bisa cancel booking sendiri, refund dihitung berdasarkan kebijakan default (hardcoded dulu, jangan polymorphic).
**Keputusan asumsi yang diusulkan:**
- **Kebijakan default hardcoded** (akan jadi data-driven di R-5):
- ≥7 hari sebelum berangkat → 80% refund (organizer ambil 20% admin fee)
- 3-7 hari sebelum berangkat → 50% refund
- <3 hari atau no-show → 0% refund (tetap create Refund record amount=0 untuk audit, atau skip — pilih skip lebih sederhana)
- Konstanta di `lib/refund-policy.ts` — supaya satu sumber kebenaran, mudah diubah.
- User refund **tidak auto-approve** — tetap butuh admin approval di MVP. Alasan: cegah abuse (spam cancel), dan validasi window calculation di sisi admin.
- Setelah refund SUCCEEDED, slot di trip kembali tersedia (`status: FULL → OPEN` kalau participantCount turun).
- UI user: tombol "Cancel & request refund" di trip detail (kalau status booking = PAID dan trip belum berangkat).
| # | Item | Status | File |
|---|---|---|---|
| R3.1 | `lib/refund-policy.ts``calculateRefundAmount(bookingAmount, daysUntilDeparture)` | ⏳ | `lib/refund-policy.ts` |
| R3.2 | `refundService.requestUserCancellation(bookingId, userId)` — pakai policy + create Refund PENDING | ⏳ | `server/services/refund.service.ts` |
| R3.3 | UI user di trip detail: tombol "Cancel booking" + modal preview refund amount | ⏳ | `features/booking/components/cancel-booking-button.tsx` |
| R3.4 | Server action `cancelBookingAction` | ⏳ | `features/booking/actions.ts` |
| R3.5 | Display refund policy di trip detail (di blok info atau accordion) — transparency | ⏳ | [app/trips/[id]/page.tsx](app/trips/%5Bid%5D/page.tsx) |
**Tindakan manual:**
1. Tulis copy kebijakan refund untuk halaman Terms & Privacy.
2. Tambah link kebijakan di footer.
---
## PR-R4 — Integrasi Midtrans Refund API (async, idempotent) ⏳
Sambungkan eksekusi refund ke Midtrans Refund API. Untuk channel yang support refund online (BCA VA, GoPay, dst). Channel manual transfer tetap mark-succeeded manual oleh admin.
**Keputusan asumsi yang diusulkan:**
- Refund API Midtrans **async** — POST refund return `pending`, callback datang via webhook (extend [app/api/webhooks/midtrans/route.ts](app/api/webhooks/midtrans/route.ts) atau buat endpoint terpisah).
- **Idempotency key** wajib di header request — pakai `Refund.idempotencyKey` yang sudah di-generate R-1.
- State transition: APPROVED → PROCESSING (saat request dikirim) → SUCCEEDED/FAILED (via webhook).
- Eksekusi via job queue (Vercel Cron daily) atau sync di server action — **saran cron** supaya retry-able kalau gateway down. Job: pick all APPROVED refunds, kirim ke Midtrans, update PROCESSING.
- Validasi `Payment.method` — kalau `manual_transfer`, refund tetap manual (no gateway call). Skip Midtrans path.
| # | Item | Status | File |
|---|---|---|---|
| R4.1 | Helper `lib/midtrans-refund.ts` — POST refund dengan idempotency key | ⏳ | `lib/midtrans-refund.ts` |
| R4.2 | `refundService.executeRefund(refundId)` — APPROVED → PROCESSING + call Midtrans | ⏳ | `server/services/refund.service.ts` |
| R4.3 | Webhook handler refund — PROCESSING → SUCCEEDED/FAILED via callback | ⏳ | [app/api/webhooks/midtrans/route.ts](app/api/webhooks/midtrans/route.ts) |
| R4.4 | Cron `/api/cron/execute-refunds` — pick APPROVED refunds, kirim ke gateway | ⏳ | `app/api/cron/execute-refunds/route.ts` |
| R4.5 | Daftarkan cron `*/15 * * * *` (every 15 min) di system crontab | ⏳ | [docs/CRON_SETUP.md](docs/CRON_SETUP.md) |
**Tindakan manual:**
1. Aktifkan Refund API di dashboard Midtrans (perlu request ke Midtrans support untuk channel tertentu).
2. Test refund di sandbox dengan dummy transaction.
3. Set webhook URL refund di Midtrans dashboard (kalau beda dari payment webhook).
---
## PR-R5 — Refund Policy Model (per-trip customization) ⏳
Pindah refund policy dari hardcoded ke data-driven. Organizer bisa pilih policy per-trip (mis. trip premium = strict cancellation).
**Keputusan asumsi yang diusulkan:**
- Model `RefundPolicy` dengan tier predefined (FLEXIBLE, MODERATE, STRICT) — **bukan** field bebas. Mengikuti pola Airbnb. Mencegah organizer set policy yang aneh-aneh dan membingungkan peserta.
- 1 Trip → 1 RefundPolicy (foreign key di Trip). Default ke MODERATE.
- Setiap policy punya array `tiers` (JSON) berisi `{ minDaysBefore: number, refundPercentage: number }`.
- Migration: existing trip default ke MODERATE.
- UI organizer di create-trip form: dropdown pilih policy, dengan preview tier-nya.
| # | Item | Status | File |
|---|---|---|---|
| R5.1 | Model `RefundPolicy` + seed 3 tier (FLEXIBLE, MODERATE, STRICT) | ⏳ | [prisma/schema.prisma](prisma/schema.prisma) |
| R5.2 | Foreign key `Trip.refundPolicyId` (default MODERATE) | ⏳ | [prisma/schema.prisma](prisma/schema.prisma) |
| R5.3 | Migration + backfill existing trip ke MODERATE | ⏳ | `prisma/migrations/` |
| R5.4 | Update `lib/refund-policy.ts``calculateRefundAmount` baca dari policy | ⏳ | `lib/refund-policy.ts` |
| R5.5 | UI organizer create-trip: dropdown policy + preview | ⏳ | [features/trip/components/create-trip-form.tsx](features/trip/components/create-trip-form.tsx) |
| R5.6 | UI trip detail: tampilkan policy aktif (link ke detail tier) | ⏳ | [app/trips/[id]/page.tsx](app/trips/%5Bid%5D/page.tsx) |
**Tindakan manual:**
1. Run migration + backfill.
2. Update copy halaman terms — sebut 3 policy tier.
---
## PR-R6 — Reconciliation + Dispute Model (operational maturity) ⏳
Daily job match refund di DB vs settlement Midtrans. Model Dispute terpisah untuk chargeback/komplain pasca-refund.
**Keputusan asumsi yang diusulkan:**
- Reconciliation job: pull settlement report dari Midtrans (Settlement API) → match dengan `refund.externalRefundId` → flag drift.
- Dispute model **tertunda** sampai ada chargeback riil. Pakai `RefundReason.DISPUTE_RESOLVED` dulu di Refund model. Bikin model terpisah hanya kalau volume dispute > X per bulan.
- Alert: kalau drift > 1% dari total refund harian, kirim notif ke admin (email/Slack).
| # | Item | Status | File |
|---|---|---|---|
| R6.1 | Helper `lib/midtrans-settlement.ts` — pull settlement report | ⏳ | `lib/midtrans-settlement.ts` |
| R6.2 | Cron `/api/cron/reconcile-refunds` daily | ⏳ | `app/api/cron/reconcile-refunds/route.ts` |
| R6.3 | UI admin `/admin/refunds/reconciliation` — drift report | ⏳ | `app/admin/refunds/reconciliation/page.tsx` |
| R6.4 | (opsional, defer) Model `Dispute` + flow chargeback | ⏳ | [prisma/schema.prisma](prisma/schema.prisma) |
**Tindakan manual:**
1. Set up alert channel (email atau Slack webhook).
2. Tetapkan threshold drift (saran: 1%).
---
## ❌ Anti-list (yang harus DITOLAK kalau muncul)
- **Pakai `BookingStatus.REFUNDED` sebagai sumber kebenaran tanpa Refund model** — flag-only tidak bisa partial, tidak punya audit trail. Stuck di kasus pertama.
- **Hapus Refund row kalau gagal** — never delete financial records. Set `status = FAILED` + log alasan. Audit trail wajib.
- **Sync call ke Midtrans Refund API tanpa idempotency key** — kalau retry karena timeout atau network error → double-refund. Kerugian finansial nyata.
- **Auto-execute refund tanpa approval admin di MVP** — fraud risk. Auto-approve OK untuk SYSTEM refund (organizer cancel = clear policy), tapi eksekusi tetap controlled. Bisa di-relax setelah volume + trust matang.
- **Polymorphic refund policy dari awal** — lompat langsung ke data-driven sebelum hardcoded teruji = over-engineering. Phasing R-3 (hardcoded) lalu R-5 (data-driven) lebih sehat.
- **Trigger refund di server action user-facing tanpa state machine** — user spam click → multiple refund request. Idempotency check via `(bookingId, status IN ('PENDING','APPROVED','PROCESSING'))` unique-ish.
- **Refund partial dengan float math** — selalu integer (rupiah). Hitung % dengan `Math.floor(amount * percentage / 100)` supaya tidak ada sub-rupiah.
- **Mention "kami akan refund X%" di UI tanpa lock policy** — kebijakan harus visible di trip detail SEBELUM user join, bukan kejutan saat cancel.
- **Skip approval admin untuk refund di atas threshold (mis. > 5jt)** — fraud risk internal. 4-eyes principle wajib untuk nominal besar, walau policy clear.
- **Bundle Refund + Dispute di model yang sama** — beda flow, beda inisiator (refund = merchant, dispute = bank). Separation of concern penting walau di MVP belum perlu Dispute model.
---
## Saran phasing
PR berurutan, masing-masing mandiri (siap di-deploy). **Don't bundle.**
1. **R-1** — Schema + service stub + UI admin. Foundation, blocker untuk semua PR berikut.
2. **R-2** — Auto-trigger saat organizer CLOSED. Paling sering kepakai, low-complexity. **Mulai dari sini setelah R-1.**
3. **R-3** — Self-service user cancel + hardcoded policy. Komplet flow user-side.
4. **R-4** — Midtrans Refund API. Paling kompleks (async, idempotent, webhook). Bisa hidup tanpa ini selama admin willing manual transfer.
5. **R-5** — Refund policy data-driven. Quality-of-life untuk organizer, bukan blocker.
6. **R-6** — Reconciliation + dispute. Operational maturity, untuk volume yang lebih besar.
**Bobot effort kasar:** R-1 (M) → R-2 (M) → R-3 (M) → R-4 (L) → R-5 (M) → R-6 (L).
---
## Pertanyaan terbuka sebelum mulai R-1
1. **MVP scope** — mau prioritaskan **organizer-cancel** dulu (R-2, paling sering), atau langsung **user cancel dengan window** (R-3)? Saran: R-2 dulu — clear policy, low-complexity.
2. **Approval flow** — semua refund butuh approval admin (lebih aman), atau auto-approve untuk SYSTEM refund? Saran: auto-approve SYSTEM, manual approve untuk USER + ADMIN_ADJUSTMENT.
3. **Partial refund booking status** — tambah `BookingStatus.PARTIALLY_REFUNDED` (lebih eksplisit) atau biarkan tetap PAID + lihat `booking.refunds[]`? Saran: tambah enum baru — query-friendly.
4. **Midtrans Refund API channel** — apakah anda sudah cek channel mana yang support online refund? BCA VA + GoPay biasanya support, manual_transfer pasti tidak. Cek dashboard sebelum mulai R-4.
5. **Dispute model timing** — bikin Dispute model di R-1 (early separation) atau defer sampai R-6? Saran: defer — YAGNI sampai ada kasus chargeback riil.
6. **Threshold approval admin** — ada nominal di atas mana refund wajib approval 2 admin (4-eyes)? Saran: > Rp 1jt butuh dual approval, < Rp 1jt single approval cukup.
+17
View File
@@ -0,0 +1,17 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Admin · Refund Manual",
description:
"Halaman admin untuk meninjau laporan refund dari peserta dan organizer.",
alternates: { canonical: "/admin/refunds" },
robots: { index: false, follow: false },
};
export default function AdminRefundsLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}
+126
View File
@@ -0,0 +1,126 @@
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { refundRepo } from "@/server/repositories/refund.repo";
import { CreateRefundForm } from "@/features/refund/components/create-refund-form";
import {
RefundReviewCard,
type RefundCardData,
} from "@/features/refund/components/refund-review-card";
type Tab = "PENDING" | "APPROVED" | "REJECTED" | "SUCCEEDED" | "FAILED";
const TABS: { key: Tab; label: string }[] = [
{ key: "PENDING", label: "Pending" },
{ key: "APPROVED", label: "Disetujui" },
{ key: "SUCCEEDED", label: "Selesai" },
{ key: "REJECTED", label: "Ditolak" },
{ key: "FAILED", label: "Gagal" },
];
interface PageProps {
searchParams: Promise<{ tab?: string }>;
}
export default async function AdminRefundsPage({ searchParams }: PageProps) {
const session = await getServerSession(authOptions);
if (!session?.user) redirect("/login?callbackUrl=/admin/refunds");
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)
: "PENDING";
const rows = await refundRepo.listByStatus(tab);
const items: RefundCardData[] = rows.map((r) => ({
id: r.id,
amount: r.amount,
currency: r.currency,
reason: r.reason,
reportedBy: r.reportedBy,
reportNote: r.reportNote,
initiatedBy: r.initiatedBy,
status: r.status,
adminNote: r.adminNote,
createdAt: r.createdAt,
reviewedAt: r.reviewedAt,
succeededAt: r.succeededAt,
failedAt: r.failedAt,
reviewedBy: r.reviewedBy,
booking: {
id: r.booking.id,
amount: r.booking.amount,
status: r.booking.status,
trip: {
id: r.booking.trip.id,
title: r.booking.trip.title,
date: r.booking.trip.date,
},
user: r.booking.user,
payments: r.booking.payments.map((p) => ({
id: p.id,
provider: p.provider,
method: p.method,
amount: p.amount,
status: p.status,
paidAt: p.paidAt,
})),
},
}));
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">
Review Refund Manual
</h1>
<p className="mt-1 text-sm text-neutral-500">
Tinjau laporan refund dari peserta dan organizer. Setiap refund harus
melalui approval admin sebelum dieksekusi.
</p>
</header>
<CreateRefundForm />
<div className="mb-6 flex flex-wrap gap-2">
{TABS.map((t) => (
<a
key={t.key}
href={`/admin/refunds?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 refund pada status ini.
</p>
</div>
) : (
<div className="space-y-4">
{items.map((r) => (
<RefundReviewCard key={r.id} refund={r} />
))}
</div>
)}
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from "next/server";
import { tripService } from "@/server/services/trip.service";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Cron daily — flip trip yang sudah lewat tanggal selesai dari OPEN/FULL ke
* COMPLETED. Idempotent: run berulang aman.
*
* Trigger via system crontab (lihat [docs/CRON_SETUP.md](../../../docs/CRON_SETUP.md))
* atau cron service apapun. Header wajib: `Authorization: Bearer ${CRON_SECRET}`.
*
* Set env `CRON_SECRET` (random ≥32 char) di hosting. Kalau env tidak di-set,
* endpoint hard-fail 500 supaya tidak accidentally jalan tanpa proteksi.
*/
export async function GET(req: NextRequest) {
const secret = process.env.CRON_SECRET;
if (!secret) {
console.error("[cron/auto-complete-trips] CRON_SECRET tidak di-set");
return NextResponse.json(
{ error: "Server misconfigured" },
{ status: 500 }
);
}
const authHeader = req.headers.get("authorization");
if (authHeader !== `Bearer ${secret}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const result = await tripService.autoCompletePastTrips();
console.log("[cron/auto-complete-trips] selesai", {
count: result.count,
ids: result.ids,
});
return NextResponse.json({
ok: true,
completed: result.count,
ids: result.ids,
});
} catch (err) {
console.error("[cron/auto-complete-trips] gagal", err);
return NextResponse.json(
{ error: "Gagal menjalankan auto-complete" },
{ status: 500 }
);
}
}
+12
View File
@@ -75,3 +75,15 @@ export type Booking = Prisma.BookingModel
* (di Phase MIDTRANS nanti). Untuk MANUAL biasanya cukup 1 Payment. * (di Phase MIDTRANS nanti). Untuk MANUAL biasanya cukup 1 Payment.
*/ */
export type Payment = Prisma.PaymentModel export type Payment = Prisma.PaymentModel
/**
* Model Refund
* Refund = financial event terpisah dari Booking. Satu Booking bisa punya
* banyak Refund (partial, multi-tahap). Setiap row auditable: kapan dibuat,
* siapa melaporkan, siapa approve, kapan SUCCEEDED. Never delete — kalau
* gagal, set status=FAILED + alasan.
*
* Di MVP refund dimasukkan admin secara manual berdasarkan laporan dari
* peserta atau organizer (via WhatsApp/email). Phase berikutnya akan
* menambah self-service flow dari user dan organizer.
*/
export type Refund = Prisma.RefundModel
+12
View File
@@ -99,3 +99,15 @@ export type Booking = Prisma.BookingModel
* (di Phase MIDTRANS nanti). Untuk MANUAL biasanya cukup 1 Payment. * (di Phase MIDTRANS nanti). Untuk MANUAL biasanya cukup 1 Payment.
*/ */
export type Payment = Prisma.PaymentModel export type Payment = Prisma.PaymentModel
/**
* Model Refund
* Refund = financial event terpisah dari Booking. Satu Booking bisa punya
* banyak Refund (partial, multi-tahap). Setiap row auditable: kapan dibuat,
* siapa melaporkan, siapa approve, kapan SUCCEEDED. Never delete — kalau
* gagal, set status=FAILED + alasan.
*
* Di MVP refund dimasukkan admin secara manual berdasarkan laporan dari
* peserta atau organizer (via WhatsApp/email). Phase berikutnya akan
* menambah self-service flow dari user dan organizer.
*/
export type Refund = Prisma.RefundModel
+136
View File
@@ -389,6 +389,74 @@ export type JsonNullableWithAggregatesFilterBase<$PrismaModel = never> = {
_max?: Prisma.NestedJsonNullableFilter<$PrismaModel> _max?: Prisma.NestedJsonNullableFilter<$PrismaModel>
} }
export type EnumRefundReasonFilter<$PrismaModel = never> = {
equals?: $Enums.RefundReason | Prisma.EnumRefundReasonFieldRefInput<$PrismaModel>
in?: $Enums.RefundReason[] | Prisma.ListEnumRefundReasonFieldRefInput<$PrismaModel>
notIn?: $Enums.RefundReason[] | Prisma.ListEnumRefundReasonFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumRefundReasonFilter<$PrismaModel> | $Enums.RefundReason
}
export type EnumRefundReporterFilter<$PrismaModel = never> = {
equals?: $Enums.RefundReporter | Prisma.EnumRefundReporterFieldRefInput<$PrismaModel>
in?: $Enums.RefundReporter[] | Prisma.ListEnumRefundReporterFieldRefInput<$PrismaModel>
notIn?: $Enums.RefundReporter[] | Prisma.ListEnumRefundReporterFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumRefundReporterFilter<$PrismaModel> | $Enums.RefundReporter
}
export type EnumRefundInitiatorFilter<$PrismaModel = never> = {
equals?: $Enums.RefundInitiator | Prisma.EnumRefundInitiatorFieldRefInput<$PrismaModel>
in?: $Enums.RefundInitiator[] | Prisma.ListEnumRefundInitiatorFieldRefInput<$PrismaModel>
notIn?: $Enums.RefundInitiator[] | Prisma.ListEnumRefundInitiatorFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumRefundInitiatorFilter<$PrismaModel> | $Enums.RefundInitiator
}
export type EnumRefundStatusFilter<$PrismaModel = never> = {
equals?: $Enums.RefundStatus | Prisma.EnumRefundStatusFieldRefInput<$PrismaModel>
in?: $Enums.RefundStatus[] | Prisma.ListEnumRefundStatusFieldRefInput<$PrismaModel>
notIn?: $Enums.RefundStatus[] | Prisma.ListEnumRefundStatusFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumRefundStatusFilter<$PrismaModel> | $Enums.RefundStatus
}
export type EnumRefundReasonWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.RefundReason | Prisma.EnumRefundReasonFieldRefInput<$PrismaModel>
in?: $Enums.RefundReason[] | Prisma.ListEnumRefundReasonFieldRefInput<$PrismaModel>
notIn?: $Enums.RefundReason[] | Prisma.ListEnumRefundReasonFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumRefundReasonWithAggregatesFilter<$PrismaModel> | $Enums.RefundReason
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumRefundReasonFilter<$PrismaModel>
_max?: Prisma.NestedEnumRefundReasonFilter<$PrismaModel>
}
export type EnumRefundReporterWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.RefundReporter | Prisma.EnumRefundReporterFieldRefInput<$PrismaModel>
in?: $Enums.RefundReporter[] | Prisma.ListEnumRefundReporterFieldRefInput<$PrismaModel>
notIn?: $Enums.RefundReporter[] | Prisma.ListEnumRefundReporterFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumRefundReporterWithAggregatesFilter<$PrismaModel> | $Enums.RefundReporter
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumRefundReporterFilter<$PrismaModel>
_max?: Prisma.NestedEnumRefundReporterFilter<$PrismaModel>
}
export type EnumRefundInitiatorWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.RefundInitiator | Prisma.EnumRefundInitiatorFieldRefInput<$PrismaModel>
in?: $Enums.RefundInitiator[] | Prisma.ListEnumRefundInitiatorFieldRefInput<$PrismaModel>
notIn?: $Enums.RefundInitiator[] | Prisma.ListEnumRefundInitiatorFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumRefundInitiatorWithAggregatesFilter<$PrismaModel> | $Enums.RefundInitiator
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumRefundInitiatorFilter<$PrismaModel>
_max?: Prisma.NestedEnumRefundInitiatorFilter<$PrismaModel>
}
export type EnumRefundStatusWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.RefundStatus | Prisma.EnumRefundStatusFieldRefInput<$PrismaModel>
in?: $Enums.RefundStatus[] | Prisma.ListEnumRefundStatusFieldRefInput<$PrismaModel>
notIn?: $Enums.RefundStatus[] | Prisma.ListEnumRefundStatusFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumRefundStatusWithAggregatesFilter<$PrismaModel> | $Enums.RefundStatus
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumRefundStatusFilter<$PrismaModel>
_max?: Prisma.NestedEnumRefundStatusFilter<$PrismaModel>
}
export type NestedStringFilter<$PrismaModel = never> = { export type NestedStringFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
@@ -750,4 +818,72 @@ export type NestedJsonNullableFilterBase<$PrismaModel = never> = {
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
} }
export type NestedEnumRefundReasonFilter<$PrismaModel = never> = {
equals?: $Enums.RefundReason | Prisma.EnumRefundReasonFieldRefInput<$PrismaModel>
in?: $Enums.RefundReason[] | Prisma.ListEnumRefundReasonFieldRefInput<$PrismaModel>
notIn?: $Enums.RefundReason[] | Prisma.ListEnumRefundReasonFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumRefundReasonFilter<$PrismaModel> | $Enums.RefundReason
}
export type NestedEnumRefundReporterFilter<$PrismaModel = never> = {
equals?: $Enums.RefundReporter | Prisma.EnumRefundReporterFieldRefInput<$PrismaModel>
in?: $Enums.RefundReporter[] | Prisma.ListEnumRefundReporterFieldRefInput<$PrismaModel>
notIn?: $Enums.RefundReporter[] | Prisma.ListEnumRefundReporterFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumRefundReporterFilter<$PrismaModel> | $Enums.RefundReporter
}
export type NestedEnumRefundInitiatorFilter<$PrismaModel = never> = {
equals?: $Enums.RefundInitiator | Prisma.EnumRefundInitiatorFieldRefInput<$PrismaModel>
in?: $Enums.RefundInitiator[] | Prisma.ListEnumRefundInitiatorFieldRefInput<$PrismaModel>
notIn?: $Enums.RefundInitiator[] | Prisma.ListEnumRefundInitiatorFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumRefundInitiatorFilter<$PrismaModel> | $Enums.RefundInitiator
}
export type NestedEnumRefundStatusFilter<$PrismaModel = never> = {
equals?: $Enums.RefundStatus | Prisma.EnumRefundStatusFieldRefInput<$PrismaModel>
in?: $Enums.RefundStatus[] | Prisma.ListEnumRefundStatusFieldRefInput<$PrismaModel>
notIn?: $Enums.RefundStatus[] | Prisma.ListEnumRefundStatusFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumRefundStatusFilter<$PrismaModel> | $Enums.RefundStatus
}
export type NestedEnumRefundReasonWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.RefundReason | Prisma.EnumRefundReasonFieldRefInput<$PrismaModel>
in?: $Enums.RefundReason[] | Prisma.ListEnumRefundReasonFieldRefInput<$PrismaModel>
notIn?: $Enums.RefundReason[] | Prisma.ListEnumRefundReasonFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumRefundReasonWithAggregatesFilter<$PrismaModel> | $Enums.RefundReason
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumRefundReasonFilter<$PrismaModel>
_max?: Prisma.NestedEnumRefundReasonFilter<$PrismaModel>
}
export type NestedEnumRefundReporterWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.RefundReporter | Prisma.EnumRefundReporterFieldRefInput<$PrismaModel>
in?: $Enums.RefundReporter[] | Prisma.ListEnumRefundReporterFieldRefInput<$PrismaModel>
notIn?: $Enums.RefundReporter[] | Prisma.ListEnumRefundReporterFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumRefundReporterWithAggregatesFilter<$PrismaModel> | $Enums.RefundReporter
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumRefundReporterFilter<$PrismaModel>
_max?: Prisma.NestedEnumRefundReporterFilter<$PrismaModel>
}
export type NestedEnumRefundInitiatorWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.RefundInitiator | Prisma.EnumRefundInitiatorFieldRefInput<$PrismaModel>
in?: $Enums.RefundInitiator[] | Prisma.ListEnumRefundInitiatorFieldRefInput<$PrismaModel>
notIn?: $Enums.RefundInitiator[] | Prisma.ListEnumRefundInitiatorFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumRefundInitiatorWithAggregatesFilter<$PrismaModel> | $Enums.RefundInitiator
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumRefundInitiatorFilter<$PrismaModel>
_max?: Prisma.NestedEnumRefundInitiatorFilter<$PrismaModel>
}
export type NestedEnumRefundStatusWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.RefundStatus | Prisma.EnumRefundStatusFieldRefInput<$PrismaModel>
in?: $Enums.RefundStatus[] | Prisma.ListEnumRefundStatusFieldRefInput<$PrismaModel>
notIn?: $Enums.RefundStatus[] | Prisma.ListEnumRefundStatusFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumRefundStatusWithAggregatesFilter<$PrismaModel> | $Enums.RefundStatus
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumRefundStatusFilter<$PrismaModel>
_max?: Prisma.NestedEnumRefundStatusFilter<$PrismaModel>
}
+43
View File
@@ -68,6 +68,7 @@ export const BookingStatus = {
PAID: 'PAID', PAID: 'PAID',
CANCELLED: 'CANCELLED', CANCELLED: 'CANCELLED',
REFUNDED: 'REFUNDED', REFUNDED: 'REFUNDED',
PARTIALLY_REFUNDED: 'PARTIALLY_REFUNDED',
EXPIRED: 'EXPIRED' EXPIRED: 'EXPIRED'
} as const } as const
@@ -93,3 +94,45 @@ export const PaymentStatus = {
} as const } as const
export type PaymentStatus = (typeof PaymentStatus)[keyof typeof PaymentStatus] export type PaymentStatus = (typeof PaymentStatus)[keyof typeof PaymentStatus]
export const RefundReason = {
USER_CANCELLATION: 'USER_CANCELLATION',
ORGANIZER_CANCELLED: 'ORGANIZER_CANCELLED',
TRIP_ISSUE: 'TRIP_ISSUE',
ADMIN_ADJUSTMENT: 'ADMIN_ADJUSTMENT',
DISPUTE_RESOLVED: 'DISPUTE_RESOLVED',
OTHER: 'OTHER'
} as const
export type RefundReason = (typeof RefundReason)[keyof typeof RefundReason]
export const RefundStatus = {
PENDING: 'PENDING',
APPROVED: 'APPROVED',
REJECTED: 'REJECTED',
PROCESSING: 'PROCESSING',
SUCCEEDED: 'SUCCEEDED',
FAILED: 'FAILED'
} as const
export type RefundStatus = (typeof RefundStatus)[keyof typeof RefundStatus]
export const RefundInitiator = {
USER: 'USER',
ORGANIZER: 'ORGANIZER',
SYSTEM: 'SYSTEM',
ADMIN: 'ADMIN'
} as const
export type RefundInitiator = (typeof RefundInitiator)[keyof typeof RefundInitiator]
export const RefundReporter = {
PARTICIPANT: 'PARTICIPANT',
ORGANIZER: 'ORGANIZER'
} as const
export type RefundReporter = (typeof RefundReporter)[keyof typeof RefundReporter]
File diff suppressed because one or more lines are too long
@@ -393,7 +393,8 @@ export const ModelName = {
TripImage: 'TripImage', TripImage: 'TripImage',
TripParticipant: 'TripParticipant', TripParticipant: 'TripParticipant',
Booking: 'Booking', Booking: 'Booking',
Payment: 'Payment' Payment: 'Payment',
Refund: 'Refund'
} as const } as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName] export type ModelName = (typeof ModelName)[keyof typeof ModelName]
@@ -409,7 +410,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
omit: GlobalOmitOptions omit: GlobalOmitOptions
} }
meta: { meta: {
modelProps: "user" | "userProfile" | "account" | "organizerVerification" | "trip" | "tripReview" | "tripImage" | "tripParticipant" | "booking" | "payment" modelProps: "user" | "userProfile" | "account" | "organizerVerification" | "trip" | "tripReview" | "tripImage" | "tripParticipant" | "booking" | "payment" | "refund"
txIsolationLevel: TransactionIsolationLevel txIsolationLevel: TransactionIsolationLevel
} }
model: { model: {
@@ -1153,6 +1154,80 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
} }
} }
} }
Refund: {
payload: Prisma.$RefundPayload<ExtArgs>
fields: Prisma.RefundFieldRefs
operations: {
findUnique: {
args: Prisma.RefundFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$RefundPayload> | null
}
findUniqueOrThrow: {
args: Prisma.RefundFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$RefundPayload>
}
findFirst: {
args: Prisma.RefundFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$RefundPayload> | null
}
findFirstOrThrow: {
args: Prisma.RefundFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$RefundPayload>
}
findMany: {
args: Prisma.RefundFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$RefundPayload>[]
}
create: {
args: Prisma.RefundCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$RefundPayload>
}
createMany: {
args: Prisma.RefundCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.RefundCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$RefundPayload>[]
}
delete: {
args: Prisma.RefundDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$RefundPayload>
}
update: {
args: Prisma.RefundUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$RefundPayload>
}
deleteMany: {
args: Prisma.RefundDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.RefundUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.RefundUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$RefundPayload>[]
}
upsert: {
args: Prisma.RefundUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$RefundPayload>
}
aggregate: {
args: Prisma.RefundAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateRefund>
}
groupBy: {
args: Prisma.RefundGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.RefundGroupByOutputType>[]
}
count: {
args: Prisma.RefundCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.RefundCountAggregateOutputType> | number
}
}
}
} }
} & { } & {
other: { other: {
@@ -1365,6 +1440,31 @@ export const PaymentScalarFieldEnum = {
export type PaymentScalarFieldEnum = (typeof PaymentScalarFieldEnum)[keyof typeof PaymentScalarFieldEnum] export type PaymentScalarFieldEnum = (typeof PaymentScalarFieldEnum)[keyof typeof PaymentScalarFieldEnum]
export const RefundScalarFieldEnum = {
id: 'id',
bookingId: 'bookingId',
paymentId: 'paymentId',
amount: 'amount',
currency: 'currency',
reason: 'reason',
reportedBy: 'reportedBy',
reportNote: 'reportNote',
initiatedBy: 'initiatedBy',
status: 'status',
idempotencyKey: 'idempotencyKey',
adminNote: 'adminNote',
reviewedById: 'reviewedById',
reviewedAt: 'reviewedAt',
succeededAt: 'succeededAt',
failedAt: 'failedAt',
externalRefundId: 'externalRefundId',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type RefundScalarFieldEnum = (typeof RefundScalarFieldEnum)[keyof typeof RefundScalarFieldEnum]
export const SortOrder = { export const SortOrder = {
asc: 'asc', asc: 'asc',
desc: 'desc' desc: 'desc'
@@ -1587,6 +1687,62 @@ export type EnumQueryModeFieldRefInput<$PrismaModel> = FieldRefInputType<$Prisma
/**
* Reference to a field of type 'RefundReason'
*/
export type EnumRefundReasonFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'RefundReason'>
/**
* Reference to a field of type 'RefundReason[]'
*/
export type ListEnumRefundReasonFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'RefundReason[]'>
/**
* Reference to a field of type 'RefundReporter'
*/
export type EnumRefundReporterFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'RefundReporter'>
/**
* Reference to a field of type 'RefundReporter[]'
*/
export type ListEnumRefundReporterFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'RefundReporter[]'>
/**
* Reference to a field of type 'RefundInitiator'
*/
export type EnumRefundInitiatorFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'RefundInitiator'>
/**
* Reference to a field of type 'RefundInitiator[]'
*/
export type ListEnumRefundInitiatorFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'RefundInitiator[]'>
/**
* Reference to a field of type 'RefundStatus'
*/
export type EnumRefundStatusFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'RefundStatus'>
/**
* Reference to a field of type 'RefundStatus[]'
*/
export type ListEnumRefundStatusFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'RefundStatus[]'>
/** /**
* Reference to a field of type 'Float' * Reference to a field of type 'Float'
*/ */
@@ -1720,6 +1876,7 @@ export type GlobalOmitConfig = {
tripParticipant?: Prisma.TripParticipantOmit tripParticipant?: Prisma.TripParticipantOmit
booking?: Prisma.BookingOmit booking?: Prisma.BookingOmit
payment?: Prisma.PaymentOmit payment?: Prisma.PaymentOmit
refund?: Prisma.RefundOmit
} }
/* Types for Logging */ /* Types for Logging */
@@ -60,7 +60,8 @@ export const ModelName = {
TripImage: 'TripImage', TripImage: 'TripImage',
TripParticipant: 'TripParticipant', TripParticipant: 'TripParticipant',
Booking: 'Booking', Booking: 'Booking',
Payment: 'Payment' Payment: 'Payment',
Refund: 'Refund'
} as const } as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName] export type ModelName = (typeof ModelName)[keyof typeof ModelName]
@@ -252,6 +253,31 @@ export const PaymentScalarFieldEnum = {
export type PaymentScalarFieldEnum = (typeof PaymentScalarFieldEnum)[keyof typeof PaymentScalarFieldEnum] export type PaymentScalarFieldEnum = (typeof PaymentScalarFieldEnum)[keyof typeof PaymentScalarFieldEnum]
export const RefundScalarFieldEnum = {
id: 'id',
bookingId: 'bookingId',
paymentId: 'paymentId',
amount: 'amount',
currency: 'currency',
reason: 'reason',
reportedBy: 'reportedBy',
reportNote: 'reportNote',
initiatedBy: 'initiatedBy',
status: 'status',
idempotencyKey: 'idempotencyKey',
adminNote: 'adminNote',
reviewedById: 'reviewedById',
reviewedAt: 'reviewedAt',
succeededAt: 'succeededAt',
failedAt: 'failedAt',
externalRefundId: 'externalRefundId',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type RefundScalarFieldEnum = (typeof RefundScalarFieldEnum)[keyof typeof RefundScalarFieldEnum]
export const SortOrder = { export const SortOrder = {
asc: 'asc', asc: 'asc',
desc: 'desc' desc: 'desc'
+1
View File
@@ -18,4 +18,5 @@ export type * from './models/TripImage'
export type * from './models/TripParticipant' export type * from './models/TripParticipant'
export type * from './models/Booking' export type * from './models/Booking'
export type * from './models/Payment' export type * from './models/Payment'
export type * from './models/Refund'
export type * from './commonInputTypes' export type * from './commonInputTypes'
+142
View File
@@ -257,6 +257,7 @@ export type BookingWhereInput = {
user?: Prisma.XOR<Prisma.UserScalarRelationFilter, Prisma.UserWhereInput> user?: Prisma.XOR<Prisma.UserScalarRelationFilter, Prisma.UserWhereInput>
participant?: Prisma.XOR<Prisma.TripParticipantScalarRelationFilter, Prisma.TripParticipantWhereInput> participant?: Prisma.XOR<Prisma.TripParticipantScalarRelationFilter, Prisma.TripParticipantWhereInput>
payments?: Prisma.PaymentListRelationFilter payments?: Prisma.PaymentListRelationFilter
refunds?: Prisma.RefundListRelationFilter
} }
export type BookingOrderByWithRelationInput = { export type BookingOrderByWithRelationInput = {
@@ -273,6 +274,7 @@ export type BookingOrderByWithRelationInput = {
user?: Prisma.UserOrderByWithRelationInput user?: Prisma.UserOrderByWithRelationInput
participant?: Prisma.TripParticipantOrderByWithRelationInput participant?: Prisma.TripParticipantOrderByWithRelationInput
payments?: Prisma.PaymentOrderByRelationAggregateInput payments?: Prisma.PaymentOrderByRelationAggregateInput
refunds?: Prisma.RefundOrderByRelationAggregateInput
} }
export type BookingWhereUniqueInput = Prisma.AtLeast<{ export type BookingWhereUniqueInput = Prisma.AtLeast<{
@@ -293,6 +295,7 @@ export type BookingWhereUniqueInput = Prisma.AtLeast<{
user?: Prisma.XOR<Prisma.UserScalarRelationFilter, Prisma.UserWhereInput> user?: Prisma.XOR<Prisma.UserScalarRelationFilter, Prisma.UserWhereInput>
participant?: Prisma.XOR<Prisma.TripParticipantScalarRelationFilter, Prisma.TripParticipantWhereInput> participant?: Prisma.XOR<Prisma.TripParticipantScalarRelationFilter, Prisma.TripParticipantWhereInput>
payments?: Prisma.PaymentListRelationFilter payments?: Prisma.PaymentListRelationFilter
refunds?: Prisma.RefundListRelationFilter
}, "id" | "participantId" | "tripId_userId"> }, "id" | "participantId" | "tripId_userId">
export type BookingOrderByWithAggregationInput = { export type BookingOrderByWithAggregationInput = {
@@ -338,6 +341,7 @@ export type BookingCreateInput = {
user: Prisma.UserCreateNestedOneWithoutBookingsInput user: Prisma.UserCreateNestedOneWithoutBookingsInput
participant: Prisma.TripParticipantCreateNestedOneWithoutBookingInput participant: Prisma.TripParticipantCreateNestedOneWithoutBookingInput
payments?: Prisma.PaymentCreateNestedManyWithoutBookingInput payments?: Prisma.PaymentCreateNestedManyWithoutBookingInput
refunds?: Prisma.RefundCreateNestedManyWithoutBookingInput
} }
export type BookingUncheckedCreateInput = { export type BookingUncheckedCreateInput = {
@@ -351,6 +355,7 @@ export type BookingUncheckedCreateInput = {
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
payments?: Prisma.PaymentUncheckedCreateNestedManyWithoutBookingInput payments?: Prisma.PaymentUncheckedCreateNestedManyWithoutBookingInput
refunds?: Prisma.RefundUncheckedCreateNestedManyWithoutBookingInput
} }
export type BookingUpdateInput = { export type BookingUpdateInput = {
@@ -364,6 +369,7 @@ export type BookingUpdateInput = {
user?: Prisma.UserUpdateOneRequiredWithoutBookingsNestedInput user?: Prisma.UserUpdateOneRequiredWithoutBookingsNestedInput
participant?: Prisma.TripParticipantUpdateOneRequiredWithoutBookingNestedInput participant?: Prisma.TripParticipantUpdateOneRequiredWithoutBookingNestedInput
payments?: Prisma.PaymentUpdateManyWithoutBookingNestedInput payments?: Prisma.PaymentUpdateManyWithoutBookingNestedInput
refunds?: Prisma.RefundUpdateManyWithoutBookingNestedInput
} }
export type BookingUncheckedUpdateInput = { export type BookingUncheckedUpdateInput = {
@@ -377,6 +383,7 @@ export type BookingUncheckedUpdateInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
payments?: Prisma.PaymentUncheckedUpdateManyWithoutBookingNestedInput payments?: Prisma.PaymentUncheckedUpdateManyWithoutBookingNestedInput
refunds?: Prisma.RefundUncheckedUpdateManyWithoutBookingNestedInput
} }
export type BookingCreateManyInput = { export type BookingCreateManyInput = {
@@ -615,6 +622,20 @@ export type BookingUpdateOneRequiredWithoutPaymentsNestedInput = {
update?: Prisma.XOR<Prisma.XOR<Prisma.BookingUpdateToOneWithWhereWithoutPaymentsInput, Prisma.BookingUpdateWithoutPaymentsInput>, Prisma.BookingUncheckedUpdateWithoutPaymentsInput> update?: Prisma.XOR<Prisma.XOR<Prisma.BookingUpdateToOneWithWhereWithoutPaymentsInput, Prisma.BookingUpdateWithoutPaymentsInput>, Prisma.BookingUncheckedUpdateWithoutPaymentsInput>
} }
export type BookingCreateNestedOneWithoutRefundsInput = {
create?: Prisma.XOR<Prisma.BookingCreateWithoutRefundsInput, Prisma.BookingUncheckedCreateWithoutRefundsInput>
connectOrCreate?: Prisma.BookingCreateOrConnectWithoutRefundsInput
connect?: Prisma.BookingWhereUniqueInput
}
export type BookingUpdateOneRequiredWithoutRefundsNestedInput = {
create?: Prisma.XOR<Prisma.BookingCreateWithoutRefundsInput, Prisma.BookingUncheckedCreateWithoutRefundsInput>
connectOrCreate?: Prisma.BookingCreateOrConnectWithoutRefundsInput
upsert?: Prisma.BookingUpsertWithoutRefundsInput
connect?: Prisma.BookingWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.BookingUpdateToOneWithWhereWithoutRefundsInput, Prisma.BookingUpdateWithoutRefundsInput>, Prisma.BookingUncheckedUpdateWithoutRefundsInput>
}
export type BookingCreateWithoutUserInput = { export type BookingCreateWithoutUserInput = {
id?: string id?: string
amount: number amount: number
@@ -625,6 +646,7 @@ export type BookingCreateWithoutUserInput = {
trip: Prisma.TripCreateNestedOneWithoutBookingsInput trip: Prisma.TripCreateNestedOneWithoutBookingsInput
participant: Prisma.TripParticipantCreateNestedOneWithoutBookingInput participant: Prisma.TripParticipantCreateNestedOneWithoutBookingInput
payments?: Prisma.PaymentCreateNestedManyWithoutBookingInput payments?: Prisma.PaymentCreateNestedManyWithoutBookingInput
refunds?: Prisma.RefundCreateNestedManyWithoutBookingInput
} }
export type BookingUncheckedCreateWithoutUserInput = { export type BookingUncheckedCreateWithoutUserInput = {
@@ -637,6 +659,7 @@ export type BookingUncheckedCreateWithoutUserInput = {
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
payments?: Prisma.PaymentUncheckedCreateNestedManyWithoutBookingInput payments?: Prisma.PaymentUncheckedCreateNestedManyWithoutBookingInput
refunds?: Prisma.RefundUncheckedCreateNestedManyWithoutBookingInput
} }
export type BookingCreateOrConnectWithoutUserInput = { export type BookingCreateOrConnectWithoutUserInput = {
@@ -690,6 +713,7 @@ export type BookingCreateWithoutTripInput = {
user: Prisma.UserCreateNestedOneWithoutBookingsInput user: Prisma.UserCreateNestedOneWithoutBookingsInput
participant: Prisma.TripParticipantCreateNestedOneWithoutBookingInput participant: Prisma.TripParticipantCreateNestedOneWithoutBookingInput
payments?: Prisma.PaymentCreateNestedManyWithoutBookingInput payments?: Prisma.PaymentCreateNestedManyWithoutBookingInput
refunds?: Prisma.RefundCreateNestedManyWithoutBookingInput
} }
export type BookingUncheckedCreateWithoutTripInput = { export type BookingUncheckedCreateWithoutTripInput = {
@@ -702,6 +726,7 @@ export type BookingUncheckedCreateWithoutTripInput = {
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
payments?: Prisma.PaymentUncheckedCreateNestedManyWithoutBookingInput payments?: Prisma.PaymentUncheckedCreateNestedManyWithoutBookingInput
refunds?: Prisma.RefundUncheckedCreateNestedManyWithoutBookingInput
} }
export type BookingCreateOrConnectWithoutTripInput = { export type BookingCreateOrConnectWithoutTripInput = {
@@ -740,6 +765,7 @@ export type BookingCreateWithoutParticipantInput = {
trip: Prisma.TripCreateNestedOneWithoutBookingsInput trip: Prisma.TripCreateNestedOneWithoutBookingsInput
user: Prisma.UserCreateNestedOneWithoutBookingsInput user: Prisma.UserCreateNestedOneWithoutBookingsInput
payments?: Prisma.PaymentCreateNestedManyWithoutBookingInput payments?: Prisma.PaymentCreateNestedManyWithoutBookingInput
refunds?: Prisma.RefundCreateNestedManyWithoutBookingInput
} }
export type BookingUncheckedCreateWithoutParticipantInput = { export type BookingUncheckedCreateWithoutParticipantInput = {
@@ -752,6 +778,7 @@ export type BookingUncheckedCreateWithoutParticipantInput = {
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
payments?: Prisma.PaymentUncheckedCreateNestedManyWithoutBookingInput payments?: Prisma.PaymentUncheckedCreateNestedManyWithoutBookingInput
refunds?: Prisma.RefundUncheckedCreateNestedManyWithoutBookingInput
} }
export type BookingCreateOrConnectWithoutParticipantInput = { export type BookingCreateOrConnectWithoutParticipantInput = {
@@ -780,6 +807,7 @@ export type BookingUpdateWithoutParticipantInput = {
trip?: Prisma.TripUpdateOneRequiredWithoutBookingsNestedInput trip?: Prisma.TripUpdateOneRequiredWithoutBookingsNestedInput
user?: Prisma.UserUpdateOneRequiredWithoutBookingsNestedInput user?: Prisma.UserUpdateOneRequiredWithoutBookingsNestedInput
payments?: Prisma.PaymentUpdateManyWithoutBookingNestedInput payments?: Prisma.PaymentUpdateManyWithoutBookingNestedInput
refunds?: Prisma.RefundUpdateManyWithoutBookingNestedInput
} }
export type BookingUncheckedUpdateWithoutParticipantInput = { export type BookingUncheckedUpdateWithoutParticipantInput = {
@@ -792,6 +820,7 @@ export type BookingUncheckedUpdateWithoutParticipantInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
payments?: Prisma.PaymentUncheckedUpdateManyWithoutBookingNestedInput payments?: Prisma.PaymentUncheckedUpdateManyWithoutBookingNestedInput
refunds?: Prisma.RefundUncheckedUpdateManyWithoutBookingNestedInput
} }
export type BookingCreateWithoutPaymentsInput = { export type BookingCreateWithoutPaymentsInput = {
@@ -804,6 +833,7 @@ export type BookingCreateWithoutPaymentsInput = {
trip: Prisma.TripCreateNestedOneWithoutBookingsInput trip: Prisma.TripCreateNestedOneWithoutBookingsInput
user: Prisma.UserCreateNestedOneWithoutBookingsInput user: Prisma.UserCreateNestedOneWithoutBookingsInput
participant: Prisma.TripParticipantCreateNestedOneWithoutBookingInput participant: Prisma.TripParticipantCreateNestedOneWithoutBookingInput
refunds?: Prisma.RefundCreateNestedManyWithoutBookingInput
} }
export type BookingUncheckedCreateWithoutPaymentsInput = { export type BookingUncheckedCreateWithoutPaymentsInput = {
@@ -816,6 +846,7 @@ export type BookingUncheckedCreateWithoutPaymentsInput = {
status?: $Enums.BookingStatus status?: $Enums.BookingStatus
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
refunds?: Prisma.RefundUncheckedCreateNestedManyWithoutBookingInput
} }
export type BookingCreateOrConnectWithoutPaymentsInput = { export type BookingCreateOrConnectWithoutPaymentsInput = {
@@ -844,6 +875,7 @@ export type BookingUpdateWithoutPaymentsInput = {
trip?: Prisma.TripUpdateOneRequiredWithoutBookingsNestedInput trip?: Prisma.TripUpdateOneRequiredWithoutBookingsNestedInput
user?: Prisma.UserUpdateOneRequiredWithoutBookingsNestedInput user?: Prisma.UserUpdateOneRequiredWithoutBookingsNestedInput
participant?: Prisma.TripParticipantUpdateOneRequiredWithoutBookingNestedInput participant?: Prisma.TripParticipantUpdateOneRequiredWithoutBookingNestedInput
refunds?: Prisma.RefundUpdateManyWithoutBookingNestedInput
} }
export type BookingUncheckedUpdateWithoutPaymentsInput = { export type BookingUncheckedUpdateWithoutPaymentsInput = {
@@ -856,6 +888,75 @@ export type BookingUncheckedUpdateWithoutPaymentsInput = {
status?: Prisma.EnumBookingStatusFieldUpdateOperationsInput | $Enums.BookingStatus status?: Prisma.EnumBookingStatusFieldUpdateOperationsInput | $Enums.BookingStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
refunds?: Prisma.RefundUncheckedUpdateManyWithoutBookingNestedInput
}
export type BookingCreateWithoutRefundsInput = {
id?: string
amount: number
currency?: string
status?: $Enums.BookingStatus
createdAt?: Date | string
updatedAt?: Date | string
trip: Prisma.TripCreateNestedOneWithoutBookingsInput
user: Prisma.UserCreateNestedOneWithoutBookingsInput
participant: Prisma.TripParticipantCreateNestedOneWithoutBookingInput
payments?: Prisma.PaymentCreateNestedManyWithoutBookingInput
}
export type BookingUncheckedCreateWithoutRefundsInput = {
id?: string
tripId: string
userId: string
participantId: string
amount: number
currency?: string
status?: $Enums.BookingStatus
createdAt?: Date | string
updatedAt?: Date | string
payments?: Prisma.PaymentUncheckedCreateNestedManyWithoutBookingInput
}
export type BookingCreateOrConnectWithoutRefundsInput = {
where: Prisma.BookingWhereUniqueInput
create: Prisma.XOR<Prisma.BookingCreateWithoutRefundsInput, Prisma.BookingUncheckedCreateWithoutRefundsInput>
}
export type BookingUpsertWithoutRefundsInput = {
update: Prisma.XOR<Prisma.BookingUpdateWithoutRefundsInput, Prisma.BookingUncheckedUpdateWithoutRefundsInput>
create: Prisma.XOR<Prisma.BookingCreateWithoutRefundsInput, Prisma.BookingUncheckedCreateWithoutRefundsInput>
where?: Prisma.BookingWhereInput
}
export type BookingUpdateToOneWithWhereWithoutRefundsInput = {
where?: Prisma.BookingWhereInput
data: Prisma.XOR<Prisma.BookingUpdateWithoutRefundsInput, Prisma.BookingUncheckedUpdateWithoutRefundsInput>
}
export type BookingUpdateWithoutRefundsInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
amount?: Prisma.IntFieldUpdateOperationsInput | number
currency?: Prisma.StringFieldUpdateOperationsInput | string
status?: Prisma.EnumBookingStatusFieldUpdateOperationsInput | $Enums.BookingStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
trip?: Prisma.TripUpdateOneRequiredWithoutBookingsNestedInput
user?: Prisma.UserUpdateOneRequiredWithoutBookingsNestedInput
participant?: Prisma.TripParticipantUpdateOneRequiredWithoutBookingNestedInput
payments?: Prisma.PaymentUpdateManyWithoutBookingNestedInput
}
export type BookingUncheckedUpdateWithoutRefundsInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
tripId?: Prisma.StringFieldUpdateOperationsInput | string
userId?: Prisma.StringFieldUpdateOperationsInput | string
participantId?: Prisma.StringFieldUpdateOperationsInput | string
amount?: Prisma.IntFieldUpdateOperationsInput | number
currency?: Prisma.StringFieldUpdateOperationsInput | string
status?: Prisma.EnumBookingStatusFieldUpdateOperationsInput | $Enums.BookingStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
payments?: Prisma.PaymentUncheckedUpdateManyWithoutBookingNestedInput
} }
export type BookingCreateManyUserInput = { export type BookingCreateManyUserInput = {
@@ -879,6 +980,7 @@ export type BookingUpdateWithoutUserInput = {
trip?: Prisma.TripUpdateOneRequiredWithoutBookingsNestedInput trip?: Prisma.TripUpdateOneRequiredWithoutBookingsNestedInput
participant?: Prisma.TripParticipantUpdateOneRequiredWithoutBookingNestedInput participant?: Prisma.TripParticipantUpdateOneRequiredWithoutBookingNestedInput
payments?: Prisma.PaymentUpdateManyWithoutBookingNestedInput payments?: Prisma.PaymentUpdateManyWithoutBookingNestedInput
refunds?: Prisma.RefundUpdateManyWithoutBookingNestedInput
} }
export type BookingUncheckedUpdateWithoutUserInput = { export type BookingUncheckedUpdateWithoutUserInput = {
@@ -891,6 +993,7 @@ export type BookingUncheckedUpdateWithoutUserInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
payments?: Prisma.PaymentUncheckedUpdateManyWithoutBookingNestedInput payments?: Prisma.PaymentUncheckedUpdateManyWithoutBookingNestedInput
refunds?: Prisma.RefundUncheckedUpdateManyWithoutBookingNestedInput
} }
export type BookingUncheckedUpdateManyWithoutUserInput = { export type BookingUncheckedUpdateManyWithoutUserInput = {
@@ -925,6 +1028,7 @@ export type BookingUpdateWithoutTripInput = {
user?: Prisma.UserUpdateOneRequiredWithoutBookingsNestedInput user?: Prisma.UserUpdateOneRequiredWithoutBookingsNestedInput
participant?: Prisma.TripParticipantUpdateOneRequiredWithoutBookingNestedInput participant?: Prisma.TripParticipantUpdateOneRequiredWithoutBookingNestedInput
payments?: Prisma.PaymentUpdateManyWithoutBookingNestedInput payments?: Prisma.PaymentUpdateManyWithoutBookingNestedInput
refunds?: Prisma.RefundUpdateManyWithoutBookingNestedInput
} }
export type BookingUncheckedUpdateWithoutTripInput = { export type BookingUncheckedUpdateWithoutTripInput = {
@@ -937,6 +1041,7 @@ export type BookingUncheckedUpdateWithoutTripInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
payments?: Prisma.PaymentUncheckedUpdateManyWithoutBookingNestedInput payments?: Prisma.PaymentUncheckedUpdateManyWithoutBookingNestedInput
refunds?: Prisma.RefundUncheckedUpdateManyWithoutBookingNestedInput
} }
export type BookingUncheckedUpdateManyWithoutTripInput = { export type BookingUncheckedUpdateManyWithoutTripInput = {
@@ -957,10 +1062,12 @@ export type BookingUncheckedUpdateManyWithoutTripInput = {
export type BookingCountOutputType = { export type BookingCountOutputType = {
payments: number payments: number
refunds: number
} }
export type BookingCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = { export type BookingCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
payments?: boolean | BookingCountOutputTypeCountPaymentsArgs payments?: boolean | BookingCountOutputTypeCountPaymentsArgs
refunds?: boolean | BookingCountOutputTypeCountRefundsArgs
} }
/** /**
@@ -980,6 +1087,13 @@ export type BookingCountOutputTypeCountPaymentsArgs<ExtArgs extends runtime.Type
where?: Prisma.PaymentWhereInput where?: Prisma.PaymentWhereInput
} }
/**
* BookingCountOutputType without action
*/
export type BookingCountOutputTypeCountRefundsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
where?: Prisma.RefundWhereInput
}
export type BookingSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{ export type BookingSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean id?: boolean
@@ -995,6 +1109,7 @@ export type BookingSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs
user?: boolean | Prisma.UserDefaultArgs<ExtArgs> user?: boolean | Prisma.UserDefaultArgs<ExtArgs>
participant?: boolean | Prisma.TripParticipantDefaultArgs<ExtArgs> participant?: boolean | Prisma.TripParticipantDefaultArgs<ExtArgs>
payments?: boolean | Prisma.Booking$paymentsArgs<ExtArgs> payments?: boolean | Prisma.Booking$paymentsArgs<ExtArgs>
refunds?: boolean | Prisma.Booking$refundsArgs<ExtArgs>
_count?: boolean | Prisma.BookingCountOutputTypeDefaultArgs<ExtArgs> _count?: boolean | Prisma.BookingCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["booking"]> }, ExtArgs["result"]["booking"]>
@@ -1046,6 +1161,7 @@ export type BookingInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs
user?: boolean | Prisma.UserDefaultArgs<ExtArgs> user?: boolean | Prisma.UserDefaultArgs<ExtArgs>
participant?: boolean | Prisma.TripParticipantDefaultArgs<ExtArgs> participant?: boolean | Prisma.TripParticipantDefaultArgs<ExtArgs>
payments?: boolean | Prisma.Booking$paymentsArgs<ExtArgs> payments?: boolean | Prisma.Booking$paymentsArgs<ExtArgs>
refunds?: boolean | Prisma.Booking$refundsArgs<ExtArgs>
_count?: boolean | Prisma.BookingCountOutputTypeDefaultArgs<ExtArgs> _count?: boolean | Prisma.BookingCountOutputTypeDefaultArgs<ExtArgs>
} }
export type BookingIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = { export type BookingIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
@@ -1066,6 +1182,7 @@ export type $BookingPayload<ExtArgs extends runtime.Types.Extensions.InternalArg
user: Prisma.$UserPayload<ExtArgs> user: Prisma.$UserPayload<ExtArgs>
participant: Prisma.$TripParticipantPayload<ExtArgs> participant: Prisma.$TripParticipantPayload<ExtArgs>
payments: Prisma.$PaymentPayload<ExtArgs>[] payments: Prisma.$PaymentPayload<ExtArgs>[]
refunds: Prisma.$RefundPayload<ExtArgs>[]
} }
scalars: runtime.Types.Extensions.GetPayloadResult<{ scalars: runtime.Types.Extensions.GetPayloadResult<{
id: string id: string
@@ -1475,6 +1592,7 @@ export interface Prisma__BookingClient<T, Null = never, ExtArgs extends runtime.
user<T extends Prisma.UserDefaultArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.UserDefaultArgs<ExtArgs>>): Prisma.Prisma__UserClient<runtime.Types.Result.GetResult<Prisma.$UserPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | Null, Null, ExtArgs, GlobalOmitOptions> user<T extends Prisma.UserDefaultArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.UserDefaultArgs<ExtArgs>>): Prisma.Prisma__UserClient<runtime.Types.Result.GetResult<Prisma.$UserPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | Null, Null, ExtArgs, GlobalOmitOptions>
participant<T extends Prisma.TripParticipantDefaultArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.TripParticipantDefaultArgs<ExtArgs>>): Prisma.Prisma__TripParticipantClient<runtime.Types.Result.GetResult<Prisma.$TripParticipantPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | Null, Null, ExtArgs, GlobalOmitOptions> participant<T extends Prisma.TripParticipantDefaultArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.TripParticipantDefaultArgs<ExtArgs>>): Prisma.Prisma__TripParticipantClient<runtime.Types.Result.GetResult<Prisma.$TripParticipantPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | Null, Null, ExtArgs, GlobalOmitOptions>
payments<T extends Prisma.Booking$paymentsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Booking$paymentsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$PaymentPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null> payments<T extends Prisma.Booking$paymentsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Booking$paymentsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$PaymentPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
refunds<T extends Prisma.Booking$refundsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Booking$refundsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$RefundPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
/** /**
* Attaches callbacks for the resolution and/or rejection of the Promise. * Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved. * @param onfulfilled The callback to execute when the Promise is resolved.
@@ -1937,6 +2055,30 @@ export type Booking$paymentsArgs<ExtArgs extends runtime.Types.Extensions.Intern
distinct?: Prisma.PaymentScalarFieldEnum | Prisma.PaymentScalarFieldEnum[] distinct?: Prisma.PaymentScalarFieldEnum | Prisma.PaymentScalarFieldEnum[]
} }
/**
* Booking.refunds
*/
export type Booking$refundsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the Refund
*/
select?: Prisma.RefundSelect<ExtArgs> | null
/**
* Omit specific fields from the Refund
*/
omit?: Prisma.RefundOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.RefundInclude<ExtArgs> | null
where?: Prisma.RefundWhereInput
orderBy?: Prisma.RefundOrderByWithRelationInput | Prisma.RefundOrderByWithRelationInput[]
cursor?: Prisma.RefundWhereUniqueInput
take?: number
skip?: number
distinct?: Prisma.RefundScalarFieldEnum | Prisma.RefundScalarFieldEnum[]
}
/** /**
* Booking without action * Booking without action
*/ */
+183
View File
@@ -302,6 +302,7 @@ export type PaymentWhereInput = {
createdAt?: Prisma.DateTimeFilter<"Payment"> | Date | string createdAt?: Prisma.DateTimeFilter<"Payment"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"Payment"> | Date | string updatedAt?: Prisma.DateTimeFilter<"Payment"> | Date | string
booking?: Prisma.XOR<Prisma.BookingScalarRelationFilter, Prisma.BookingWhereInput> booking?: Prisma.XOR<Prisma.BookingScalarRelationFilter, Prisma.BookingWhereInput>
refunds?: Prisma.RefundListRelationFilter
} }
export type PaymentOrderByWithRelationInput = { export type PaymentOrderByWithRelationInput = {
@@ -322,6 +323,7 @@ export type PaymentOrderByWithRelationInput = {
createdAt?: Prisma.SortOrder createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder
booking?: Prisma.BookingOrderByWithRelationInput booking?: Prisma.BookingOrderByWithRelationInput
refunds?: Prisma.RefundOrderByRelationAggregateInput
} }
export type PaymentWhereUniqueInput = Prisma.AtLeast<{ export type PaymentWhereUniqueInput = Prisma.AtLeast<{
@@ -345,6 +347,7 @@ export type PaymentWhereUniqueInput = Prisma.AtLeast<{
createdAt?: Prisma.DateTimeFilter<"Payment"> | Date | string createdAt?: Prisma.DateTimeFilter<"Payment"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"Payment"> | Date | string updatedAt?: Prisma.DateTimeFilter<"Payment"> | Date | string
booking?: Prisma.XOR<Prisma.BookingScalarRelationFilter, Prisma.BookingWhereInput> booking?: Prisma.XOR<Prisma.BookingScalarRelationFilter, Prisma.BookingWhereInput>
refunds?: Prisma.RefundListRelationFilter
}, "id" | "externalOrderId"> }, "id" | "externalOrderId">
export type PaymentOrderByWithAggregationInput = { export type PaymentOrderByWithAggregationInput = {
@@ -410,6 +413,7 @@ export type PaymentCreateInput = {
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
booking: Prisma.BookingCreateNestedOneWithoutPaymentsInput booking: Prisma.BookingCreateNestedOneWithoutPaymentsInput
refunds?: Prisma.RefundCreateNestedManyWithoutPaymentInput
} }
export type PaymentUncheckedCreateInput = { export type PaymentUncheckedCreateInput = {
@@ -429,6 +433,7 @@ export type PaymentUncheckedCreateInput = {
rejectionReason?: string | null rejectionReason?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
refunds?: Prisma.RefundUncheckedCreateNestedManyWithoutPaymentInput
} }
export type PaymentUpdateInput = { export type PaymentUpdateInput = {
@@ -448,6 +453,7 @@ export type PaymentUpdateInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
booking?: Prisma.BookingUpdateOneRequiredWithoutPaymentsNestedInput booking?: Prisma.BookingUpdateOneRequiredWithoutPaymentsNestedInput
refunds?: Prisma.RefundUpdateManyWithoutPaymentNestedInput
} }
export type PaymentUncheckedUpdateInput = { export type PaymentUncheckedUpdateInput = {
@@ -467,6 +473,7 @@ export type PaymentUncheckedUpdateInput = {
rejectionReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null rejectionReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
refunds?: Prisma.RefundUncheckedUpdateManyWithoutPaymentNestedInput
} }
export type PaymentCreateManyInput = { export type PaymentCreateManyInput = {
@@ -598,6 +605,11 @@ export type PaymentSumOrderByAggregateInput = {
amount?: Prisma.SortOrder amount?: Prisma.SortOrder
} }
export type PaymentNullableScalarRelationFilter = {
is?: Prisma.PaymentWhereInput | null
isNot?: Prisma.PaymentWhereInput | null
}
export type PaymentCreateNestedManyWithoutBookingInput = { export type PaymentCreateNestedManyWithoutBookingInput = {
create?: Prisma.XOR<Prisma.PaymentCreateWithoutBookingInput, Prisma.PaymentUncheckedCreateWithoutBookingInput> | Prisma.PaymentCreateWithoutBookingInput[] | Prisma.PaymentUncheckedCreateWithoutBookingInput[] create?: Prisma.XOR<Prisma.PaymentCreateWithoutBookingInput, Prisma.PaymentUncheckedCreateWithoutBookingInput> | Prisma.PaymentCreateWithoutBookingInput[] | Prisma.PaymentUncheckedCreateWithoutBookingInput[]
connectOrCreate?: Prisma.PaymentCreateOrConnectWithoutBookingInput | Prisma.PaymentCreateOrConnectWithoutBookingInput[] connectOrCreate?: Prisma.PaymentCreateOrConnectWithoutBookingInput | Prisma.PaymentCreateOrConnectWithoutBookingInput[]
@@ -648,6 +660,22 @@ export type EnumPaymentStatusFieldUpdateOperationsInput = {
set?: $Enums.PaymentStatus set?: $Enums.PaymentStatus
} }
export type PaymentCreateNestedOneWithoutRefundsInput = {
create?: Prisma.XOR<Prisma.PaymentCreateWithoutRefundsInput, Prisma.PaymentUncheckedCreateWithoutRefundsInput>
connectOrCreate?: Prisma.PaymentCreateOrConnectWithoutRefundsInput
connect?: Prisma.PaymentWhereUniqueInput
}
export type PaymentUpdateOneWithoutRefundsNestedInput = {
create?: Prisma.XOR<Prisma.PaymentCreateWithoutRefundsInput, Prisma.PaymentUncheckedCreateWithoutRefundsInput>
connectOrCreate?: Prisma.PaymentCreateOrConnectWithoutRefundsInput
upsert?: Prisma.PaymentUpsertWithoutRefundsInput
disconnect?: Prisma.PaymentWhereInput | boolean
delete?: Prisma.PaymentWhereInput | boolean
connect?: Prisma.PaymentWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.PaymentUpdateToOneWithWhereWithoutRefundsInput, Prisma.PaymentUpdateWithoutRefundsInput>, Prisma.PaymentUncheckedUpdateWithoutRefundsInput>
}
export type PaymentCreateWithoutBookingInput = { export type PaymentCreateWithoutBookingInput = {
id?: string id?: string
provider: $Enums.PaymentProvider provider: $Enums.PaymentProvider
@@ -664,6 +692,7 @@ export type PaymentCreateWithoutBookingInput = {
rejectionReason?: string | null rejectionReason?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
refunds?: Prisma.RefundCreateNestedManyWithoutPaymentInput
} }
export type PaymentUncheckedCreateWithoutBookingInput = { export type PaymentUncheckedCreateWithoutBookingInput = {
@@ -682,6 +711,7 @@ export type PaymentUncheckedCreateWithoutBookingInput = {
rejectionReason?: string | null rejectionReason?: string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
refunds?: Prisma.RefundUncheckedCreateNestedManyWithoutPaymentInput
} }
export type PaymentCreateOrConnectWithoutBookingInput = { export type PaymentCreateOrConnectWithoutBookingInput = {
@@ -732,6 +762,98 @@ export type PaymentScalarWhereInput = {
updatedAt?: Prisma.DateTimeFilter<"Payment"> | Date | string updatedAt?: Prisma.DateTimeFilter<"Payment"> | Date | string
} }
export type PaymentCreateWithoutRefundsInput = {
id?: string
provider: $Enums.PaymentProvider
externalOrderId: string
externalTxId?: string | null
method?: string | null
amount: number
status?: $Enums.PaymentStatus
rawCallback?: Prisma.NullableJsonNullValueInput | runtime.InputJsonValue
snapToken?: string | null
expiresAt?: Date | string | null
paidAt?: Date | string | null
failedAt?: Date | string | null
rejectionReason?: string | null
createdAt?: Date | string
updatedAt?: Date | string
booking: Prisma.BookingCreateNestedOneWithoutPaymentsInput
}
export type PaymentUncheckedCreateWithoutRefundsInput = {
id?: string
bookingId: string
provider: $Enums.PaymentProvider
externalOrderId: string
externalTxId?: string | null
method?: string | null
amount: number
status?: $Enums.PaymentStatus
rawCallback?: Prisma.NullableJsonNullValueInput | runtime.InputJsonValue
snapToken?: string | null
expiresAt?: Date | string | null
paidAt?: Date | string | null
failedAt?: Date | string | null
rejectionReason?: string | null
createdAt?: Date | string
updatedAt?: Date | string
}
export type PaymentCreateOrConnectWithoutRefundsInput = {
where: Prisma.PaymentWhereUniqueInput
create: Prisma.XOR<Prisma.PaymentCreateWithoutRefundsInput, Prisma.PaymentUncheckedCreateWithoutRefundsInput>
}
export type PaymentUpsertWithoutRefundsInput = {
update: Prisma.XOR<Prisma.PaymentUpdateWithoutRefundsInput, Prisma.PaymentUncheckedUpdateWithoutRefundsInput>
create: Prisma.XOR<Prisma.PaymentCreateWithoutRefundsInput, Prisma.PaymentUncheckedCreateWithoutRefundsInput>
where?: Prisma.PaymentWhereInput
}
export type PaymentUpdateToOneWithWhereWithoutRefundsInput = {
where?: Prisma.PaymentWhereInput
data: Prisma.XOR<Prisma.PaymentUpdateWithoutRefundsInput, Prisma.PaymentUncheckedUpdateWithoutRefundsInput>
}
export type PaymentUpdateWithoutRefundsInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
provider?: Prisma.EnumPaymentProviderFieldUpdateOperationsInput | $Enums.PaymentProvider
externalOrderId?: Prisma.StringFieldUpdateOperationsInput | string
externalTxId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
method?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
amount?: Prisma.IntFieldUpdateOperationsInput | number
status?: Prisma.EnumPaymentStatusFieldUpdateOperationsInput | $Enums.PaymentStatus
rawCallback?: Prisma.NullableJsonNullValueInput | runtime.InputJsonValue
snapToken?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
expiresAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
paidAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
failedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
rejectionReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
booking?: Prisma.BookingUpdateOneRequiredWithoutPaymentsNestedInput
}
export type PaymentUncheckedUpdateWithoutRefundsInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
bookingId?: Prisma.StringFieldUpdateOperationsInput | string
provider?: Prisma.EnumPaymentProviderFieldUpdateOperationsInput | $Enums.PaymentProvider
externalOrderId?: Prisma.StringFieldUpdateOperationsInput | string
externalTxId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
method?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
amount?: Prisma.IntFieldUpdateOperationsInput | number
status?: Prisma.EnumPaymentStatusFieldUpdateOperationsInput | $Enums.PaymentStatus
rawCallback?: Prisma.NullableJsonNullValueInput | runtime.InputJsonValue
snapToken?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
expiresAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
paidAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
failedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
rejectionReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
}
export type PaymentCreateManyBookingInput = { export type PaymentCreateManyBookingInput = {
id?: string id?: string
provider: $Enums.PaymentProvider provider: $Enums.PaymentProvider
@@ -766,6 +888,7 @@ export type PaymentUpdateWithoutBookingInput = {
rejectionReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null rejectionReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
refunds?: Prisma.RefundUpdateManyWithoutPaymentNestedInput
} }
export type PaymentUncheckedUpdateWithoutBookingInput = { export type PaymentUncheckedUpdateWithoutBookingInput = {
@@ -784,6 +907,7 @@ export type PaymentUncheckedUpdateWithoutBookingInput = {
rejectionReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null rejectionReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
refunds?: Prisma.RefundUncheckedUpdateManyWithoutPaymentNestedInput
} }
export type PaymentUncheckedUpdateManyWithoutBookingInput = { export type PaymentUncheckedUpdateManyWithoutBookingInput = {
@@ -805,6 +929,35 @@ export type PaymentUncheckedUpdateManyWithoutBookingInput = {
} }
/**
* Count Type PaymentCountOutputType
*/
export type PaymentCountOutputType = {
refunds: number
}
export type PaymentCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
refunds?: boolean | PaymentCountOutputTypeCountRefundsArgs
}
/**
* PaymentCountOutputType without action
*/
export type PaymentCountOutputTypeDefaultArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the PaymentCountOutputType
*/
select?: Prisma.PaymentCountOutputTypeSelect<ExtArgs> | null
}
/**
* PaymentCountOutputType without action
*/
export type PaymentCountOutputTypeCountRefundsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
where?: Prisma.RefundWhereInput
}
export type PaymentSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{ export type PaymentSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean id?: boolean
@@ -824,6 +977,8 @@ export type PaymentSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs
createdAt?: boolean createdAt?: boolean
updatedAt?: boolean updatedAt?: boolean
booking?: boolean | Prisma.BookingDefaultArgs<ExtArgs> booking?: boolean | Prisma.BookingDefaultArgs<ExtArgs>
refunds?: boolean | Prisma.Payment$refundsArgs<ExtArgs>
_count?: boolean | Prisma.PaymentCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["payment"]> }, ExtArgs["result"]["payment"]>
export type PaymentSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{ export type PaymentSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
@@ -888,6 +1043,8 @@ export type PaymentSelectScalar = {
export type PaymentOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "bookingId" | "provider" | "externalOrderId" | "externalTxId" | "method" | "amount" | "status" | "rawCallback" | "snapToken" | "expiresAt" | "paidAt" | "failedAt" | "rejectionReason" | "createdAt" | "updatedAt", ExtArgs["result"]["payment"]> export type PaymentOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "bookingId" | "provider" | "externalOrderId" | "externalTxId" | "method" | "amount" | "status" | "rawCallback" | "snapToken" | "expiresAt" | "paidAt" | "failedAt" | "rejectionReason" | "createdAt" | "updatedAt", ExtArgs["result"]["payment"]>
export type PaymentInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = { export type PaymentInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
booking?: boolean | Prisma.BookingDefaultArgs<ExtArgs> booking?: boolean | Prisma.BookingDefaultArgs<ExtArgs>
refunds?: boolean | Prisma.Payment$refundsArgs<ExtArgs>
_count?: boolean | Prisma.PaymentCountOutputTypeDefaultArgs<ExtArgs>
} }
export type PaymentIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = { export type PaymentIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
booking?: boolean | Prisma.BookingDefaultArgs<ExtArgs> booking?: boolean | Prisma.BookingDefaultArgs<ExtArgs>
@@ -900,6 +1057,7 @@ export type $PaymentPayload<ExtArgs extends runtime.Types.Extensions.InternalArg
name: "Payment" name: "Payment"
objects: { objects: {
booking: Prisma.$BookingPayload<ExtArgs> booking: Prisma.$BookingPayload<ExtArgs>
refunds: Prisma.$RefundPayload<ExtArgs>[]
} }
scalars: runtime.Types.Extensions.GetPayloadResult<{ scalars: runtime.Types.Extensions.GetPayloadResult<{
id: string id: string
@@ -1332,6 +1490,7 @@ readonly fields: PaymentFieldRefs;
export interface Prisma__PaymentClient<T, Null = never, ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs, GlobalOmitOptions = {}> extends Prisma.PrismaPromise<T> { export interface Prisma__PaymentClient<T, Null = never, ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs, GlobalOmitOptions = {}> extends Prisma.PrismaPromise<T> {
readonly [Symbol.toStringTag]: "PrismaPromise" readonly [Symbol.toStringTag]: "PrismaPromise"
booking<T extends Prisma.BookingDefaultArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.BookingDefaultArgs<ExtArgs>>): Prisma.Prisma__BookingClient<runtime.Types.Result.GetResult<Prisma.$BookingPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | Null, Null, ExtArgs, GlobalOmitOptions> booking<T extends Prisma.BookingDefaultArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.BookingDefaultArgs<ExtArgs>>): Prisma.Prisma__BookingClient<runtime.Types.Result.GetResult<Prisma.$BookingPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | Null, Null, ExtArgs, GlobalOmitOptions>
refunds<T extends Prisma.Payment$refundsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Payment$refundsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$RefundPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
/** /**
* Attaches callbacks for the resolution and/or rejection of the Promise. * Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved. * @param onfulfilled The callback to execute when the Promise is resolved.
@@ -1777,6 +1936,30 @@ export type PaymentDeleteManyArgs<ExtArgs extends runtime.Types.Extensions.Inter
limit?: number limit?: number
} }
/**
* Payment.refunds
*/
export type Payment$refundsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the Refund
*/
select?: Prisma.RefundSelect<ExtArgs> | null
/**
* Omit specific fields from the Refund
*/
omit?: Prisma.RefundOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.RefundInclude<ExtArgs> | null
where?: Prisma.RefundWhereInput
orderBy?: Prisma.RefundOrderByWithRelationInput | Prisma.RefundOrderByWithRelationInput[]
cursor?: Prisma.RefundWhereUniqueInput
take?: number
skip?: number
distinct?: Prisma.RefundScalarFieldEnum | Prisma.RefundScalarFieldEnum[]
}
/** /**
* Payment without action * Payment without action
*/ */
File diff suppressed because it is too large Load Diff
+192
View File
@@ -229,6 +229,7 @@ export type UserWhereInput = {
bookings?: Prisma.BookingListRelationFilter bookings?: Prisma.BookingListRelationFilter
organizerVerification?: Prisma.XOR<Prisma.OrganizerVerificationNullableScalarRelationFilter, Prisma.OrganizerVerificationWhereInput> | null organizerVerification?: Prisma.XOR<Prisma.OrganizerVerificationNullableScalarRelationFilter, Prisma.OrganizerVerificationWhereInput> | null
reviewedVerifications?: Prisma.OrganizerVerificationListRelationFilter reviewedVerifications?: Prisma.OrganizerVerificationListRelationFilter
reviewedRefunds?: Prisma.RefundListRelationFilter
profile?: Prisma.XOR<Prisma.UserProfileNullableScalarRelationFilter, Prisma.UserProfileWhereInput> | null profile?: Prisma.XOR<Prisma.UserProfileNullableScalarRelationFilter, Prisma.UserProfileWhereInput> | null
} }
@@ -250,6 +251,7 @@ export type UserOrderByWithRelationInput = {
bookings?: Prisma.BookingOrderByRelationAggregateInput bookings?: Prisma.BookingOrderByRelationAggregateInput
organizerVerification?: Prisma.OrganizerVerificationOrderByWithRelationInput organizerVerification?: Prisma.OrganizerVerificationOrderByWithRelationInput
reviewedVerifications?: Prisma.OrganizerVerificationOrderByRelationAggregateInput reviewedVerifications?: Prisma.OrganizerVerificationOrderByRelationAggregateInput
reviewedRefunds?: Prisma.RefundOrderByRelationAggregateInput
profile?: Prisma.UserProfileOrderByWithRelationInput profile?: Prisma.UserProfileOrderByWithRelationInput
} }
@@ -274,6 +276,7 @@ export type UserWhereUniqueInput = Prisma.AtLeast<{
bookings?: Prisma.BookingListRelationFilter bookings?: Prisma.BookingListRelationFilter
organizerVerification?: Prisma.XOR<Prisma.OrganizerVerificationNullableScalarRelationFilter, Prisma.OrganizerVerificationWhereInput> | null organizerVerification?: Prisma.XOR<Prisma.OrganizerVerificationNullableScalarRelationFilter, Prisma.OrganizerVerificationWhereInput> | null
reviewedVerifications?: Prisma.OrganizerVerificationListRelationFilter reviewedVerifications?: Prisma.OrganizerVerificationListRelationFilter
reviewedRefunds?: Prisma.RefundListRelationFilter
profile?: Prisma.XOR<Prisma.UserProfileNullableScalarRelationFilter, Prisma.UserProfileWhereInput> | null profile?: Prisma.XOR<Prisma.UserProfileNullableScalarRelationFilter, Prisma.UserProfileWhereInput> | null
}, "id" | "email"> }, "id" | "email">
@@ -327,6 +330,7 @@ export type UserCreateInput = {
bookings?: Prisma.BookingCreateNestedManyWithoutUserInput bookings?: Prisma.BookingCreateNestedManyWithoutUserInput
organizerVerification?: Prisma.OrganizerVerificationCreateNestedOneWithoutUserInput organizerVerification?: Prisma.OrganizerVerificationCreateNestedOneWithoutUserInput
reviewedVerifications?: Prisma.OrganizerVerificationCreateNestedManyWithoutReviewedByInput reviewedVerifications?: Prisma.OrganizerVerificationCreateNestedManyWithoutReviewedByInput
reviewedRefunds?: Prisma.RefundCreateNestedManyWithoutReviewedByInput
profile?: Prisma.UserProfileCreateNestedOneWithoutUserInput profile?: Prisma.UserProfileCreateNestedOneWithoutUserInput
} }
@@ -348,6 +352,7 @@ export type UserUncheckedCreateInput = {
bookings?: Prisma.BookingUncheckedCreateNestedManyWithoutUserInput bookings?: Prisma.BookingUncheckedCreateNestedManyWithoutUserInput
organizerVerification?: Prisma.OrganizerVerificationUncheckedCreateNestedOneWithoutUserInput organizerVerification?: Prisma.OrganizerVerificationUncheckedCreateNestedOneWithoutUserInput
reviewedVerifications?: Prisma.OrganizerVerificationUncheckedCreateNestedManyWithoutReviewedByInput reviewedVerifications?: Prisma.OrganizerVerificationUncheckedCreateNestedManyWithoutReviewedByInput
reviewedRefunds?: Prisma.RefundUncheckedCreateNestedManyWithoutReviewedByInput
profile?: Prisma.UserProfileUncheckedCreateNestedOneWithoutUserInput profile?: Prisma.UserProfileUncheckedCreateNestedOneWithoutUserInput
} }
@@ -369,6 +374,7 @@ export type UserUpdateInput = {
bookings?: Prisma.BookingUpdateManyWithoutUserNestedInput bookings?: Prisma.BookingUpdateManyWithoutUserNestedInput
organizerVerification?: Prisma.OrganizerVerificationUpdateOneWithoutUserNestedInput organizerVerification?: Prisma.OrganizerVerificationUpdateOneWithoutUserNestedInput
reviewedVerifications?: Prisma.OrganizerVerificationUpdateManyWithoutReviewedByNestedInput reviewedVerifications?: Prisma.OrganizerVerificationUpdateManyWithoutReviewedByNestedInput
reviewedRefunds?: Prisma.RefundUpdateManyWithoutReviewedByNestedInput
profile?: Prisma.UserProfileUpdateOneWithoutUserNestedInput profile?: Prisma.UserProfileUpdateOneWithoutUserNestedInput
} }
@@ -390,6 +396,7 @@ export type UserUncheckedUpdateInput = {
bookings?: Prisma.BookingUncheckedUpdateManyWithoutUserNestedInput bookings?: Prisma.BookingUncheckedUpdateManyWithoutUserNestedInput
organizerVerification?: Prisma.OrganizerVerificationUncheckedUpdateOneWithoutUserNestedInput organizerVerification?: Prisma.OrganizerVerificationUncheckedUpdateOneWithoutUserNestedInput
reviewedVerifications?: Prisma.OrganizerVerificationUncheckedUpdateManyWithoutReviewedByNestedInput reviewedVerifications?: Prisma.OrganizerVerificationUncheckedUpdateManyWithoutReviewedByNestedInput
reviewedRefunds?: Prisma.RefundUncheckedUpdateManyWithoutReviewedByNestedInput
profile?: Prisma.UserProfileUncheckedUpdateOneWithoutUserNestedInput profile?: Prisma.UserProfileUncheckedUpdateOneWithoutUserNestedInput
} }
@@ -615,6 +622,22 @@ export type UserUpdateOneRequiredWithoutBookingsNestedInput = {
update?: Prisma.XOR<Prisma.XOR<Prisma.UserUpdateToOneWithWhereWithoutBookingsInput, Prisma.UserUpdateWithoutBookingsInput>, Prisma.UserUncheckedUpdateWithoutBookingsInput> update?: Prisma.XOR<Prisma.XOR<Prisma.UserUpdateToOneWithWhereWithoutBookingsInput, Prisma.UserUpdateWithoutBookingsInput>, Prisma.UserUncheckedUpdateWithoutBookingsInput>
} }
export type UserCreateNestedOneWithoutReviewedRefundsInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutReviewedRefundsInput, Prisma.UserUncheckedCreateWithoutReviewedRefundsInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutReviewedRefundsInput
connect?: Prisma.UserWhereUniqueInput
}
export type UserUpdateOneWithoutReviewedRefundsNestedInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutReviewedRefundsInput, Prisma.UserUncheckedCreateWithoutReviewedRefundsInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutReviewedRefundsInput
upsert?: Prisma.UserUpsertWithoutReviewedRefundsInput
disconnect?: Prisma.UserWhereInput | boolean
delete?: Prisma.UserWhereInput | boolean
connect?: Prisma.UserWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.UserUpdateToOneWithWhereWithoutReviewedRefundsInput, Prisma.UserUpdateWithoutReviewedRefundsInput>, Prisma.UserUncheckedUpdateWithoutReviewedRefundsInput>
}
export type UserCreateWithoutProfileInput = { export type UserCreateWithoutProfileInput = {
id?: string id?: string
name: string name: string
@@ -633,6 +656,7 @@ export type UserCreateWithoutProfileInput = {
bookings?: Prisma.BookingCreateNestedManyWithoutUserInput bookings?: Prisma.BookingCreateNestedManyWithoutUserInput
organizerVerification?: Prisma.OrganizerVerificationCreateNestedOneWithoutUserInput organizerVerification?: Prisma.OrganizerVerificationCreateNestedOneWithoutUserInput
reviewedVerifications?: Prisma.OrganizerVerificationCreateNestedManyWithoutReviewedByInput reviewedVerifications?: Prisma.OrganizerVerificationCreateNestedManyWithoutReviewedByInput
reviewedRefunds?: Prisma.RefundCreateNestedManyWithoutReviewedByInput
} }
export type UserUncheckedCreateWithoutProfileInput = { export type UserUncheckedCreateWithoutProfileInput = {
@@ -653,6 +677,7 @@ export type UserUncheckedCreateWithoutProfileInput = {
bookings?: Prisma.BookingUncheckedCreateNestedManyWithoutUserInput bookings?: Prisma.BookingUncheckedCreateNestedManyWithoutUserInput
organizerVerification?: Prisma.OrganizerVerificationUncheckedCreateNestedOneWithoutUserInput organizerVerification?: Prisma.OrganizerVerificationUncheckedCreateNestedOneWithoutUserInput
reviewedVerifications?: Prisma.OrganizerVerificationUncheckedCreateNestedManyWithoutReviewedByInput reviewedVerifications?: Prisma.OrganizerVerificationUncheckedCreateNestedManyWithoutReviewedByInput
reviewedRefunds?: Prisma.RefundUncheckedCreateNestedManyWithoutReviewedByInput
} }
export type UserCreateOrConnectWithoutProfileInput = { export type UserCreateOrConnectWithoutProfileInput = {
@@ -689,6 +714,7 @@ export type UserUpdateWithoutProfileInput = {
bookings?: Prisma.BookingUpdateManyWithoutUserNestedInput bookings?: Prisma.BookingUpdateManyWithoutUserNestedInput
organizerVerification?: Prisma.OrganizerVerificationUpdateOneWithoutUserNestedInput organizerVerification?: Prisma.OrganizerVerificationUpdateOneWithoutUserNestedInput
reviewedVerifications?: Prisma.OrganizerVerificationUpdateManyWithoutReviewedByNestedInput reviewedVerifications?: Prisma.OrganizerVerificationUpdateManyWithoutReviewedByNestedInput
reviewedRefunds?: Prisma.RefundUpdateManyWithoutReviewedByNestedInput
} }
export type UserUncheckedUpdateWithoutProfileInput = { export type UserUncheckedUpdateWithoutProfileInput = {
@@ -709,6 +735,7 @@ export type UserUncheckedUpdateWithoutProfileInput = {
bookings?: Prisma.BookingUncheckedUpdateManyWithoutUserNestedInput bookings?: Prisma.BookingUncheckedUpdateManyWithoutUserNestedInput
organizerVerification?: Prisma.OrganizerVerificationUncheckedUpdateOneWithoutUserNestedInput organizerVerification?: Prisma.OrganizerVerificationUncheckedUpdateOneWithoutUserNestedInput
reviewedVerifications?: Prisma.OrganizerVerificationUncheckedUpdateManyWithoutReviewedByNestedInput reviewedVerifications?: Prisma.OrganizerVerificationUncheckedUpdateManyWithoutReviewedByNestedInput
reviewedRefunds?: Prisma.RefundUncheckedUpdateManyWithoutReviewedByNestedInput
} }
export type UserCreateWithoutAccountsInput = { export type UserCreateWithoutAccountsInput = {
@@ -728,6 +755,7 @@ export type UserCreateWithoutAccountsInput = {
bookings?: Prisma.BookingCreateNestedManyWithoutUserInput bookings?: Prisma.BookingCreateNestedManyWithoutUserInput
organizerVerification?: Prisma.OrganizerVerificationCreateNestedOneWithoutUserInput organizerVerification?: Prisma.OrganizerVerificationCreateNestedOneWithoutUserInput
reviewedVerifications?: Prisma.OrganizerVerificationCreateNestedManyWithoutReviewedByInput reviewedVerifications?: Prisma.OrganizerVerificationCreateNestedManyWithoutReviewedByInput
reviewedRefunds?: Prisma.RefundCreateNestedManyWithoutReviewedByInput
profile?: Prisma.UserProfileCreateNestedOneWithoutUserInput profile?: Prisma.UserProfileCreateNestedOneWithoutUserInput
} }
@@ -748,6 +776,7 @@ export type UserUncheckedCreateWithoutAccountsInput = {
bookings?: Prisma.BookingUncheckedCreateNestedManyWithoutUserInput bookings?: Prisma.BookingUncheckedCreateNestedManyWithoutUserInput
organizerVerification?: Prisma.OrganizerVerificationUncheckedCreateNestedOneWithoutUserInput organizerVerification?: Prisma.OrganizerVerificationUncheckedCreateNestedOneWithoutUserInput
reviewedVerifications?: Prisma.OrganizerVerificationUncheckedCreateNestedManyWithoutReviewedByInput reviewedVerifications?: Prisma.OrganizerVerificationUncheckedCreateNestedManyWithoutReviewedByInput
reviewedRefunds?: Prisma.RefundUncheckedCreateNestedManyWithoutReviewedByInput
profile?: Prisma.UserProfileUncheckedCreateNestedOneWithoutUserInput profile?: Prisma.UserProfileUncheckedCreateNestedOneWithoutUserInput
} }
@@ -784,6 +813,7 @@ export type UserUpdateWithoutAccountsInput = {
bookings?: Prisma.BookingUpdateManyWithoutUserNestedInput bookings?: Prisma.BookingUpdateManyWithoutUserNestedInput
organizerVerification?: Prisma.OrganizerVerificationUpdateOneWithoutUserNestedInput organizerVerification?: Prisma.OrganizerVerificationUpdateOneWithoutUserNestedInput
reviewedVerifications?: Prisma.OrganizerVerificationUpdateManyWithoutReviewedByNestedInput reviewedVerifications?: Prisma.OrganizerVerificationUpdateManyWithoutReviewedByNestedInput
reviewedRefunds?: Prisma.RefundUpdateManyWithoutReviewedByNestedInput
profile?: Prisma.UserProfileUpdateOneWithoutUserNestedInput profile?: Prisma.UserProfileUpdateOneWithoutUserNestedInput
} }
@@ -804,6 +834,7 @@ export type UserUncheckedUpdateWithoutAccountsInput = {
bookings?: Prisma.BookingUncheckedUpdateManyWithoutUserNestedInput bookings?: Prisma.BookingUncheckedUpdateManyWithoutUserNestedInput
organizerVerification?: Prisma.OrganizerVerificationUncheckedUpdateOneWithoutUserNestedInput organizerVerification?: Prisma.OrganizerVerificationUncheckedUpdateOneWithoutUserNestedInput
reviewedVerifications?: Prisma.OrganizerVerificationUncheckedUpdateManyWithoutReviewedByNestedInput reviewedVerifications?: Prisma.OrganizerVerificationUncheckedUpdateManyWithoutReviewedByNestedInput
reviewedRefunds?: Prisma.RefundUncheckedUpdateManyWithoutReviewedByNestedInput
profile?: Prisma.UserProfileUncheckedUpdateOneWithoutUserNestedInput profile?: Prisma.UserProfileUncheckedUpdateOneWithoutUserNestedInput
} }
@@ -824,6 +855,7 @@ export type UserCreateWithoutOrganizerVerificationInput = {
tripReviews?: Prisma.TripReviewCreateNestedManyWithoutUserInput tripReviews?: Prisma.TripReviewCreateNestedManyWithoutUserInput
bookings?: Prisma.BookingCreateNestedManyWithoutUserInput bookings?: Prisma.BookingCreateNestedManyWithoutUserInput
reviewedVerifications?: Prisma.OrganizerVerificationCreateNestedManyWithoutReviewedByInput reviewedVerifications?: Prisma.OrganizerVerificationCreateNestedManyWithoutReviewedByInput
reviewedRefunds?: Prisma.RefundCreateNestedManyWithoutReviewedByInput
profile?: Prisma.UserProfileCreateNestedOneWithoutUserInput profile?: Prisma.UserProfileCreateNestedOneWithoutUserInput
} }
@@ -844,6 +876,7 @@ export type UserUncheckedCreateWithoutOrganizerVerificationInput = {
tripReviews?: Prisma.TripReviewUncheckedCreateNestedManyWithoutUserInput tripReviews?: Prisma.TripReviewUncheckedCreateNestedManyWithoutUserInput
bookings?: Prisma.BookingUncheckedCreateNestedManyWithoutUserInput bookings?: Prisma.BookingUncheckedCreateNestedManyWithoutUserInput
reviewedVerifications?: Prisma.OrganizerVerificationUncheckedCreateNestedManyWithoutReviewedByInput reviewedVerifications?: Prisma.OrganizerVerificationUncheckedCreateNestedManyWithoutReviewedByInput
reviewedRefunds?: Prisma.RefundUncheckedCreateNestedManyWithoutReviewedByInput
profile?: Prisma.UserProfileUncheckedCreateNestedOneWithoutUserInput profile?: Prisma.UserProfileUncheckedCreateNestedOneWithoutUserInput
} }
@@ -869,6 +902,7 @@ export type UserCreateWithoutReviewedVerificationsInput = {
tripReviews?: Prisma.TripReviewCreateNestedManyWithoutUserInput tripReviews?: Prisma.TripReviewCreateNestedManyWithoutUserInput
bookings?: Prisma.BookingCreateNestedManyWithoutUserInput bookings?: Prisma.BookingCreateNestedManyWithoutUserInput
organizerVerification?: Prisma.OrganizerVerificationCreateNestedOneWithoutUserInput organizerVerification?: Prisma.OrganizerVerificationCreateNestedOneWithoutUserInput
reviewedRefunds?: Prisma.RefundCreateNestedManyWithoutReviewedByInput
profile?: Prisma.UserProfileCreateNestedOneWithoutUserInput profile?: Prisma.UserProfileCreateNestedOneWithoutUserInput
} }
@@ -889,6 +923,7 @@ export type UserUncheckedCreateWithoutReviewedVerificationsInput = {
tripReviews?: Prisma.TripReviewUncheckedCreateNestedManyWithoutUserInput tripReviews?: Prisma.TripReviewUncheckedCreateNestedManyWithoutUserInput
bookings?: Prisma.BookingUncheckedCreateNestedManyWithoutUserInput bookings?: Prisma.BookingUncheckedCreateNestedManyWithoutUserInput
organizerVerification?: Prisma.OrganizerVerificationUncheckedCreateNestedOneWithoutUserInput organizerVerification?: Prisma.OrganizerVerificationUncheckedCreateNestedOneWithoutUserInput
reviewedRefunds?: Prisma.RefundUncheckedCreateNestedManyWithoutReviewedByInput
profile?: Prisma.UserProfileUncheckedCreateNestedOneWithoutUserInput profile?: Prisma.UserProfileUncheckedCreateNestedOneWithoutUserInput
} }
@@ -925,6 +960,7 @@ export type UserUpdateWithoutOrganizerVerificationInput = {
tripReviews?: Prisma.TripReviewUpdateManyWithoutUserNestedInput tripReviews?: Prisma.TripReviewUpdateManyWithoutUserNestedInput
bookings?: Prisma.BookingUpdateManyWithoutUserNestedInput bookings?: Prisma.BookingUpdateManyWithoutUserNestedInput
reviewedVerifications?: Prisma.OrganizerVerificationUpdateManyWithoutReviewedByNestedInput reviewedVerifications?: Prisma.OrganizerVerificationUpdateManyWithoutReviewedByNestedInput
reviewedRefunds?: Prisma.RefundUpdateManyWithoutReviewedByNestedInput
profile?: Prisma.UserProfileUpdateOneWithoutUserNestedInput profile?: Prisma.UserProfileUpdateOneWithoutUserNestedInput
} }
@@ -945,6 +981,7 @@ export type UserUncheckedUpdateWithoutOrganizerVerificationInput = {
tripReviews?: Prisma.TripReviewUncheckedUpdateManyWithoutUserNestedInput tripReviews?: Prisma.TripReviewUncheckedUpdateManyWithoutUserNestedInput
bookings?: Prisma.BookingUncheckedUpdateManyWithoutUserNestedInput bookings?: Prisma.BookingUncheckedUpdateManyWithoutUserNestedInput
reviewedVerifications?: Prisma.OrganizerVerificationUncheckedUpdateManyWithoutReviewedByNestedInput reviewedVerifications?: Prisma.OrganizerVerificationUncheckedUpdateManyWithoutReviewedByNestedInput
reviewedRefunds?: Prisma.RefundUncheckedUpdateManyWithoutReviewedByNestedInput
profile?: Prisma.UserProfileUncheckedUpdateOneWithoutUserNestedInput profile?: Prisma.UserProfileUncheckedUpdateOneWithoutUserNestedInput
} }
@@ -976,6 +1013,7 @@ export type UserUpdateWithoutReviewedVerificationsInput = {
tripReviews?: Prisma.TripReviewUpdateManyWithoutUserNestedInput tripReviews?: Prisma.TripReviewUpdateManyWithoutUserNestedInput
bookings?: Prisma.BookingUpdateManyWithoutUserNestedInput bookings?: Prisma.BookingUpdateManyWithoutUserNestedInput
organizerVerification?: Prisma.OrganizerVerificationUpdateOneWithoutUserNestedInput organizerVerification?: Prisma.OrganizerVerificationUpdateOneWithoutUserNestedInput
reviewedRefunds?: Prisma.RefundUpdateManyWithoutReviewedByNestedInput
profile?: Prisma.UserProfileUpdateOneWithoutUserNestedInput profile?: Prisma.UserProfileUpdateOneWithoutUserNestedInput
} }
@@ -996,6 +1034,7 @@ export type UserUncheckedUpdateWithoutReviewedVerificationsInput = {
tripReviews?: Prisma.TripReviewUncheckedUpdateManyWithoutUserNestedInput tripReviews?: Prisma.TripReviewUncheckedUpdateManyWithoutUserNestedInput
bookings?: Prisma.BookingUncheckedUpdateManyWithoutUserNestedInput bookings?: Prisma.BookingUncheckedUpdateManyWithoutUserNestedInput
organizerVerification?: Prisma.OrganizerVerificationUncheckedUpdateOneWithoutUserNestedInput organizerVerification?: Prisma.OrganizerVerificationUncheckedUpdateOneWithoutUserNestedInput
reviewedRefunds?: Prisma.RefundUncheckedUpdateManyWithoutReviewedByNestedInput
profile?: Prisma.UserProfileUncheckedUpdateOneWithoutUserNestedInput profile?: Prisma.UserProfileUncheckedUpdateOneWithoutUserNestedInput
} }
@@ -1016,6 +1055,7 @@ export type UserCreateWithoutTripsInput = {
bookings?: Prisma.BookingCreateNestedManyWithoutUserInput bookings?: Prisma.BookingCreateNestedManyWithoutUserInput
organizerVerification?: Prisma.OrganizerVerificationCreateNestedOneWithoutUserInput organizerVerification?: Prisma.OrganizerVerificationCreateNestedOneWithoutUserInput
reviewedVerifications?: Prisma.OrganizerVerificationCreateNestedManyWithoutReviewedByInput reviewedVerifications?: Prisma.OrganizerVerificationCreateNestedManyWithoutReviewedByInput
reviewedRefunds?: Prisma.RefundCreateNestedManyWithoutReviewedByInput
profile?: Prisma.UserProfileCreateNestedOneWithoutUserInput profile?: Prisma.UserProfileCreateNestedOneWithoutUserInput
} }
@@ -1036,6 +1076,7 @@ export type UserUncheckedCreateWithoutTripsInput = {
bookings?: Prisma.BookingUncheckedCreateNestedManyWithoutUserInput bookings?: Prisma.BookingUncheckedCreateNestedManyWithoutUserInput
organizerVerification?: Prisma.OrganizerVerificationUncheckedCreateNestedOneWithoutUserInput organizerVerification?: Prisma.OrganizerVerificationUncheckedCreateNestedOneWithoutUserInput
reviewedVerifications?: Prisma.OrganizerVerificationUncheckedCreateNestedManyWithoutReviewedByInput reviewedVerifications?: Prisma.OrganizerVerificationUncheckedCreateNestedManyWithoutReviewedByInput
reviewedRefunds?: Prisma.RefundUncheckedCreateNestedManyWithoutReviewedByInput
profile?: Prisma.UserProfileUncheckedCreateNestedOneWithoutUserInput profile?: Prisma.UserProfileUncheckedCreateNestedOneWithoutUserInput
} }
@@ -1072,6 +1113,7 @@ export type UserUpdateWithoutTripsInput = {
bookings?: Prisma.BookingUpdateManyWithoutUserNestedInput bookings?: Prisma.BookingUpdateManyWithoutUserNestedInput
organizerVerification?: Prisma.OrganizerVerificationUpdateOneWithoutUserNestedInput organizerVerification?: Prisma.OrganizerVerificationUpdateOneWithoutUserNestedInput
reviewedVerifications?: Prisma.OrganizerVerificationUpdateManyWithoutReviewedByNestedInput reviewedVerifications?: Prisma.OrganizerVerificationUpdateManyWithoutReviewedByNestedInput
reviewedRefunds?: Prisma.RefundUpdateManyWithoutReviewedByNestedInput
profile?: Prisma.UserProfileUpdateOneWithoutUserNestedInput profile?: Prisma.UserProfileUpdateOneWithoutUserNestedInput
} }
@@ -1092,6 +1134,7 @@ export type UserUncheckedUpdateWithoutTripsInput = {
bookings?: Prisma.BookingUncheckedUpdateManyWithoutUserNestedInput bookings?: Prisma.BookingUncheckedUpdateManyWithoutUserNestedInput
organizerVerification?: Prisma.OrganizerVerificationUncheckedUpdateOneWithoutUserNestedInput organizerVerification?: Prisma.OrganizerVerificationUncheckedUpdateOneWithoutUserNestedInput
reviewedVerifications?: Prisma.OrganizerVerificationUncheckedUpdateManyWithoutReviewedByNestedInput reviewedVerifications?: Prisma.OrganizerVerificationUncheckedUpdateManyWithoutReviewedByNestedInput
reviewedRefunds?: Prisma.RefundUncheckedUpdateManyWithoutReviewedByNestedInput
profile?: Prisma.UserProfileUncheckedUpdateOneWithoutUserNestedInput profile?: Prisma.UserProfileUncheckedUpdateOneWithoutUserNestedInput
} }
@@ -1112,6 +1155,7 @@ export type UserCreateWithoutTripReviewsInput = {
bookings?: Prisma.BookingCreateNestedManyWithoutUserInput bookings?: Prisma.BookingCreateNestedManyWithoutUserInput
organizerVerification?: Prisma.OrganizerVerificationCreateNestedOneWithoutUserInput organizerVerification?: Prisma.OrganizerVerificationCreateNestedOneWithoutUserInput
reviewedVerifications?: Prisma.OrganizerVerificationCreateNestedManyWithoutReviewedByInput reviewedVerifications?: Prisma.OrganizerVerificationCreateNestedManyWithoutReviewedByInput
reviewedRefunds?: Prisma.RefundCreateNestedManyWithoutReviewedByInput
profile?: Prisma.UserProfileCreateNestedOneWithoutUserInput profile?: Prisma.UserProfileCreateNestedOneWithoutUserInput
} }
@@ -1132,6 +1176,7 @@ export type UserUncheckedCreateWithoutTripReviewsInput = {
bookings?: Prisma.BookingUncheckedCreateNestedManyWithoutUserInput bookings?: Prisma.BookingUncheckedCreateNestedManyWithoutUserInput
organizerVerification?: Prisma.OrganizerVerificationUncheckedCreateNestedOneWithoutUserInput organizerVerification?: Prisma.OrganizerVerificationUncheckedCreateNestedOneWithoutUserInput
reviewedVerifications?: Prisma.OrganizerVerificationUncheckedCreateNestedManyWithoutReviewedByInput reviewedVerifications?: Prisma.OrganizerVerificationUncheckedCreateNestedManyWithoutReviewedByInput
reviewedRefunds?: Prisma.RefundUncheckedCreateNestedManyWithoutReviewedByInput
profile?: Prisma.UserProfileUncheckedCreateNestedOneWithoutUserInput profile?: Prisma.UserProfileUncheckedCreateNestedOneWithoutUserInput
} }
@@ -1168,6 +1213,7 @@ export type UserUpdateWithoutTripReviewsInput = {
bookings?: Prisma.BookingUpdateManyWithoutUserNestedInput bookings?: Prisma.BookingUpdateManyWithoutUserNestedInput
organizerVerification?: Prisma.OrganizerVerificationUpdateOneWithoutUserNestedInput organizerVerification?: Prisma.OrganizerVerificationUpdateOneWithoutUserNestedInput
reviewedVerifications?: Prisma.OrganizerVerificationUpdateManyWithoutReviewedByNestedInput reviewedVerifications?: Prisma.OrganizerVerificationUpdateManyWithoutReviewedByNestedInput
reviewedRefunds?: Prisma.RefundUpdateManyWithoutReviewedByNestedInput
profile?: Prisma.UserProfileUpdateOneWithoutUserNestedInput profile?: Prisma.UserProfileUpdateOneWithoutUserNestedInput
} }
@@ -1188,6 +1234,7 @@ export type UserUncheckedUpdateWithoutTripReviewsInput = {
bookings?: Prisma.BookingUncheckedUpdateManyWithoutUserNestedInput bookings?: Prisma.BookingUncheckedUpdateManyWithoutUserNestedInput
organizerVerification?: Prisma.OrganizerVerificationUncheckedUpdateOneWithoutUserNestedInput organizerVerification?: Prisma.OrganizerVerificationUncheckedUpdateOneWithoutUserNestedInput
reviewedVerifications?: Prisma.OrganizerVerificationUncheckedUpdateManyWithoutReviewedByNestedInput reviewedVerifications?: Prisma.OrganizerVerificationUncheckedUpdateManyWithoutReviewedByNestedInput
reviewedRefunds?: Prisma.RefundUncheckedUpdateManyWithoutReviewedByNestedInput
profile?: Prisma.UserProfileUncheckedUpdateOneWithoutUserNestedInput profile?: Prisma.UserProfileUncheckedUpdateOneWithoutUserNestedInput
} }
@@ -1208,6 +1255,7 @@ export type UserCreateWithoutParticipationsInput = {
bookings?: Prisma.BookingCreateNestedManyWithoutUserInput bookings?: Prisma.BookingCreateNestedManyWithoutUserInput
organizerVerification?: Prisma.OrganizerVerificationCreateNestedOneWithoutUserInput organizerVerification?: Prisma.OrganizerVerificationCreateNestedOneWithoutUserInput
reviewedVerifications?: Prisma.OrganizerVerificationCreateNestedManyWithoutReviewedByInput reviewedVerifications?: Prisma.OrganizerVerificationCreateNestedManyWithoutReviewedByInput
reviewedRefunds?: Prisma.RefundCreateNestedManyWithoutReviewedByInput
profile?: Prisma.UserProfileCreateNestedOneWithoutUserInput profile?: Prisma.UserProfileCreateNestedOneWithoutUserInput
} }
@@ -1228,6 +1276,7 @@ export type UserUncheckedCreateWithoutParticipationsInput = {
bookings?: Prisma.BookingUncheckedCreateNestedManyWithoutUserInput bookings?: Prisma.BookingUncheckedCreateNestedManyWithoutUserInput
organizerVerification?: Prisma.OrganizerVerificationUncheckedCreateNestedOneWithoutUserInput organizerVerification?: Prisma.OrganizerVerificationUncheckedCreateNestedOneWithoutUserInput
reviewedVerifications?: Prisma.OrganizerVerificationUncheckedCreateNestedManyWithoutReviewedByInput reviewedVerifications?: Prisma.OrganizerVerificationUncheckedCreateNestedManyWithoutReviewedByInput
reviewedRefunds?: Prisma.RefundUncheckedCreateNestedManyWithoutReviewedByInput
profile?: Prisma.UserProfileUncheckedCreateNestedOneWithoutUserInput profile?: Prisma.UserProfileUncheckedCreateNestedOneWithoutUserInput
} }
@@ -1264,6 +1313,7 @@ export type UserUpdateWithoutParticipationsInput = {
bookings?: Prisma.BookingUpdateManyWithoutUserNestedInput bookings?: Prisma.BookingUpdateManyWithoutUserNestedInput
organizerVerification?: Prisma.OrganizerVerificationUpdateOneWithoutUserNestedInput organizerVerification?: Prisma.OrganizerVerificationUpdateOneWithoutUserNestedInput
reviewedVerifications?: Prisma.OrganizerVerificationUpdateManyWithoutReviewedByNestedInput reviewedVerifications?: Prisma.OrganizerVerificationUpdateManyWithoutReviewedByNestedInput
reviewedRefunds?: Prisma.RefundUpdateManyWithoutReviewedByNestedInput
profile?: Prisma.UserProfileUpdateOneWithoutUserNestedInput profile?: Prisma.UserProfileUpdateOneWithoutUserNestedInput
} }
@@ -1284,6 +1334,7 @@ export type UserUncheckedUpdateWithoutParticipationsInput = {
bookings?: Prisma.BookingUncheckedUpdateManyWithoutUserNestedInput bookings?: Prisma.BookingUncheckedUpdateManyWithoutUserNestedInput
organizerVerification?: Prisma.OrganizerVerificationUncheckedUpdateOneWithoutUserNestedInput organizerVerification?: Prisma.OrganizerVerificationUncheckedUpdateOneWithoutUserNestedInput
reviewedVerifications?: Prisma.OrganizerVerificationUncheckedUpdateManyWithoutReviewedByNestedInput reviewedVerifications?: Prisma.OrganizerVerificationUncheckedUpdateManyWithoutReviewedByNestedInput
reviewedRefunds?: Prisma.RefundUncheckedUpdateManyWithoutReviewedByNestedInput
profile?: Prisma.UserProfileUncheckedUpdateOneWithoutUserNestedInput profile?: Prisma.UserProfileUncheckedUpdateOneWithoutUserNestedInput
} }
@@ -1304,6 +1355,7 @@ export type UserCreateWithoutBookingsInput = {
tripReviews?: Prisma.TripReviewCreateNestedManyWithoutUserInput tripReviews?: Prisma.TripReviewCreateNestedManyWithoutUserInput
organizerVerification?: Prisma.OrganizerVerificationCreateNestedOneWithoutUserInput organizerVerification?: Prisma.OrganizerVerificationCreateNestedOneWithoutUserInput
reviewedVerifications?: Prisma.OrganizerVerificationCreateNestedManyWithoutReviewedByInput reviewedVerifications?: Prisma.OrganizerVerificationCreateNestedManyWithoutReviewedByInput
reviewedRefunds?: Prisma.RefundCreateNestedManyWithoutReviewedByInput
profile?: Prisma.UserProfileCreateNestedOneWithoutUserInput profile?: Prisma.UserProfileCreateNestedOneWithoutUserInput
} }
@@ -1324,6 +1376,7 @@ export type UserUncheckedCreateWithoutBookingsInput = {
tripReviews?: Prisma.TripReviewUncheckedCreateNestedManyWithoutUserInput tripReviews?: Prisma.TripReviewUncheckedCreateNestedManyWithoutUserInput
organizerVerification?: Prisma.OrganizerVerificationUncheckedCreateNestedOneWithoutUserInput organizerVerification?: Prisma.OrganizerVerificationUncheckedCreateNestedOneWithoutUserInput
reviewedVerifications?: Prisma.OrganizerVerificationUncheckedCreateNestedManyWithoutReviewedByInput reviewedVerifications?: Prisma.OrganizerVerificationUncheckedCreateNestedManyWithoutReviewedByInput
reviewedRefunds?: Prisma.RefundUncheckedCreateNestedManyWithoutReviewedByInput
profile?: Prisma.UserProfileUncheckedCreateNestedOneWithoutUserInput profile?: Prisma.UserProfileUncheckedCreateNestedOneWithoutUserInput
} }
@@ -1360,6 +1413,7 @@ export type UserUpdateWithoutBookingsInput = {
tripReviews?: Prisma.TripReviewUpdateManyWithoutUserNestedInput tripReviews?: Prisma.TripReviewUpdateManyWithoutUserNestedInput
organizerVerification?: Prisma.OrganizerVerificationUpdateOneWithoutUserNestedInput organizerVerification?: Prisma.OrganizerVerificationUpdateOneWithoutUserNestedInput
reviewedVerifications?: Prisma.OrganizerVerificationUpdateManyWithoutReviewedByNestedInput reviewedVerifications?: Prisma.OrganizerVerificationUpdateManyWithoutReviewedByNestedInput
reviewedRefunds?: Prisma.RefundUpdateManyWithoutReviewedByNestedInput
profile?: Prisma.UserProfileUpdateOneWithoutUserNestedInput profile?: Prisma.UserProfileUpdateOneWithoutUserNestedInput
} }
@@ -1380,6 +1434,107 @@ export type UserUncheckedUpdateWithoutBookingsInput = {
tripReviews?: Prisma.TripReviewUncheckedUpdateManyWithoutUserNestedInput tripReviews?: Prisma.TripReviewUncheckedUpdateManyWithoutUserNestedInput
organizerVerification?: Prisma.OrganizerVerificationUncheckedUpdateOneWithoutUserNestedInput organizerVerification?: Prisma.OrganizerVerificationUncheckedUpdateOneWithoutUserNestedInput
reviewedVerifications?: Prisma.OrganizerVerificationUncheckedUpdateManyWithoutReviewedByNestedInput reviewedVerifications?: Prisma.OrganizerVerificationUncheckedUpdateManyWithoutReviewedByNestedInput
reviewedRefunds?: Prisma.RefundUncheckedUpdateManyWithoutReviewedByNestedInput
profile?: Prisma.UserProfileUncheckedUpdateOneWithoutUserNestedInput
}
export type UserCreateWithoutReviewedRefundsInput = {
id?: string
name: string
email: string
password?: string | null
image?: string | null
emailVerified?: Date | string | null
acceptedTermsAndPrivacy?: boolean
acceptedAt?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
accounts?: Prisma.AccountCreateNestedManyWithoutUserInput
trips?: Prisma.TripCreateNestedManyWithoutOrganizerInput
participations?: Prisma.TripParticipantCreateNestedManyWithoutUserInput
tripReviews?: Prisma.TripReviewCreateNestedManyWithoutUserInput
bookings?: Prisma.BookingCreateNestedManyWithoutUserInput
organizerVerification?: Prisma.OrganizerVerificationCreateNestedOneWithoutUserInput
reviewedVerifications?: Prisma.OrganizerVerificationCreateNestedManyWithoutReviewedByInput
profile?: Prisma.UserProfileCreateNestedOneWithoutUserInput
}
export type UserUncheckedCreateWithoutReviewedRefundsInput = {
id?: string
name: string
email: string
password?: string | null
image?: string | null
emailVerified?: Date | string | null
acceptedTermsAndPrivacy?: boolean
acceptedAt?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput
trips?: Prisma.TripUncheckedCreateNestedManyWithoutOrganizerInput
participations?: Prisma.TripParticipantUncheckedCreateNestedManyWithoutUserInput
tripReviews?: Prisma.TripReviewUncheckedCreateNestedManyWithoutUserInput
bookings?: Prisma.BookingUncheckedCreateNestedManyWithoutUserInput
organizerVerification?: Prisma.OrganizerVerificationUncheckedCreateNestedOneWithoutUserInput
reviewedVerifications?: Prisma.OrganizerVerificationUncheckedCreateNestedManyWithoutReviewedByInput
profile?: Prisma.UserProfileUncheckedCreateNestedOneWithoutUserInput
}
export type UserCreateOrConnectWithoutReviewedRefundsInput = {
where: Prisma.UserWhereUniqueInput
create: Prisma.XOR<Prisma.UserCreateWithoutReviewedRefundsInput, Prisma.UserUncheckedCreateWithoutReviewedRefundsInput>
}
export type UserUpsertWithoutReviewedRefundsInput = {
update: Prisma.XOR<Prisma.UserUpdateWithoutReviewedRefundsInput, Prisma.UserUncheckedUpdateWithoutReviewedRefundsInput>
create: Prisma.XOR<Prisma.UserCreateWithoutReviewedRefundsInput, Prisma.UserUncheckedCreateWithoutReviewedRefundsInput>
where?: Prisma.UserWhereInput
}
export type UserUpdateToOneWithWhereWithoutReviewedRefundsInput = {
where?: Prisma.UserWhereInput
data: Prisma.XOR<Prisma.UserUpdateWithoutReviewedRefundsInput, Prisma.UserUncheckedUpdateWithoutReviewedRefundsInput>
}
export type UserUpdateWithoutReviewedRefundsInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
email?: Prisma.StringFieldUpdateOperationsInput | string
password?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
emailVerified?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
acceptedTermsAndPrivacy?: Prisma.BoolFieldUpdateOperationsInput | boolean
acceptedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput
trips?: Prisma.TripUpdateManyWithoutOrganizerNestedInput
participations?: Prisma.TripParticipantUpdateManyWithoutUserNestedInput
tripReviews?: Prisma.TripReviewUpdateManyWithoutUserNestedInput
bookings?: Prisma.BookingUpdateManyWithoutUserNestedInput
organizerVerification?: Prisma.OrganizerVerificationUpdateOneWithoutUserNestedInput
reviewedVerifications?: Prisma.OrganizerVerificationUpdateManyWithoutReviewedByNestedInput
profile?: Prisma.UserProfileUpdateOneWithoutUserNestedInput
}
export type UserUncheckedUpdateWithoutReviewedRefundsInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
email?: Prisma.StringFieldUpdateOperationsInput | string
password?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
emailVerified?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
acceptedTermsAndPrivacy?: Prisma.BoolFieldUpdateOperationsInput | boolean
acceptedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput
trips?: Prisma.TripUncheckedUpdateManyWithoutOrganizerNestedInput
participations?: Prisma.TripParticipantUncheckedUpdateManyWithoutUserNestedInput
tripReviews?: Prisma.TripReviewUncheckedUpdateManyWithoutUserNestedInput
bookings?: Prisma.BookingUncheckedUpdateManyWithoutUserNestedInput
organizerVerification?: Prisma.OrganizerVerificationUncheckedUpdateOneWithoutUserNestedInput
reviewedVerifications?: Prisma.OrganizerVerificationUncheckedUpdateManyWithoutReviewedByNestedInput
profile?: Prisma.UserProfileUncheckedUpdateOneWithoutUserNestedInput profile?: Prisma.UserProfileUncheckedUpdateOneWithoutUserNestedInput
} }
@@ -1395,6 +1550,7 @@ export type UserCountOutputType = {
tripReviews: number tripReviews: number
bookings: number bookings: number
reviewedVerifications: number reviewedVerifications: number
reviewedRefunds: number
} }
export type UserCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = { export type UserCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
@@ -1404,6 +1560,7 @@ export type UserCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.I
tripReviews?: boolean | UserCountOutputTypeCountTripReviewsArgs tripReviews?: boolean | UserCountOutputTypeCountTripReviewsArgs
bookings?: boolean | UserCountOutputTypeCountBookingsArgs bookings?: boolean | UserCountOutputTypeCountBookingsArgs
reviewedVerifications?: boolean | UserCountOutputTypeCountReviewedVerificationsArgs reviewedVerifications?: boolean | UserCountOutputTypeCountReviewedVerificationsArgs
reviewedRefunds?: boolean | UserCountOutputTypeCountReviewedRefundsArgs
} }
/** /**
@@ -1458,6 +1615,13 @@ export type UserCountOutputTypeCountReviewedVerificationsArgs<ExtArgs extends ru
where?: Prisma.OrganizerVerificationWhereInput where?: Prisma.OrganizerVerificationWhereInput
} }
/**
* UserCountOutputType without action
*/
export type UserCountOutputTypeCountReviewedRefundsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
where?: Prisma.RefundWhereInput
}
export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{ export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean id?: boolean
@@ -1477,6 +1641,7 @@ export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = r
bookings?: boolean | Prisma.User$bookingsArgs<ExtArgs> bookings?: boolean | Prisma.User$bookingsArgs<ExtArgs>
organizerVerification?: boolean | Prisma.User$organizerVerificationArgs<ExtArgs> organizerVerification?: boolean | Prisma.User$organizerVerificationArgs<ExtArgs>
reviewedVerifications?: boolean | Prisma.User$reviewedVerificationsArgs<ExtArgs> reviewedVerifications?: boolean | Prisma.User$reviewedVerificationsArgs<ExtArgs>
reviewedRefunds?: boolean | Prisma.User$reviewedRefundsArgs<ExtArgs>
profile?: boolean | Prisma.User$profileArgs<ExtArgs> profile?: boolean | Prisma.User$profileArgs<ExtArgs>
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs> _count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["user"]> }, ExtArgs["result"]["user"]>
@@ -1529,6 +1694,7 @@ export type UserInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs =
bookings?: boolean | Prisma.User$bookingsArgs<ExtArgs> bookings?: boolean | Prisma.User$bookingsArgs<ExtArgs>
organizerVerification?: boolean | Prisma.User$organizerVerificationArgs<ExtArgs> organizerVerification?: boolean | Prisma.User$organizerVerificationArgs<ExtArgs>
reviewedVerifications?: boolean | Prisma.User$reviewedVerificationsArgs<ExtArgs> reviewedVerifications?: boolean | Prisma.User$reviewedVerificationsArgs<ExtArgs>
reviewedRefunds?: boolean | Prisma.User$reviewedRefundsArgs<ExtArgs>
profile?: boolean | Prisma.User$profileArgs<ExtArgs> profile?: boolean | Prisma.User$profileArgs<ExtArgs>
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs> _count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
} }
@@ -1545,6 +1711,7 @@ export type $UserPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs =
bookings: Prisma.$BookingPayload<ExtArgs>[] bookings: Prisma.$BookingPayload<ExtArgs>[]
organizerVerification: Prisma.$OrganizerVerificationPayload<ExtArgs> | null organizerVerification: Prisma.$OrganizerVerificationPayload<ExtArgs> | null
reviewedVerifications: Prisma.$OrganizerVerificationPayload<ExtArgs>[] reviewedVerifications: Prisma.$OrganizerVerificationPayload<ExtArgs>[]
reviewedRefunds: Prisma.$RefundPayload<ExtArgs>[]
profile: Prisma.$UserProfilePayload<ExtArgs> | null profile: Prisma.$UserProfilePayload<ExtArgs> | null
} }
scalars: runtime.Types.Extensions.GetPayloadResult<{ scalars: runtime.Types.Extensions.GetPayloadResult<{
@@ -1971,6 +2138,7 @@ export interface Prisma__UserClient<T, Null = never, ExtArgs extends runtime.Typ
bookings<T extends Prisma.User$bookingsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$bookingsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$BookingPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null> bookings<T extends Prisma.User$bookingsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$bookingsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$BookingPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
organizerVerification<T extends Prisma.User$organizerVerificationArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$organizerVerificationArgs<ExtArgs>>): Prisma.Prisma__OrganizerVerificationClient<runtime.Types.Result.GetResult<Prisma.$OrganizerVerificationPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions> organizerVerification<T extends Prisma.User$organizerVerificationArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$organizerVerificationArgs<ExtArgs>>): Prisma.Prisma__OrganizerVerificationClient<runtime.Types.Result.GetResult<Prisma.$OrganizerVerificationPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions>
reviewedVerifications<T extends Prisma.User$reviewedVerificationsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$reviewedVerificationsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$OrganizerVerificationPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null> reviewedVerifications<T extends Prisma.User$reviewedVerificationsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$reviewedVerificationsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$OrganizerVerificationPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
reviewedRefunds<T extends Prisma.User$reviewedRefundsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$reviewedRefundsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$RefundPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
profile<T extends Prisma.User$profileArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$profileArgs<ExtArgs>>): Prisma.Prisma__UserProfileClient<runtime.Types.Result.GetResult<Prisma.$UserProfilePayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions> profile<T extends Prisma.User$profileArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$profileArgs<ExtArgs>>): Prisma.Prisma__UserProfileClient<runtime.Types.Result.GetResult<Prisma.$UserProfilePayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions>
/** /**
* Attaches callbacks for the resolution and/or rejection of the Promise. * Attaches callbacks for the resolution and/or rejection of the Promise.
@@ -2566,6 +2734,30 @@ export type User$reviewedVerificationsArgs<ExtArgs extends runtime.Types.Extensi
distinct?: Prisma.OrganizerVerificationScalarFieldEnum | Prisma.OrganizerVerificationScalarFieldEnum[] distinct?: Prisma.OrganizerVerificationScalarFieldEnum | Prisma.OrganizerVerificationScalarFieldEnum[]
} }
/**
* User.reviewedRefunds
*/
export type User$reviewedRefundsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the Refund
*/
select?: Prisma.RefundSelect<ExtArgs> | null
/**
* Omit specific fields from the Refund
*/
omit?: Prisma.RefundOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.RefundInclude<ExtArgs> | null
where?: Prisma.RefundWhereInput
orderBy?: Prisma.RefundOrderByWithRelationInput | Prisma.RefundOrderByWithRelationInput[]
cursor?: Prisma.RefundWhereUniqueInput
take?: number
skip?: number
distinct?: Prisma.RefundScalarFieldEnum | Prisma.RefundScalarFieldEnum[]
}
/** /**
* User.profile * User.profile
*/ */
+88 -4
View File
@@ -6,17 +6,21 @@ import Image from "next/image";
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 { bookingService } from "@/server/services/booking.service"; import { bookingService } from "@/server/services/booking.service";
import { bookingRepo } from "@/server/repositories/booking.repo";
import { trustService } from "@/server/services/trust.service"; import { trustService } from "@/server/services/trust.service";
import { formatRupiah } from "@/lib/utils"; import { formatRupiah } from "@/lib/utils";
import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates"; import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
import { siteConfig, siteUrl, absoluteUrl } from "@/lib/site"; import { siteConfig, siteUrl, absoluteUrl } from "@/lib/site";
import { JoinTripButton } from "@/features/trip/components/join-trip-button"; import { JoinTripButton } from "@/features/trip/components/join-trip-button";
import { CancelTripButton } from "@/features/trip/components/cancel-trip-button";
import { CancelBookingButton } from "@/features/booking/components/cancel-booking-button";
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 { 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 { categoryMeta } from "@/lib/activity-category"; import { categoryMeta } from "@/lib/activity-category";
import { vibeMeta } from "@/lib/vibe"; import { vibeMeta } from "@/lib/vibe";
import { isFreeTrip } from "@/lib/trip-pricing"; import { isFreeTrip } from "@/lib/trip-pricing";
@@ -24,6 +28,7 @@ import {
isPastTripLastDayForReview, isPastTripLastDayForReview,
isTripDepartureDayPast, isTripDepartureDayPast,
} from "@/lib/trip-dates"; } from "@/lib/trip-dates";
import { previewRefund } from "@/lib/refund-policy";
export async function generateMetadata({ export async function generateMetadata({
params, params,
@@ -137,6 +142,31 @@ export default async function TripDetailPage({
? await bookingService.getAwaitingManualForTrip(trip.id) ? await bookingService.getAwaitingManualForTrip(trip.id)
: []; : [];
// Booking peserta saat ini — dipakai untuk render CancelBookingButton vs
// tombol "Batal Ikut" biasa. Hanya untuk non-organizer yang ikut trip.
const myBooking =
session?.user && !isOrganizer && currentParticipation
? await bookingService.getByTripAndUser(trip.id, session.user.id)
: null;
// Untuk CancelTripButton: jumlah booking PAID/PARTIALLY_REFUNDED (yang akan
// auto-refund). Hanya dihitung saat organizer mengakses trip yang masih
// bisa dibatalkan.
const canOrganizerCancel =
isOrganizer &&
(trip.status === "OPEN" || trip.status === "FULL") &&
!isDeparturePast;
const paidBookingCount = canOrganizerCancel
? await bookingRepo.countSettledForTrip(trip.id)
: 0;
// Preview refund untuk CancelBookingButton (server-side supaya konsisten
// dengan service yang juga pakai policy yang sama).
const refundPreview =
myBooking && myBooking.status === "PAID" && !isDeparturePast
? previewRefund(myBooking.amount, trip.date)
: null;
const catMeta = categoryMeta(trip.category); const catMeta = categoryMeta(trip.category);
const tripUrl = absoluteUrl(`/trips/${trip.id}`); const tripUrl = absoluteUrl(`/trips/${trip.id}`);
@@ -344,10 +374,22 @@ export default async function TripDetailPage({
{/* Participant Progress */} {/* Participant Progress */}
<div className="rounded-xl border border-neutral-200 p-3 sm:p-4"> <div className="rounded-xl border border-neutral-200 p-3 sm:p-4">
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between gap-2">
<span className="text-xs font-semibold text-neutral-700 sm:text-sm"> <div className="flex items-center gap-2">
Peserta <span className="text-xs font-semibold text-neutral-700 sm:text-sm">
</span> Peserta
</span>
{spotsLeft > 0 && spotsLeft <= 3 && (
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-bold text-amber-800 sm:text-[11px]">
Tinggal {spotsLeft} spot!
</span>
)}
{spotsLeft <= 0 && (
<span className="rounded-full bg-neutral-200 px-2 py-0.5 text-[10px] font-bold text-neutral-700 sm:text-[11px]">
Penuh
</span>
)}
</div>
<span className="text-xs font-bold text-neutral-800 sm:text-sm"> <span className="text-xs font-bold text-neutral-800 sm:text-sm">
{participantCount}{" "} {participantCount}{" "}
<span className="font-normal text-neutral-400"> <span className="font-normal text-neutral-400">
@@ -383,6 +425,23 @@ export default async function TripDetailPage({
</> </>
)} )}
</p> </p>
{confirmedCount > 0 && (
<p className="mt-2 text-[11px] text-neutral-600 sm:text-xs">
<span aria-hidden>👥</span> Sudah join:{" "}
<span className="font-medium text-neutral-800">
{confirmedParticipants
.slice(0, 3)
.map((p) => p.user.name.split(" ")[0])
.join(", ")}
</span>
{confirmedCount > 3 && (
<span className="text-neutral-500">
{" "}
+{confirmedCount - 3} lainnya
</span>
)}
</p>
)}
</div> </div>
<TripProgramBlock <TripProgramBlock
@@ -451,8 +510,33 @@ export default async function TripDetailPage({
isFull={spotsLeft <= 0} isFull={spotsLeft <= 0}
tripStatus={trip.status} tripStatus={trip.status}
isDeparturePast={isDeparturePast} isDeparturePast={isDeparturePast}
hideCancelButton={!!refundPreview}
/> />
{/* Peserta PAID: cancel + request refund (lewat policy default). */}
{refundPreview && (
<CancelBookingButton
tripId={trip.id}
preview={{
days: refundPreview.days,
refundAmount: refundPreview.refundAmount,
bookingAmount: refundPreview.bookingAmount,
tierLabel: refundPreview.tier.label,
}}
/>
)}
{/* Organizer: batalkan trip (auto-refund peserta PAID). */}
{canOrganizerCancel && (
<CancelTripButton
tripId={trip.id}
paidParticipantCount={paidBookingCount}
/>
)}
{/* Kebijakan refund — transparency sebelum user cancel. */}
{!tripIsFree && <RefundPolicySection />}
<TripReviewSection <TripReviewSection
tripId={trip.id} tripId={trip.id}
reviews={trip.reviews.map((r) => ({ reviews={trip.reviews.map((r) => ({
+16 -2
View File
@@ -158,7 +158,14 @@ function FreeTripSection({
bookingStatus, bookingStatus,
}: { }: {
tripId: string; tripId: string;
bookingStatus: "PENDING" | "AWAITING_PAY" | "PAID" | "CANCELLED" | "REFUNDED" | "EXPIRED"; bookingStatus:
| "PENDING"
| "AWAITING_PAY"
| "PAID"
| "CANCELLED"
| "REFUNDED"
| "PARTIALLY_REFUNDED"
| "EXPIRED";
}) { }) {
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">
@@ -208,7 +215,14 @@ async function PaidTripSection({
organizerId: string; organizerId: string;
organizerName: string; organizerName: string;
price: number; price: number;
bookingStatus: "PENDING" | "AWAITING_PAY" | "PAID" | "CANCELLED" | "REFUNDED" | "EXPIRED"; bookingStatus:
| "PENDING"
| "AWAITING_PAY"
| "PAID"
| "CANCELLED"
| "REFUNDED"
| "PARTIALLY_REFUNDED"
| "EXPIRED";
paymentMarkedAt: Date | null; paymentMarkedAt: Date | null;
paymentPaidAt: Date | null; paymentPaidAt: Date | null;
}) { }) {
+23
View File
@@ -3,8 +3,12 @@ import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { profileService } from "@/server/services/profile.service"; import { profileService } from "@/server/services/profile.service";
import { trustService } from "@/server/services/trust.service";
import { reviewService } from "@/server/services/review.service";
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 { OrganizerStatsPanel } from "@/features/profile/components/organizer-stats-panel";
import { OrganizerReviewsList } from "@/features/review/components/organizer-reviews-list";
import { siteConfig } from "@/lib/site"; import { siteConfig } from "@/lib/site";
import { vibeMeta } from "@/lib/vibe"; import { vibeMeta } from "@/lib/vibe";
@@ -45,6 +49,16 @@ export default async function PublicProfilePage({ params }: PageProps) {
year: "numeric", year: "numeric",
}); });
// Trust panel hanya relevan untuk user yang berperan organizer.
// Hindari query Prisma yang nggak perlu untuk user yang murni peserta.
const isOrganizerProfile = organizedTrips.length > 0 || isVerifiedOrganizer;
const [organizerTrust, organizerReviews] = isOrganizerProfile
? await Promise.all([
trustService.getOrganizerTrust(user.id),
reviewService.getReviewsByOrganizer(user.id),
])
: [null, []];
return ( return (
<div className="mx-auto max-w-4xl px-4 py-6 sm:py-8"> <div className="mx-auto max-w-4xl px-4 py-6 sm:py-8">
{/* Header */} {/* Header */}
@@ -156,6 +170,15 @@ export default async function PublicProfilePage({ params }: PageProps) {
</div> </div>
</section> </section>
{organizerTrust && <OrganizerStatsPanel trust={organizerTrust} />}
{organizerTrust && organizerReviews.length > 0 && (
<OrganizerReviewsList
reviews={organizerReviews}
totalCount={organizerTrust.reviewCount}
/>
)}
{/* Empty profile hint */} {/* Empty profile hint */}
{!profile && ( {!profile && (
<p className="mt-5 rounded-xl border border-dashed border-neutral-200 bg-neutral-50 px-4 py-3 text-center text-xs text-neutral-500"> <p className="mt-5 rounded-xl border border-dashed border-neutral-200 bg-neutral-50 px-4 py-3 text-center text-xs text-neutral-500">
+107
View File
@@ -0,0 +1,107 @@
# Cron Setup (PM2 / self-hosted Linux)
Aplikasi punya beberapa endpoint cron yang harus di-trigger periodik dari luar. Karena deploy pakai PM2 di VPS Linux (bukan Vercel), trigger schedule pakai **system crontab** — zero dependency, OS-native.
## Daftar cron job
| Endpoint | Schedule | Tujuan |
|---|---|---|
| `GET /api/cron/auto-complete-trips` | `0 18 * * *` (18:00 UTC = 01:00 WIB) | Flip trip yang sudah lewat tanggal selesai dari `OPEN`/`FULL` ke `COMPLETED`. |
## Setup di server
### 1. Set `CRON_SECRET` di env production
Generate random secret 32 byte:
```bash
openssl rand -hex 32
```
Tambah ke file `.env` yang dibaca PM2 (atau yang pasti ter-load saat process boot):
```bash
CRON_SECRET="<hasil-openssl-tadi>"
```
Restart PM2 supaya proses re-load env:
```bash
pm2 restart setrip --update-env
```
### 2. Daftarkan crontab
Edit crontab user yang punya akses ke env (biasanya user yang menjalankan PM2):
```bash
crontab -e
```
Tambah baris (ganti `https://your-domain.com` dan `<CRON_SECRET>` sesuai env yang di-set di step 1):
```cron
# Setrip — auto-complete trips harian (jam 01:00 WIB)
0 18 * * * curl -fsS -H "Authorization: Bearer <CRON_SECRET>" https://your-domain.com/api/cron/auto-complete-trips >> /var/log/setrip-cron.log 2>&1
```
Verifikasi crontab tersimpan:
```bash
crontab -l
```
### 3. Siapkan file log
```bash
sudo touch /var/log/setrip-cron.log
sudo chown $(whoami) /var/log/setrip-cron.log
```
## Test manual
Sebelum tunggu schedule, panggil endpoint langsung untuk verifikasi:
```bash
curl -fsS -H "Authorization: Bearer $CRON_SECRET" https://your-domain.com/api/cron/auto-complete-trips
```
**Expected response:**
- Belum ada trip yang lewat: `{"ok":true,"completed":0,"ids":[]}`
- Ada trip yang lewat: `{"ok":true,"completed":2,"ids":["clx...","cly..."]}`
**Kalau dapat 401:** `CRON_SECRET` di env tidak match dengan header. Cek `pm2 env <id>`.
**Kalau dapat 500:** `CRON_SECRET` belum di-set di env.
## Monitoring
Tail log cron:
```bash
tail -f /var/log/setrip-cron.log
```
Cek log app PM2 (untuk `console.log` dari endpoint):
```bash
pm2 logs setrip --lines 100 | grep cron
```
## Troubleshooting
**Cron jalan tapi tidak ada efek di DB:**
- Cek `pm2 logs setrip` untuk error.
- Verifikasi waktu server: `date` (output harus UTC kalau pakai schedule UTC).
**Cron tidak jalan sama sekali:**
- Cek service cron aktif: `systemctl status cron` (Debian/Ubuntu) atau `systemctl status crond` (RHEL/CentOS).
- Cek crontab terdaftar di user yang benar: `sudo crontab -u $(whoami) -l`.
**Secret bocor:**
- Generate ulang `CRON_SECRET`, update di `.env` + crontab line, restart PM2.
## Hari kalau pindah ke Vercel / PaaS lain
Tinggal hapus crontab line + bikin `vercel.json` (atau equivalent platform). Endpoint sudah platform-agnostic — proteksinya sama (header `Authorization: Bearer <CRON_SECRET>`).
+144
View File
@@ -0,0 +1,144 @@
# Setrip — Trust & Trip Detail Roadmap
Status implementasi yang menaikkan kepercayaan calon peserta — trip detail experience yang meyakinkan + sistem reputasi organizer yang transparan.
> **Prinsip:** trust = fungsi dari (a) kelengkapan informasi trip dan (b) reputasi organizer yang transparan. Setiap fitur dievaluasi: apakah memberi calon peserta alasan obyektif untuk percaya?
---
## Audit state sekarang (baseline)
**Trip detail (~80% sudah ada):**
- ✅ Itinerary, include/exclude, meeting point — schema lengkap di `Trip`, ditampilkan via `TripProgramBlock`.
- ✅ Participant preview (kartu confirmed peserta dengan avatar, kota, interests).
- ✅ Slot tersisa (X / Y) dengan progress bar berwarna.
- ⚠️ Urgency message ada tapi tidak mencolok saat slot menipis.
- ⚠️ Itinerary text bebas — organizer butuh hint format supaya konsisten isi lengkap.
**Review/trust (~40% sudah ada):**
- ✅ Model `TripReview` (rating + comment per trip+user, unique).
- ✅ Trust panel di trip detail (verified badge, trips created, avg rating, review count).
- ❌ Profil organizer publik `/u/[id]` belum tampilkan rating/review aggregate.
- ❌ Belum ada total participants served, completion rate, rating breakdown.
- ❌ Belum ada list ulasan terkumpul per organizer.
- ❌ `TripStatus.COMPLETED` enum-nya ada tapi tidak pernah di-set.
File baseline: [app/trips/[id]/page.tsx](app/trips/%5Bid%5D/page.tsx), [server/services/trust.service.ts](server/services/trust.service.ts), [server/services/review.service.ts](server/services/review.service.ts), [app/u/[id]/page.tsx](app/u/%5Bid%5D/page.tsx).
---
## PR-1 — Trip Detail Polish (UI only) ✅
Selesai. Cosmetic. Tidak ada migration, tidak ada perubahan service/repo.
| # | Item | Status | File |
|---|---|---|---|
| 1.1 | Urgency badge mencolok saat `spotsLeft <= 3` ("⚡ Tinggal X spot!") di header progress bar | ✅ | [app/trips/[id]/page.tsx](app/trips/%5Bid%5D/page.tsx) |
| 1.2 | Participant preview ringkas di blok progress ("👥 Sudah join: Andi, Rina, Budi +4") — first impression tanpa scroll | ✅ | [app/trips/[id]/page.tsx](app/trips/%5Bid%5D/page.tsx) |
| 1.3 | Hint deskriptif + placeholder lebih konkret di field itinerary form create-trip | ✅ | [features/trip/components/create-trip-form.tsx](features/trip/components/create-trip-form.tsx) |
**Tindakan manual:** tidak ada.
---
## PR-2 — Organizer Trust Aggregates (service + UI, tanpa migration) ✅
Selesai. `tsc --noEmit` lulus. Tanpa schema baru, tanpa migration.
**Keputusan asumsi yang dipakai:**
- `tripsCompleted``Trip.status = COMPLETED` (status itu tidak pernah di-set). Pakai `endDate < now()` (fallback `date < now()`) AND `status != CLOSED`.
- `tripsCancelled` = `Trip.status = CLOSED` (organizer batalkan trip eksplisit).
- `completionRate` butuh sample ≥ 3 (`COMPLETION_RATE_MIN_SAMPLE` di [lib/trust.ts](lib/trust.ts)) supaya tidak menyesatkan organizer baru.
- Rating breakdown di-render sebagai bar chart kecil (visual cue lebih kuat dari angka mentah).
- `OrganizerStatsPanel` di profil publik tidak di-render untuk user yang murni peserta — query Prisma juga di-skip kalau `organizedTrips.length === 0 && !isVerifiedOrganizer`.
- Trip detail: stat box "Trip dibuat" diganti jadi **"Trip selesai"** (lebih meaningful) + tambah **"Peserta dilayani"**. Total 3 stat box, masih kompak.
| # | Item | Status | File |
|---|---|---|---|
| 2.1 | Extend `OrganizerTrust` type: `tripsCompleted`, `tripsCancelled`, `totalParticipantsServed`, `completionRate`, `ratingBreakdown` | ✅ | [server/services/trust.service.ts](server/services/trust.service.ts) |
| 2.2 | `tripsCompleted` di-derive dari `endDate < now()` (fallback `date < now()`) AND `status != CLOSED` | ✅ | [server/services/trust.service.ts](server/services/trust.service.ts) |
| 2.3 | `totalParticipantsServed` = count `TripParticipant CONFIRMED` di trip yang sudah lewat & tidak dibatalkan | ✅ | [server/services/trust.service.ts](server/services/trust.service.ts) |
| 2.4 | `completionRate` = `tripsCompleted / (tripsCompleted + tripsCancelled)`. Null bila sample < 3 | ✅ | [server/services/trust.service.ts](server/services/trust.service.ts), [lib/trust.ts](lib/trust.ts) |
| 2.5 | `ratingBreakdown` via `prisma.tripReview.groupBy({ by: ['rating'] })` | ✅ | [server/services/trust.service.ts](server/services/trust.service.ts) |
| 2.6 | Komponen `OrganizerStatsPanel` (badges + 4 stat box + bar chart breakdown) | ✅ | [features/profile/components/organizer-stats-panel.tsx](features/profile/components/organizer-stats-panel.tsx) |
| 2.7 | Update `OrganizerTrustPanel` di trip detail — Trip selesai (+ subtitle "berjalan"), Peserta dilayani, Rating | ✅ | [features/trip/components/organizer-trust-panel.tsx](features/trip/components/organizer-trust-panel.tsx) |
| 2.8 | Render `OrganizerStatsPanel` di `/u/[id]` (skip query untuk non-organizer) | ✅ | [app/u/[id]/page.tsx](app/u/%5Bid%5D/page.tsx) |
**Tindakan manual:** tidak ada.
---
## PR-3 — Organizer Reviews Aggregator (service + UI, tanpa migration) ✅
Selesai. `tsc --noEmit` lulus. Tanpa schema baru, tanpa migration.
**Keputusan asumsi yang dipakai:**
- Default limit 20 ulasan terbaru — cukup untuk MVP, tidak perlu pagination dulu.
- Komponen RSC (server component, no `"use client"`) — pure render, tidak ada interaktivitas.
- Tipe `OrganizerReviewItem` di-extract dari `Awaited<ReturnType<typeof reviewRepo.findByOrganizer>>[number]` supaya schema repo = sumber kebenaran tanpa duplikasi tipe.
- Fetch trust + reviews via single `Promise.all` di `/u/[id]` (paralel, hemat 1 round-trip).
- Komponen di-skip kalau `reviews.length === 0` — biar tidak makin "kosong" di profil organizer baru. Stats panel sudah punya pesan "Belum ada ulasan".
- Rating ditampilkan sebagai bintang penuh (`★★★★☆`) bukan angka — visual cue lebih kuat untuk testimoni.
- Header section: "X terbaru dari Y ulasan" kalau di-limit, atau cuma "Y ulasan" kalau seluruh list ditampilkan.
| # | Item | Status | File |
|---|---|---|---|
| 3.1 | `reviewService.getReviewsByOrganizer(organizerId, limit?)` + tipe `OrganizerReviewItem` | ✅ | [server/services/review.service.ts](server/services/review.service.ts) |
| 3.2 | Repo helper `findByOrganizer` (default limit 20, urut newest, include user + trip) | ✅ | [server/repositories/review.repo.ts](server/repositories/review.repo.ts) |
| 3.3 | Komponen `OrganizerReviewsList` (avatar + name + bintang + trip link + tanggal + comment) | ✅ | [features/review/components/organizer-reviews-list.tsx](features/review/components/organizer-reviews-list.tsx) |
| 3.4 | Render di `/u/[id]` di bawah `OrganizerStatsPanel`, fetch via `Promise.all` paralel | ✅ | [app/u/[id]/page.tsx](app/u/%5Bid%5D/page.tsx) |
**Tindakan manual:** tidak ada.
---
## PR-4 — Trip Completion Mechanism (cron daily) ✅
Selesai. Pilihan **B** (cron daily) — `Trip.status = COMPLETED` di-set otomatis untuk trip yang `endDate` (atau `date` kalau endDate null) sudah lewat hari ini UTC.
**Keputusan asumsi yang dipakai:**
- Cutoff = `utcStartOfDay(new Date())` (start of today UTC). Trip dengan `endDate < cutoff` di-flip; trip yang berakhir *hari ini* belum.
- Hanya trip dengan status `OPEN` atau `FULL` yang di-flip. `CLOSED` tidak disentuh (organizer eksplisit membatalkan; tetap dibedakan dari COMPLETED untuk perhitungan `tripsCancelled` di trust panel).
- Idempotent: dua kali run di hari yang sama, run kedua match 0 row.
- Endpoint diproteksi via `Authorization: Bearer ${CRON_SECRET}`. Kalau `CRON_SECRET` tidak di-set, endpoint hard-fail 500 (mencegah accidentally jalan tanpa proteksi).
- Schedule cron: `0 18 * * *` (jam 18:00 UTC = 01:00 WIB hari berikutnya) — buffer ~7 jam pasca-akhir hari WIB sebelum flip.
- **`trustService` tetap pakai computed-from-`endDate`** (tidak diganti ke `status = COMPLETED`). Alasan: trust calc tetap correct walau cron telat / down sehari, dan backward-compat untuk trip lama yang dibuat sebelum cron aktif.
- Vercel Cron via `vercel.json` — host lain tinggal panggil endpoint yang sama dari cron eksternal apa saja (GitHub Actions, cron-job.org, dst) dengan header yang sama.
| # | Item | Status | File |
|---|---|---|---|
| 4.1 | Repo helper `bulkCompletePastTrips(cutoff)` (idempotent, batch update) | ✅ | [server/repositories/trip.repo.ts](server/repositories/trip.repo.ts) |
| 4.2 | Service `tripService.autoCompletePastTrips()` | ✅ | [server/services/trip.service.ts](server/services/trip.service.ts) |
| 4.3 | API route `/api/cron/auto-complete-trips` (GET, proteksi `CRON_SECRET`) | ✅ | [app/api/cron/auto-complete-trips/route.ts](app/api/cron/auto-complete-trips/route.ts) |
| 4.4 | Schedule `0 18 * * *` di Vercel Cron | ✅ | [vercel.json](vercel.json) |
**Tindakan manual:**
1. Set env `CRON_SECRET` di hosting (random ≥32 char). Generate cepat: `openssl rand -hex 32`.
2. Kalau host bukan Vercel: panggil endpoint dari cron eksternal apa saja (GitHub Actions schedule, cron-job.org, EasyCron, dst) dengan header `Authorization: Bearer ${CRON_SECRET}`. `vercel.json` bisa dihapus.
---
## ❌ Anti-list (yang harus DITOLAK kalau muncul)
- **Model `OrganizerReview` terpisah**`TripReview` sudah cukup, 1 trip = 1 organizer. Bikin model baru = duplikasi data + sumber kebenaran ambigu.
- **Denormalisasi cache** (mis. `User.cachedAvgRating`) sebelum aggregate query terbukti lambat. Premature optimization → drift jadi tech debt cepat.
- **Auto-hapus review buruk** atau organizer "respon" review (untuk MVP). Bisa nanti — fokus dulu menampilkan data jujur.
- **"Trust score" gabungan satu angka** — kasih breakdown agar calon peserta evaluasi sendiri. Single number gampang dimanipulasi & menyesatkan.
- **Review user/peserta** (no-show, kooperatif?) — itu C6 di [SOCIAL_ROADMAP.md](SOCIAL_ROADMAP.md). Scope berbeda, jangan campur.
- **Rating dengan setengah bintang / kustom 1-10** — tetap 1-5 integer (sudah di schema). Granularitas lebih halus tidak meningkatkan trust, hanya menambah noise.
---
## Saran phasing
PR berurutan. Setiap PR mandiri (siap di-deploy):
1. **PR-1** — Trip detail polish. Cepat, low-risk, no migration. **Mulai dari sini.**
2. **PR-2** — Trust aggregates di service + UI. Read-only, no migration.
3. **PR-3** — Reviews list per organizer di service + UI. Read-only, no migration.
4. **PR-4** — Diskusi opsi completion mechanism (atau skip kalau opsi C dipilih).
**Pertanyaan terbuka sebelum PR-2:**
1. Apakah `tripsCompleted` count termasuk trip dengan `status = CLOSED` yang `endDate < now()`? Saran: tidak — CLOSED = dibatalkan, dipisah ke `tripsCancelled`.
2. Threshold minimum supaya `completionRate` ditampilkan? Saran: min 3 trip selesai supaya angka tidak menyesatkan (1 trip dibatalkan dari 1 trip = 0% looks bad untuk organizer baru).
3. Tampilkan rating breakdown sebagai bar chart atau hanya angka? Saran: bar chart kecil — visual cue lebih kuat untuk credibility.
+9
View File
@@ -27,3 +27,12 @@ NEXT_PUBLIC_MIDTRANS_CLIENT_KEY=
# Dibaca di server (untuk Snap API endpoint) DAN client (untuk Snap.js URL). # Dibaca di server (untuk Snap API endpoint) DAN client (untuk Snap.js URL).
NEXT_PUBLIC_MIDTRANS_IS_PRODUCTION=false NEXT_PUBLIC_MIDTRANS_IS_PRODUCTION=false
# Webhook URL di Midtrans dashboard harus diset ke: <NEXT_PUBLIC_SITE_URL>/api/webhooks/midtrans # Webhook URL di Midtrans dashboard harus diset ke: <NEXT_PUBLIC_SITE_URL>/api/webhooks/midtrans
# === Cron jobs (auto-complete trip, dst) ===
# Bearer token yang harus di-kirim cron eksternal (system crontab / Vercel Cron / dst)
# saat memanggil endpoint `/api/cron/*`. Kalau kosong, endpoint hard-fail 500.
# Generate ≥32-byte hex secret:
# openssl rand -hex 32
# Setup detail: lihat docs/CRON_SETUP.md
CRON_SECRET=
+43
View File
@@ -5,6 +5,7 @@ import { authOptions } from "@/lib/auth";
import { tripService } from "@/server/services/trip.service"; 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 { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
export async function markParticipantPaidAction(tripId: string) { export async function markParticipantPaidAction(tripId: string) {
@@ -94,3 +95,45 @@ export async function confirmParticipantPaymentAction(
return { error: (err as Error).message }; return { error: (err as Error).message };
} }
} }
/**
* Peserta cancel booking PAID dengan refund request. Server menghitung
* nominal refund pakai policy default (lib/refund-policy.ts) client
* cuma kirim bookingId untuk cegah tampering.
*/
export async function cancelBookingWithRefundAction(tripId: string) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return { error: "Kamu harus login terlebih dahulu" };
}
try {
const booking = await bookingService.getByTripAndUser(
tripId,
session.user.id
);
if (!booking) {
return { error: "Kamu tidak terdaftar di trip ini" };
}
const result = await refundService.requestUserCancellation({
bookingId: booking.id,
userId: session.user.id,
});
revalidatePath(`/trips/${tripId}`);
revalidatePath("/trips");
revalidatePath("/");
revalidatePath("/profile");
revalidatePath("/admin/refunds");
return {
success: true as const,
kind: result.kind,
refundAmount: result.refundAmount,
days: result.days,
};
} catch (err) {
return { error: (err as Error).message };
}
}
@@ -0,0 +1,161 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { cancelBookingWithRefundAction } from "@/features/booking/actions";
import { formatRupiah } from "@/lib/utils";
interface CancelBookingButtonProps {
tripId: string;
/** Hasil preview server-side (dihitung di trip detail page). */
preview: {
days: number;
refundAmount: number;
bookingAmount: number;
tierLabel: string;
};
}
type ServerResult =
| { kind: "REFUND_PENDING"; refundAmount: number; days: number }
| { kind: "CANCELLED_NO_REFUND"; days: number };
export function CancelBookingButton({ tripId, preview }: CancelBookingButtonProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [result, setResult] = useState<ServerResult | null>(null);
async function handleConfirm() {
setLoading(true);
setError("");
const res = await cancelBookingWithRefundAction(tripId);
setLoading(false);
if ("error" in res) {
setError(res.error ?? "Terjadi kesalahan");
return;
}
setResult({
kind: res.kind,
refundAmount: res.refundAmount,
days: res.days,
} as ServerResult);
router.refresh();
}
if (result?.kind === "REFUND_PENDING") {
return (
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
<p className="font-semibold">Request refund dibuat.</p>
<p className="mt-1 text-xs">
Refund <span className="font-bold">{formatRupiah(result.refundAmount)}</span>{" "}
menunggu review admin. Setelah disetujui dan ditransfer manual, slot
kamu di trip akan otomatis dibebaskan.
</p>
</div>
);
}
if (result?.kind === "CANCELLED_NO_REFUND") {
return (
<div className="rounded-2xl border border-neutral-200 bg-neutral-50 p-4 text-sm text-neutral-700">
<p className="font-semibold">Booking dibatalkan.</p>
<p className="mt-1 text-xs">
Pembatalan di H-{result.days} berada di luar window refund tidak
ada nominal yang dikembalikan.
</p>
</div>
);
}
if (!open) {
return (
<button
type="button"
onClick={() => setOpen(true)}
className="w-full rounded-xl border-2 border-red-200 py-3 text-sm font-bold text-red-600 transition-colors hover:bg-red-50"
>
Cancel & Request Refund
</button>
);
}
const percentage = preview.bookingAmount
? Math.floor((preview.refundAmount * 100) / preview.bookingAmount)
: 0;
const noRefund = preview.refundAmount === 0;
return (
<div className="space-y-3 rounded-2xl border-2 border-red-200 bg-red-50 p-4 text-sm">
<div>
<p className="font-bold text-red-900">Cancel booking?</p>
<p className="mt-1 text-xs text-red-800/80">
Kamu cancel di <span className="font-semibold">H-{preview.days}</span>{" "}
dari tanggal berangkat.
</p>
</div>
<div className="rounded-xl border border-red-200 bg-white p-3">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
Estimasi refund (sesuai policy)
</p>
<p className="mt-1 text-lg font-bold text-neutral-900">
{formatRupiah(preview.refundAmount)}
{!noRefund && (
<span className="ml-2 text-xs font-medium text-neutral-500">
({percentage}% dari {formatRupiah(preview.bookingAmount)})
</span>
)}
</p>
<p className="mt-1 text-[11px] text-neutral-500">
Tier: {preview.tierLabel}
</p>
{noRefund ? (
<p className="mt-2 text-xs text-red-700">
Di luar window refund uang tidak dikembalikan. Booking akan
di-cancel langsung.
</p>
) : (
<p className="mt-2 text-xs text-neutral-600">
Refund akan masuk antrian review admin. Setelah disetujui & uang
ditransfer, booking otomatis ditandai{" "}
{percentage === 100 ? "REFUNDED" : "PARTIALLY_REFUNDED"}.
</p>
)}
</div>
{error && (
<div className="rounded-lg bg-white px-3 py-2 text-xs font-medium text-red-700">
{error}
</div>
)}
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={handleConfirm}
disabled={loading}
className="rounded-xl bg-red-600 px-4 py-2 text-sm font-bold text-white hover:bg-red-700 disabled:opacity-50"
>
{loading
? "Memproses…"
: noRefund
? "Konfirmasi Cancel"
: "Konfirmasi & Request Refund"}
</button>
<button
type="button"
onClick={() => {
setOpen(false);
setError("");
}}
disabled={loading}
className="rounded-xl border border-red-200 bg-white px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-100 disabled:opacity-50"
>
Batal
</button>
</div>
</div>
);
}
@@ -0,0 +1,161 @@
import type { OrganizerTrust } from "@/server/services/trust.service";
interface OrganizerStatsPanelProps {
trust: OrganizerTrust;
}
/**
* Panel reputasi organizer untuk halaman profil publik /u/[id].
* Tidak render kalau user belum punya history sebagai organizer.
*/
export function OrganizerStatsPanel({ trust }: OrganizerStatsPanelProps) {
const {
isVerified,
isTripLeader,
tripsCreated,
tripsCompleted,
totalParticipantsServed,
completionRate,
avgRating,
reviewCount,
ratingBreakdown,
} = trust;
if (tripsCreated === 0 && reviewCount === 0 && !isVerified) {
return null;
}
const maxBreakdown = Math.max(
ratingBreakdown[1],
ratingBreakdown[2],
ratingBreakdown[3],
ratingBreakdown[4],
ratingBreakdown[5],
1
);
return (
<section className="mt-5 rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm sm:p-6">
<h2 className="mb-3 text-sm font-bold text-neutral-700 sm:text-base">
Reputasi sebagai organizer
</h2>
{(isVerified || isTripLeader) && (
<div className="mb-4 flex flex-wrap gap-2">
{isVerified && (
<span
className="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2.5 py-0.5 text-[11px] font-bold uppercase tracking-wide text-primary-800"
title="Identitas organizer telah diverifikasi (KTP & rekening)"
>
Verified Organizer
</span>
)}
{isTripLeader && (
<span className="inline-flex items-center rounded-full bg-secondary-100 px-2.5 py-0.5 text-[11px] font-bold uppercase tracking-wide text-secondary-900">
Trip leader
</span>
)}
</div>
)}
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-4">
<Stat
label="Trip selesai"
value={tripsCompleted.toString()}
tone="primary"
/>
<Stat
label="Peserta dilayani"
value={totalParticipantsServed.toString()}
tone="secondary"
/>
<Stat
label="Completion rate"
value={
completionRate != null
? `${Math.round(completionRate * 100)}%`
: "—"
}
subtitle={
completionRate == null ? "Belum cukup data" : undefined
}
tone="neutral"
/>
<Stat
label="Rating"
value={avgRating != null ? `${avgRating}` : "—"}
subtitle={
reviewCount > 0
? `${reviewCount} ulasan`
: "Belum ada ulasan"
}
tone="amber"
/>
</div>
{reviewCount > 0 && (
<div className="mt-4 border-t border-neutral-100 pt-4">
<h3 className="mb-2 text-xs font-semibold text-neutral-600">
Distribusi rating
</h3>
<div className="space-y-1.5">
{([5, 4, 3, 2, 1] as const).map((star) => {
const count = ratingBreakdown[star];
const percent = (count / maxBreakdown) * 100;
return (
<div
key={star}
className="flex items-center gap-2 text-xs"
>
<span className="w-8 shrink-0 font-medium text-neutral-600">
{star}
</span>
<div className="h-2 flex-1 overflow-hidden rounded-full bg-neutral-100">
<div
className="h-full rounded-full bg-amber-400 transition-all"
style={{ width: `${percent}%` }}
/>
</div>
<span className="w-8 shrink-0 text-right text-neutral-500">
{count}
</span>
</div>
);
})}
</div>
</div>
)}
</section>
);
}
const TONE_CLASSES = {
primary: { bg: "bg-primary-50", value: "text-primary-700" },
secondary: { bg: "bg-secondary-50", value: "text-secondary-700" },
neutral: { bg: "bg-neutral-50", value: "text-neutral-800" },
amber: { bg: "bg-amber-50", value: "text-amber-700" },
} as const;
interface StatProps {
label: string;
value: string;
subtitle?: string;
tone: keyof typeof TONE_CLASSES;
}
function Stat({ label, value, subtitle, tone }: StatProps) {
const cls = TONE_CLASSES[tone];
return (
<div className={`rounded-xl px-3 py-2.5 ${cls.bg}`}>
<p className="text-[10px] font-medium uppercase tracking-wide text-neutral-500 sm:text-[11px]">
{label}
</p>
<p className={`mt-0.5 text-lg font-bold sm:text-xl ${cls.value}`}>
{value}
</p>
{subtitle && (
<p className="text-[10px] text-neutral-400">{subtitle}</p>
)}
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
"use server";
import { getServerSession } from "next-auth";
import { revalidatePath } from "next/cache";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { refundService } from "@/server/services/refund.service";
import { createRefundSchema, refundDecisionSchema } 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 createRefundAction(formData: FormData) {
const admin = await requireAdmin();
if (!admin) return { error: "Tidak memiliki akses admin" };
const parsed = createRefundSchema.safeParse({
bookingId: formData.get("bookingId") as string,
reason: formData.get("reason") as string,
reportedBy: formData.get("reportedBy") as string,
reportNote: formData.get("reportNote") as string,
amount: (formData.get("amount") as string) || undefined,
});
if (!parsed.success) {
return { error: parsed.error.issues[0].message };
}
try {
await refundService.requestRefund({
bookingId: parsed.data.bookingId,
reason: parsed.data.reason,
reportedBy: parsed.data.reportedBy,
reportNote: parsed.data.reportNote,
amount: parsed.data.amount,
initiatedByAdminId: admin.id,
});
revalidatePath("/admin/refunds");
return { success: true };
} catch (err) {
return { error: (err as Error).message };
}
}
export async function decideRefundAction(formData: FormData) {
const admin = await requireAdmin();
if (!admin) return { error: "Tidak memiliki akses admin" };
const parsed = refundDecisionSchema.safeParse({
refundId: formData.get("refundId") as string,
decision: formData.get("decision") as string,
adminNote: (formData.get("adminNote") as string) || undefined,
});
if (!parsed.success) {
return { error: parsed.error.issues[0].message };
}
const { refundId, decision, adminNote } = parsed.data;
const needsNote = decision === "REJECT" || decision === "SUCCEEDED" || decision === "FAILED";
if (needsNote && (!adminNote || !adminNote.trim())) {
return { error: "Catatan/alasan admin wajib diisi untuk tindakan ini" };
}
try {
if (decision === "APPROVE") {
await refundService.approveRefund({
refundId,
adminId: admin.id,
adminNote,
});
} else if (decision === "REJECT") {
await refundService.rejectRefund({
refundId,
adminId: admin.id,
adminNote: adminNote!,
});
} else if (decision === "SUCCEEDED") {
await refundService.markSucceededManual({
refundId,
adminId: admin.id,
adminNote: adminNote!,
});
} else {
await refundService.markFailed({
refundId,
adminId: admin.id,
adminNote: adminNote!,
});
}
revalidatePath("/admin/refunds");
return { success: true };
} catch (err) {
return { error: (err as Error).message };
}
}
@@ -0,0 +1,206 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { createRefundAction } from "@/features/refund/actions";
const REASON_OPTIONS = [
{ value: "USER_CANCELLATION", label: "Peserta cancel sendiri" },
{ value: "ORGANIZER_CANCELLED", label: "Organizer batalkan trip" },
{ value: "TRIP_ISSUE", label: "Masalah saat/setelah trip" },
{ value: "ADMIN_ADJUSTMENT", label: "Penyesuaian admin" },
{ value: "DISPUTE_RESOLVED", label: "Hasil dispute / chargeback" },
{ value: "OTHER", label: "Lain-lain" },
];
const REPORTER_OPTIONS = [
{ value: "PARTICIPANT", label: "Peserta" },
{ value: "ORGANIZER", label: "Organizer" },
];
export function CreateRefundForm() {
const router = useRouter();
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError("");
setLoading(true);
const fd = new FormData(e.currentTarget);
const result = await createRefundAction(fd);
setLoading(false);
if (result.error) {
setError(result.error);
return;
}
(e.target as HTMLFormElement).reset();
setOpen(false);
router.refresh();
}
if (!open) {
return (
<div className="mb-6 flex justify-end">
<button
type="button"
onClick={() => setOpen(true)}
className="rounded-xl bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700"
>
+ Catat Laporan Refund
</button>
</div>
);
}
return (
<form
onSubmit={onSubmit}
className="mb-6 space-y-4 rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm sm:p-6"
>
<header className="flex items-start justify-between gap-3 border-b border-neutral-100 pb-3">
<div>
<h2 className="text-base font-bold text-neutral-900">
Catat Laporan Refund Manual
</h2>
<p className="mt-0.5 text-xs text-neutral-500">
Masukkan laporan yang diterima dari peserta atau organizer (via
WhatsApp/email). Refund akan masuk antrian PENDING untuk di-review.
</p>
</div>
<button
type="button"
onClick={() => {
setOpen(false);
setError("");
}}
className="rounded-lg px-2 py-1 text-xs font-medium text-neutral-500 hover:bg-neutral-100"
>
Tutup
</button>
</header>
{error && (
<div className="rounded-lg bg-red-50 px-3 py-2 text-xs text-red-600">
{error}
</div>
)}
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Booking ID" required>
<input
name="bookingId"
required
placeholder="cuid booking yang dilaporkan"
className="w-full rounded-xl border border-neutral-200 bg-neutral-50 px-3 py-2 text-sm font-mono focus:bg-white"
/>
</Field>
<Field label="Pelapor" required>
<select
name="reportedBy"
required
defaultValue=""
className="w-full rounded-xl border border-neutral-200 bg-neutral-50 px-3 py-2 text-sm focus:bg-white"
>
<option value="" disabled>
Pilih pelapor
</option>
{REPORTER_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</Field>
<Field label="Alasan" required>
<select
name="reason"
required
defaultValue=""
className="w-full rounded-xl border border-neutral-200 bg-neutral-50 px-3 py-2 text-sm focus:bg-white"
>
<option value="" disabled>
Pilih alasan
</option>
{REASON_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</Field>
<Field
label="Nominal (IDR)"
hint="Kosongkan untuk full remaining"
>
<input
name="amount"
inputMode="numeric"
pattern="[0-9]*"
placeholder="mis. 500000"
className="w-full rounded-xl border border-neutral-200 bg-neutral-50 px-3 py-2 text-sm focus:bg-white"
/>
</Field>
</div>
<Field label="Isi laporan" required>
<textarea
name="reportNote"
required
rows={3}
placeholder="Salin/ringkas laporan dari peserta/organizer"
className="w-full rounded-xl border border-neutral-200 bg-neutral-50 px-3 py-2 text-sm focus:bg-white"
/>
</Field>
<div className="flex justify-end gap-2 border-t border-neutral-100 pt-3">
<button
type="button"
onClick={() => {
setOpen(false);
setError("");
}}
className="rounded-xl border border-neutral-200 px-4 py-2 text-sm font-medium text-neutral-600 hover:bg-neutral-50"
>
Batal
</button>
<button
type="submit"
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"
>
{loading ? "Menyimpan…" : "Simpan Laporan"}
</button>
</div>
</form>
);
}
function Field({
label,
hint,
required,
children,
}: {
label: string;
hint?: string;
required?: boolean;
children: React.ReactNode;
}) {
return (
<label className="block">
<div className="mb-1 flex items-baseline justify-between gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-neutral-600">
{label}
{required && <span className="text-red-500"> *</span>}
</span>
{hint && <span className="text-xs text-neutral-400">{hint}</span>}
</div>
{children}
</label>
);
}
@@ -0,0 +1,45 @@
import { getRefundPolicyTiers } from "@/lib/refund-policy";
/**
* Display kebijakan refund default di trip detail. Sumber tier:
* lib/refund-policy.ts. Compact agar tidak mendominasi page.
*/
export function RefundPolicySection() {
const tiers = getRefundPolicyTiers();
return (
<details className="rounded-xl border border-neutral-200 bg-neutral-50/60 p-3 text-xs sm:text-sm">
<summary className="cursor-pointer select-none font-semibold text-neutral-700">
🛟 Kebijakan refund saat peserta cancel
</summary>
<div className="mt-2 space-y-2 text-neutral-600">
<p className="text-[11px] text-neutral-500 sm:text-xs">
Kebijakan ini berlaku saat <strong>peserta</strong> cancel booking
yang sudah lunas. Kalau <strong>organizer</strong> membatalkan trip,
peserta yang sudah bayar selalu dapat refund 100%.
</p>
<ul className="space-y-1">
{tiers.map((t) => (
<li key={t.minDaysBefore} className="flex items-baseline gap-2">
<span
className={`inline-flex min-w-[3rem] justify-center rounded-full px-2 py-0.5 text-[10px] font-bold ${
t.refundPercentage >= 80
? "bg-primary-100 text-primary-700"
: t.refundPercentage >= 50
? "bg-amber-100 text-amber-700"
: "bg-red-100 text-red-700"
}`}
>
{t.refundPercentage}%
</span>
<span>{t.label}</span>
</li>
))}
</ul>
<p className="text-[11px] text-neutral-500 sm:text-xs">
Refund diproses manual oleh admin SeTrip perlu 13 hari kerja
setelah disetujui untuk uang masuk ke rekening kamu.
</p>
</div>
</details>
);
}
@@ -0,0 +1,378 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { decideRefundAction } from "@/features/refund/actions";
import { formatRupiah } from "@/lib/utils";
type RefundStatus =
| "PENDING"
| "APPROVED"
| "REJECTED"
| "PROCESSING"
| "SUCCEEDED"
| "FAILED";
type Decision = "APPROVE" | "REJECT" | "SUCCEEDED" | "FAILED";
export type RefundCardData = {
id: string;
amount: number;
currency: string;
reason: string;
reportedBy: "PARTICIPANT" | "ORGANIZER";
reportNote: string;
initiatedBy: string;
status: RefundStatus;
adminNote: string | null;
createdAt: Date;
reviewedAt: Date | null;
succeededAt: Date | null;
failedAt: Date | null;
reviewedBy: { id: string; name: string; email: string } | null;
booking: {
id: string;
amount: number;
status: string;
trip: { id: string; title: string; date: Date };
user: { id: string; name: string; email: string };
payments: {
id: string;
provider: string;
method: string | null;
amount: number;
status: string;
paidAt: Date | null;
}[];
};
};
function formatDate(d: Date): string {
return new Date(d).toLocaleString("id-ID", {
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
const REASON_LABEL: Record<string, string> = {
USER_CANCELLATION: "Peserta cancel",
ORGANIZER_CANCELLED: "Organizer batalkan",
TRIP_ISSUE: "Masalah trip",
ADMIN_ADJUSTMENT: "Penyesuaian admin",
DISPUTE_RESOLVED: "Dispute resolved",
OTHER: "Lain-lain",
};
export function RefundReviewCard({ refund }: { refund: RefundCardData }) {
const router = useRouter();
const [openAction, setOpenAction] = useState<Decision | null>(null);
const [note, setNote] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const paidPayment = refund.booking.payments.find(
(p) => p.status === "PAID" || p.status === "REFUNDED"
);
async function submit(decision: Decision) {
setError("");
setLoading(true);
const fd = new FormData();
fd.set("refundId", refund.id);
fd.set("decision", decision);
if (note.trim()) fd.set("adminNote", note);
const result = await decideRefundAction(fd);
setLoading(false);
if (result.error) {
setError(result.error);
return;
}
setOpenAction(null);
setNote("");
router.refresh();
}
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">
{refund.booking.trip.title}
</h3>
<p className="mt-0.5 text-xs text-neutral-500">
Dilaporkan {formatDate(refund.createdAt)} oleh{" "}
<span className="font-semibold">
{refund.reportedBy === "PARTICIPANT" ? "Peserta" : "Organizer"}
</span>
{" · "}
<span className="font-mono">{refund.id.slice(0, 8)}</span>
</p>
</div>
<StatusPill status={refund.status} />
</header>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<Field
label="Peserta booking"
value={`${refund.booking.user.name} · ${refund.booking.user.email}`}
/>
<Field label="Booking ID" value={refund.booking.id} mono />
<Field
label="Tanggal trip"
value={formatDate(refund.booking.trip.date)}
/>
<Field
label="Alasan"
value={REASON_LABEL[refund.reason] ?? refund.reason}
/>
<Field
label="Nominal refund"
value={`${formatRupiah(refund.amount)} ${refund.currency !== "IDR" ? `(${refund.currency})` : ""}`}
highlight
/>
<Field
label="Total dibayar"
value={
paidPayment
? `${formatRupiah(paidPayment.amount)} · ${paidPayment.provider} ${paidPayment.method ?? ""}`
: "—"
}
/>
</div>
<div className="mt-4 rounded-xl bg-neutral-50 p-3 text-sm text-neutral-700">
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-neutral-500">
Isi laporan
</p>
<p className="whitespace-pre-wrap">{refund.reportNote}</p>
</div>
{refund.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">{refund.adminNote}</p>
</div>
)}
{refund.reviewedBy && refund.reviewedAt && (
<p className="mt-3 text-xs text-neutral-500">
Diproses oleh {refund.reviewedBy.name} pada{" "}
{formatDate(refund.reviewedAt)}
{refund.succeededAt && ` · uang keluar ${formatDate(refund.succeededAt)}`}
{refund.failedAt && ` · gagal ${formatDate(refund.failedAt)}`}
</p>
)}
{(refund.status === "PENDING" || refund.status === "APPROVED") && (
<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>
)}
{openAction ? (
<ActionForm
decision={openAction}
note={note}
setNote={setNote}
loading={loading}
onCancel={() => {
setOpenAction(null);
setNote("");
setError("");
}}
onConfirm={() => submit(openAction)}
/>
) : (
<div className="flex flex-wrap gap-2">
{refund.status === "PENDING" && (
<>
<button
type="button"
onClick={() => setOpenAction("APPROVE")}
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"
>
Setujui
</button>
<button
type="button"
onClick={() => setOpenAction("REJECT")}
disabled={loading}
className="rounded-xl border border-red-200 bg-white px-4 py-2 text-sm font-bold text-red-600 hover:bg-red-50 disabled:opacity-50"
>
Tolak
</button>
</>
)}
{refund.status === "APPROVED" && (
<>
<button
type="button"
onClick={() => setOpenAction("SUCCEEDED")}
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
</button>
<button
type="button"
onClick={() => setOpenAction("FAILED")}
disabled={loading}
className="rounded-xl border border-red-200 bg-white px-4 py-2 text-sm font-bold text-red-600 hover:bg-red-50 disabled:opacity-50"
>
Tandai gagal
</button>
</>
)}
</div>
)}
</div>
)}
</article>
);
}
function ActionForm({
decision,
note,
setNote,
loading,
onCancel,
onConfirm,
}: {
decision: Decision;
note: string;
setNote: (v: string) => void;
loading: boolean;
onCancel: () => void;
onConfirm: () => void;
}) {
const cfg = {
APPROVE: {
label: "Setujui Refund",
placeholder: "Catatan untuk approval (opsional)",
required: false,
btnLabel: "Konfirmasi Setuju",
btnClass: "bg-primary-600 hover:bg-primary-700",
},
REJECT: {
label: "Tolak Refund",
placeholder: "Alasan penolakan (wajib)",
required: true,
btnLabel: "Konfirmasi Tolak",
btnClass: "bg-red-600 hover:bg-red-700",
},
SUCCEEDED: {
label: "Tandai Sudah Transfer",
placeholder: "Referensi transfer / nomor mutasi bank (wajib)",
required: true,
btnLabel: "Tandai SUCCEEDED",
btnClass: "bg-primary-600 hover:bg-primary-700",
},
FAILED: {
label: "Tandai Gagal",
placeholder: "Alasan gagal (wajib)",
required: true,
btnLabel: "Tandai FAILED",
btnClass: "bg-red-600 hover:bg-red-700",
},
}[decision];
return (
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-600">
{cfg.label}
</p>
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
rows={2}
placeholder={cfg.placeholder}
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={onConfirm}
disabled={loading || (cfg.required && !note.trim())}
className={`rounded-xl px-4 py-2 text-sm font-bold text-white disabled:opacity-50 ${cfg.btnClass}`}
>
{loading ? "Memproses…" : cfg.btnLabel}
</button>
<button
type="button"
onClick={onCancel}
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>
);
}
function Field({
label,
value,
mono,
highlight,
}: {
label: string;
value: string;
mono?: boolean;
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 ${mono ? "font-mono" : ""} ${
highlight ? "font-bold text-primary-700" : "text-neutral-800"
}`}
>
{value}
</p>
</div>
);
}
function StatusPill({ status }: { status: RefundStatus }) {
const cfg: Record<RefundStatus, { label: string; cls: string }> = {
PENDING: {
label: "Pending Review",
cls: "bg-amber-50 text-amber-700 ring-amber-200",
},
APPROVED: {
label: "Disetujui",
cls: "bg-blue-50 text-blue-700 ring-blue-200",
},
REJECTED: { label: "Ditolak", cls: "bg-red-50 text-red-700 ring-red-200" },
PROCESSING: {
label: "Diproses",
cls: "bg-violet-50 text-violet-700 ring-violet-200",
},
SUCCEEDED: {
label: "Selesai",
cls: "bg-primary-50 text-primary-700 ring-primary-200",
},
FAILED: { label: "Gagal", cls: "bg-red-50 text-red-700 ring-red-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>
);
}
+57
View File
@@ -0,0 +1,57 @@
import { z } from "zod/v4";
import { LIMITS } from "@/lib/limits";
const reasonValues = [
"USER_CANCELLATION",
"ORGANIZER_CANCELLED",
"TRIP_ISSUE",
"ADMIN_ADJUSTMENT",
"DISPUTE_RESOLVED",
"OTHER",
] as const;
const reporterValues = ["PARTICIPANT", "ORGANIZER"] as const;
const refundNote = z
.string()
.trim()
.min(3, "Isi catatan minimal 3 karakter")
.max(
LIMITS.MAX_REFUND_NOTE_LENGTH,
`Catatan maksimal ${LIMITS.MAX_REFUND_NOTE_LENGTH} karakter`
);
export const createRefundSchema = z.object({
bookingId: z.string().trim().min(1, "Booking ID wajib"),
reason: z.enum(reasonValues, { error: "Alasan tidak valid" }),
reportedBy: z.enum(reporterValues, { error: "Pelapor tidak valid" }),
reportNote: refundNote,
/** Kosong = full remaining. Angka positif (IDR) untuk partial. */
amount: z
.string()
.trim()
.optional()
.transform((v) => (v && v.length > 0 ? Number(v.replace(/[^\d]/g, "")) : undefined))
.refine(
(n) => n === undefined || (Number.isInteger(n) && n > 0),
"Nominal harus bilangan bulat positif"
),
});
export const refundDecisionSchema = z.object({
refundId: z.string().trim().min(1, "Refund ID wajib"),
decision: z.enum(["APPROVE", "REJECT", "SUCCEEDED", "FAILED"], {
error: "Keputusan tidak valid",
}),
adminNote: z
.string()
.trim()
.max(
LIMITS.MAX_REFUND_NOTE_LENGTH,
`Catatan maksimal ${LIMITS.MAX_REFUND_NOTE_LENGTH} karakter`
)
.optional(),
});
export type CreateRefundInput = z.infer<typeof createRefundSchema>;
export type RefundDecisionInput = z.infer<typeof refundDecisionSchema>;
@@ -0,0 +1,107 @@
import Link from "next/link";
import Image from "next/image";
import type { OrganizerReviewItem } from "@/server/services/review.service";
interface OrganizerReviewsListProps {
reviews: OrganizerReviewItem[];
/// Total review keseluruhan organizer (bisa lebih besar dari `reviews.length`
/// kalau di-limit di service). Kalau tidak diberikan, fallback ke `reviews.length`.
totalCount?: number;
}
export function OrganizerReviewsList({
reviews,
totalCount,
}: OrganizerReviewsListProps) {
if (reviews.length === 0) {
return null;
}
const total = totalCount ?? reviews.length;
const showingMore = total > reviews.length;
return (
<section className="mt-5 rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm sm:p-6">
<div className="mb-4 flex flex-wrap items-baseline justify-between gap-2">
<h2 className="text-sm font-bold text-neutral-700 sm:text-base">
Ulasan dari peserta
</h2>
<p className="text-xs text-neutral-500">
{showingMore
? `${reviews.length} terbaru dari ${total} ulasan`
: `${total} ulasan`}
</p>
</div>
<ul className="space-y-3">
{reviews.map((r) => (
<li
key={r.id}
className="rounded-xl border border-neutral-100 bg-neutral-50/60 px-3 py-3 sm:px-4 sm:py-3.5"
>
<div className="flex items-start gap-3">
{r.user.image ? (
<Image
src={r.user.image}
alt=""
width={36}
height={36}
className="h-9 w-9 shrink-0 rounded-full object-cover"
/>
) : (
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary-600 text-xs font-bold text-white">
{r.user.name.charAt(0).toUpperCase()}
</div>
)}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline justify-between gap-x-2 gap-y-0.5">
<Link
href={`/u/${r.user.id}`}
className="text-xs font-semibold text-neutral-800 hover:text-primary-700 sm:text-sm"
>
{r.user.name}
</Link>
<span className="text-xs font-bold text-amber-600">
{"★".repeat(r.rating)}
<span className="text-neutral-300">
{"★".repeat(5 - r.rating)}
</span>
</span>
</div>
<p className="text-[11px] text-neutral-500 sm:text-xs">
via{" "}
<Link
href={`/trips/${r.trip.id}`}
className="text-neutral-600 underline-offset-2 hover:text-primary-700 hover:underline"
>
{r.trip.title}
</Link>
<span className="text-neutral-400">
{" "}
· {formatReviewDate(r.createdAt)}
</span>
</p>
{r.comment && (
<p className="mt-2 whitespace-pre-wrap text-xs leading-relaxed text-neutral-700 sm:text-sm">
{r.comment}
</p>
)}
</div>
</div>
</li>
))}
</ul>
</section>
);
}
function formatReviewDate(date: Date): string {
return new Date(date).toLocaleDateString("id-ID", {
day: "numeric",
month: "short",
year: "numeric",
});
}
+24
View File
@@ -180,3 +180,27 @@ export async function rejectParticipantAction(
return { error: (err as Error).message }; return { error: (err as Error).message };
} }
} }
export async function cancelTripAction(tripId: string) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return { error: "Kamu harus login terlebih dahulu" };
}
try {
const result = await tripService.closeTrip(tripId, session.user.id);
revalidatePath(`/trips/${tripId}`);
revalidatePath("/trips");
revalidatePath("/");
revalidatePath("/profile");
revalidatePath("/admin/refunds");
return {
success: true as const,
refundCount: result.refundsCreated.length,
cancelledCount: result.cancelledBookings.length,
skippedCount: result.skippedBookings.length,
};
} catch (err) {
return { error: (err as Error).message };
}
}
@@ -0,0 +1,143 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { cancelTripAction } from "@/features/trip/actions";
interface CancelTripButtonProps {
tripId: string;
/** Jumlah peserta dengan booking PAID — preview impact. */
paidParticipantCount: number;
}
export function CancelTripButton({
tripId,
paidParticipantCount,
}: CancelTripButtonProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [confirmText, setConfirmText] = useState("");
const [result, setResult] = useState<
| { refundCount: number; cancelledCount: number; skippedCount: number }
| null
>(null);
async function handleConfirm() {
setLoading(true);
setError("");
const res = await cancelTripAction(tripId);
setLoading(false);
if ("error" in res) {
setError(res.error ?? "Terjadi kesalahan");
return;
}
setResult({
refundCount: res.refundCount,
cancelledCount: res.cancelledCount,
skippedCount: res.skippedCount,
});
router.refresh();
}
if (result) {
return (
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
<p className="font-semibold">Trip dibatalkan.</p>
<ul className="mt-2 list-inside list-disc space-y-0.5 text-xs">
<li>{result.refundCount} refund dibuat (menunggu admin transfer)</li>
<li>
{result.cancelledCount} booking belum-bayar di-cancel langsung
</li>
{result.skippedCount > 0 && (
<li>
{result.skippedCount} booking di-skip (sudah punya refund aktif
admin akan handle manual)
</li>
)}
</ul>
</div>
);
}
if (!open) {
return (
<button
type="button"
onClick={() => setOpen(true)}
className="w-full rounded-xl border-2 border-red-200 py-3 text-sm font-bold text-red-600 transition-colors hover:bg-red-50"
>
Batalkan Trip
</button>
);
}
const requireConfirm = paidParticipantCount > 0;
const canSubmit = !requireConfirm || confirmText.trim() === "BATAL";
return (
<div className="space-y-3 rounded-2xl border-2 border-red-200 bg-red-50 p-4 text-sm">
<div>
<p className="font-bold text-red-900">Yakin batalkan trip ini?</p>
<p className="mt-1 text-xs text-red-800/80">
Aksi ini <span className="font-semibold">tidak bisa di-undo</span>.
Trip akan ditandai CLOSED dan semua peserta dibatalkan.
{paidParticipantCount > 0 && (
<>
{" "}Sistem akan otomatis membuat{" "}
<span className="font-bold">
{paidParticipantCount} refund
</span>{" "}
full amount untuk peserta yang sudah membayar admin SeTrip akan
memproses transfer.
</>
)}
</p>
</div>
{requireConfirm && (
<label className="block">
<span className="text-xs font-semibold uppercase tracking-wide text-red-700">
Ketik <span className="font-mono">BATAL</span> untuk konfirmasi
</span>
<input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder="BATAL"
className="mt-1 w-full rounded-xl border border-red-300 bg-white px-3 py-2 font-mono text-sm focus:border-red-500 focus:outline-none"
/>
</label>
)}
{error && (
<div className="rounded-lg bg-white px-3 py-2 text-xs font-medium text-red-700">
{error}
</div>
)}
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={handleConfirm}
disabled={loading || !canSubmit}
className="rounded-xl bg-red-600 px-4 py-2 text-sm font-bold text-white hover:bg-red-700 disabled:opacity-50"
>
{loading ? "Memproses…" : "Ya, Batalkan Trip"}
</button>
<button
type="button"
onClick={() => {
setOpen(false);
setConfirmText("");
setError("");
}}
disabled={loading}
className="rounded-xl border border-red-200 bg-white px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-100 disabled:opacity-50"
>
Batal
</button>
</div>
</div>
);
}
@@ -248,15 +248,21 @@ export function CreateTripForm({ isVerifiedOrganizer }: CreateTripFormProps) {
</div> </div>
<div> <div>
<label htmlFor="itinerary" className="mb-1.5 block text-sm font-semibold text-neutral-700"> <label htmlFor="itinerary" className="mb-1 block text-sm font-semibold text-neutral-700">
Itinerary Itinerary
</label> </label>
<p className="mb-1.5 text-[11px] text-neutral-500">
Tulis per hari supaya peserta tahu alur itinerary lengkap bikin
trust naik drastis.
</p>
<textarea <textarea
id="itinerary" id="itinerary"
name="itinerary" name="itinerary"
rows={5} rows={6}
className="w-full rounded-xl border border-neutral-200 bg-neutral-50 px-4 py-2.5 text-sm text-neutral-800 placeholder:text-neutral-400 focus:bg-white" className="w-full rounded-xl border border-neutral-200 bg-neutral-50 px-4 py-2.5 text-sm text-neutral-800 placeholder:text-neutral-400 focus:bg-white"
placeholder={"Hari 1: …\nHari 2: …"} placeholder={
"Hari 1: 05:00 kumpul di meeting point\n07:00 berangkat\n12:00 ishoma di rest area\n16:00 sampai basecamp, briefing\n\nHari 2: 04:00 summit attack\n08:00 kembali ke basecamp\n..."
}
/> />
</div> </div>
+13 -7
View File
@@ -23,6 +23,9 @@ interface JoinTripButtonProps {
tripStatus: string; tripStatus: string;
/** Tanggal berangkat trip sudah lewat */ /** Tanggal berangkat trip sudah lewat */
isDeparturePast?: boolean; isDeparturePast?: boolean;
/** Sembunyikan tombol cancel dipakai saat booking PAID dan parent
* menampilkan CancelBookingButton (refund flow) di tempat terpisah. */
hideCancelButton?: boolean;
} }
export function JoinTripButton({ export function JoinTripButton({
@@ -36,6 +39,7 @@ export function JoinTripButton({
isFull, isFull,
tripStatus, tripStatus,
isDeparturePast, isDeparturePast,
hideCancelButton,
}: JoinTripButtonProps) { }: JoinTripButtonProps) {
const router = useRouter(); const router = useRouter();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -163,13 +167,15 @@ export function JoinTripButton({
</Link> </Link>
)} )}
{isJoined ? ( {isJoined ? (
<button hideCancelButton ? null : (
onClick={handleCancel} <button
disabled={loading} onClick={handleCancel}
className="w-full rounded-xl border-2 border-red-200 py-3 text-sm font-bold text-red-600 transition-colors hover:bg-red-50 disabled:opacity-50" disabled={loading}
> className="w-full rounded-xl border-2 border-red-200 py-3 text-sm font-bold text-red-600 transition-colors hover:bg-red-50 disabled:opacity-50"
{loading ? "Memproses..." : "Batal Ikut"} >
</button> {loading ? "Memproses..." : "Batal Ikut"}
</button>
)
) : ( ) : (
<button <button
onClick={handleJoin} onClick={handleJoin}
@@ -56,10 +56,23 @@ export function OrganizerTrustPanel({
<div className="flex flex-1 flex-wrap gap-3 border-t border-neutral-100 pt-3 sm:border-t-0 sm:border-l sm:pl-4 sm:pt-0"> <div className="flex flex-1 flex-wrap gap-3 border-t border-neutral-100 pt-3 sm:border-t-0 sm:border-l sm:pl-4 sm:pt-0">
<div className="min-w-[100px] rounded-lg bg-white/80 px-3 py-2 shadow-sm ring-1 ring-neutral-100"> <div className="min-w-[100px] rounded-lg bg-white/80 px-3 py-2 shadow-sm ring-1 ring-neutral-100">
<p className="text-[10px] font-medium text-neutral-500 sm:text-xs"> <p className="text-[10px] font-medium text-neutral-500 sm:text-xs">
Trip dibuat Trip selesai
</p> </p>
<p className="text-lg font-bold text-neutral-800"> <p className="text-lg font-bold text-primary-700">
{trust.tripsCreated} {trust.tripsCompleted}
</p>
{trust.tripsCreated > trust.tripsCompleted && (
<p className="text-[10px] text-neutral-400">
+ {trust.tripsCreated - trust.tripsCompleted} berjalan
</p>
)}
</div>
<div className="min-w-[100px] rounded-lg bg-white/80 px-3 py-2 shadow-sm ring-1 ring-neutral-100">
<p className="text-[10px] font-medium text-neutral-500 sm:text-xs">
Peserta dilayani
</p>
<p className="text-lg font-bold text-secondary-700">
{trust.totalParticipantsServed}
</p> </p>
</div> </div>
<div className="min-w-[100px] rounded-lg bg-white/80 px-3 py-2 shadow-sm ring-1 ring-neutral-100"> <div className="min-w-[100px] rounded-lg bg-white/80 px-3 py-2 shadow-sm ring-1 ring-neutral-100">
+2
View File
@@ -31,4 +31,6 @@ export const LIMITS = {
MAX_BANK_ACCOUNT_NUMBER_LENGTH: 32, MAX_BANK_ACCOUNT_NUMBER_LENGTH: 32,
MAX_REJECTION_REASON_LENGTH: 500, MAX_REJECTION_REASON_LENGTH: 500,
NIK_LENGTH: 16, NIK_LENGTH: 16,
/** Catatan laporan dari peserta/organizer + catatan admin pada refund. */
MAX_REFUND_NOTE_LENGTH: 1000,
} as const; } as const;
+11 -1
View File
@@ -162,7 +162,17 @@ export function verifyMidtransSignature(
/** /**
* Map kombinasi `transaction_status` + `fraud_status` Midtrans ke `PaymentStatus` internal. * Map kombinasi `transaction_status` + `fraud_status` Midtrans ke `PaymentStatus` internal.
* Tabel rujukan ada di PAYMENT_ROADMAP.md PR C. *
* | Midtrans | fraud_status | PaymentStatus |
* |---------------------|--------------|---------------|
* | capture | accept | PAID |
* | capture | challenge | AWAITING |
* | settlement | | PAID |
* | pending | | AWAITING |
* | deny | | FAILED |
* | expire | | EXPIRED |
* | cancel | | CANCELLED |
* | refund / partial | | REFUNDED |
*/ */
export function mapMidtransStatus( export function mapMidtransStatus(
transactionStatus: string, transactionStatus: string,
+93
View File
@@ -0,0 +1,93 @@
/**
* Refund policy hardcoded untuk MVP (PR-R3). Akan jadi data-driven di PR-R5.
*
* Aturan: hitung persentase refund berdasarkan jarak hari ke tanggal berangkat
* (UTC calendar day). Selalu integer rupiah pakai Math.floor supaya tidak
* ada sub-rupiah dan total refund tidak pernah melebihi nominal yang dibayar.
*
* Tier:
* - 7 hari sebelum berangkat 80% refund (organizer ambil 20% admin fee)
* - 36 hari sebelum berangkat 50% refund
* - <3 hari sebelum berangkat / sudah lewat 0% (tidak ada refund)
*/
import { utcStartOfDay } from "@/lib/trip-dates";
export interface RefundTier {
/** Minimum jumlah hari sebelum berangkat untuk tier ini. */
minDaysBefore: number;
/** Persentase nominal yang di-refund (0100). */
refundPercentage: number;
/** Label untuk UI. */
label: string;
}
const TIERS: RefundTier[] = [
{ minDaysBefore: 7, refundPercentage: 80, label: "≥ 7 hari sebelum berangkat" },
{ minDaysBefore: 3, refundPercentage: 50, label: "36 hari sebelum berangkat" },
{ minDaysBefore: 0, refundPercentage: 0, label: "Kurang dari 3 hari / sudah lewat" },
];
export function getRefundPolicyTiers(): RefundTier[] {
return TIERS;
}
/**
* Jumlah hari kalender UTC dari sekarang ke tanggal berangkat. Negative kalau
* tanggal sudah lewat. Pakai start-of-day UTC supaya jam tidak mempengaruhi.
*/
export function daysUntilDeparture(
departureDate: Date,
now: Date = new Date()
): number {
const todayMs = utcStartOfDay(now).getTime();
const depMs = utcStartOfDay(departureDate).getTime();
const oneDayMs = 24 * 60 * 60 * 1000;
return Math.floor((depMs - todayMs) / oneDayMs);
}
/** Tier aktif untuk jumlah hari yang diberikan. */
export function getTierForDays(days: number): RefundTier {
for (const tier of TIERS) {
if (days >= tier.minDaysBefore) {
return tier;
}
}
return TIERS[TIERS.length - 1];
}
/**
* Hitung nominal refund (IDR integer) berdasarkan harga booking dan jarak ke
* tanggal berangkat. Floor supaya tidak pernah > bookingAmount.
*/
export function calculateRefundAmount(
bookingAmount: number,
days: number
): number {
if (bookingAmount <= 0) return 0;
const tier = getTierForDays(days);
return Math.floor((bookingAmount * tier.refundPercentage) / 100);
}
export interface RefundPreview {
days: number;
tier: RefundTier;
refundAmount: number;
bookingAmount: number;
}
/** Bundle lengkap untuk display di UI — preview cancel booking. */
export function previewRefund(
bookingAmount: number,
departureDate: Date,
now: Date = new Date()
): RefundPreview {
const days = daysUntilDeparture(departureDate, now);
const tier = getTierForDays(days);
return {
days,
tier,
refundAmount: calculateRefundAmount(bookingAmount, days),
bookingAmount,
};
}
+7
View File
@@ -1,6 +1,13 @@
/** Minimal trip sebagai organizer untuk badge "Trip leader" (heuristik MVP). */ /** Minimal trip sebagai organizer untuk badge "Trip leader" (heuristik MVP). */
export const TRIP_LEADER_MIN_TRIPS = 2; export const TRIP_LEADER_MIN_TRIPS = 2;
/**
* Minimal sample (trip selesai + trip dibatalkan) sebelum completion rate
* ditampilkan ke publik. Mencegah angka menyesatkan untuk organizer baru:
* mis. 1 trip dibatalkan dari 1 trip = 0% tidak fair sebagai sinyal trust.
*/
export const COMPLETION_RATE_MIN_SAMPLE = 3;
/** Bentuk data minimal untuk cek status verifikasi organizer. */ /** Bentuk data minimal untuk cek status verifikasi organizer. */
type WithOrganizerVerification = { type WithOrganizerVerification = {
organizerVerification?: { status: "PENDING" | "APPROVED" | "REJECTED" } | null; organizerVerification?: { status: "PENDING" | "APPROVED" | "REJECTED" } | null;
+44 -44
View File
@@ -1,12 +1,12 @@
{ {
"name": "setrip", "name": "setrip",
"version": "0.10.0", "version": "0.10.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "setrip", "name": "setrip",
"version": "0.10.0", "version": "0.10.3",
"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",
@@ -1657,9 +1657,9 @@
} }
}, },
"node_modules/@next/env": { "node_modules/@next/env": {
"version": "16.2.5", "version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.5.tgz", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.6.tgz",
"integrity": "sha512-Lb9ElHD2klcyeVD25vW+siPFqz9QMzDUSgvFZNO+dZEKoMHex4viJhVuzBhrXKqb+UKnih7mVYbt50/7KLsSCA==", "integrity": "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@next/eslint-plugin-next": { "node_modules/@next/eslint-plugin-next": {
@@ -1673,9 +1673,9 @@
} }
}, },
"node_modules/@next/swc-darwin-arm64": { "node_modules/@next/swc-darwin-arm64": {
"version": "16.2.5", "version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.5.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.6.tgz",
"integrity": "sha512-BW+8PGVmsruomXHsitD8JG6gny9lEdobctjBwvtPF8AKtxGDR7nR35FOl/oK9UAPXBOBm+vx0k8qtpeHOXQMGQ==", "integrity": "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1689,9 +1689,9 @@
} }
}, },
"node_modules/@next/swc-darwin-x64": { "node_modules/@next/swc-darwin-x64": {
"version": "16.2.5", "version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.5.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.6.tgz",
"integrity": "sha512-ZoCGnCl9LlQJWmqXrZAUlNxvuNmclvE+7zUif+nDydkkehl9FKxHJ+wxSQMj+C37BYFerKiEdX9s9o02ir975Q==", "integrity": "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1705,9 +1705,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-gnu": { "node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.2.5", "version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.5.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.6.tgz",
"integrity": "sha512-AwcZzMChaWkOTZt3vu+2ZMIj8g4dYQY+B8VUVhlFSQ2JtvyZpefyYHTe00D6b6L7BysYw7vl3zsvs9jix8tl5Q==", "integrity": "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1724,9 +1724,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-musl": { "node_modules/@next/swc-linux-arm64-musl": {
"version": "16.2.5", "version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.5.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.6.tgz",
"integrity": "sha512-QqMgqWbCBFsfiQ7BF3dUlW8HJy1LWhpcqbTpoHMWA9IV+TnWwDKozQJA5NdIAHjQ00yX2Q7AUkLr/XK4n77q8A==", "integrity": "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1743,9 +1743,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-gnu": { "node_modules/@next/swc-linux-x64-gnu": {
"version": "16.2.5", "version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.5.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.6.tgz",
"integrity": "sha512-3hzeiFGZtyATVx9pCeuzTshXmh50vHZitqaeZiyJZaUmjQyrfjsVUgS8apOj1vEJCIpKJM/55F45yPAV2kpjsA==", "integrity": "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1762,9 +1762,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-musl": { "node_modules/@next/swc-linux-x64-musl": {
"version": "16.2.5", "version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.5.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.6.tgz",
"integrity": "sha512-0mzZV/mAt7Qj2tYNdTB6AqrS8dwng/AQLSYC5Z1YLpZdi2wxqKDPK7RY2RvjB1fXyJfOfdA3l/yTF5yLi+WfuQ==", "integrity": "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1781,9 +1781,9 @@
} }
}, },
"node_modules/@next/swc-win32-arm64-msvc": { "node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.2.5", "version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.5.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.6.tgz",
"integrity": "sha512-f/H4nZ2zJBvA8/+HpsB9mNonF9zfQoAU6D0WxJrfzhJDvJLfngVN85oqxUyrDVK99DIFfFYhLpGa5K+c5uotSw==", "integrity": "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1797,9 +1797,9 @@
} }
}, },
"node_modules/@next/swc-win32-x64-msvc": { "node_modules/@next/swc-win32-x64-msvc": {
"version": "16.2.5", "version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.5.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.6.tgz",
"integrity": "sha512-nuP7DHs4koAojsIxVPkihNgKiRUKtCU65j5X6DAbSy8VBrfT/o90bCLLHPf51JEdOZwZMFzM6e0NiGWfIWjVAg==", "integrity": "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -4870,9 +4870,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-uri": { "node_modules/fast-uri": {
"version": "3.1.0", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -6563,12 +6563,12 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/next": { "node_modules/next": {
"version": "16.2.5", "version": "16.2.6",
"resolved": "https://registry.npmjs.org/next/-/next-16.2.5.tgz", "resolved": "https://registry.npmjs.org/next/-/next-16.2.6.tgz",
"integrity": "sha512-TkVTm9F2WEulkgGljm4wPwNgvCCWCVw6StUHsZb8WZpHFRjepoUWg3d7L4IMg7IyjcJ4Co9eVhpro8e8O+KarQ==", "integrity": "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@next/env": "16.2.5", "@next/env": "16.2.6",
"@swc/helpers": "0.5.15", "@swc/helpers": "0.5.15",
"baseline-browser-mapping": "^2.9.19", "baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579", "caniuse-lite": "^1.0.30001579",
@@ -6582,14 +6582,14 @@
"node": ">=20.9.0" "node": ">=20.9.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@next/swc-darwin-arm64": "16.2.5", "@next/swc-darwin-arm64": "16.2.6",
"@next/swc-darwin-x64": "16.2.5", "@next/swc-darwin-x64": "16.2.6",
"@next/swc-linux-arm64-gnu": "16.2.5", "@next/swc-linux-arm64-gnu": "16.2.6",
"@next/swc-linux-arm64-musl": "16.2.5", "@next/swc-linux-arm64-musl": "16.2.6",
"@next/swc-linux-x64-gnu": "16.2.5", "@next/swc-linux-x64-gnu": "16.2.6",
"@next/swc-linux-x64-musl": "16.2.5", "@next/swc-linux-x64-musl": "16.2.6",
"@next/swc-win32-arm64-msvc": "16.2.5", "@next/swc-win32-arm64-msvc": "16.2.6",
"@next/swc-win32-x64-msvc": "16.2.5", "@next/swc-win32-x64-msvc": "16.2.6",
"sharp": "^0.34.5" "sharp": "^0.34.5"
}, },
"peerDependencies": { "peerDependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "setrip", "name": "setrip",
"version": "0.10.0", "version": "0.10.3",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -0,0 +1,57 @@
-- AlterEnum
ALTER TYPE "BookingStatus" ADD VALUE 'PARTIALLY_REFUNDED';
-- CreateEnum
CREATE TYPE "RefundReason" AS ENUM ('USER_CANCELLATION', 'ORGANIZER_CANCELLED', 'TRIP_ISSUE', 'ADMIN_ADJUSTMENT', 'DISPUTE_RESOLVED', 'OTHER');
-- CreateEnum
CREATE TYPE "RefundStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED', 'PROCESSING', 'SUCCEEDED', 'FAILED');
-- CreateEnum
CREATE TYPE "RefundInitiator" AS ENUM ('USER', 'ORGANIZER', 'SYSTEM', 'ADMIN');
-- CreateEnum
CREATE TYPE "RefundReporter" AS ENUM ('PARTICIPANT', 'ORGANIZER');
-- CreateTable
CREATE TABLE "Refund" (
"id" TEXT NOT NULL,
"bookingId" TEXT NOT NULL,
"paymentId" TEXT,
"amount" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'IDR',
"reason" "RefundReason" NOT NULL,
"reportedBy" "RefundReporter" NOT NULL,
"reportNote" TEXT NOT NULL,
"initiatedBy" "RefundInitiator" NOT NULL DEFAULT 'ADMIN',
"status" "RefundStatus" NOT NULL DEFAULT 'PENDING',
"idempotencyKey" TEXT NOT NULL,
"adminNote" TEXT,
"reviewedById" TEXT,
"reviewedAt" TIMESTAMP(3),
"succeededAt" TIMESTAMP(3),
"failedAt" TIMESTAMP(3),
"externalRefundId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Refund_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Refund_idempotencyKey_key" ON "Refund"("idempotencyKey");
-- CreateIndex
CREATE INDEX "Refund_bookingId_status_idx" ON "Refund"("bookingId", "status");
-- CreateIndex
CREATE INDEX "Refund_status_createdAt_idx" ON "Refund"("status", "createdAt");
-- AddForeignKey
ALTER TABLE "Refund" ADD CONSTRAINT "Refund_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Refund" ADD CONSTRAINT "Refund_paymentId_fkey" FOREIGN KEY ("paymentId") REFERENCES "Payment"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Refund" ADD CONSTRAINT "Refund_reviewedById_fkey" FOREIGN KEY ("reviewedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+108
View File
@@ -32,6 +32,8 @@ model User {
organizerVerification OrganizerVerification? @relation("OrganizerVerificationOwner") organizerVerification OrganizerVerification? @relation("OrganizerVerificationOwner")
reviewedVerifications OrganizerVerification[] @relation("OrganizerVerificationReviewer") reviewedVerifications OrganizerVerification[] @relation("OrganizerVerificationReviewer")
reviewedRefunds Refund[] @relation("RefundReviewer")
profile UserProfile? profile UserProfile?
} }
@@ -258,6 +260,7 @@ model Booking {
status BookingStatus @default(PENDING) status BookingStatus @default(PENDING)
payments Payment[] payments Payment[]
refunds Refund[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -275,6 +278,7 @@ enum BookingStatus {
PAID PAID
CANCELLED CANCELLED
REFUNDED REFUNDED
PARTIALLY_REFUNDED
EXPIRED EXPIRED
} }
@@ -307,6 +311,8 @@ model Payment {
failedAt DateTime? failedAt DateTime?
rejectionReason String? rejectionReason String?
refunds Refund[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -328,3 +334,105 @@ enum PaymentStatus {
CANCELLED CANCELLED
REFUNDED REFUNDED
} }
/// Refund = financial event terpisah dari Booking. Satu Booking bisa punya
/// banyak Refund (partial, multi-tahap). Setiap row auditable: kapan dibuat,
/// siapa melaporkan, siapa approve, kapan SUCCEEDED. Never delete — kalau
/// gagal, set status=FAILED + alasan.
///
/// Di MVP refund dimasukkan admin secara manual berdasarkan laporan dari
/// peserta atau organizer (via WhatsApp/email). Phase berikutnya akan
/// menambah self-service flow dari user dan organizer.
model Refund {
id String @id @default(cuid())
bookingId String
booking Booking @relation(fields: [bookingId], references: [id], onDelete: Restrict)
/// Payment yang di-refund. Opsional di MVP (manual transfer bisa tidak
/// terikat ke Payment row tertentu); wajib saat integrasi Midtrans (R-4).
paymentId String?
payment Payment? @relation(fields: [paymentId], references: [id], onDelete: Restrict)
/// Nominal refund dalam satuan terkecil (IDR rupiah, integer). Boleh < total
/// payment untuk partial. Service layer enforce SUM(SUCCEEDED) <= payment.amount.
amount Int
currency String @default("IDR")
reason RefundReason
/// Siapa yang melaporkan kebutuhan refund ini ke admin.
reportedBy RefundReporter
/// Isi laporan dari peserta/organizer yang admin terima (mis. WA, email).
reportNote String
/// Pihak yang membuat record di sistem. Di MVP selalu ADMIN; saat self-service
/// nanti USER/ORGANIZER, dan SYSTEM untuk auto-trigger dari trip dibatalkan.
initiatedBy RefundInitiator @default(ADMIN)
status RefundStatus @default(PENDING)
/// Idempotency key, dipakai saat panggil Midtrans Refund API di R-4. Generate
/// sekali saat create supaya retry gateway tidak double-refund.
idempotencyKey String @unique
/// Catatan admin: alasan tolak, referensi transfer manual, dst. Bebas teks.
adminNote String?
/// Admin yang terakhir mengubah status (approve/reject/mark-succeeded/failed).
reviewedById String?
reviewedBy User? @relation("RefundReviewer", fields: [reviewedById], references: [id], onDelete: SetNull)
reviewedAt DateTime?
succeededAt DateTime?
failedAt DateTime?
/// ID refund di gateway (mis. Midtrans refund_id). Kosong untuk manual transfer.
externalRefundId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([bookingId, status])
@@index([status, createdAt])
}
enum RefundReason {
/// Peserta cancel booking sendiri (mengikuti refund window policy).
USER_CANCELLATION
/// Organizer membatalkan trip — peserta dapat full refund.
ORGANIZER_CANCELLED
/// Masalah saat/setelah trip (mis. itinerary tidak sesuai).
TRIP_ISSUE
/// Penyesuaian dari admin (kompensasi, koreksi nominal, dll.).
ADMIN_ADJUSTMENT
/// Hasil resolusi dispute / chargeback bank.
DISPUTE_RESOLVED
OTHER
}
enum RefundStatus {
/// Baru dilaporkan, menunggu review admin.
PENDING
/// Admin sudah setujui, siap dieksekusi (manual transfer / gateway).
APPROVED
/// Admin tolak (alasan di `adminNote`).
REJECTED
/// (R-4) Request sudah dikirim ke gateway, menunggu callback.
PROCESSING
/// Uang sudah keluar dari kas Setrip / merchant gateway.
SUCCEEDED
/// Eksekusi gagal (alasan di `adminNote`). Record tidak dihapus.
FAILED
}
enum RefundInitiator {
USER
ORGANIZER
SYSTEM
ADMIN
}
enum RefundReporter {
PARTICIPANT
ORGANIZER
}
+1257 -436
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -71,4 +71,17 @@ export const bookingRepo = {
data: { status }, data: { status },
}); });
}, },
/**
* Jumlah booking PAID/PARTIALLY_REFUNDED di trip. Dipakai untuk preview
* dampak cancel-trip (berapa peserta yang akan dapat auto-refund).
*/
async countSettledForTrip(tripId: string) {
return prisma.booking.count({
where: {
tripId,
status: { in: ["PAID", "PARTIALLY_REFUNDED"] },
},
});
},
}; };
+111
View File
@@ -0,0 +1,111 @@
import { prisma } from "@/lib/prisma";
import { Prisma } from "@/app/generated/prisma/client";
const refundListInclude = {
booking: {
include: {
trip: { select: { id: true, title: true, date: true, organizerId: true } },
user: { select: { id: true, name: true, email: true, image: true } },
payments: {
orderBy: { createdAt: "desc" },
select: {
id: true,
provider: true,
method: true,
amount: true,
status: true,
paidAt: true,
},
},
},
},
payment: {
select: {
id: true,
provider: true,
method: true,
amount: true,
status: true,
},
},
reviewedBy: { select: { id: true, name: true, email: true } },
} satisfies Prisma.RefundInclude;
export const refundRepo = {
async findById(id: string) {
return prisma.refund.findUnique({
where: { id },
include: refundListInclude,
});
},
async listByStatus(
status?: "PENDING" | "APPROVED" | "REJECTED" | "PROCESSING" | "SUCCEEDED" | "FAILED"
) {
return prisma.refund.findMany({
where: status ? { status } : undefined,
orderBy: { createdAt: "desc" },
include: refundListInclude,
});
},
async listByBooking(bookingId: string) {
return prisma.refund.findMany({
where: { bookingId },
orderBy: { createdAt: "desc" },
});
},
/** Total nominal yang sudah SUCCEEDED untuk satu booking. Dipakai service untuk
* validasi `SUM(SUCCEEDED) + new.amount <= payment.amount`. */
async sumSucceededAmount(bookingId: string, tx?: Prisma.TransactionClient): Promise<number> {
const client = tx ?? prisma;
const agg = await client.refund.aggregate({
where: { bookingId, status: "SUCCEEDED" },
_sum: { amount: true },
});
return agg._sum.amount ?? 0;
},
/** Pending + approved + processing refund yang "in-flight" (belum settled).
* Dipakai untuk cek apakah booking masih punya refund aktif. */
async hasActiveRefund(bookingId: string, tx?: Prisma.TransactionClient): Promise<boolean> {
const client = tx ?? prisma;
const count = await client.refund.count({
where: {
bookingId,
status: { in: ["PENDING", "APPROVED", "PROCESSING"] },
},
});
return count > 0;
},
async create(
data: Pick<
Prisma.RefundUncheckedCreateInput,
| "bookingId"
| "paymentId"
| "amount"
| "reason"
| "reportedBy"
| "reportNote"
| "initiatedBy"
| "idempotencyKey"
>,
tx?: Prisma.TransactionClient
) {
const client = tx ?? prisma;
return client.refund.create({ data });
},
async update(
id: string,
data: Prisma.RefundUncheckedUpdateInput,
tx?: Prisma.TransactionClient
) {
const client = tx ?? prisma;
return client.refund.update({ where: { id }, data });
},
};
export type RefundWithRelations = Awaited<ReturnType<typeof refundRepo.findById>>;
+20
View File
@@ -23,4 +23,24 @@ export const reviewRepo = {
}, },
}); });
}, },
/**
* Semua review untuk trip yang di-host organizer ini, terbaru di depan.
* Dipakai di profil publik organizer untuk menampilkan testimoni.
*/
async findByOrganizer(organizerId: string, limit = 20) {
return prisma.tripReview.findMany({
where: { trip: { organizerId } },
orderBy: { createdAt: "desc" },
take: limit,
select: {
id: true,
rating: true,
comment: true,
createdAt: true,
user: { select: { id: true, name: true, image: true } },
trip: { select: { id: true, title: true, destination: true } },
},
});
},
}; };
+35
View File
@@ -211,4 +211,39 @@ export const tripRepo = {
async updateStatus(id: string, status: "OPEN" | "FULL" | "CLOSED" | "COMPLETED") { async updateStatus(id: string, status: "OPEN" | "FULL" | "CLOSED" | "COMPLETED") {
return prisma.trip.update({ where: { id }, data: { status } }); return prisma.trip.update({ where: { id }, data: { status } });
}, },
/**
* Bulk transisi trip yang sudah lewat `cutoff` (start of today UTC) dari
* status OPEN/FULL ke COMPLETED. Idempotent second run tidak akan match
* apa-apa karena status sudah berubah.
*
* Returns daftar id yang ter-update untuk telemetri/log.
*/
async bulkCompletePastTrips(cutoff: Date) {
const trips = await prisma.trip.findMany({
where: {
status: { in: ["OPEN", "FULL"] },
OR: [
{ endDate: { lt: cutoff } },
{ AND: [{ endDate: null }, { date: { lt: cutoff } }] },
],
},
select: { id: true },
});
if (trips.length === 0) {
return { count: 0, ids: [] as string[] };
}
const ids = trips.map((t) => t.id);
const result = await prisma.trip.updateMany({
where: {
id: { in: ids },
status: { in: ["OPEN", "FULL"] },
},
data: { status: "COMPLETED" },
});
return { count: result.count, ids };
},
}; };
+502
View File
@@ -0,0 +1,502 @@
import { randomBytes } from "crypto";
import { Prisma } from "@/app/generated/prisma/client";
import { prisma } from "@/lib/prisma";
import { refundRepo } from "@/server/repositories/refund.repo";
import { calculateRefundAmount, daysUntilDeparture } from "@/lib/refund-policy";
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"
);
}
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 refund. Coba lagi sebentar.");
}
function newIdempotencyKey(): string {
return `refund_${randomBytes(16).toString("hex")}`;
}
type RequestRefundInput = {
bookingId: string;
reason:
| "USER_CANCELLATION"
| "ORGANIZER_CANCELLED"
| "TRIP_ISSUE"
| "ADMIN_ADJUSTMENT"
| "DISPUTE_RESOLVED"
| "OTHER";
reportedBy: "PARTICIPANT" | "ORGANIZER";
reportNote: string;
/** Nominal refund (IDR). Kalau tidak diisi service akan pakai sisa
* refundable amount (payment.amount - sudah-di-refund). */
amount?: number;
/** Admin yang memasukkan laporan ke sistem. */
initiatedByAdminId: string;
};
export const refundService = {
/**
* Admin mencatat laporan refund dari peserta atau organizer ke sistem.
* Status awal: PENDING. Belum mengubah Booking/Payment status.
*
* Idempotency: kalau booking masih punya refund PENDING/APPROVED/PROCESSING,
* tolak admin harus selesaikan yang lama dulu (reject atau succeeded).
*/
async requestRefund(input: RequestRefundInput) {
return runSerializable(async (tx) => {
const booking = await tx.booking.findUnique({
where: { id: input.bookingId },
include: {
payments: {
where: { status: "PAID" },
orderBy: { paidAt: "desc" },
take: 1,
},
},
});
if (!booking) {
throw new Error("Booking tidak ditemukan");
}
if (booking.amount <= 0) {
throw new Error("Booking gratis — tidak ada nominal untuk di-refund");
}
if (booking.status === "CANCELLED" || booking.status === "EXPIRED") {
throw new Error(
"Booking sudah dibatalkan/expired — tidak ada pembayaran untuk di-refund"
);
}
if (booking.status === "REFUNDED") {
throw new Error("Booking sudah refund penuh");
}
const paidPayment = booking.payments[0];
if (!paidPayment) {
throw new Error(
"Tidak ada Payment dengan status PAID di booking ini — tidak bisa di-refund"
);
}
const hasActive = await refundRepo.hasActiveRefund(input.bookingId, tx);
if (hasActive) {
throw new Error(
"Booking ini masih punya refund yang sedang diproses. Selesaikan dulu sebelum membuat yang baru."
);
}
const alreadyRefunded = await refundRepo.sumSucceededAmount(input.bookingId, tx);
const remaining = paidPayment.amount - alreadyRefunded;
if (remaining <= 0) {
throw new Error("Seluruh nominal sudah di-refund");
}
const amount = input.amount ?? remaining;
if (!Number.isInteger(amount) || amount <= 0) {
throw new Error("Nominal refund harus bilangan bulat positif");
}
if (amount > remaining) {
throw new Error(
`Nominal refund (Rp ${amount.toLocaleString("id-ID")}) melebihi sisa yang bisa di-refund (Rp ${remaining.toLocaleString("id-ID")})`
);
}
return refundRepo.create(
{
bookingId: input.bookingId,
paymentId: paidPayment.id,
amount,
reason: input.reason,
reportedBy: input.reportedBy,
reportNote: input.reportNote,
initiatedBy: "ADMIN",
idempotencyKey: newIdempotencyKey(),
},
tx
);
});
},
/** PENDING → APPROVED. Boleh menambah catatan admin (opsional). */
async approveRefund(input: { refundId: string; adminId: string; adminNote?: string }) {
return runSerializable(async (tx) => {
const refund = await tx.refund.findUnique({ where: { id: input.refundId } });
if (!refund) {
throw new Error("Refund tidak ditemukan");
}
if (refund.status !== "PENDING") {
throw new Error("Hanya refund berstatus PENDING yang bisa disetujui");
}
return refundRepo.update(
input.refundId,
{
status: "APPROVED",
reviewedById: input.adminId,
reviewedAt: new Date(),
adminNote: input.adminNote ?? refund.adminNote,
},
tx
);
});
},
/** PENDING → REJECTED. Alasan wajib supaya audit trail jelas. */
async rejectRefund(input: { refundId: string; adminId: string; adminNote: string }) {
if (!input.adminNote.trim()) {
throw new Error("Alasan tolak wajib diisi");
}
return runSerializable(async (tx) => {
const refund = await tx.refund.findUnique({ where: { id: input.refundId } });
if (!refund) {
throw new Error("Refund tidak ditemukan");
}
if (refund.status !== "PENDING") {
throw new Error("Hanya refund berstatus PENDING yang bisa ditolak");
}
return refundRepo.update(
input.refundId,
{
status: "REJECTED",
reviewedById: input.adminId,
reviewedAt: new Date(),
adminNote: input.adminNote.trim(),
},
tx
);
});
},
/**
* APPROVED SUCCEEDED untuk manual transfer (admin sudah transfer manual
* ke rekening peserta). adminNote diharapkan berisi referensi transfer.
*
* Side effects:
* - Update Payment.status REFUNDED hanya saat full refund.
* - Update Booking.status REFUNDED (full) atau PARTIALLY_REFUNDED (partial).
* - Untuk USER_CANCELLATION: bebaskan slot set TripParticipant CANCELLED
* dan re-open Trip (FULL OPEN) kalau peserta aktif < maxParticipants.
* Untuk ORGANIZER_CANCELLED slot tidak perlu dibebaskan (trip sudah CLOSED).
*/
async markSucceededManual(input: {
refundId: string;
adminId: string;
adminNote: string;
}) {
if (!input.adminNote.trim()) {
throw new Error("Catatan/referensi transfer wajib diisi");
}
return runSerializable(async (tx) => {
const refund = await tx.refund.findUnique({
where: { id: input.refundId },
});
if (!refund) {
throw new Error("Refund tidak ditemukan");
}
if (refund.status !== "APPROVED") {
throw new Error(
"Hanya refund APPROVED yang bisa ditandai SUCCEEDED. Setujui dulu."
);
}
const now = new Date();
await refundRepo.update(
input.refundId,
{
status: "SUCCEEDED",
succeededAt: now,
reviewedById: input.adminId,
reviewedAt: now,
adminNote: input.adminNote.trim(),
},
tx
);
const totalRefunded = await refundRepo.sumSucceededAmount(
refund.bookingId,
tx
);
if (refund.paymentId) {
const payment = await tx.payment.findUnique({
where: { id: refund.paymentId },
});
if (payment && totalRefunded >= payment.amount) {
await tx.payment.update({
where: { id: payment.id },
data: { status: "REFUNDED" },
});
}
}
const booking = await tx.booking.findUnique({
where: { id: refund.bookingId },
include: {
trip: { select: { id: true, status: true, maxParticipants: true } },
payments: {
where: { status: { in: ["PAID", "REFUNDED"] } },
orderBy: { paidAt: "desc" },
take: 1,
},
},
});
if (!booking) {
throw new Error("Booking tidak ditemukan saat menutup refund");
}
const paid = booking.payments[0];
if (paid) {
const nextStatus =
totalRefunded >= paid.amount ? "REFUNDED" : "PARTIALLY_REFUNDED";
if (booking.status !== nextStatus) {
await tx.booking.update({
where: { id: booking.id },
data: { status: nextStatus },
});
}
}
// Slot release untuk user cancellation. Organizer cancel di-handle
// closeTrip (participant + trip sudah di-CANCELLED/CLOSED di sana).
if (refund.reason === "USER_CANCELLATION") {
await tx.tripParticipant.updateMany({
where: {
id: booking.participantId,
status: { not: "CANCELLED" },
},
data: {
status: "CANCELLED",
markedPaidAt: null,
paymentConfirmedAt: null,
},
});
if (booking.trip.status === "FULL") {
const remaining = await tx.tripParticipant.count({
where: { tripId: booking.tripId, status: { not: "CANCELLED" } },
});
if (remaining < booking.trip.maxParticipants) {
await tx.trip.update({
where: { id: booking.tripId },
data: { status: "OPEN" },
});
}
}
}
return { ok: true as const };
});
},
/**
* Peserta cancel booking sendiri. Hitung refund pakai policy default
* (lib/refund-policy.ts) hardcoded MVP, akan jadi data-driven di R-5.
*
* Behaviour:
* - Kalau hasil hitung = 0 (di luar window): cancel participant + booking
* langsung, tanpa Refund row (uang tidak balik).
* - Kalau hasil hitung > 0: buat Refund PENDING (initiatedBy=USER,
* reportedBy=PARTICIPANT, reason=USER_CANCELLATION). Participant + booking
* TETAP CONFIRMED/PAID sampai admin mark SUCCEEDED slot baru bebas saat
* refund tuntas. Cegah double-request via hasActiveRefund.
*/
async requestUserCancellation(input: {
bookingId: string;
userId: string;
}) {
return runSerializable(async (tx) => {
const booking = await tx.booking.findUnique({
where: { id: input.bookingId },
include: {
trip: {
select: {
id: true,
date: true,
status: true,
maxParticipants: true,
},
},
payments: {
where: { status: "PAID" },
orderBy: { paidAt: "desc" },
take: 1,
},
},
});
if (!booking) {
throw new Error("Booking tidak ditemukan");
}
if (booking.userId !== input.userId) {
throw new Error("Booking ini bukan milikmu");
}
if (booking.status !== "PAID") {
throw new Error(
"Hanya booking PAID yang bisa cancel dengan refund. Booking yang belum lunas bisa cancel dari tombol 'Batal Ikut'."
);
}
if (isTripDepartureDayPast(booking.trip.date)) {
throw new Error(
"Trip sudah lewat tanggal berangkat — pembatalan ditutup"
);
}
const paid = booking.payments[0];
if (!paid) {
throw new Error(
"Tidak ada Payment dengan status PAID di booking ini"
);
}
const days = daysUntilDeparture(booking.trip.date);
const refundAmount = calculateRefundAmount(paid.amount, days);
if (refundAmount === 0) {
await tx.tripParticipant.update({
where: { id: booking.participantId },
data: {
status: "CANCELLED",
markedPaidAt: null,
paymentConfirmedAt: null,
},
});
await tx.booking.update({
where: { id: booking.id },
data: { status: "CANCELLED" },
});
if (booking.trip.status === "FULL") {
const remaining = await tx.tripParticipant.count({
where: { tripId: booking.tripId, status: { not: "CANCELLED" } },
});
if (remaining < booking.trip.maxParticipants) {
await tx.trip.update({
where: { id: booking.tripId },
data: { status: "OPEN" },
});
}
}
return {
kind: "CANCELLED_NO_REFUND" as const,
days,
refundAmount: 0,
};
}
const hasActive = await refundRepo.hasActiveRefund(input.bookingId, tx);
if (hasActive) {
throw new Error(
"Booking ini sudah punya refund yang sedang diproses"
);
}
const percentage = Math.floor((refundAmount * 100) / paid.amount);
const refund = await refundRepo.create(
{
bookingId: booking.id,
paymentId: paid.id,
amount: refundAmount,
reason: "USER_CANCELLATION",
reportedBy: "PARTICIPANT",
reportNote: `Self-service cancel oleh peserta — H-${days} dari tanggal berangkat (refund ${percentage}%).`,
initiatedBy: "USER",
idempotencyKey: newIdempotencyKey(),
},
tx
);
return {
kind: "REFUND_PENDING" as const,
refundId: refund.id,
days,
refundAmount,
};
});
},
/**
* Dipanggil dari tripService.closeTrip (organizer cancel trip) dengan tx
* yang sama. Buat Refund auto-approved untuk satu booking PAID. Tidak
* mengecek hasActiveRefund (caller harus filter dulu) supaya batch closeTrip
* idempotent dengan retry-safe.
*
* Refund langsung APPROVED policy jelas (organizer cancel = 100% refund),
* tapi eksekusi (SUCCEEDED) tetap manual oleh admin.
*/
async createSystemRefundForClosedTrip(
tx: Prisma.TransactionClient,
input: {
bookingId: string;
paymentId: string;
amount: number;
}
) {
const now = new Date();
return tx.refund.create({
data: {
bookingId: input.bookingId,
paymentId: input.paymentId,
amount: input.amount,
reason: "ORGANIZER_CANCELLED",
reportedBy: "ORGANIZER",
reportNote: "Organizer membatalkan trip — auto-create oleh SYSTEM.",
initiatedBy: "SYSTEM",
idempotencyKey: newIdempotencyKey(),
status: "APPROVED",
reviewedAt: now,
adminNote: "Auto-approved (SYSTEM): organizer cancel = full refund.",
},
});
},
/**
* APPROVED/PROCESSING FAILED. Catatan wajib (alasan gagal).
* Tidak mengubah Booking/Payment uang belum keluar.
*/
async markFailed(input: { refundId: string; adminId: string; adminNote: string }) {
if (!input.adminNote.trim()) {
throw new Error("Alasan gagal wajib diisi");
}
return runSerializable(async (tx) => {
const refund = await tx.refund.findUnique({ where: { id: input.refundId } });
if (!refund) {
throw new Error("Refund tidak ditemukan");
}
if (refund.status !== "APPROVED" && refund.status !== "PROCESSING") {
throw new Error(
"Hanya refund APPROVED atau PROCESSING yang bisa ditandai FAILED"
);
}
return refundRepo.update(
input.refundId,
{
status: "FAILED",
failedAt: new Date(),
reviewedById: input.adminId,
reviewedAt: new Date(),
adminNote: input.adminNote.trim(),
},
tx
);
});
},
};
+11
View File
@@ -3,6 +3,10 @@ import { participantRepo } from "@/server/repositories/participant.repo";
import { reviewRepo } from "@/server/repositories/review.repo"; import { reviewRepo } from "@/server/repositories/review.repo";
import { isPastTripLastDayForReview } from "@/lib/trip-dates"; import { isPastTripLastDayForReview } from "@/lib/trip-dates";
export type OrganizerReviewItem = Awaited<
ReturnType<typeof reviewRepo.findByOrganizer>
>[number];
export const reviewService = { export const reviewService = {
async upsertReview( async upsertReview(
tripId: string, tripId: string,
@@ -41,4 +45,11 @@ export const reviewService = {
comment: input.comment ?? null, comment: input.comment ?? null,
}); });
}, },
async getReviewsByOrganizer(
organizerId: string,
limit?: number
): Promise<OrganizerReviewItem[]> {
return reviewRepo.findByOrganizer(organizerId, limit);
},
}; };
+188
View File
@@ -5,6 +5,7 @@ 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 { bookingService } from "@/server/services/booking.service";
import { refundService } from "@/server/services/refund.service";
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";
@@ -253,6 +254,19 @@ export const tripService = {
throw new Error("Kamu tidak terdaftar di trip ini"); throw new Error("Kamu tidak terdaftar di trip ini");
} }
// Safety: kalau booking sudah PAID, paksa lewat refund flow supaya tidak
// ada uang menggantung tanpa Refund record.
const existingBooking = await bookingRepo.findByTripAndUser(tripId, userId);
if (
existingBooking &&
(existingBooking.status === "PAID" ||
existingBooking.status === "PARTIALLY_REFUNDED")
) {
throw new Error(
"Booking kamu sudah lunas — pakai tombol 'Cancel & Request Refund' supaya uang bisa dikembalikan."
);
}
const result = await prisma.$transaction(async (tx) => { const result = await prisma.$transaction(async (tx) => {
const cancelled = await tx.tripParticipant.update({ const cancelled = await tx.tripParticipant.update({
where: { tripId_userId: { tripId, userId } }, where: { tripId_userId: { tripId, userId } },
@@ -385,6 +399,20 @@ export const tripService = {
return bookingService.markPaidManual(booking.id, userId); return bookingService.markPaidManual(booking.id, userId);
}, },
/**
* Auto-complete trip yang sudah lewat. Dipakai cron harian.
*
* Cutoff = start of today UTC. Trip dengan endDate < cutoff (atau, kalau
* endDate null, date < cutoff) di-set status COMPLETED selama statusnya
* masih OPEN/FULL. CLOSED trip tidak disentuh (organizer eksplisit batalkan).
*
* Idempotent: dua kali run di hari sama, run kedua nge-match 0 row.
*/
async autoCompletePastTrips() {
const cutoff = utcStartOfDay(new Date());
return tripRepo.bulkCompletePastTrips(cutoff);
},
async confirmParticipantPayment( async confirmParticipantPayment(
tripId: string, tripId: string,
participantId: string, participantId: string,
@@ -408,4 +436,164 @@ export const tripService = {
return bookingService.confirmPaidManual(booking.id, organizerId); return bookingService.confirmPaidManual(booking.id, organizerId);
}, },
/**
* Organizer batalkan trip (Trip.status = CLOSED). Atomic dalam satu
* serializable transaction:
* - Set Trip.status = CLOSED.
* - Untuk setiap peserta aktif:
* - Booking PAID buat Refund ORGANIZER_CANCELLED (auto-approved, full
* amount). Booking tetap PAID sampai admin mark SUCCEEDED jejak
* finansial harus terjaga.
* - Booking PENDING/AWAITING_PAY set CANCELLED langsung (uang belum
* masuk, tidak ada refund).
* - Booking PARTIALLY_REFUNDED / dengan refund aktif di-skip (admin
* handle manual supaya tidak double-refund).
* - Semua TripParticipant aktif CANCELLED.
*
* Idempotent: trip yang sudah CLOSED/COMPLETED akan ditolak supaya tidak
* dobel-buat refund.
*/
async closeTrip(tripId: string, organizerId: string) {
let lastErr: unknown;
for (let attempt = 0; attempt < SERIAL_TX_ATTEMPTS; attempt++) {
try {
return await prisma.$transaction(
async (tx) => {
const trip = await tx.trip.findUnique({
where: { id: tripId },
select: { id: true, status: true, organizerId: true, date: true },
});
if (!trip) {
throw new Error("Trip tidak ditemukan");
}
if (trip.organizerId !== organizerId) {
throw new Error(
"Hanya organizer trip ini yang bisa membatalkan trip"
);
}
if (trip.status === "CLOSED") {
throw new Error("Trip sudah dibatalkan");
}
if (trip.status === "COMPLETED") {
throw new Error(
"Trip sudah selesai (COMPLETED) — tidak bisa dibatalkan"
);
}
if (isTripDepartureDayPast(trip.date)) {
throw new Error(
"Tanggal berangkat sudah lewat — gunakan flow pelaporan biasa ke admin"
);
}
const bookings = await tx.booking.findMany({
where: { tripId },
include: {
payments: {
where: { status: "PAID" },
orderBy: { paidAt: "desc" },
take: 1,
},
refunds: {
where: {
status: { in: ["PENDING", "APPROVED", "PROCESSING"] },
},
select: { id: true },
},
},
});
const refundsCreated: string[] = [];
const cancelledBookings: string[] = [];
const skippedBookings: string[] = [];
for (const b of bookings) {
if (b.status === "CANCELLED" || b.status === "EXPIRED") {
continue;
}
if (b.status === "REFUNDED") {
continue;
}
if (b.refunds.length > 0) {
// Sudah ada refund aktif (mis. user request cancel). Admin
// handle manual supaya tidak konflik dengan refund existing.
skippedBookings.push(b.id);
continue;
}
if (b.status === "PAID" || b.status === "PARTIALLY_REFUNDED") {
const paid = b.payments[0];
if (!paid) {
// Payment tidak konsisten dgn booking status — skip + flag.
skippedBookings.push(b.id);
continue;
}
// Untuk PARTIALLY_REFUNDED, hitung sisa refundable.
const alreadyRefunded = await tx.refund.aggregate({
where: { bookingId: b.id, status: "SUCCEEDED" },
_sum: { amount: true },
});
const remaining = paid.amount - (alreadyRefunded._sum.amount ?? 0);
if (remaining <= 0) {
continue;
}
const refund = await refundService.createSystemRefundForClosedTrip(
tx,
{
bookingId: b.id,
paymentId: paid.id,
amount: remaining,
}
);
refundsCreated.push(refund.id);
} else {
// PENDING / AWAITING_PAY → uang belum masuk → langsung CANCELLED.
await tx.booking.update({
where: { id: b.id },
data: { status: "CANCELLED" },
});
cancelledBookings.push(b.id);
}
}
// Semua participant aktif → CANCELLED (apapun status booking-nya).
await tx.tripParticipant.updateMany({
where: { tripId, status: { not: "CANCELLED" } },
data: {
status: "CANCELLED",
markedPaidAt: null,
paymentConfirmedAt: null,
},
});
await tx.trip.update({
where: { id: tripId },
data: { status: "CLOSED" },
});
return {
ok: true as const,
refundsCreated,
cancelledBookings,
skippedBookings,
};
},
{
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 membatalkan trip. Coba lagi sebentar.");
},
}; };
+90 -4
View File
@@ -1,38 +1,124 @@
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { TRIP_LEADER_MIN_TRIPS } from "@/lib/trust"; import {
COMPLETION_RATE_MIN_SAMPLE,
TRIP_LEADER_MIN_TRIPS,
} from "@/lib/trust";
export type RatingBreakdown = Record<1 | 2 | 3 | 4 | 5, number>;
export type OrganizerTrust = { export type OrganizerTrust = {
isVerified: boolean; isVerified: boolean;
tripsCreated: number; tripsCreated: number;
/// Trip yang sudah lewat tanggal selesai-nya AND tidak dibatalkan (status != CLOSED).
/// Dihitung on-the-fly dari endDate (fallback ke date kalau endDate null) — tidak
/// bergantung pada Trip.status = COMPLETED yang saat ini belum pernah di-set.
tripsCompleted: number;
/// Trip dengan status = CLOSED (dibatalkan organizer).
tripsCancelled: number;
/// Akumulasi peserta CONFIRMED di seluruh trip yang sudah selesai.
totalParticipantsServed: number;
/// tripsCompleted / (tripsCompleted + tripsCancelled). Null kalau sample
/// < COMPLETION_RATE_MIN_SAMPLE — mencegah angka menyesatkan untuk
/// organizer yang masih sedikit history-nya.
completionRate: number | null;
avgRating: number | null; avgRating: number | null;
reviewCount: number; reviewCount: number;
ratingBreakdown: RatingBreakdown;
isTripLeader: boolean; isTripLeader: boolean;
}; };
export const trustService = { export const trustService = {
async getOrganizerTrust(organizerId: string): Promise<OrganizerTrust> { async getOrganizerTrust(organizerId: string): Promise<OrganizerTrust> {
const [tripsCreated, reviewAgg, organizerVerification] = await Promise.all([ const now = new Date();
// Filter "trip yang sudah lewat": endDate < now, atau (endDate null AND date < now).
// Trip multi-hari pakai endDate; trip 1 hari biasanya endDate null jadi fallback ke date.
const pastTripFilter = {
OR: [
{ endDate: { lt: now } },
{ AND: [{ endDate: null }, { date: { lt: now } }] },
],
};
const [
tripsCreated,
tripsCompleted,
tripsCancelled,
totalParticipantsServed,
reviewAgg,
ratingGroups,
organizerVerification,
] = await Promise.all([
prisma.trip.count({ where: { organizerId } }), prisma.trip.count({ where: { organizerId } }),
prisma.tripReview.aggregate({ prisma.trip.count({
where: { where: {
trip: { organizerId }, organizerId,
status: { not: "CLOSED" },
...pastTripFilter,
}, },
}),
prisma.trip.count({
where: { organizerId, status: "CLOSED" },
}),
prisma.tripParticipant.count({
where: {
status: "CONFIRMED",
trip: {
organizerId,
status: { not: "CLOSED" },
...pastTripFilter,
},
},
}),
prisma.tripReview.aggregate({
where: { trip: { organizerId } },
_avg: { rating: true }, _avg: { rating: true },
_count: { _all: true }, _count: { _all: true },
}), }),
prisma.tripReview.groupBy({
by: ["rating"],
where: { trip: { organizerId } },
_count: { _all: true },
}),
prisma.organizerVerification.findUnique({ prisma.organizerVerification.findUnique({
where: { userId: organizerId }, where: { userId: organizerId },
select: { status: true }, select: { status: true },
}), }),
]); ]);
const ratingBreakdown: RatingBreakdown = {
1: 0,
2: 0,
3: 0,
4: 0,
5: 0,
};
for (const row of ratingGroups) {
const r = row.rating;
if (r >= 1 && r <= 5) {
ratingBreakdown[r as 1 | 2 | 3 | 4 | 5] = row._count._all;
}
}
const completionSample = tripsCompleted + tripsCancelled;
const completionRate =
completionSample >= COMPLETION_RATE_MIN_SAMPLE
? tripsCompleted / completionSample
: null;
const avg = reviewAgg._avg.rating; const avg = reviewAgg._avg.rating;
return { return {
isVerified: organizerVerification?.status === "APPROVED", isVerified: organizerVerification?.status === "APPROVED",
tripsCreated, tripsCreated,
tripsCompleted,
tripsCancelled,
totalParticipantsServed,
completionRate,
avgRating: avgRating:
avg != null ? Math.round(Number(avg) * 10) / 10 : null, avg != null ? Math.round(Number(avg) * 10) / 10 : null,
reviewCount: reviewAgg._count._all, reviewCount: reviewAgg._count._all,
ratingBreakdown,
isTripLeader: tripsCreated >= TRIP_LEADER_MIN_TRIPS, isTripLeader: tripsCreated >= TRIP_LEADER_MIN_TRIPS,
}; };
}, },