add booking and payment schema
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { Prisma } from "@/app/generated/prisma/client";
|
||||
|
||||
export const bookingRepo = {
|
||||
async findById(id: string) {
|
||||
return prisma.booking.findUnique({ where: { id } });
|
||||
},
|
||||
|
||||
async findByParticipantId(participantId: string) {
|
||||
return prisma.booking.findUnique({
|
||||
where: { participantId },
|
||||
include: { payments: { orderBy: { createdAt: "desc" } } },
|
||||
});
|
||||
},
|
||||
|
||||
async findByTripAndUser(tripId: string, userId: string) {
|
||||
return prisma.booking.findFirst({
|
||||
where: { tripId, userId },
|
||||
include: { payments: { orderBy: { createdAt: "desc" } } },
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Daftar booking di trip ini yang masih menunggu konfirmasi pembayaran
|
||||
* dari organizer (Payment MANUAL status AWAITING).
|
||||
*/
|
||||
async findAwaitingManualConfirmation(tripId: string) {
|
||||
return prisma.booking.findMany({
|
||||
where: {
|
||||
tripId,
|
||||
status: "AWAITING_PAY",
|
||||
payments: {
|
||||
some: { provider: "MANUAL", status: "AWAITING" },
|
||||
},
|
||||
},
|
||||
include: {
|
||||
participant: true,
|
||||
user: { select: { id: true, name: true, image: true } },
|
||||
payments: {
|
||||
where: { provider: "MANUAL", status: "AWAITING" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
orderBy: { updatedAt: "asc" },
|
||||
});
|
||||
},
|
||||
|
||||
async create(
|
||||
data: Pick<
|
||||
Prisma.BookingUncheckedCreateInput,
|
||||
"tripId" | "userId" | "participantId" | "amount" | "status"
|
||||
>
|
||||
) {
|
||||
return prisma.booking.create({ data });
|
||||
},
|
||||
|
||||
async updateStatus(id: string, status: Prisma.BookingUpdateInput["status"]) {
|
||||
return prisma.booking.update({
|
||||
where: { id },
|
||||
data: { status },
|
||||
});
|
||||
},
|
||||
|
||||
async updateStatusByParticipantId(
|
||||
participantId: string,
|
||||
status: Prisma.BookingUpdateInput["status"]
|
||||
) {
|
||||
return prisma.booking.updateMany({
|
||||
where: { participantId },
|
||||
data: { status },
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { Prisma } from "@/app/generated/prisma/client";
|
||||
|
||||
export const paymentRepo = {
|
||||
async findById(id: string) {
|
||||
return prisma.payment.findUnique({ where: { id } });
|
||||
},
|
||||
|
||||
async findByExternalOrderId(externalOrderId: string) {
|
||||
return prisma.payment.findUnique({ where: { externalOrderId } });
|
||||
},
|
||||
|
||||
async findActiveManualForBooking(bookingId: string) {
|
||||
return prisma.payment.findFirst({
|
||||
where: {
|
||||
bookingId,
|
||||
provider: "MANUAL",
|
||||
status: { in: ["PENDING", "AWAITING"] },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
},
|
||||
|
||||
async create(
|
||||
data: Pick<
|
||||
Prisma.PaymentUncheckedCreateInput,
|
||||
| "bookingId"
|
||||
| "provider"
|
||||
| "externalOrderId"
|
||||
| "amount"
|
||||
| "status"
|
||||
| "method"
|
||||
| "expiresAt"
|
||||
>
|
||||
) {
|
||||
return prisma.payment.create({ data });
|
||||
},
|
||||
|
||||
async update(id: string, data: Prisma.PaymentUpdateInput) {
|
||||
return prisma.payment.update({ where: { id }, data });
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,216 @@
|
||||
import { Prisma } from "@/app/generated/prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { bookingRepo } from "@/server/repositories/booking.repo";
|
||||
import { paymentRepo } from "@/server/repositories/payment.repo";
|
||||
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
function manualOrderId(bookingId: string): string {
|
||||
return `manual-${bookingId}`;
|
||||
}
|
||||
|
||||
export const bookingService = {
|
||||
async getByParticipantId(participantId: string) {
|
||||
return bookingRepo.findByParticipantId(participantId);
|
||||
},
|
||||
|
||||
async getByTripAndUser(tripId: string, userId: string) {
|
||||
return bookingRepo.findByTripAndUser(tripId, userId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Peserta tandai sudah transfer manual. Idempotent: kalau sudah ada Payment
|
||||
* MANUAL aktif, biarkan; kalau Booking sudah PAID, tolak.
|
||||
*/
|
||||
async markPaidManual(bookingId: string, userId: string) {
|
||||
let lastErr: unknown;
|
||||
for (let attempt = 0; attempt < SERIAL_TX_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
return await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const booking = await tx.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
include: { trip: { select: { price: true } } },
|
||||
});
|
||||
if (!booking) {
|
||||
throw new Error("Booking tidak ditemukan");
|
||||
}
|
||||
if (booking.userId !== userId) {
|
||||
throw new Error("Booking ini bukan milikmu");
|
||||
}
|
||||
if (booking.amount <= 0) {
|
||||
throw new Error(
|
||||
"Booking ini tidak butuh pembayaran (gratis)"
|
||||
);
|
||||
}
|
||||
if (booking.status === "PAID") {
|
||||
throw new Error("Pembayaran sudah dikonfirmasi");
|
||||
}
|
||||
if (booking.status !== "AWAITING_PAY") {
|
||||
throw new Error(
|
||||
"Booking belum siap menerima pembayaran (tunggu approve organizer)"
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await tx.payment.findFirst({
|
||||
where: {
|
||||
bookingId,
|
||||
provider: "MANUAL",
|
||||
status: { in: ["PENDING", "AWAITING"] },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
if (existing && existing.status === "AWAITING") {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const payment = existing
|
||||
? await tx.payment.update({
|
||||
where: { id: existing.id },
|
||||
data: { status: "AWAITING" },
|
||||
})
|
||||
: await tx.payment.create({
|
||||
data: {
|
||||
bookingId,
|
||||
provider: "MANUAL",
|
||||
externalOrderId: manualOrderId(bookingId),
|
||||
amount: booking.amount,
|
||||
status: "AWAITING",
|
||||
},
|
||||
});
|
||||
|
||||
// Backward-compat: tetap update timestamp di TripParticipant
|
||||
// selama UI lama masih membaca kolom ini.
|
||||
await tx.tripParticipant.update({
|
||||
where: { id: booking.participantId },
|
||||
data: { markedPaidAt: new Date() },
|
||||
});
|
||||
|
||||
return payment;
|
||||
},
|
||||
{
|
||||
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 menandai pembayaran. Coba lagi sebentar.");
|
||||
},
|
||||
|
||||
/**
|
||||
* Organizer konfirmasi pembayaran manual masuk.
|
||||
* Idempotent: kalau sudah PAID, tolak (UI lama bisa muncul tombol dua kali).
|
||||
*/
|
||||
async confirmPaidManual(bookingId: string, organizerId: string) {
|
||||
let lastErr: unknown;
|
||||
for (let attempt = 0; attempt < SERIAL_TX_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
return await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const booking = await tx.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
include: { trip: { select: { organizerId: true, price: true } } },
|
||||
});
|
||||
if (!booking) {
|
||||
throw new Error("Booking tidak ditemukan");
|
||||
}
|
||||
if (booking.trip.organizerId !== organizerId) {
|
||||
throw new Error(
|
||||
"Hanya organizer trip ini yang bisa mengonfirmasi pembayaran"
|
||||
);
|
||||
}
|
||||
if (booking.amount <= 0) {
|
||||
throw new Error(
|
||||
"Booking ini gratis — tidak ada pembayaran yang perlu dikonfirmasi"
|
||||
);
|
||||
}
|
||||
if (booking.status === "PAID") {
|
||||
throw new Error("Pembayaran sudah dikonfirmasi sebelumnya");
|
||||
}
|
||||
|
||||
const awaitingPayment = await tx.payment.findFirst({
|
||||
where: {
|
||||
bookingId,
|
||||
provider: "MANUAL",
|
||||
status: "AWAITING",
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
if (!awaitingPayment) {
|
||||
throw new Error(
|
||||
"Peserta belum menandai sudah membayar"
|
||||
);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
await tx.payment.update({
|
||||
where: { id: awaitingPayment.id },
|
||||
data: {
|
||||
status: "PAID",
|
||||
paidAt: now,
|
||||
method: "manual_transfer",
|
||||
},
|
||||
});
|
||||
|
||||
await tx.booking.update({
|
||||
where: { id: bookingId },
|
||||
data: { status: "PAID" },
|
||||
});
|
||||
|
||||
// Backward-compat: tetap update timestamp di TripParticipant.
|
||||
await tx.tripParticipant.update({
|
||||
where: { id: booking.participantId },
|
||||
data: { paymentConfirmedAt: now },
|
||||
});
|
||||
|
||||
return { ok: true as const };
|
||||
},
|
||||
{
|
||||
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 mengonfirmasi pembayaran. Coba lagi sebentar.");
|
||||
},
|
||||
|
||||
/**
|
||||
* Daftar booking yang masih menunggu konfirmasi organizer di trip tertentu.
|
||||
* Dipakai OrganizerPaymentQueue.
|
||||
*/
|
||||
async getAwaitingManualForTrip(tripId: string) {
|
||||
return bookingRepo.findAwaitingManualConfirmation(tripId);
|
||||
},
|
||||
};
|
||||
@@ -3,6 +3,8 @@ import type { ActivityCategory, Vibe } from "@/app/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
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 { LIMITS } from "@/lib/limits";
|
||||
import { utcStartOfDay, isTripDepartureDayPast } from "@/lib/trip-dates";
|
||||
import { isFreeTrip } from "@/lib/trip-pricing";
|
||||
@@ -140,6 +142,7 @@ export const tripService = {
|
||||
date: true,
|
||||
organizerId: true,
|
||||
maxParticipants: true,
|
||||
price: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -186,6 +189,22 @@ export const tripService = {
|
||||
data: { tripId, userId, status: "PENDING" },
|
||||
});
|
||||
|
||||
// Booking 1-1 ke participant. Upsert untuk handle re-join setelah CANCELLED.
|
||||
await tx.booking.upsert({
|
||||
where: { participantId: participant.id },
|
||||
create: {
|
||||
tripId,
|
||||
userId,
|
||||
participantId: participant.id,
|
||||
amount: trip.price,
|
||||
status: "PENDING",
|
||||
},
|
||||
update: {
|
||||
status: "PENDING",
|
||||
amount: trip.price,
|
||||
},
|
||||
});
|
||||
|
||||
const newCount = await tx.tripParticipant.count({
|
||||
where: { tripId, status: { not: "CANCELLED" } },
|
||||
});
|
||||
@@ -234,7 +253,21 @@ export const tripService = {
|
||||
throw new Error("Kamu tidak terdaftar di trip ini");
|
||||
}
|
||||
|
||||
const result = await participantRepo.cancel(tripId, userId);
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const cancelled = await tx.tripParticipant.update({
|
||||
where: { tripId_userId: { tripId, userId } },
|
||||
data: {
|
||||
status: "CANCELLED",
|
||||
markedPaidAt: null,
|
||||
paymentConfirmedAt: null,
|
||||
},
|
||||
});
|
||||
await tx.booking.updateMany({
|
||||
where: { participantId: existing.id },
|
||||
data: { status: "CANCELLED" },
|
||||
});
|
||||
return cancelled;
|
||||
});
|
||||
|
||||
if (trip.status === "FULL") {
|
||||
const count = await participantRepo.countByTrip(tripId);
|
||||
@@ -267,7 +300,19 @@ export const tripService = {
|
||||
throw new Error("Peserta ini tidak dalam status menunggu persetujuan");
|
||||
}
|
||||
|
||||
return participantRepo.setStatus(participantId, "CONFIRMED");
|
||||
// Trip gratis: Booking langsung PAID. Trip berbayar: AWAITING_PAY (tinggal bayar).
|
||||
const nextBookingStatus = isFreeTrip(trip) ? "PAID" : "AWAITING_PAY";
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
await tx.booking.updateMany({
|
||||
where: { participantId },
|
||||
data: { status: nextBookingStatus },
|
||||
});
|
||||
return tx.tripParticipant.update({
|
||||
where: { id: participantId },
|
||||
data: { status: "CONFIRMED" },
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async rejectParticipant(
|
||||
@@ -291,10 +336,20 @@ export const tripService = {
|
||||
throw new Error("Hanya permintaan yang masih menunggu yang bisa ditolak");
|
||||
}
|
||||
|
||||
await participantRepo.setStatusAndClearPayment(
|
||||
participantId,
|
||||
"CANCELLED"
|
||||
);
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.tripParticipant.update({
|
||||
where: { id: participantId },
|
||||
data: {
|
||||
status: "CANCELLED",
|
||||
markedPaidAt: null,
|
||||
paymentConfirmedAt: null,
|
||||
},
|
||||
});
|
||||
await tx.booking.updateMany({
|
||||
where: { participantId },
|
||||
data: { status: "CANCELLED" },
|
||||
});
|
||||
});
|
||||
|
||||
if (trip.status === "FULL") {
|
||||
const count = await participantRepo.countByTrip(tripId);
|
||||
@@ -322,40 +377,12 @@ export const tripService = {
|
||||
throw new Error("Trip sudah lewat — pembayaran tidak perlu ditandai");
|
||||
}
|
||||
|
||||
const p = await participantRepo.findByTripAndUser(tripId, userId);
|
||||
if (!p || p.status === "CANCELLED") {
|
||||
const booking = await bookingRepo.findByTripAndUser(tripId, userId);
|
||||
if (!booking || booking.status === "CANCELLED") {
|
||||
throw new Error("Kamu tidak terdaftar di trip ini");
|
||||
}
|
||||
|
||||
if (p.paymentConfirmedAt) {
|
||||
throw new Error("Pembayaran kamu sudah dikonfirmasi organizer");
|
||||
}
|
||||
if (p.markedPaidAt) {
|
||||
throw new Error("Kamu sudah menandai sudah bayar — tunggu konfirmasi organizer");
|
||||
}
|
||||
|
||||
const updated = await participantRepo.tryMarkPaidByUser(tripId, userId);
|
||||
if (updated.count === 0) {
|
||||
const again = await participantRepo.findByTripAndUser(tripId, userId);
|
||||
if (!again || again.status === "CANCELLED") {
|
||||
throw new Error("Kamu tidak terdaftar di trip ini");
|
||||
}
|
||||
if (again.paymentConfirmedAt) {
|
||||
throw new Error("Pembayaran kamu sudah dikonfirmasi organizer");
|
||||
}
|
||||
if (again.markedPaidAt) {
|
||||
throw new Error(
|
||||
"Kamu sudah menandai sudah bayar — tunggu konfirmasi organizer"
|
||||
);
|
||||
}
|
||||
throw new Error("Tidak bisa menandai pembayaran. Coba lagi sebentar.");
|
||||
}
|
||||
|
||||
const row = await participantRepo.findByTripAndUser(tripId, userId);
|
||||
if (!row) {
|
||||
throw new Error("Data peserta tidak ditemukan setelah update");
|
||||
}
|
||||
return row;
|
||||
return bookingService.markPaidManual(booking.id, userId);
|
||||
},
|
||||
|
||||
async confirmParticipantPayment(
|
||||
@@ -374,29 +401,11 @@ export const tripService = {
|
||||
throw new Error("Trip ini gratis — tidak ada pembayaran yang perlu dikonfirmasi");
|
||||
}
|
||||
|
||||
const participant = await participantRepo.findById(participantId);
|
||||
if (!participant || participant.tripId !== tripId) {
|
||||
throw new Error("Peserta tidak ditemukan");
|
||||
}
|
||||
if (participant.status === "CANCELLED") {
|
||||
throw new Error("Peserta sudah tidak aktif");
|
||||
}
|
||||
if (!participant.markedPaidAt) {
|
||||
throw new Error("Peserta belum menandai sudah membayar");
|
||||
}
|
||||
if (participant.paymentConfirmedAt) {
|
||||
throw new Error("Pembayaran peserta ini sudah dikonfirmasi");
|
||||
const booking = await bookingRepo.findByParticipantId(participantId);
|
||||
if (!booking || booking.tripId !== tripId) {
|
||||
throw new Error("Booking tidak ditemukan");
|
||||
}
|
||||
|
||||
const updated = await participantRepo.tryConfirmPaymentByOrganizer(
|
||||
participantId
|
||||
);
|
||||
if (updated.count === 0) {
|
||||
throw new Error(
|
||||
"Konfirmasi tidak diproses — mungkin sudah dikonfirmasi atau pembayaran belum ditandai peserta."
|
||||
);
|
||||
}
|
||||
|
||||
return participantRepo.findById(participantId);
|
||||
return bookingService.confirmPaidManual(booking.id, organizerId);
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user