Files
setrip/prisma/schema.prisma
T

514 lines
16 KiB
Plaintext

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")
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/<id>.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?
/// Itinerary hari per hari (teks bebas, bullet OK)
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)
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[]
@@index([category, status, date])
@@index([vibe, status, date])
}
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-<bookingId>`.
/// Format MIDTRANS nanti: `midtrans-<bookingId>-<retry>`.
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
}