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