- ✅
- ✅ - ✅ - ✅
This commit is contained in:
@@ -1,24 +1,4 @@
|
||||
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) {
|
||||
@@ -28,199 +8,4 @@ export const bookingService = {
|
||||
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);
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user