Files
setrip/server/services/booking.service.ts
T
2026-05-08 20:59:01 +07:00

217 lines
6.7 KiB
TypeScript

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);
},
};