Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 427bfc0447 | |||
| 54f4569107 | |||
| d2b0a780d5 | |||
| 22e1e8fbea | |||
| 11b2d45d20 | |||
| 744ee3446b | |||
| 9a163c4f13 | |||
| 54cd984a7e |
@@ -2,7 +2,15 @@
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
## Forbidden
|
||||
|
||||
- Jangan query database langsung di component
|
||||
- Jangan buat arsitektur over-engineered
|
||||
- Jangan menambahkan dependency tanpa kebutuhan jelas
|
||||
- Jangan buat arsitektur over-engineered, tidak apa apa jika lebih baik untuk performance dan struktur yang baik
|
||||
- Jangan menambahkan dependency tanpa kebutuhan jelas, tambahkan jika memang dibutuhkan dan gunakan dependency yang aman
|
||||
|
||||
## Output Style
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -75,3 +75,15 @@ export type Booking = Prisma.BookingModel
|
||||
* (di Phase MIDTRANS nanti). Untuk MANUAL biasanya cukup 1 Payment.
|
||||
*/
|
||||
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
|
||||
|
||||
@@ -99,3 +99,15 @@ export type Booking = Prisma.BookingModel
|
||||
* (di Phase MIDTRANS nanti). Untuk MANUAL biasanya cukup 1 Payment.
|
||||
*/
|
||||
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
|
||||
|
||||
@@ -389,6 +389,74 @@ export type JsonNullableWithAggregatesFilterBase<$PrismaModel = never> = {
|
||||
_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> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
@@ -750,4 +818,72 @@ export type NestedJsonNullableFilterBase<$PrismaModel = never> = {
|
||||
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>
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ export const BookingStatus = {
|
||||
PAID: 'PAID',
|
||||
CANCELLED: 'CANCELLED',
|
||||
REFUNDED: 'REFUNDED',
|
||||
PARTIALLY_REFUNDED: 'PARTIALLY_REFUNDED',
|
||||
EXPIRED: 'EXPIRED'
|
||||
} as const
|
||||
|
||||
@@ -93,3 +94,45 @@ export const PaymentStatus = {
|
||||
} as const
|
||||
|
||||
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',
|
||||
TripParticipant: 'TripParticipant',
|
||||
Booking: 'Booking',
|
||||
Payment: 'Payment'
|
||||
Payment: 'Payment',
|
||||
Refund: 'Refund'
|
||||
} as const
|
||||
|
||||
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
|
||||
@@ -409,7 +410,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
||||
omit: GlobalOmitOptions
|
||||
}
|
||||
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
|
||||
}
|
||||
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: {
|
||||
@@ -1365,6 +1440,31 @@ export const 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 = {
|
||||
asc: 'asc',
|
||||
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'
|
||||
*/
|
||||
@@ -1720,6 +1876,7 @@ export type GlobalOmitConfig = {
|
||||
tripParticipant?: Prisma.TripParticipantOmit
|
||||
booking?: Prisma.BookingOmit
|
||||
payment?: Prisma.PaymentOmit
|
||||
refund?: Prisma.RefundOmit
|
||||
}
|
||||
|
||||
/* Types for Logging */
|
||||
|
||||
@@ -60,7 +60,8 @@ export const ModelName = {
|
||||
TripImage: 'TripImage',
|
||||
TripParticipant: 'TripParticipant',
|
||||
Booking: 'Booking',
|
||||
Payment: 'Payment'
|
||||
Payment: 'Payment',
|
||||
Refund: 'Refund'
|
||||
} as const
|
||||
|
||||
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
|
||||
@@ -252,6 +253,31 @@ export const 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 = {
|
||||
asc: 'asc',
|
||||
desc: 'desc'
|
||||
|
||||
@@ -18,4 +18,5 @@ export type * from './models/TripImage'
|
||||
export type * from './models/TripParticipant'
|
||||
export type * from './models/Booking'
|
||||
export type * from './models/Payment'
|
||||
export type * from './models/Refund'
|
||||
export type * from './commonInputTypes'
|
||||
@@ -257,6 +257,7 @@ export type BookingWhereInput = {
|
||||
user?: Prisma.XOR<Prisma.UserScalarRelationFilter, Prisma.UserWhereInput>
|
||||
participant?: Prisma.XOR<Prisma.TripParticipantScalarRelationFilter, Prisma.TripParticipantWhereInput>
|
||||
payments?: Prisma.PaymentListRelationFilter
|
||||
refunds?: Prisma.RefundListRelationFilter
|
||||
}
|
||||
|
||||
export type BookingOrderByWithRelationInput = {
|
||||
@@ -273,6 +274,7 @@ export type BookingOrderByWithRelationInput = {
|
||||
user?: Prisma.UserOrderByWithRelationInput
|
||||
participant?: Prisma.TripParticipantOrderByWithRelationInput
|
||||
payments?: Prisma.PaymentOrderByRelationAggregateInput
|
||||
refunds?: Prisma.RefundOrderByRelationAggregateInput
|
||||
}
|
||||
|
||||
export type BookingWhereUniqueInput = Prisma.AtLeast<{
|
||||
@@ -293,6 +295,7 @@ export type BookingWhereUniqueInput = Prisma.AtLeast<{
|
||||
user?: Prisma.XOR<Prisma.UserScalarRelationFilter, Prisma.UserWhereInput>
|
||||
participant?: Prisma.XOR<Prisma.TripParticipantScalarRelationFilter, Prisma.TripParticipantWhereInput>
|
||||
payments?: Prisma.PaymentListRelationFilter
|
||||
refunds?: Prisma.RefundListRelationFilter
|
||||
}, "id" | "participantId" | "tripId_userId">
|
||||
|
||||
export type BookingOrderByWithAggregationInput = {
|
||||
@@ -338,6 +341,7 @@ export type BookingCreateInput = {
|
||||
user: Prisma.UserCreateNestedOneWithoutBookingsInput
|
||||
participant: Prisma.TripParticipantCreateNestedOneWithoutBookingInput
|
||||
payments?: Prisma.PaymentCreateNestedManyWithoutBookingInput
|
||||
refunds?: Prisma.RefundCreateNestedManyWithoutBookingInput
|
||||
}
|
||||
|
||||
export type BookingUncheckedCreateInput = {
|
||||
@@ -351,6 +355,7 @@ export type BookingUncheckedCreateInput = {
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
payments?: Prisma.PaymentUncheckedCreateNestedManyWithoutBookingInput
|
||||
refunds?: Prisma.RefundUncheckedCreateNestedManyWithoutBookingInput
|
||||
}
|
||||
|
||||
export type BookingUpdateInput = {
|
||||
@@ -364,6 +369,7 @@ export type BookingUpdateInput = {
|
||||
user?: Prisma.UserUpdateOneRequiredWithoutBookingsNestedInput
|
||||
participant?: Prisma.TripParticipantUpdateOneRequiredWithoutBookingNestedInput
|
||||
payments?: Prisma.PaymentUpdateManyWithoutBookingNestedInput
|
||||
refunds?: Prisma.RefundUpdateManyWithoutBookingNestedInput
|
||||
}
|
||||
|
||||
export type BookingUncheckedUpdateInput = {
|
||||
@@ -377,6 +383,7 @@ export type BookingUncheckedUpdateInput = {
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
payments?: Prisma.PaymentUncheckedUpdateManyWithoutBookingNestedInput
|
||||
refunds?: Prisma.RefundUncheckedUpdateManyWithoutBookingNestedInput
|
||||
}
|
||||
|
||||
export type BookingCreateManyInput = {
|
||||
@@ -615,6 +622,20 @@ export type BookingUpdateOneRequiredWithoutPaymentsNestedInput = {
|
||||
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 = {
|
||||
id?: string
|
||||
amount: number
|
||||
@@ -625,6 +646,7 @@ export type BookingCreateWithoutUserInput = {
|
||||
trip: Prisma.TripCreateNestedOneWithoutBookingsInput
|
||||
participant: Prisma.TripParticipantCreateNestedOneWithoutBookingInput
|
||||
payments?: Prisma.PaymentCreateNestedManyWithoutBookingInput
|
||||
refunds?: Prisma.RefundCreateNestedManyWithoutBookingInput
|
||||
}
|
||||
|
||||
export type BookingUncheckedCreateWithoutUserInput = {
|
||||
@@ -637,6 +659,7 @@ export type BookingUncheckedCreateWithoutUserInput = {
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
payments?: Prisma.PaymentUncheckedCreateNestedManyWithoutBookingInput
|
||||
refunds?: Prisma.RefundUncheckedCreateNestedManyWithoutBookingInput
|
||||
}
|
||||
|
||||
export type BookingCreateOrConnectWithoutUserInput = {
|
||||
@@ -690,6 +713,7 @@ export type BookingCreateWithoutTripInput = {
|
||||
user: Prisma.UserCreateNestedOneWithoutBookingsInput
|
||||
participant: Prisma.TripParticipantCreateNestedOneWithoutBookingInput
|
||||
payments?: Prisma.PaymentCreateNestedManyWithoutBookingInput
|
||||
refunds?: Prisma.RefundCreateNestedManyWithoutBookingInput
|
||||
}
|
||||
|
||||
export type BookingUncheckedCreateWithoutTripInput = {
|
||||
@@ -702,6 +726,7 @@ export type BookingUncheckedCreateWithoutTripInput = {
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
payments?: Prisma.PaymentUncheckedCreateNestedManyWithoutBookingInput
|
||||
refunds?: Prisma.RefundUncheckedCreateNestedManyWithoutBookingInput
|
||||
}
|
||||
|
||||
export type BookingCreateOrConnectWithoutTripInput = {
|
||||
@@ -740,6 +765,7 @@ export type BookingCreateWithoutParticipantInput = {
|
||||
trip: Prisma.TripCreateNestedOneWithoutBookingsInput
|
||||
user: Prisma.UserCreateNestedOneWithoutBookingsInput
|
||||
payments?: Prisma.PaymentCreateNestedManyWithoutBookingInput
|
||||
refunds?: Prisma.RefundCreateNestedManyWithoutBookingInput
|
||||
}
|
||||
|
||||
export type BookingUncheckedCreateWithoutParticipantInput = {
|
||||
@@ -752,6 +778,7 @@ export type BookingUncheckedCreateWithoutParticipantInput = {
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
payments?: Prisma.PaymentUncheckedCreateNestedManyWithoutBookingInput
|
||||
refunds?: Prisma.RefundUncheckedCreateNestedManyWithoutBookingInput
|
||||
}
|
||||
|
||||
export type BookingCreateOrConnectWithoutParticipantInput = {
|
||||
@@ -780,6 +807,7 @@ export type BookingUpdateWithoutParticipantInput = {
|
||||
trip?: Prisma.TripUpdateOneRequiredWithoutBookingsNestedInput
|
||||
user?: Prisma.UserUpdateOneRequiredWithoutBookingsNestedInput
|
||||
payments?: Prisma.PaymentUpdateManyWithoutBookingNestedInput
|
||||
refunds?: Prisma.RefundUpdateManyWithoutBookingNestedInput
|
||||
}
|
||||
|
||||
export type BookingUncheckedUpdateWithoutParticipantInput = {
|
||||
@@ -792,6 +820,7 @@ export type BookingUncheckedUpdateWithoutParticipantInput = {
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
payments?: Prisma.PaymentUncheckedUpdateManyWithoutBookingNestedInput
|
||||
refunds?: Prisma.RefundUncheckedUpdateManyWithoutBookingNestedInput
|
||||
}
|
||||
|
||||
export type BookingCreateWithoutPaymentsInput = {
|
||||
@@ -804,6 +833,7 @@ export type BookingCreateWithoutPaymentsInput = {
|
||||
trip: Prisma.TripCreateNestedOneWithoutBookingsInput
|
||||
user: Prisma.UserCreateNestedOneWithoutBookingsInput
|
||||
participant: Prisma.TripParticipantCreateNestedOneWithoutBookingInput
|
||||
refunds?: Prisma.RefundCreateNestedManyWithoutBookingInput
|
||||
}
|
||||
|
||||
export type BookingUncheckedCreateWithoutPaymentsInput = {
|
||||
@@ -816,6 +846,7 @@ export type BookingUncheckedCreateWithoutPaymentsInput = {
|
||||
status?: $Enums.BookingStatus
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
refunds?: Prisma.RefundUncheckedCreateNestedManyWithoutBookingInput
|
||||
}
|
||||
|
||||
export type BookingCreateOrConnectWithoutPaymentsInput = {
|
||||
@@ -844,6 +875,7 @@ export type BookingUpdateWithoutPaymentsInput = {
|
||||
trip?: Prisma.TripUpdateOneRequiredWithoutBookingsNestedInput
|
||||
user?: Prisma.UserUpdateOneRequiredWithoutBookingsNestedInput
|
||||
participant?: Prisma.TripParticipantUpdateOneRequiredWithoutBookingNestedInput
|
||||
refunds?: Prisma.RefundUpdateManyWithoutBookingNestedInput
|
||||
}
|
||||
|
||||
export type BookingUncheckedUpdateWithoutPaymentsInput = {
|
||||
@@ -856,6 +888,75 @@ export type BookingUncheckedUpdateWithoutPaymentsInput = {
|
||||
status?: Prisma.EnumBookingStatusFieldUpdateOperationsInput | $Enums.BookingStatus
|
||||
createdAt?: 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 = {
|
||||
@@ -879,6 +980,7 @@ export type BookingUpdateWithoutUserInput = {
|
||||
trip?: Prisma.TripUpdateOneRequiredWithoutBookingsNestedInput
|
||||
participant?: Prisma.TripParticipantUpdateOneRequiredWithoutBookingNestedInput
|
||||
payments?: Prisma.PaymentUpdateManyWithoutBookingNestedInput
|
||||
refunds?: Prisma.RefundUpdateManyWithoutBookingNestedInput
|
||||
}
|
||||
|
||||
export type BookingUncheckedUpdateWithoutUserInput = {
|
||||
@@ -891,6 +993,7 @@ export type BookingUncheckedUpdateWithoutUserInput = {
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
payments?: Prisma.PaymentUncheckedUpdateManyWithoutBookingNestedInput
|
||||
refunds?: Prisma.RefundUncheckedUpdateManyWithoutBookingNestedInput
|
||||
}
|
||||
|
||||
export type BookingUncheckedUpdateManyWithoutUserInput = {
|
||||
@@ -925,6 +1028,7 @@ export type BookingUpdateWithoutTripInput = {
|
||||
user?: Prisma.UserUpdateOneRequiredWithoutBookingsNestedInput
|
||||
participant?: Prisma.TripParticipantUpdateOneRequiredWithoutBookingNestedInput
|
||||
payments?: Prisma.PaymentUpdateManyWithoutBookingNestedInput
|
||||
refunds?: Prisma.RefundUpdateManyWithoutBookingNestedInput
|
||||
}
|
||||
|
||||
export type BookingUncheckedUpdateWithoutTripInput = {
|
||||
@@ -937,6 +1041,7 @@ export type BookingUncheckedUpdateWithoutTripInput = {
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
payments?: Prisma.PaymentUncheckedUpdateManyWithoutBookingNestedInput
|
||||
refunds?: Prisma.RefundUncheckedUpdateManyWithoutBookingNestedInput
|
||||
}
|
||||
|
||||
export type BookingUncheckedUpdateManyWithoutTripInput = {
|
||||
@@ -957,10 +1062,12 @@ export type BookingUncheckedUpdateManyWithoutTripInput = {
|
||||
|
||||
export type BookingCountOutputType = {
|
||||
payments: number
|
||||
refunds: number
|
||||
}
|
||||
|
||||
export type BookingCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
payments?: boolean | BookingCountOutputTypeCountPaymentsArgs
|
||||
refunds?: boolean | BookingCountOutputTypeCountRefundsArgs
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -980,6 +1087,13 @@ export type BookingCountOutputTypeCountPaymentsArgs<ExtArgs extends runtime.Type
|
||||
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<{
|
||||
id?: boolean
|
||||
@@ -995,6 +1109,7 @@ export type BookingSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs
|
||||
user?: boolean | Prisma.UserDefaultArgs<ExtArgs>
|
||||
participant?: boolean | Prisma.TripParticipantDefaultArgs<ExtArgs>
|
||||
payments?: boolean | Prisma.Booking$paymentsArgs<ExtArgs>
|
||||
refunds?: boolean | Prisma.Booking$refundsArgs<ExtArgs>
|
||||
_count?: boolean | Prisma.BookingCountOutputTypeDefaultArgs<ExtArgs>
|
||||
}, ExtArgs["result"]["booking"]>
|
||||
|
||||
@@ -1046,6 +1161,7 @@ export type BookingInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs
|
||||
user?: boolean | Prisma.UserDefaultArgs<ExtArgs>
|
||||
participant?: boolean | Prisma.TripParticipantDefaultArgs<ExtArgs>
|
||||
payments?: boolean | Prisma.Booking$paymentsArgs<ExtArgs>
|
||||
refunds?: boolean | Prisma.Booking$refundsArgs<ExtArgs>
|
||||
_count?: boolean | Prisma.BookingCountOutputTypeDefaultArgs<ExtArgs>
|
||||
}
|
||||
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>
|
||||
participant: Prisma.$TripParticipantPayload<ExtArgs>
|
||||
payments: Prisma.$PaymentPayload<ExtArgs>[]
|
||||
refunds: Prisma.$RefundPayload<ExtArgs>[]
|
||||
}
|
||||
scalars: runtime.Types.Extensions.GetPayloadResult<{
|
||||
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>
|
||||
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>
|
||||
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.
|
||||
* @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[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
@@ -302,6 +302,7 @@ export type PaymentWhereInput = {
|
||||
createdAt?: Prisma.DateTimeFilter<"Payment"> | Date | string
|
||||
updatedAt?: Prisma.DateTimeFilter<"Payment"> | Date | string
|
||||
booking?: Prisma.XOR<Prisma.BookingScalarRelationFilter, Prisma.BookingWhereInput>
|
||||
refunds?: Prisma.RefundListRelationFilter
|
||||
}
|
||||
|
||||
export type PaymentOrderByWithRelationInput = {
|
||||
@@ -322,6 +323,7 @@ export type PaymentOrderByWithRelationInput = {
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
booking?: Prisma.BookingOrderByWithRelationInput
|
||||
refunds?: Prisma.RefundOrderByRelationAggregateInput
|
||||
}
|
||||
|
||||
export type PaymentWhereUniqueInput = Prisma.AtLeast<{
|
||||
@@ -345,6 +347,7 @@ export type PaymentWhereUniqueInput = Prisma.AtLeast<{
|
||||
createdAt?: Prisma.DateTimeFilter<"Payment"> | Date | string
|
||||
updatedAt?: Prisma.DateTimeFilter<"Payment"> | Date | string
|
||||
booking?: Prisma.XOR<Prisma.BookingScalarRelationFilter, Prisma.BookingWhereInput>
|
||||
refunds?: Prisma.RefundListRelationFilter
|
||||
}, "id" | "externalOrderId">
|
||||
|
||||
export type PaymentOrderByWithAggregationInput = {
|
||||
@@ -410,6 +413,7 @@ export type PaymentCreateInput = {
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
booking: Prisma.BookingCreateNestedOneWithoutPaymentsInput
|
||||
refunds?: Prisma.RefundCreateNestedManyWithoutPaymentInput
|
||||
}
|
||||
|
||||
export type PaymentUncheckedCreateInput = {
|
||||
@@ -429,6 +433,7 @@ export type PaymentUncheckedCreateInput = {
|
||||
rejectionReason?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
refunds?: Prisma.RefundUncheckedCreateNestedManyWithoutPaymentInput
|
||||
}
|
||||
|
||||
export type PaymentUpdateInput = {
|
||||
@@ -448,6 +453,7 @@ export type PaymentUpdateInput = {
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
booking?: Prisma.BookingUpdateOneRequiredWithoutPaymentsNestedInput
|
||||
refunds?: Prisma.RefundUpdateManyWithoutPaymentNestedInput
|
||||
}
|
||||
|
||||
export type PaymentUncheckedUpdateInput = {
|
||||
@@ -467,6 +473,7 @@ export type PaymentUncheckedUpdateInput = {
|
||||
rejectionReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
refunds?: Prisma.RefundUncheckedUpdateManyWithoutPaymentNestedInput
|
||||
}
|
||||
|
||||
export type PaymentCreateManyInput = {
|
||||
@@ -598,6 +605,11 @@ export type PaymentSumOrderByAggregateInput = {
|
||||
amount?: Prisma.SortOrder
|
||||
}
|
||||
|
||||
export type PaymentNullableScalarRelationFilter = {
|
||||
is?: Prisma.PaymentWhereInput | null
|
||||
isNot?: Prisma.PaymentWhereInput | null
|
||||
}
|
||||
|
||||
export type PaymentCreateNestedManyWithoutBookingInput = {
|
||||
create?: Prisma.XOR<Prisma.PaymentCreateWithoutBookingInput, Prisma.PaymentUncheckedCreateWithoutBookingInput> | Prisma.PaymentCreateWithoutBookingInput[] | Prisma.PaymentUncheckedCreateWithoutBookingInput[]
|
||||
connectOrCreate?: Prisma.PaymentCreateOrConnectWithoutBookingInput | Prisma.PaymentCreateOrConnectWithoutBookingInput[]
|
||||
@@ -648,6 +660,22 @@ export type EnumPaymentStatusFieldUpdateOperationsInput = {
|
||||
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 = {
|
||||
id?: string
|
||||
provider: $Enums.PaymentProvider
|
||||
@@ -664,6 +692,7 @@ export type PaymentCreateWithoutBookingInput = {
|
||||
rejectionReason?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
refunds?: Prisma.RefundCreateNestedManyWithoutPaymentInput
|
||||
}
|
||||
|
||||
export type PaymentUncheckedCreateWithoutBookingInput = {
|
||||
@@ -682,6 +711,7 @@ export type PaymentUncheckedCreateWithoutBookingInput = {
|
||||
rejectionReason?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
refunds?: Prisma.RefundUncheckedCreateNestedManyWithoutPaymentInput
|
||||
}
|
||||
|
||||
export type PaymentCreateOrConnectWithoutBookingInput = {
|
||||
@@ -732,6 +762,98 @@ export type PaymentScalarWhereInput = {
|
||||
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 = {
|
||||
id?: string
|
||||
provider: $Enums.PaymentProvider
|
||||
@@ -766,6 +888,7 @@ export type PaymentUpdateWithoutBookingInput = {
|
||||
rejectionReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
refunds?: Prisma.RefundUpdateManyWithoutPaymentNestedInput
|
||||
}
|
||||
|
||||
export type PaymentUncheckedUpdateWithoutBookingInput = {
|
||||
@@ -784,6 +907,7 @@ export type PaymentUncheckedUpdateWithoutBookingInput = {
|
||||
rejectionReason?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
refunds?: Prisma.RefundUncheckedUpdateManyWithoutPaymentNestedInput
|
||||
}
|
||||
|
||||
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<{
|
||||
id?: boolean
|
||||
@@ -824,6 +977,8 @@ export type PaymentSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs
|
||||
createdAt?: boolean
|
||||
updatedAt?: boolean
|
||||
booking?: boolean | Prisma.BookingDefaultArgs<ExtArgs>
|
||||
refunds?: boolean | Prisma.Payment$refundsArgs<ExtArgs>
|
||||
_count?: boolean | Prisma.PaymentCountOutputTypeDefaultArgs<ExtArgs>
|
||||
}, ExtArgs["result"]["payment"]>
|
||||
|
||||
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 PaymentInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
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> = {
|
||||
booking?: boolean | Prisma.BookingDefaultArgs<ExtArgs>
|
||||
@@ -900,6 +1057,7 @@ export type $PaymentPayload<ExtArgs extends runtime.Types.Extensions.InternalArg
|
||||
name: "Payment"
|
||||
objects: {
|
||||
booking: Prisma.$BookingPayload<ExtArgs>
|
||||
refunds: Prisma.$RefundPayload<ExtArgs>[]
|
||||
}
|
||||
scalars: runtime.Types.Extensions.GetPayloadResult<{
|
||||
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> {
|
||||
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>
|
||||
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.
|
||||
* @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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -229,6 +229,7 @@ export type UserWhereInput = {
|
||||
bookings?: Prisma.BookingListRelationFilter
|
||||
organizerVerification?: Prisma.XOR<Prisma.OrganizerVerificationNullableScalarRelationFilter, Prisma.OrganizerVerificationWhereInput> | null
|
||||
reviewedVerifications?: Prisma.OrganizerVerificationListRelationFilter
|
||||
reviewedRefunds?: Prisma.RefundListRelationFilter
|
||||
profile?: Prisma.XOR<Prisma.UserProfileNullableScalarRelationFilter, Prisma.UserProfileWhereInput> | null
|
||||
}
|
||||
|
||||
@@ -250,6 +251,7 @@ export type UserOrderByWithRelationInput = {
|
||||
bookings?: Prisma.BookingOrderByRelationAggregateInput
|
||||
organizerVerification?: Prisma.OrganizerVerificationOrderByWithRelationInput
|
||||
reviewedVerifications?: Prisma.OrganizerVerificationOrderByRelationAggregateInput
|
||||
reviewedRefunds?: Prisma.RefundOrderByRelationAggregateInput
|
||||
profile?: Prisma.UserProfileOrderByWithRelationInput
|
||||
}
|
||||
|
||||
@@ -274,6 +276,7 @@ export type UserWhereUniqueInput = Prisma.AtLeast<{
|
||||
bookings?: Prisma.BookingListRelationFilter
|
||||
organizerVerification?: Prisma.XOR<Prisma.OrganizerVerificationNullableScalarRelationFilter, Prisma.OrganizerVerificationWhereInput> | null
|
||||
reviewedVerifications?: Prisma.OrganizerVerificationListRelationFilter
|
||||
reviewedRefunds?: Prisma.RefundListRelationFilter
|
||||
profile?: Prisma.XOR<Prisma.UserProfileNullableScalarRelationFilter, Prisma.UserProfileWhereInput> | null
|
||||
}, "id" | "email">
|
||||
|
||||
@@ -327,6 +330,7 @@ export type UserCreateInput = {
|
||||
bookings?: Prisma.BookingCreateNestedManyWithoutUserInput
|
||||
organizerVerification?: Prisma.OrganizerVerificationCreateNestedOneWithoutUserInput
|
||||
reviewedVerifications?: Prisma.OrganizerVerificationCreateNestedManyWithoutReviewedByInput
|
||||
reviewedRefunds?: Prisma.RefundCreateNestedManyWithoutReviewedByInput
|
||||
profile?: Prisma.UserProfileCreateNestedOneWithoutUserInput
|
||||
}
|
||||
|
||||
@@ -348,6 +352,7 @@ export type UserUncheckedCreateInput = {
|
||||
bookings?: Prisma.BookingUncheckedCreateNestedManyWithoutUserInput
|
||||
organizerVerification?: Prisma.OrganizerVerificationUncheckedCreateNestedOneWithoutUserInput
|
||||
reviewedVerifications?: Prisma.OrganizerVerificationUncheckedCreateNestedManyWithoutReviewedByInput
|
||||
reviewedRefunds?: Prisma.RefundUncheckedCreateNestedManyWithoutReviewedByInput
|
||||
profile?: Prisma.UserProfileUncheckedCreateNestedOneWithoutUserInput
|
||||
}
|
||||
|
||||
@@ -369,6 +374,7 @@ export type UserUpdateInput = {
|
||||
bookings?: Prisma.BookingUpdateManyWithoutUserNestedInput
|
||||
organizerVerification?: Prisma.OrganizerVerificationUpdateOneWithoutUserNestedInput
|
||||
reviewedVerifications?: Prisma.OrganizerVerificationUpdateManyWithoutReviewedByNestedInput
|
||||
reviewedRefunds?: Prisma.RefundUpdateManyWithoutReviewedByNestedInput
|
||||
profile?: Prisma.UserProfileUpdateOneWithoutUserNestedInput
|
||||
}
|
||||
|
||||
@@ -390,6 +396,7 @@ export type UserUncheckedUpdateInput = {
|
||||
bookings?: Prisma.BookingUncheckedUpdateManyWithoutUserNestedInput
|
||||
organizerVerification?: Prisma.OrganizerVerificationUncheckedUpdateOneWithoutUserNestedInput
|
||||
reviewedVerifications?: Prisma.OrganizerVerificationUncheckedUpdateManyWithoutReviewedByNestedInput
|
||||
reviewedRefunds?: Prisma.RefundUncheckedUpdateManyWithoutReviewedByNestedInput
|
||||
profile?: Prisma.UserProfileUncheckedUpdateOneWithoutUserNestedInput
|
||||
}
|
||||
|
||||
@@ -615,6 +622,22 @@ export type UserUpdateOneRequiredWithoutBookingsNestedInput = {
|
||||
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 = {
|
||||
id?: string
|
||||
name: string
|
||||
@@ -633,6 +656,7 @@ export type UserCreateWithoutProfileInput = {
|
||||
bookings?: Prisma.BookingCreateNestedManyWithoutUserInput
|
||||
organizerVerification?: Prisma.OrganizerVerificationCreateNestedOneWithoutUserInput
|
||||
reviewedVerifications?: Prisma.OrganizerVerificationCreateNestedManyWithoutReviewedByInput
|
||||
reviewedRefunds?: Prisma.RefundCreateNestedManyWithoutReviewedByInput
|
||||
}
|
||||
|
||||
export type UserUncheckedCreateWithoutProfileInput = {
|
||||
@@ -653,6 +677,7 @@ export type UserUncheckedCreateWithoutProfileInput = {
|
||||
bookings?: Prisma.BookingUncheckedCreateNestedManyWithoutUserInput
|
||||
organizerVerification?: Prisma.OrganizerVerificationUncheckedCreateNestedOneWithoutUserInput
|
||||
reviewedVerifications?: Prisma.OrganizerVerificationUncheckedCreateNestedManyWithoutReviewedByInput
|
||||
reviewedRefunds?: Prisma.RefundUncheckedCreateNestedManyWithoutReviewedByInput
|
||||
}
|
||||
|
||||
export type UserCreateOrConnectWithoutProfileInput = {
|
||||
@@ -689,6 +714,7 @@ export type UserUpdateWithoutProfileInput = {
|
||||
bookings?: Prisma.BookingUpdateManyWithoutUserNestedInput
|
||||
organizerVerification?: Prisma.OrganizerVerificationUpdateOneWithoutUserNestedInput
|
||||
reviewedVerifications?: Prisma.OrganizerVerificationUpdateManyWithoutReviewedByNestedInput
|
||||
reviewedRefunds?: Prisma.RefundUpdateManyWithoutReviewedByNestedInput
|
||||
}
|
||||
|
||||
export type UserUncheckedUpdateWithoutProfileInput = {
|
||||
@@ -709,6 +735,7 @@ export type UserUncheckedUpdateWithoutProfileInput = {
|
||||
bookings?: Prisma.BookingUncheckedUpdateManyWithoutUserNestedInput
|
||||
organizerVerification?: Prisma.OrganizerVerificationUncheckedUpdateOneWithoutUserNestedInput
|
||||
reviewedVerifications?: Prisma.OrganizerVerificationUncheckedUpdateManyWithoutReviewedByNestedInput
|
||||
reviewedRefunds?: Prisma.RefundUncheckedUpdateManyWithoutReviewedByNestedInput
|
||||
}
|
||||
|
||||
export type UserCreateWithoutAccountsInput = {
|
||||
@@ -728,6 +755,7 @@ export type UserCreateWithoutAccountsInput = {
|
||||
bookings?: Prisma.BookingCreateNestedManyWithoutUserInput
|
||||
organizerVerification?: Prisma.OrganizerVerificationCreateNestedOneWithoutUserInput
|
||||
reviewedVerifications?: Prisma.OrganizerVerificationCreateNestedManyWithoutReviewedByInput
|
||||
reviewedRefunds?: Prisma.RefundCreateNestedManyWithoutReviewedByInput
|
||||
profile?: Prisma.UserProfileCreateNestedOneWithoutUserInput
|
||||
}
|
||||
|
||||
@@ -748,6 +776,7 @@ export type UserUncheckedCreateWithoutAccountsInput = {
|
||||
bookings?: Prisma.BookingUncheckedCreateNestedManyWithoutUserInput
|
||||
organizerVerification?: Prisma.OrganizerVerificationUncheckedCreateNestedOneWithoutUserInput
|
||||
reviewedVerifications?: Prisma.OrganizerVerificationUncheckedCreateNestedManyWithoutReviewedByInput
|
||||
reviewedRefunds?: Prisma.RefundUncheckedCreateNestedManyWithoutReviewedByInput
|
||||
profile?: Prisma.UserProfileUncheckedCreateNestedOneWithoutUserInput
|
||||
}
|
||||
|
||||
@@ -784,6 +813,7 @@ export type UserUpdateWithoutAccountsInput = {
|
||||
bookings?: Prisma.BookingUpdateManyWithoutUserNestedInput
|
||||
organizerVerification?: Prisma.OrganizerVerificationUpdateOneWithoutUserNestedInput
|
||||
reviewedVerifications?: Prisma.OrganizerVerificationUpdateManyWithoutReviewedByNestedInput
|
||||
reviewedRefunds?: Prisma.RefundUpdateManyWithoutReviewedByNestedInput
|
||||
profile?: Prisma.UserProfileUpdateOneWithoutUserNestedInput
|
||||
}
|
||||
|
||||
@@ -804,6 +834,7 @@ export type UserUncheckedUpdateWithoutAccountsInput = {
|
||||
bookings?: Prisma.BookingUncheckedUpdateManyWithoutUserNestedInput
|
||||
organizerVerification?: Prisma.OrganizerVerificationUncheckedUpdateOneWithoutUserNestedInput
|
||||
reviewedVerifications?: Prisma.OrganizerVerificationUncheckedUpdateManyWithoutReviewedByNestedInput
|
||||
reviewedRefunds?: Prisma.RefundUncheckedUpdateManyWithoutReviewedByNestedInput
|
||||
profile?: Prisma.UserProfileUncheckedUpdateOneWithoutUserNestedInput
|
||||
}
|
||||
|
||||
@@ -824,6 +855,7 @@ export type UserCreateWithoutOrganizerVerificationInput = {
|
||||
tripReviews?: Prisma.TripReviewCreateNestedManyWithoutUserInput
|
||||
bookings?: Prisma.BookingCreateNestedManyWithoutUserInput
|
||||
reviewedVerifications?: Prisma.OrganizerVerificationCreateNestedManyWithoutReviewedByInput
|
||||
reviewedRefunds?: Prisma.RefundCreateNestedManyWithoutReviewedByInput
|
||||
profile?: Prisma.UserProfileCreateNestedOneWithoutUserInput
|
||||
}
|
||||
|
||||
@@ -844,6 +876,7 @@ export type UserUncheckedCreateWithoutOrganizerVerificationInput = {
|
||||
tripReviews?: Prisma.TripReviewUncheckedCreateNestedManyWithoutUserInput
|
||||
bookings?: Prisma.BookingUncheckedCreateNestedManyWithoutUserInput
|
||||
reviewedVerifications?: Prisma.OrganizerVerificationUncheckedCreateNestedManyWithoutReviewedByInput
|
||||
reviewedRefunds?: Prisma.RefundUncheckedCreateNestedManyWithoutReviewedByInput
|
||||
profile?: Prisma.UserProfileUncheckedCreateNestedOneWithoutUserInput
|
||||
}
|
||||
|
||||
@@ -869,6 +902,7 @@ export type UserCreateWithoutReviewedVerificationsInput = {
|
||||
tripReviews?: Prisma.TripReviewCreateNestedManyWithoutUserInput
|
||||
bookings?: Prisma.BookingCreateNestedManyWithoutUserInput
|
||||
organizerVerification?: Prisma.OrganizerVerificationCreateNestedOneWithoutUserInput
|
||||
reviewedRefunds?: Prisma.RefundCreateNestedManyWithoutReviewedByInput
|
||||
profile?: Prisma.UserProfileCreateNestedOneWithoutUserInput
|
||||
}
|
||||
|
||||
@@ -889,6 +923,7 @@ export type UserUncheckedCreateWithoutReviewedVerificationsInput = {
|
||||
tripReviews?: Prisma.TripReviewUncheckedCreateNestedManyWithoutUserInput
|
||||
bookings?: Prisma.BookingUncheckedCreateNestedManyWithoutUserInput
|
||||
organizerVerification?: Prisma.OrganizerVerificationUncheckedCreateNestedOneWithoutUserInput
|
||||
reviewedRefunds?: Prisma.RefundUncheckedCreateNestedManyWithoutReviewedByInput
|
||||
profile?: Prisma.UserProfileUncheckedCreateNestedOneWithoutUserInput
|
||||
}
|
||||
|
||||
@@ -925,6 +960,7 @@ export type UserUpdateWithoutOrganizerVerificationInput = {
|
||||
tripReviews?: Prisma.TripReviewUpdateManyWithoutUserNestedInput
|
||||
bookings?: Prisma.BookingUpdateManyWithoutUserNestedInput
|
||||
reviewedVerifications?: Prisma.OrganizerVerificationUpdateManyWithoutReviewedByNestedInput
|
||||
reviewedRefunds?: Prisma.RefundUpdateManyWithoutReviewedByNestedInput
|
||||
profile?: Prisma.UserProfileUpdateOneWithoutUserNestedInput
|
||||
}
|
||||
|
||||
@@ -945,6 +981,7 @@ export type UserUncheckedUpdateWithoutOrganizerVerificationInput = {
|
||||
tripReviews?: Prisma.TripReviewUncheckedUpdateManyWithoutUserNestedInput
|
||||
bookings?: Prisma.BookingUncheckedUpdateManyWithoutUserNestedInput
|
||||
reviewedVerifications?: Prisma.OrganizerVerificationUncheckedUpdateManyWithoutReviewedByNestedInput
|
||||
reviewedRefunds?: Prisma.RefundUncheckedUpdateManyWithoutReviewedByNestedInput
|
||||
profile?: Prisma.UserProfileUncheckedUpdateOneWithoutUserNestedInput
|
||||
}
|
||||
|
||||
@@ -976,6 +1013,7 @@ export type UserUpdateWithoutReviewedVerificationsInput = {
|
||||
tripReviews?: Prisma.TripReviewUpdateManyWithoutUserNestedInput
|
||||
bookings?: Prisma.BookingUpdateManyWithoutUserNestedInput
|
||||
organizerVerification?: Prisma.OrganizerVerificationUpdateOneWithoutUserNestedInput
|
||||
reviewedRefunds?: Prisma.RefundUpdateManyWithoutReviewedByNestedInput
|
||||
profile?: Prisma.UserProfileUpdateOneWithoutUserNestedInput
|
||||
}
|
||||
|
||||
@@ -996,6 +1034,7 @@ export type UserUncheckedUpdateWithoutReviewedVerificationsInput = {
|
||||
tripReviews?: Prisma.TripReviewUncheckedUpdateManyWithoutUserNestedInput
|
||||
bookings?: Prisma.BookingUncheckedUpdateManyWithoutUserNestedInput
|
||||
organizerVerification?: Prisma.OrganizerVerificationUncheckedUpdateOneWithoutUserNestedInput
|
||||
reviewedRefunds?: Prisma.RefundUncheckedUpdateManyWithoutReviewedByNestedInput
|
||||
profile?: Prisma.UserProfileUncheckedUpdateOneWithoutUserNestedInput
|
||||
}
|
||||
|
||||
@@ -1016,6 +1055,7 @@ export type UserCreateWithoutTripsInput = {
|
||||
bookings?: Prisma.BookingCreateNestedManyWithoutUserInput
|
||||
organizerVerification?: Prisma.OrganizerVerificationCreateNestedOneWithoutUserInput
|
||||
reviewedVerifications?: Prisma.OrganizerVerificationCreateNestedManyWithoutReviewedByInput
|
||||
reviewedRefunds?: Prisma.RefundCreateNestedManyWithoutReviewedByInput
|
||||
profile?: Prisma.UserProfileCreateNestedOneWithoutUserInput
|
||||
}
|
||||
|
||||
@@ -1036,6 +1076,7 @@ export type UserUncheckedCreateWithoutTripsInput = {
|
||||
bookings?: Prisma.BookingUncheckedCreateNestedManyWithoutUserInput
|
||||
organizerVerification?: Prisma.OrganizerVerificationUncheckedCreateNestedOneWithoutUserInput
|
||||
reviewedVerifications?: Prisma.OrganizerVerificationUncheckedCreateNestedManyWithoutReviewedByInput
|
||||
reviewedRefunds?: Prisma.RefundUncheckedCreateNestedManyWithoutReviewedByInput
|
||||
profile?: Prisma.UserProfileUncheckedCreateNestedOneWithoutUserInput
|
||||
}
|
||||
|
||||
@@ -1072,6 +1113,7 @@ export type UserUpdateWithoutTripsInput = {
|
||||
bookings?: Prisma.BookingUpdateManyWithoutUserNestedInput
|
||||
organizerVerification?: Prisma.OrganizerVerificationUpdateOneWithoutUserNestedInput
|
||||
reviewedVerifications?: Prisma.OrganizerVerificationUpdateManyWithoutReviewedByNestedInput
|
||||
reviewedRefunds?: Prisma.RefundUpdateManyWithoutReviewedByNestedInput
|
||||
profile?: Prisma.UserProfileUpdateOneWithoutUserNestedInput
|
||||
}
|
||||
|
||||
@@ -1092,6 +1134,7 @@ export type UserUncheckedUpdateWithoutTripsInput = {
|
||||
bookings?: Prisma.BookingUncheckedUpdateManyWithoutUserNestedInput
|
||||
organizerVerification?: Prisma.OrganizerVerificationUncheckedUpdateOneWithoutUserNestedInput
|
||||
reviewedVerifications?: Prisma.OrganizerVerificationUncheckedUpdateManyWithoutReviewedByNestedInput
|
||||
reviewedRefunds?: Prisma.RefundUncheckedUpdateManyWithoutReviewedByNestedInput
|
||||
profile?: Prisma.UserProfileUncheckedUpdateOneWithoutUserNestedInput
|
||||
}
|
||||
|
||||
@@ -1112,6 +1155,7 @@ export type UserCreateWithoutTripReviewsInput = {
|
||||
bookings?: Prisma.BookingCreateNestedManyWithoutUserInput
|
||||
organizerVerification?: Prisma.OrganizerVerificationCreateNestedOneWithoutUserInput
|
||||
reviewedVerifications?: Prisma.OrganizerVerificationCreateNestedManyWithoutReviewedByInput
|
||||
reviewedRefunds?: Prisma.RefundCreateNestedManyWithoutReviewedByInput
|
||||
profile?: Prisma.UserProfileCreateNestedOneWithoutUserInput
|
||||
}
|
||||
|
||||
@@ -1132,6 +1176,7 @@ export type UserUncheckedCreateWithoutTripReviewsInput = {
|
||||
bookings?: Prisma.BookingUncheckedCreateNestedManyWithoutUserInput
|
||||
organizerVerification?: Prisma.OrganizerVerificationUncheckedCreateNestedOneWithoutUserInput
|
||||
reviewedVerifications?: Prisma.OrganizerVerificationUncheckedCreateNestedManyWithoutReviewedByInput
|
||||
reviewedRefunds?: Prisma.RefundUncheckedCreateNestedManyWithoutReviewedByInput
|
||||
profile?: Prisma.UserProfileUncheckedCreateNestedOneWithoutUserInput
|
||||
}
|
||||
|
||||
@@ -1168,6 +1213,7 @@ export type UserUpdateWithoutTripReviewsInput = {
|
||||
bookings?: Prisma.BookingUpdateManyWithoutUserNestedInput
|
||||
organizerVerification?: Prisma.OrganizerVerificationUpdateOneWithoutUserNestedInput
|
||||
reviewedVerifications?: Prisma.OrganizerVerificationUpdateManyWithoutReviewedByNestedInput
|
||||
reviewedRefunds?: Prisma.RefundUpdateManyWithoutReviewedByNestedInput
|
||||
profile?: Prisma.UserProfileUpdateOneWithoutUserNestedInput
|
||||
}
|
||||
|
||||
@@ -1188,6 +1234,7 @@ export type UserUncheckedUpdateWithoutTripReviewsInput = {
|
||||
bookings?: Prisma.BookingUncheckedUpdateManyWithoutUserNestedInput
|
||||
organizerVerification?: Prisma.OrganizerVerificationUncheckedUpdateOneWithoutUserNestedInput
|
||||
reviewedVerifications?: Prisma.OrganizerVerificationUncheckedUpdateManyWithoutReviewedByNestedInput
|
||||
reviewedRefunds?: Prisma.RefundUncheckedUpdateManyWithoutReviewedByNestedInput
|
||||
profile?: Prisma.UserProfileUncheckedUpdateOneWithoutUserNestedInput
|
||||
}
|
||||
|
||||
@@ -1208,6 +1255,7 @@ export type UserCreateWithoutParticipationsInput = {
|
||||
bookings?: Prisma.BookingCreateNestedManyWithoutUserInput
|
||||
organizerVerification?: Prisma.OrganizerVerificationCreateNestedOneWithoutUserInput
|
||||
reviewedVerifications?: Prisma.OrganizerVerificationCreateNestedManyWithoutReviewedByInput
|
||||
reviewedRefunds?: Prisma.RefundCreateNestedManyWithoutReviewedByInput
|
||||
profile?: Prisma.UserProfileCreateNestedOneWithoutUserInput
|
||||
}
|
||||
|
||||
@@ -1228,6 +1276,7 @@ export type UserUncheckedCreateWithoutParticipationsInput = {
|
||||
bookings?: Prisma.BookingUncheckedCreateNestedManyWithoutUserInput
|
||||
organizerVerification?: Prisma.OrganizerVerificationUncheckedCreateNestedOneWithoutUserInput
|
||||
reviewedVerifications?: Prisma.OrganizerVerificationUncheckedCreateNestedManyWithoutReviewedByInput
|
||||
reviewedRefunds?: Prisma.RefundUncheckedCreateNestedManyWithoutReviewedByInput
|
||||
profile?: Prisma.UserProfileUncheckedCreateNestedOneWithoutUserInput
|
||||
}
|
||||
|
||||
@@ -1264,6 +1313,7 @@ export type UserUpdateWithoutParticipationsInput = {
|
||||
bookings?: Prisma.BookingUpdateManyWithoutUserNestedInput
|
||||
organizerVerification?: Prisma.OrganizerVerificationUpdateOneWithoutUserNestedInput
|
||||
reviewedVerifications?: Prisma.OrganizerVerificationUpdateManyWithoutReviewedByNestedInput
|
||||
reviewedRefunds?: Prisma.RefundUpdateManyWithoutReviewedByNestedInput
|
||||
profile?: Prisma.UserProfileUpdateOneWithoutUserNestedInput
|
||||
}
|
||||
|
||||
@@ -1284,6 +1334,7 @@ export type UserUncheckedUpdateWithoutParticipationsInput = {
|
||||
bookings?: Prisma.BookingUncheckedUpdateManyWithoutUserNestedInput
|
||||
organizerVerification?: Prisma.OrganizerVerificationUncheckedUpdateOneWithoutUserNestedInput
|
||||
reviewedVerifications?: Prisma.OrganizerVerificationUncheckedUpdateManyWithoutReviewedByNestedInput
|
||||
reviewedRefunds?: Prisma.RefundUncheckedUpdateManyWithoutReviewedByNestedInput
|
||||
profile?: Prisma.UserProfileUncheckedUpdateOneWithoutUserNestedInput
|
||||
}
|
||||
|
||||
@@ -1304,6 +1355,7 @@ export type UserCreateWithoutBookingsInput = {
|
||||
tripReviews?: Prisma.TripReviewCreateNestedManyWithoutUserInput
|
||||
organizerVerification?: Prisma.OrganizerVerificationCreateNestedOneWithoutUserInput
|
||||
reviewedVerifications?: Prisma.OrganizerVerificationCreateNestedManyWithoutReviewedByInput
|
||||
reviewedRefunds?: Prisma.RefundCreateNestedManyWithoutReviewedByInput
|
||||
profile?: Prisma.UserProfileCreateNestedOneWithoutUserInput
|
||||
}
|
||||
|
||||
@@ -1324,6 +1376,7 @@ export type UserUncheckedCreateWithoutBookingsInput = {
|
||||
tripReviews?: Prisma.TripReviewUncheckedCreateNestedManyWithoutUserInput
|
||||
organizerVerification?: Prisma.OrganizerVerificationUncheckedCreateNestedOneWithoutUserInput
|
||||
reviewedVerifications?: Prisma.OrganizerVerificationUncheckedCreateNestedManyWithoutReviewedByInput
|
||||
reviewedRefunds?: Prisma.RefundUncheckedCreateNestedManyWithoutReviewedByInput
|
||||
profile?: Prisma.UserProfileUncheckedCreateNestedOneWithoutUserInput
|
||||
}
|
||||
|
||||
@@ -1360,6 +1413,7 @@ export type UserUpdateWithoutBookingsInput = {
|
||||
tripReviews?: Prisma.TripReviewUpdateManyWithoutUserNestedInput
|
||||
organizerVerification?: Prisma.OrganizerVerificationUpdateOneWithoutUserNestedInput
|
||||
reviewedVerifications?: Prisma.OrganizerVerificationUpdateManyWithoutReviewedByNestedInput
|
||||
reviewedRefunds?: Prisma.RefundUpdateManyWithoutReviewedByNestedInput
|
||||
profile?: Prisma.UserProfileUpdateOneWithoutUserNestedInput
|
||||
}
|
||||
|
||||
@@ -1380,6 +1434,107 @@ export type UserUncheckedUpdateWithoutBookingsInput = {
|
||||
tripReviews?: Prisma.TripReviewUncheckedUpdateManyWithoutUserNestedInput
|
||||
organizerVerification?: Prisma.OrganizerVerificationUncheckedUpdateOneWithoutUserNestedInput
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1395,6 +1550,7 @@ export type UserCountOutputType = {
|
||||
tripReviews: number
|
||||
bookings: number
|
||||
reviewedVerifications: number
|
||||
reviewedRefunds: number
|
||||
}
|
||||
|
||||
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
|
||||
bookings?: boolean | UserCountOutputTypeCountBookingsArgs
|
||||
reviewedVerifications?: boolean | UserCountOutputTypeCountReviewedVerificationsArgs
|
||||
reviewedRefunds?: boolean | UserCountOutputTypeCountReviewedRefundsArgs
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1458,6 +1615,13 @@ export type UserCountOutputTypeCountReviewedVerificationsArgs<ExtArgs extends ru
|
||||
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<{
|
||||
id?: boolean
|
||||
@@ -1477,6 +1641,7 @@ export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = r
|
||||
bookings?: boolean | Prisma.User$bookingsArgs<ExtArgs>
|
||||
organizerVerification?: boolean | Prisma.User$organizerVerificationArgs<ExtArgs>
|
||||
reviewedVerifications?: boolean | Prisma.User$reviewedVerificationsArgs<ExtArgs>
|
||||
reviewedRefunds?: boolean | Prisma.User$reviewedRefundsArgs<ExtArgs>
|
||||
profile?: boolean | Prisma.User$profileArgs<ExtArgs>
|
||||
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
|
||||
}, ExtArgs["result"]["user"]>
|
||||
@@ -1529,6 +1694,7 @@ export type UserInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs =
|
||||
bookings?: boolean | Prisma.User$bookingsArgs<ExtArgs>
|
||||
organizerVerification?: boolean | Prisma.User$organizerVerificationArgs<ExtArgs>
|
||||
reviewedVerifications?: boolean | Prisma.User$reviewedVerificationsArgs<ExtArgs>
|
||||
reviewedRefunds?: boolean | Prisma.User$reviewedRefundsArgs<ExtArgs>
|
||||
profile?: boolean | Prisma.User$profileArgs<ExtArgs>
|
||||
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
|
||||
}
|
||||
@@ -1545,6 +1711,7 @@ export type $UserPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs =
|
||||
bookings: Prisma.$BookingPayload<ExtArgs>[]
|
||||
organizerVerification: Prisma.$OrganizerVerificationPayload<ExtArgs> | null
|
||||
reviewedVerifications: Prisma.$OrganizerVerificationPayload<ExtArgs>[]
|
||||
reviewedRefunds: Prisma.$RefundPayload<ExtArgs>[]
|
||||
profile: Prisma.$UserProfilePayload<ExtArgs> | null
|
||||
}
|
||||
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>
|
||||
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>
|
||||
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>
|
||||
/**
|
||||
* 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[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
+88
-4
@@ -6,17 +6,21 @@ import Image from "next/image";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { tripService } from "@/server/services/trip.service";
|
||||
import { bookingService } from "@/server/services/booking.service";
|
||||
import { bookingRepo } from "@/server/repositories/booking.repo";
|
||||
import { trustService } from "@/server/services/trust.service";
|
||||
import { formatRupiah } from "@/lib/utils";
|
||||
import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
|
||||
import { siteConfig, siteUrl, absoluteUrl } from "@/lib/site";
|
||||
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 { OrganizerTrustPanel } from "@/features/trip/components/organizer-trust-panel";
|
||||
import { TripProgramBlock } from "@/features/trip/components/trip-program-block";
|
||||
import { OrganizerPaymentQueue } from "@/features/booking/components/organizer-payment-queue";
|
||||
import { ImageGallery } from "@/features/trip/components/image-gallery";
|
||||
import { TripReviewSection } from "@/features/review/components/trip-review-section";
|
||||
import { RefundPolicySection } from "@/features/refund/components/refund-policy-section";
|
||||
import { categoryMeta } from "@/lib/activity-category";
|
||||
import { vibeMeta } from "@/lib/vibe";
|
||||
import { isFreeTrip } from "@/lib/trip-pricing";
|
||||
@@ -24,6 +28,7 @@ import {
|
||||
isPastTripLastDayForReview,
|
||||
isTripDepartureDayPast,
|
||||
} from "@/lib/trip-dates";
|
||||
import { previewRefund } from "@/lib/refund-policy";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
@@ -137,6 +142,31 @@ export default async function TripDetailPage({
|
||||
? 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 tripUrl = absoluteUrl(`/trips/${trip.id}`);
|
||||
@@ -344,10 +374,22 @@ export default async function TripDetailPage({
|
||||
|
||||
{/* Participant Progress */}
|
||||
<div className="rounded-xl border border-neutral-200 p-3 sm:p-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-neutral-700 sm:text-sm">
|
||||
Peserta
|
||||
</span>
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-neutral-700 sm:text-sm">
|
||||
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">
|
||||
{participantCount}{" "}
|
||||
<span className="font-normal text-neutral-400">
|
||||
@@ -383,6 +425,23 @@ export default async function TripDetailPage({
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
|
||||
<TripProgramBlock
|
||||
@@ -451,8 +510,33 @@ export default async function TripDetailPage({
|
||||
isFull={spotsLeft <= 0}
|
||||
tripStatus={trip.status}
|
||||
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
|
||||
tripId={trip.id}
|
||||
reviews={trip.reviews.map((r) => ({
|
||||
|
||||
@@ -158,7 +158,14 @@ function FreeTripSection({
|
||||
bookingStatus,
|
||||
}: {
|
||||
tripId: string;
|
||||
bookingStatus: "PENDING" | "AWAITING_PAY" | "PAID" | "CANCELLED" | "REFUNDED" | "EXPIRED";
|
||||
bookingStatus:
|
||||
| "PENDING"
|
||||
| "AWAITING_PAY"
|
||||
| "PAID"
|
||||
| "CANCELLED"
|
||||
| "REFUNDED"
|
||||
| "PARTIALLY_REFUNDED"
|
||||
| "EXPIRED";
|
||||
}) {
|
||||
return (
|
||||
<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;
|
||||
organizerName: string;
|
||||
price: number;
|
||||
bookingStatus: "PENDING" | "AWAITING_PAY" | "PAID" | "CANCELLED" | "REFUNDED" | "EXPIRED";
|
||||
bookingStatus:
|
||||
| "PENDING"
|
||||
| "AWAITING_PAY"
|
||||
| "PAID"
|
||||
| "CANCELLED"
|
||||
| "REFUNDED"
|
||||
| "PARTIALLY_REFUNDED"
|
||||
| "EXPIRED";
|
||||
paymentMarkedAt: Date | null;
|
||||
paymentPaidAt: Date | null;
|
||||
}) {
|
||||
|
||||
@@ -3,8 +3,12 @@ import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { notFound } from "next/navigation";
|
||||
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 { 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 { vibeMeta } from "@/lib/vibe";
|
||||
|
||||
@@ -45,6 +49,16 @@ export default async function PublicProfilePage({ params }: PageProps) {
|
||||
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 (
|
||||
<div className="mx-auto max-w-4xl px-4 py-6 sm:py-8">
|
||||
{/* Header */}
|
||||
@@ -156,6 +170,15 @@ export default async function PublicProfilePage({ params }: PageProps) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{organizerTrust && <OrganizerStatsPanel trust={organizerTrust} />}
|
||||
|
||||
{organizerTrust && organizerReviews.length > 0 && (
|
||||
<OrganizerReviewsList
|
||||
reviews={organizerReviews}
|
||||
totalCount={organizerTrust.reviewCount}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Empty profile hint */}
|
||||
{!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">
|
||||
|
||||
@@ -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>`).
|
||||
@@ -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.
|
||||
+10
-1
@@ -26,4 +26,13 @@ NEXT_PUBLIC_MIDTRANS_CLIENT_KEY=
|
||||
# 'true' untuk production, 'false' atau kosong untuk sandbox.
|
||||
# Dibaca di server (untuk Snap API endpoint) DAN client (untuk Snap.js URL).
|
||||
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=
|
||||
@@ -5,6 +5,7 @@ import { authOptions } from "@/lib/auth";
|
||||
import { tripService } from "@/server/services/trip.service";
|
||||
import { paymentService } from "@/server/services/payment.service";
|
||||
import { bookingService } from "@/server/services/booking.service";
|
||||
import { refundService } from "@/server/services/refund.service";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export async function markParticipantPaidAction(tripId: string) {
|
||||
@@ -94,3 +95,45 @@ export async function confirmParticipantPaymentAction(
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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 1–3 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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -180,3 +180,27 @@ export async function rejectParticipantAction(
|
||||
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>
|
||||
<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
|
||||
</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
|
||||
id="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"
|
||||
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>
|
||||
|
||||
|
||||
@@ -23,6 +23,9 @@ interface JoinTripButtonProps {
|
||||
tripStatus: string;
|
||||
/** Tanggal berangkat trip sudah lewat */
|
||||
isDeparturePast?: boolean;
|
||||
/** Sembunyikan tombol cancel — dipakai saat booking PAID dan parent
|
||||
* menampilkan CancelBookingButton (refund flow) di tempat terpisah. */
|
||||
hideCancelButton?: boolean;
|
||||
}
|
||||
|
||||
export function JoinTripButton({
|
||||
@@ -36,6 +39,7 @@ export function JoinTripButton({
|
||||
isFull,
|
||||
tripStatus,
|
||||
isDeparturePast,
|
||||
hideCancelButton,
|
||||
}: JoinTripButtonProps) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -163,13 +167,15 @@ export function JoinTripButton({
|
||||
</Link>
|
||||
)}
|
||||
{isJoined ? (
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
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>
|
||||
hideCancelButton ? null : (
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
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>
|
||||
)
|
||||
) : (
|
||||
<button
|
||||
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="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">
|
||||
Trip dibuat
|
||||
Trip selesai
|
||||
</p>
|
||||
<p className="text-lg font-bold text-neutral-800">
|
||||
{trust.tripsCreated}
|
||||
<p className="text-lg font-bold text-primary-700">
|
||||
{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>
|
||||
</div>
|
||||
<div className="min-w-[100px] rounded-lg bg-white/80 px-3 py-2 shadow-sm ring-1 ring-neutral-100">
|
||||
|
||||
@@ -31,4 +31,6 @@ export const LIMITS = {
|
||||
MAX_BANK_ACCOUNT_NUMBER_LENGTH: 32,
|
||||
MAX_REJECTION_REASON_LENGTH: 500,
|
||||
NIK_LENGTH: 16,
|
||||
/** Catatan laporan dari peserta/organizer + catatan admin pada refund. */
|
||||
MAX_REFUND_NOTE_LENGTH: 1000,
|
||||
} as const;
|
||||
|
||||
+11
-1
@@ -162,7 +162,17 @@ export function verifyMidtransSignature(
|
||||
|
||||
/**
|
||||
* 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(
|
||||
transactionStatus: string,
|
||||
|
||||
@@ -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)
|
||||
* - 3–6 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 (0–100). */
|
||||
refundPercentage: number;
|
||||
/** Label untuk UI. */
|
||||
label: string;
|
||||
}
|
||||
|
||||
const TIERS: RefundTier[] = [
|
||||
{ minDaysBefore: 7, refundPercentage: 80, label: "≥ 7 hari sebelum berangkat" },
|
||||
{ minDaysBefore: 3, refundPercentage: 50, label: "3–6 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,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
/** Minimal trip sebagai organizer untuk badge "Trip leader" (heuristik MVP). */
|
||||
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. */
|
||||
type WithOrganizerVerification = {
|
||||
organizerVerification?: { status: "PENDING" | "APPROVED" | "REJECTED" } | null;
|
||||
|
||||
Generated
+44
-44
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "setrip",
|
||||
"version": "0.10.0",
|
||||
"version": "0.10.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "setrip",
|
||||
"version": "0.10.0",
|
||||
"version": "0.10.3",
|
||||
"dependencies": {
|
||||
"@next-auth/prisma-adapter": "^1.0.7",
|
||||
"@prisma/adapter-pg": "^7.7.0",
|
||||
@@ -1657,9 +1657,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "16.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.5.tgz",
|
||||
"integrity": "sha512-Lb9ElHD2klcyeVD25vW+siPFqz9QMzDUSgvFZNO+dZEKoMHex4viJhVuzBhrXKqb+UKnih7mVYbt50/7KLsSCA==",
|
||||
"version": "16.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.6.tgz",
|
||||
"integrity": "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/eslint-plugin-next": {
|
||||
@@ -1673,9 +1673,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "16.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.5.tgz",
|
||||
"integrity": "sha512-BW+8PGVmsruomXHsitD8JG6gny9lEdobctjBwvtPF8AKtxGDR7nR35FOl/oK9UAPXBOBm+vx0k8qtpeHOXQMGQ==",
|
||||
"version": "16.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.6.tgz",
|
||||
"integrity": "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1689,9 +1689,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "16.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.5.tgz",
|
||||
"integrity": "sha512-ZoCGnCl9LlQJWmqXrZAUlNxvuNmclvE+7zUif+nDydkkehl9FKxHJ+wxSQMj+C37BYFerKiEdX9s9o02ir975Q==",
|
||||
"version": "16.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.6.tgz",
|
||||
"integrity": "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1705,9 +1705,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "16.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.5.tgz",
|
||||
"integrity": "sha512-AwcZzMChaWkOTZt3vu+2ZMIj8g4dYQY+B8VUVhlFSQ2JtvyZpefyYHTe00D6b6L7BysYw7vl3zsvs9jix8tl5Q==",
|
||||
"version": "16.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.6.tgz",
|
||||
"integrity": "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1724,9 +1724,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "16.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.5.tgz",
|
||||
"integrity": "sha512-QqMgqWbCBFsfiQ7BF3dUlW8HJy1LWhpcqbTpoHMWA9IV+TnWwDKozQJA5NdIAHjQ00yX2Q7AUkLr/XK4n77q8A==",
|
||||
"version": "16.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.6.tgz",
|
||||
"integrity": "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1743,9 +1743,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "16.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.5.tgz",
|
||||
"integrity": "sha512-3hzeiFGZtyATVx9pCeuzTshXmh50vHZitqaeZiyJZaUmjQyrfjsVUgS8apOj1vEJCIpKJM/55F45yPAV2kpjsA==",
|
||||
"version": "16.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.6.tgz",
|
||||
"integrity": "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1762,9 +1762,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "16.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.5.tgz",
|
||||
"integrity": "sha512-0mzZV/mAt7Qj2tYNdTB6AqrS8dwng/AQLSYC5Z1YLpZdi2wxqKDPK7RY2RvjB1fXyJfOfdA3l/yTF5yLi+WfuQ==",
|
||||
"version": "16.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.6.tgz",
|
||||
"integrity": "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1781,9 +1781,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "16.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.5.tgz",
|
||||
"integrity": "sha512-f/H4nZ2zJBvA8/+HpsB9mNonF9zfQoAU6D0WxJrfzhJDvJLfngVN85oqxUyrDVK99DIFfFYhLpGa5K+c5uotSw==",
|
||||
"version": "16.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.6.tgz",
|
||||
"integrity": "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1797,9 +1797,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "16.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.5.tgz",
|
||||
"integrity": "sha512-nuP7DHs4koAojsIxVPkihNgKiRUKtCU65j5X6DAbSy8VBrfT/o90bCLLHPf51JEdOZwZMFzM6e0NiGWfIWjVAg==",
|
||||
"version": "16.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.6.tgz",
|
||||
"integrity": "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -4870,9 +4870,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
||||
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -6563,12 +6563,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "16.2.5",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.2.5.tgz",
|
||||
"integrity": "sha512-TkVTm9F2WEulkgGljm4wPwNgvCCWCVw6StUHsZb8WZpHFRjepoUWg3d7L4IMg7IyjcJ4Co9eVhpro8e8O+KarQ==",
|
||||
"version": "16.2.6",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.2.6.tgz",
|
||||
"integrity": "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "16.2.5",
|
||||
"@next/env": "16.2.6",
|
||||
"@swc/helpers": "0.5.15",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
@@ -6582,14 +6582,14 @@
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "16.2.5",
|
||||
"@next/swc-darwin-x64": "16.2.5",
|
||||
"@next/swc-linux-arm64-gnu": "16.2.5",
|
||||
"@next/swc-linux-arm64-musl": "16.2.5",
|
||||
"@next/swc-linux-x64-gnu": "16.2.5",
|
||||
"@next/swc-linux-x64-musl": "16.2.5",
|
||||
"@next/swc-win32-arm64-msvc": "16.2.5",
|
||||
"@next/swc-win32-x64-msvc": "16.2.5",
|
||||
"@next/swc-darwin-arm64": "16.2.6",
|
||||
"@next/swc-darwin-x64": "16.2.6",
|
||||
"@next/swc-linux-arm64-gnu": "16.2.6",
|
||||
"@next/swc-linux-arm64-musl": "16.2.6",
|
||||
"@next/swc-linux-x64-gnu": "16.2.6",
|
||||
"@next/swc-linux-x64-musl": "16.2.6",
|
||||
"@next/swc-win32-arm64-msvc": "16.2.6",
|
||||
"@next/swc-win32-x64-msvc": "16.2.6",
|
||||
"sharp": "^0.34.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "setrip",
|
||||
"version": "0.10.0",
|
||||
"version": "0.10.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"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;
|
||||
@@ -32,6 +32,8 @@ model User {
|
||||
organizerVerification OrganizerVerification? @relation("OrganizerVerificationOwner")
|
||||
reviewedVerifications OrganizerVerification[] @relation("OrganizerVerificationReviewer")
|
||||
|
||||
reviewedRefunds Refund[] @relation("RefundReviewer")
|
||||
|
||||
profile UserProfile?
|
||||
}
|
||||
|
||||
@@ -258,6 +260,7 @@ model Booking {
|
||||
status BookingStatus @default(PENDING)
|
||||
|
||||
payments Payment[]
|
||||
refunds Refund[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -275,6 +278,7 @@ enum BookingStatus {
|
||||
PAID
|
||||
CANCELLED
|
||||
REFUNDED
|
||||
PARTIALLY_REFUNDED
|
||||
EXPIRED
|
||||
}
|
||||
|
||||
@@ -307,6 +311,8 @@ model Payment {
|
||||
failedAt DateTime?
|
||||
rejectionReason String?
|
||||
|
||||
refunds Refund[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -328,3 +334,105 @@ enum PaymentStatus {
|
||||
CANCELLED
|
||||
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
File diff suppressed because it is too large
Load Diff
@@ -71,4 +71,17 @@ export const bookingRepo = {
|
||||
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"] },
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>>;
|
||||
@@ -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 } },
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -211,4 +211,39 @@ export const tripRepo = {
|
||||
async updateStatus(id: string, status: "OPEN" | "FULL" | "CLOSED" | "COMPLETED") {
|
||||
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 };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -3,6 +3,10 @@ import { participantRepo } from "@/server/repositories/participant.repo";
|
||||
import { reviewRepo } from "@/server/repositories/review.repo";
|
||||
import { isPastTripLastDayForReview } from "@/lib/trip-dates";
|
||||
|
||||
export type OrganizerReviewItem = Awaited<
|
||||
ReturnType<typeof reviewRepo.findByOrganizer>
|
||||
>[number];
|
||||
|
||||
export const reviewService = {
|
||||
async upsertReview(
|
||||
tripId: string,
|
||||
@@ -41,4 +45,11 @@ export const reviewService = {
|
||||
comment: input.comment ?? null,
|
||||
});
|
||||
},
|
||||
|
||||
async getReviewsByOrganizer(
|
||||
organizerId: string,
|
||||
limit?: number
|
||||
): Promise<OrganizerReviewItem[]> {
|
||||
return reviewRepo.findByOrganizer(organizerId, limit);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import { tripRepo, type TripFilters } from "@/server/repositories/trip.repo";
|
||||
import { participantRepo } from "@/server/repositories/participant.repo";
|
||||
import { bookingRepo } from "@/server/repositories/booking.repo";
|
||||
import { bookingService } from "@/server/services/booking.service";
|
||||
import { refundService } from "@/server/services/refund.service";
|
||||
import { LIMITS } from "@/lib/limits";
|
||||
import { utcStartOfDay, isTripDepartureDayPast } from "@/lib/trip-dates";
|
||||
import { isFreeTrip } from "@/lib/trip-pricing";
|
||||
@@ -253,6 +254,19 @@ export const tripService = {
|
||||
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 cancelled = await tx.tripParticipant.update({
|
||||
where: { tripId_userId: { tripId, userId } },
|
||||
@@ -385,6 +399,20 @@ export const tripService = {
|
||||
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(
|
||||
tripId: string,
|
||||
participantId: string,
|
||||
@@ -408,4 +436,164 @@ export const tripService = {
|
||||
|
||||
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.");
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,38 +1,124 @@
|
||||
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 = {
|
||||
isVerified: boolean;
|
||||
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;
|
||||
reviewCount: number;
|
||||
ratingBreakdown: RatingBreakdown;
|
||||
isTripLeader: boolean;
|
||||
};
|
||||
|
||||
export const trustService = {
|
||||
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.tripReview.aggregate({
|
||||
prisma.trip.count({
|
||||
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 },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
prisma.tripReview.groupBy({
|
||||
by: ["rating"],
|
||||
where: { trip: { organizerId } },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
prisma.organizerVerification.findUnique({
|
||||
where: { userId: organizerId },
|
||||
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;
|
||||
|
||||
return {
|
||||
isVerified: organizerVerification?.status === "APPROVED",
|
||||
tripsCreated,
|
||||
tripsCompleted,
|
||||
tripsCancelled,
|
||||
totalParticipantsServed,
|
||||
completionRate,
|
||||
avgRating:
|
||||
avg != null ? Math.round(Number(avg) * 10) / 10 : null,
|
||||
reviewCount: reviewAgg._count._all,
|
||||
ratingBreakdown,
|
||||
isTripLeader: tripsCreated >= TRIP_LEADER_MIN_TRIPS,
|
||||
};
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user