227 lines
7.2 KiB
TypeScript
227 lines
7.2 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";
|
|
import { isTripDepartureDayPast } from "@/lib/trip-dates";
|
|
import { payoutService } from "@/server/services/payout.service";
|
|
|
|
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, date: 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)"
|
|
);
|
|
}
|
|
if (isTripDepartureDayPast(booking.trip.date)) {
|
|
throw new Error(
|
|
"Trip sudah lewat tanggal berangkat — pembayaran ditutup"
|
|
);
|
|
}
|
|
|
|
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 },
|
|
});
|
|
|
|
// Escrow: tahan uang di Payout HELD sampai trip selesai + buffer.
|
|
await payoutService.createForPaidBooking(tx, { bookingId });
|
|
|
|
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);
|
|
},
|
|
};
|