add booking and payment schema
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Backfill Booking + Payment dari TripParticipant lama.
|
||||
*
|
||||
* Idempotent — jalan ulang aman. Skip baris yang sudah punya Booking.
|
||||
*
|
||||
* Mapping:
|
||||
* - participant.status === "CANCELLED" → Booking CANCELLED, no Payment
|
||||
* - participant.status === "PENDING" → Booking PENDING, no Payment
|
||||
* - participant.status === "CONFIRMED" + free → Booking PAID, no Payment
|
||||
* - participant.status === "CONFIRMED" + paid:
|
||||
* - paymentConfirmedAt set → Booking PAID, Payment PAID (paidAt = paymentConfirmedAt)
|
||||
* - markedPaidAt set, no confirm → Booking AWAITING_PAY, Payment AWAITING
|
||||
* - neither → Booking AWAITING_PAY, no Payment
|
||||
*
|
||||
* Jalankan: `npx tsx prisma/backfill-bookings.ts`
|
||||
*/
|
||||
import { PrismaClient, Prisma } from "../app/generated/prisma/client";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
|
||||
const adapter = new PrismaPg({
|
||||
connectionString: process.env.DATABASE_URL!,
|
||||
});
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
|
||||
async function main() {
|
||||
const participants = await prisma.tripParticipant.findMany({
|
||||
include: {
|
||||
trip: { select: { price: true } },
|
||||
booking: { select: { id: true } },
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
|
||||
let createdBookings = 0;
|
||||
let createdPayments = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const p of participants) {
|
||||
if (p.booking) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const isFree = p.trip.price <= 0;
|
||||
|
||||
let bookingStatus: Prisma.BookingCreateInput["status"];
|
||||
if (p.status === "CANCELLED") {
|
||||
bookingStatus = "CANCELLED";
|
||||
} else if (p.status === "PENDING") {
|
||||
bookingStatus = "PENDING";
|
||||
} else if (isFree) {
|
||||
bookingStatus = "PAID";
|
||||
} else if (p.paymentConfirmedAt) {
|
||||
bookingStatus = "PAID";
|
||||
} else {
|
||||
bookingStatus = "AWAITING_PAY";
|
||||
}
|
||||
|
||||
const booking = await prisma.booking.create({
|
||||
data: {
|
||||
tripId: p.tripId,
|
||||
userId: p.userId,
|
||||
participantId: p.id,
|
||||
amount: p.trip.price,
|
||||
status: bookingStatus,
|
||||
},
|
||||
});
|
||||
createdBookings++;
|
||||
|
||||
// Payment row hanya kalau ada jejak pembayaran manual
|
||||
if (!isFree && (p.markedPaidAt || p.paymentConfirmedAt)) {
|
||||
const paymentStatus: Prisma.PaymentCreateInput["status"] =
|
||||
p.paymentConfirmedAt ? "PAID" : "AWAITING";
|
||||
await prisma.payment.create({
|
||||
data: {
|
||||
bookingId: booking.id,
|
||||
provider: "MANUAL",
|
||||
externalOrderId: `manual-${booking.id}`,
|
||||
amount: p.trip.price,
|
||||
status: paymentStatus,
|
||||
method: "manual_transfer",
|
||||
paidAt: p.paymentConfirmedAt ?? null,
|
||||
},
|
||||
});
|
||||
createdPayments++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`✅ Backfill selesai. Booking dibuat: ${createdBookings}, Payment dibuat: ${createdPayments}, dilewati (sudah ada): ${skipped}`
|
||||
);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error("❌ Backfill gagal:", e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "BookingStatus" AS ENUM ('PENDING', 'AWAITING_PAY', 'PAID', 'CANCELLED', 'REFUNDED', 'EXPIRED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PaymentProvider" AS ENUM ('MANUAL', 'MIDTRANS');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PaymentStatus" AS ENUM ('PENDING', 'AWAITING', 'PAID', 'FAILED', 'EXPIRED', 'CANCELLED', 'REFUNDED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Booking" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tripId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"participantId" TEXT NOT NULL,
|
||||
"amount" INTEGER NOT NULL,
|
||||
"currency" TEXT NOT NULL DEFAULT 'IDR',
|
||||
"status" "BookingStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Booking_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Payment" (
|
||||
"id" TEXT NOT NULL,
|
||||
"bookingId" TEXT NOT NULL,
|
||||
"provider" "PaymentProvider" NOT NULL,
|
||||
"externalOrderId" TEXT NOT NULL,
|
||||
"externalTxId" TEXT,
|
||||
"method" TEXT,
|
||||
"amount" INTEGER NOT NULL,
|
||||
"status" "PaymentStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"rawCallback" JSONB,
|
||||
"snapToken" TEXT,
|
||||
"expiresAt" TIMESTAMP(3),
|
||||
"paidAt" TIMESTAMP(3),
|
||||
"failedAt" TIMESTAMP(3),
|
||||
"rejectionReason" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Payment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Booking_participantId_key" ON "Booking"("participantId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Booking_tripId_status_idx" ON "Booking"("tripId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Booking_userId_idx" ON "Booking"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Payment_externalOrderId_key" ON "Payment"("externalOrderId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Payment_bookingId_status_idx" ON "Payment"("bookingId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Payment_provider_status_idx" ON "Payment"("provider", "status");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_tripId_fkey" FOREIGN KEY ("tripId") REFERENCES "Trip"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_participantId_fkey" FOREIGN KEY ("participantId") REFERENCES "TripParticipant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Payment" ADD CONSTRAINT "Payment_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
+136
-42
@@ -8,38 +8,39 @@ datasource db {
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
email String @unique
|
||||
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?
|
||||
password String?
|
||||
image String?
|
||||
/// Diisi PrismaAdapter NextAuth saat email diverifikasi provider OAuth (Google selalu sudah verified).
|
||||
emailVerified DateTime?
|
||||
emailVerified DateTime?
|
||||
/// Apakah user telah menyetujui Syarat & Ketentuan dan Kebijakan Privasi
|
||||
acceptedTermsAndPrivacy Boolean @default(false)
|
||||
acceptedTermsAndPrivacy Boolean @default(false)
|
||||
/// Waktu user menyetujui Syarat & Ketentuan dan Kebijakan Privasi
|
||||
acceptedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
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")
|
||||
organizerVerification OrganizerVerification? @relation("OrganizerVerificationOwner")
|
||||
reviewedVerifications OrganizerVerification[] @relation("OrganizerVerificationReviewer")
|
||||
|
||||
profile UserProfile?
|
||||
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)
|
||||
id String @id @default(cuid())
|
||||
userId String @unique
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
/// Bio singkat, teks bebas
|
||||
bio String?
|
||||
@@ -89,13 +90,13 @@ model OrganizerVerification {
|
||||
user User @relation("OrganizerVerificationOwner", fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
/// Nama lengkap sesuai KTP
|
||||
fullName String
|
||||
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
|
||||
nikHash String @unique
|
||||
birthDate DateTime
|
||||
address String
|
||||
|
||||
/// Storage key foto KTP (mis. `ktp/<id>.jpg`). File disimpan terenkripsi di luar /public.
|
||||
ktpImageKey String
|
||||
@@ -111,7 +112,7 @@ model OrganizerVerification {
|
||||
rejectionReason String?
|
||||
reviewedAt DateTime?
|
||||
reviewedById String?
|
||||
reviewedBy User? @relation("OrganizerVerificationReviewer", fields: [reviewedById], references: [id])
|
||||
reviewedBy User? @relation("OrganizerVerificationReviewer", fields: [reviewedById], references: [id])
|
||||
verifiedAt DateTime?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
@@ -125,7 +126,7 @@ enum VerificationStatus {
|
||||
}
|
||||
|
||||
model Trip {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
description String?
|
||||
/// Kategori aktivitas.
|
||||
@@ -147,16 +148,17 @@ model Trip {
|
||||
price Int
|
||||
/// Ritme/energi trip — dipakai untuk matching dengan vibe user.
|
||||
vibe Vibe?
|
||||
status TripStatus @default(OPEN)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
status TripStatus @default(OPEN)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
organizerId String
|
||||
organizer User @relation(fields: [organizerId], references: [id])
|
||||
organizer User @relation(fields: [organizerId], references: [id])
|
||||
|
||||
participants TripParticipant[]
|
||||
images TripImage[]
|
||||
reviews TripReview[]
|
||||
bookings Booking[]
|
||||
|
||||
@@index([category, status, date])
|
||||
@@index([vibe, status, date])
|
||||
@@ -170,38 +172,42 @@ model TripReview {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tripId String
|
||||
trip Trip @relation(fields: [tripId], references: [id], onDelete: Cascade)
|
||||
trip Trip @relation(fields: [tripId], references: [id], onDelete: Cascade)
|
||||
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
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)
|
||||
id String @id @default(cuid())
|
||||
url String
|
||||
caption String?
|
||||
order Int @default(0)
|
||||
|
||||
tripId String
|
||||
trip Trip @relation(fields: [tripId], references: [id], onDelete: Cascade)
|
||||
trip Trip @relation(fields: [tripId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model TripParticipant {
|
||||
id String @id @default(cuid())
|
||||
status ParticipantStatus @default(PENDING)
|
||||
createdAt DateTime @default(now())
|
||||
/// Peserta menekan "Saya sudah bayar" (pembayaran manual)
|
||||
markedPaidAt DateTime?
|
||||
/// Organizer mengonfirmasi uang sudah masuk
|
||||
paymentConfirmedAt DateTime?
|
||||
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])
|
||||
trip Trip @relation(fields: [tripId], references: [id])
|
||||
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
booking Booking?
|
||||
|
||||
@@unique([tripId, userId])
|
||||
}
|
||||
@@ -231,3 +237,91 @@ enum ParticipantStatus {
|
||||
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[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([tripId, status])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
enum BookingStatus {
|
||||
PENDING
|
||||
AWAITING_PAY
|
||||
PAID
|
||||
CANCELLED
|
||||
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?
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user