generator client { provider = "prisma-client" output = "../app/generated/prisma" } datasource db { provider = "postgresql" } model User { id String @id @default(cuid()) name String email String @unique /// Hash bcrypt. Null untuk user yang sign-in via OAuth (mis. Google). password String? image String? /// Diisi PrismaAdapter NextAuth saat email diverifikasi provider OAuth (Google selalu sudah verified). emailVerified DateTime? /// Apakah user telah menyetujui Syarat & Ketentuan dan Kebijakan Privasi acceptedTermsAndPrivacy Boolean @default(false) /// Waktu user menyetujui Syarat & Ketentuan dan Kebijakan Privasi acceptedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt accounts Account[] trips Trip[] participations TripParticipant[] tripReviews TripReview[] bookings Booking[] organizerVerification OrganizerVerification? @relation("OrganizerVerificationOwner") reviewedVerifications OrganizerVerification[] @relation("OrganizerVerificationReviewer") reviewedRefunds Refund[] @relation("RefundReviewer") /// Payout yang diterima user ini sebagai organizer (escrow trip selesai). payouts Payout[] @relation("PayoutOrganizer") /// Payout yang ditandai admin sebagai PAID/CANCELLED oleh user ini. processedPayouts Payout[] @relation("PayoutProcessor") /// Trip yang dibatalkan admin ini lewat panel admin (intervensi). adminCancelledTrips Trip[] @relation("TripCancelledByAdmin") profile UserProfile? } /// Profil sosial publik. Berisi info yang user pilih untuk dibagikan ke peserta lain /// (bio, kota, minat, vibe). Tidak menyimpan data sensitif — KYC tetap di OrganizerVerification. model UserProfile { id String @id @default(cuid()) userId String @unique user User @relation(fields: [userId], references: [id], onDelete: Cascade) /// Bio singkat, teks bebas bio String? /// Kota domisili (teks bebas, mis. "Bandung", "Jakarta Selatan") city String? /// Tag minat aktivitas (mis. ["hiking", "fotografi", "yoga"]) interests String[] @default([]) /// Username Instagram (tanpa @, opsional) instagram String? /// Gaya jalan / energi user — dipakai untuk matching teman dengan ritme serupa. vibe Vibe? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } enum Vibe { CHILL BALANCED HARDCORE } /// Tabel link akun OAuth pihak ketiga (Google, dst). Diisi oleh PrismaAdapter NextAuth. /// Session tidak pakai DB — kita pakai JWT, jadi Session/VerificationToken tidak perlu. model Account { id String @id @default(cuid()) userId String type String provider String providerAccountId String refresh_token String? access_token String? expires_at Int? token_type String? scope String? id_token String? session_state String? user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([provider, providerAccountId]) } model OrganizerVerification { id String @id @default(cuid()) userId String @unique user User @relation("OrganizerVerificationOwner", fields: [userId], references: [id], onDelete: Cascade) /// Nama lengkap sesuai KTP fullName String /// NIK terenkripsi (AES-256-GCM, base64). Plaintext tidak disimpan. nikEncrypted String /// HMAC-SHA256(NIK + pepper) untuk uniqueness lookup tanpa membuka plaintext. nikHash String @unique birthDate DateTime address String /// Storage key foto KTP (mis. `ktp/.jpg`). File disimpan terenkripsi di luar /public. ktpImageKey String /// Storage key foto liveness — user memegang kertas bertuliskan "SETRIP". /// (Sebelumnya: selfie memegang KTP. Diganti supaya user tidak perlu memajang KTP dua kali.) livenessKey String bankName String bankAccountNumber String bankAccountName String status VerificationStatus @default(PENDING) rejectionReason String? reviewedAt DateTime? reviewedById String? reviewedBy User? @relation("OrganizerVerificationReviewer", fields: [reviewedById], references: [id]) verifiedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } enum VerificationStatus { PENDING APPROVED REJECTED } model Trip { id String @id @default(cuid()) title String description String? /// Kategori aktivitas. category ActivityCategory @default(HIKING) /// Destinasi/subjek aktivitas — interpretasinya tergantung kategori (gunung untuk HIKING, spot untuk DIVING, venue untuk CONCERT, tema untuk WORKSHOP, dst). destination String location String /// Titik kumpul / meeting point (teks bebas) meetingPoint String? /// @deprecated — itinerary lama bentuk teks bebas, backward-compat untuk data /// lama. Trip baru pakai `itineraryItems` (struktural per hari + jam). itinerary String? /// Yang termasuk harga (teks bebas) whatsIncluded String? /// Yang tidak termasuk (teks bebas) whatsExcluded String? date DateTime endDate DateTime? maxParticipants Int price Int /// Ritme/energi trip — dipakai untuk matching dengan vibe user. vibe Vibe? status TripStatus @default(OPEN) /// Admin yang membatalkan trip via panel admin (intervensi). NULL kalau /// organizer yang cancel sendiri atau trip tidak dibatalkan. cancelledByAdminId String? cancelledByAdmin User? @relation("TripCancelledByAdmin", fields: [cancelledByAdminId], references: [id], onDelete: SetNull) /// Alasan admin membatalkan trip — wajib diisi saat admin cancel. cancelledReason String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt organizerId String organizer User @relation(fields: [organizerId], references: [id]) participants TripParticipant[] images TripImage[] reviews TripReview[] bookings Booking[] payouts Payout[] itineraryItems TripItineraryItem[] @@index([category, status, date]) @@index([vibe, status, date]) } /// Itinerary item terstruktur per hari + jam. Satu Trip punya banyak item; /// dikelompokkan per `day` lalu diurutkan `order`. Format jam: "HH:mm" 24-jam. model TripItineraryItem { id String @id @default(cuid()) tripId String trip Trip @relation(fields: [tripId], references: [id], onDelete: Cascade) /// Hari ke-berapa, mulai dari 1. day Int /// Waktu mulai aktivitas, format "HH:mm". startTime String /// Waktu selesai (opsional), format "HH:mm". endTime String? /// Deskripsi aktivitas singkat. activity String /// Urutan dalam hari, untuk preserve order saat render. order Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([tripId, day, order]) } model TripReview { id String @id @default(cuid()) rating Int comment String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt tripId String trip Trip @relation(fields: [tripId], references: [id], onDelete: Cascade) userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([tripId, userId]) } model TripImage { id String @id @default(cuid()) url String caption String? order Int @default(0) tripId String trip Trip @relation(fields: [tripId], references: [id], onDelete: Cascade) } model TripParticipant { id String @id @default(cuid()) status ParticipantStatus @default(PENDING) createdAt DateTime @default(now()) /// @deprecated — sumber kebenaran pindah ke Booking/Payment. Tetap di-update /// untuk backward-compat selama transisi UI lama. Akan dihapus PR berikutnya. markedPaidAt DateTime? /// @deprecated — sumber kebenaran pindah ke Booking/Payment. Tetap di-update /// untuk backward-compat selama transisi UI lama. Akan dihapus PR berikutnya. paymentConfirmedAt DateTime? tripId String trip Trip @relation(fields: [tripId], references: [id]) userId String user User @relation(fields: [userId], references: [id]) booking Booking? @@unique([tripId, userId]) } enum TripStatus { OPEN FULL CLOSED COMPLETED } enum ActivityCategory { HIKING CAMPING SNORKELING DIVING ISLAND_HOPPING CITY_TRIP CULINARY CONCERT WORKSHOP RETREAT } enum ParticipantStatus { PENDING CONFIRMED CANCELLED } /// Booking 1-1 ke TripParticipant. Lifecycle ikut peserta: /// - join → Booking PENDING (menunggu approve organizer) /// - organizer confirm → AWAITING_PAY (paid trip) atau PAID (free trip) /// - peserta + organizer rampungkan pembayaran → PAID /// - cancel/reject → CANCELLED /// `amount` adalah snapshot harga saat booking dibuat — protect dari perubahan trip.price. 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], onDelete: Cascade) amount Int currency String @default("IDR") status BookingStatus @default(PENDING) payments Payment[] refunds Refund[] payout Payout? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt /// Konsistensi: 1-1 ke participant via participantId, dan participant unique /// per (tripId, userId). Constraint ini eksplisit + jadi index untuk query /// `findByTripAndUser`. @@unique([tripId, userId]) @@index([tripId, status]) } enum BookingStatus { PENDING AWAITING_PAY PAID CANCELLED REFUNDED PARTIALLY_REFUNDED EXPIRED } /// Satu attempt pembayaran. Satu Booking bisa punya banyak Payment kalau retry /// (di Phase MIDTRANS nanti). Untuk MANUAL biasanya cukup 1 Payment. model Payment { id String @id @default(cuid()) bookingId String booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade) provider PaymentProvider /// order_id eksternal (unik per attempt). Format MANUAL: `manual-`. /// Format MIDTRANS nanti: `midtrans--`. externalOrderId String @unique /// transaction_id dari gateway. Kosong untuk MANUAL atau sebelum first callback. externalTxId String? /// Metode konkret: bca_va, gopay, qris, manual_transfer, dst. method String? amount Int status PaymentStatus @default(PENDING) /// Snapshot mentah callback gateway (untuk audit & dispute). rawCallback Json? /// Snap token Midtrans / redirect URL. snapToken String? /// Kapan attempt ini kadaluarsa (Midtrans default 24 jam). expiresAt DateTime? paidAt DateTime? failedAt DateTime? rejectionReason String? refunds Refund[] 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 } /// 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 } /// Escrow payout ke organizer. Uang peserta ditahan sejak Booking → PAID sampai /// trip selesai + buffer beberapa hari, baru di-release untuk ditransfer admin. /// /// State machine: /// HELD → diciptakan saat booking PAID, heldUntil = endDate/date + 3 hari /// RELEASED → cron flip setelah heldUntil lewat + trip COMPLETED /// PAID → admin sudah transfer manual ke rekening organizer /// CANCELLED → booking di-refund / trip dibatalkan; payout tidak jadi /// /// Audit: 1-1 dengan Booking (unique). Refund SUCCEEDED mengurangi amount /// (partial) atau membatalkan payout (full). model Payout { id String @id @default(cuid()) bookingId String @unique booking Booking @relation(fields: [bookingId], references: [id], onDelete: Restrict) tripId String trip Trip @relation(fields: [tripId], references: [id]) organizerId String organizer User @relation("PayoutOrganizer", fields: [organizerId], references: [id]) /// Nominal yg organizer terima (IDR integer). Default = booking.amount saat /// payout dibuat. Refund SUCCEEDED memotong nilai ini supaya total payout + /// total refund = uang yang dibayar peserta. amount Int currency String @default("IDR") status PayoutStatus @default(HELD) /// Tanggal payout boleh di-release ke organizer /// (= trip.endDate ?? trip.date + buffer days). heldUntil DateTime releasedAt DateTime? paidAt DateTime? cancelledAt DateTime? /// Snapshot bank info organizer dari OrganizerVerification saat payout dibuat. /// Disimpan inline supaya audit-friendly walau organizer ganti bank nanti. bankName String? bankAccountNumber String? bankAccountName String? /// Catatan admin: referensi transfer manual, alasan cancel, dst. adminNote String? /// Admin yang menandai PAID/CANCELLED. processedById String? processedBy User? @relation("PayoutProcessor", fields: [processedById], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([organizerId, status]) @@index([status, heldUntil]) } enum PayoutStatus { /// Menunggu trip selesai + buffer beberapa hari sebelum boleh ditransfer. HELD /// Buffer lewat & trip COMPLETED, siap di-transfer admin ke rekening organizer. RELEASED /// Admin sudah transfer ke rekening organizer. PAID /// Booking di-refund penuh / trip dibatalkan — uang tidak jadi ke organizer. CANCELLED }