add booking and payment schema

This commit is contained in:
2026-05-08 20:59:01 +07:00
parent c9c4c0e683
commit 2223a4630e
23 changed files with 5618 additions and 184 deletions
+101
View File
@@ -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
View File
@@ -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
}