import { randomBytes } from "crypto"; import { Prisma } from "@/app/generated/prisma/client"; import { prisma } from "@/lib/prisma"; import { refundRepo } from "@/server/repositories/refund.repo"; import { calculateRefundAmount, daysUntilDeparture } from "@/lib/refund-policy"; import { isTripDepartureDayPast } from "@/lib/trip-dates"; 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" ); } async function runSerializable(fn: (tx: Prisma.TransactionClient) => Promise): Promise { let lastErr: unknown; for (let attempt = 0; attempt < SERIAL_TX_ATTEMPTS; attempt++) { try { return await prisma.$transaction(fn, { 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 memproses refund. Coba lagi sebentar."); } function newIdempotencyKey(): string { return `refund_${randomBytes(16).toString("hex")}`; } type RequestRefundInput = { bookingId: string; reason: | "USER_CANCELLATION" | "ORGANIZER_CANCELLED" | "TRIP_ISSUE" | "ADMIN_ADJUSTMENT" | "DISPUTE_RESOLVED" | "OTHER"; reportedBy: "PARTICIPANT" | "ORGANIZER"; reportNote: string; /** Nominal refund (IDR). Kalau tidak diisi → service akan pakai sisa * refundable amount (payment.amount - sudah-di-refund). */ amount?: number; /** Admin yang memasukkan laporan ke sistem. */ initiatedByAdminId: string; }; export const refundService = { /** * Admin mencatat laporan refund dari peserta atau organizer ke sistem. * Status awal: PENDING. Belum mengubah Booking/Payment status. * * Idempotency: kalau booking masih punya refund PENDING/APPROVED/PROCESSING, * tolak — admin harus selesaikan yang lama dulu (reject atau succeeded). */ async requestRefund(input: RequestRefundInput) { return runSerializable(async (tx) => { const booking = await tx.booking.findUnique({ where: { id: input.bookingId }, include: { payments: { where: { status: "PAID" }, orderBy: { paidAt: "desc" }, take: 1, }, }, }); if (!booking) { throw new Error("Booking tidak ditemukan"); } if (booking.amount <= 0) { throw new Error("Booking gratis — tidak ada nominal untuk di-refund"); } if (booking.status === "CANCELLED" || booking.status === "EXPIRED") { throw new Error( "Booking sudah dibatalkan/expired — tidak ada pembayaran untuk di-refund" ); } if (booking.status === "REFUNDED") { throw new Error("Booking sudah refund penuh"); } const paidPayment = booking.payments[0]; if (!paidPayment) { throw new Error( "Tidak ada Payment dengan status PAID di booking ini — tidak bisa di-refund" ); } const hasActive = await refundRepo.hasActiveRefund(input.bookingId, tx); if (hasActive) { throw new Error( "Booking ini masih punya refund yang sedang diproses. Selesaikan dulu sebelum membuat yang baru." ); } const alreadyRefunded = await refundRepo.sumSucceededAmount(input.bookingId, tx); const remaining = paidPayment.amount - alreadyRefunded; if (remaining <= 0) { throw new Error("Seluruh nominal sudah di-refund"); } const amount = input.amount ?? remaining; if (!Number.isInteger(amount) || amount <= 0) { throw new Error("Nominal refund harus bilangan bulat positif"); } if (amount > remaining) { throw new Error( `Nominal refund (Rp ${amount.toLocaleString("id-ID")}) melebihi sisa yang bisa di-refund (Rp ${remaining.toLocaleString("id-ID")})` ); } return refundRepo.create( { bookingId: input.bookingId, paymentId: paidPayment.id, amount, reason: input.reason, reportedBy: input.reportedBy, reportNote: input.reportNote, initiatedBy: "ADMIN", idempotencyKey: newIdempotencyKey(), }, tx ); }); }, /** PENDING → APPROVED. Boleh menambah catatan admin (opsional). */ async approveRefund(input: { refundId: string; adminId: string; adminNote?: string }) { return runSerializable(async (tx) => { const refund = await tx.refund.findUnique({ where: { id: input.refundId } }); if (!refund) { throw new Error("Refund tidak ditemukan"); } if (refund.status !== "PENDING") { throw new Error("Hanya refund berstatus PENDING yang bisa disetujui"); } return refundRepo.update( input.refundId, { status: "APPROVED", reviewedById: input.adminId, reviewedAt: new Date(), adminNote: input.adminNote ?? refund.adminNote, }, tx ); }); }, /** PENDING → REJECTED. Alasan wajib supaya audit trail jelas. */ async rejectRefund(input: { refundId: string; adminId: string; adminNote: string }) { if (!input.adminNote.trim()) { throw new Error("Alasan tolak wajib diisi"); } return runSerializable(async (tx) => { const refund = await tx.refund.findUnique({ where: { id: input.refundId } }); if (!refund) { throw new Error("Refund tidak ditemukan"); } if (refund.status !== "PENDING") { throw new Error("Hanya refund berstatus PENDING yang bisa ditolak"); } return refundRepo.update( input.refundId, { status: "REJECTED", reviewedById: input.adminId, reviewedAt: new Date(), adminNote: input.adminNote.trim(), }, tx ); }); }, /** * APPROVED → SUCCEEDED untuk manual transfer (admin sudah transfer manual * ke rekening peserta). adminNote diharapkan berisi referensi transfer. * * Side effects: * - Update Payment.status → REFUNDED hanya saat full refund. * - Update Booking.status → REFUNDED (full) atau PARTIALLY_REFUNDED (partial). * - Untuk USER_CANCELLATION: bebaskan slot — set TripParticipant → CANCELLED * dan re-open Trip (FULL → OPEN) kalau peserta aktif < maxParticipants. * Untuk ORGANIZER_CANCELLED slot tidak perlu dibebaskan (trip sudah CLOSED). */ async markSucceededManual(input: { refundId: string; adminId: string; adminNote: string; }) { if (!input.adminNote.trim()) { throw new Error("Catatan/referensi transfer wajib diisi"); } return runSerializable(async (tx) => { const refund = await tx.refund.findUnique({ where: { id: input.refundId }, }); if (!refund) { throw new Error("Refund tidak ditemukan"); } if (refund.status !== "APPROVED") { throw new Error( "Hanya refund APPROVED yang bisa ditandai SUCCEEDED. Setujui dulu." ); } const now = new Date(); await refundRepo.update( input.refundId, { status: "SUCCEEDED", succeededAt: now, reviewedById: input.adminId, reviewedAt: now, adminNote: input.adminNote.trim(), }, tx ); const totalRefunded = await refundRepo.sumSucceededAmount( refund.bookingId, tx ); if (refund.paymentId) { const payment = await tx.payment.findUnique({ where: { id: refund.paymentId }, }); if (payment && totalRefunded >= payment.amount) { await tx.payment.update({ where: { id: payment.id }, data: { status: "REFUNDED" }, }); } } const booking = await tx.booking.findUnique({ where: { id: refund.bookingId }, include: { trip: { select: { id: true, status: true, maxParticipants: true } }, payments: { where: { status: { in: ["PAID", "REFUNDED"] } }, orderBy: { paidAt: "desc" }, take: 1, }, }, }); if (!booking) { throw new Error("Booking tidak ditemukan saat menutup refund"); } const paid = booking.payments[0]; if (paid) { const nextStatus = totalRefunded >= paid.amount ? "REFUNDED" : "PARTIALLY_REFUNDED"; if (booking.status !== nextStatus) { await tx.booking.update({ where: { id: booking.id }, data: { status: nextStatus }, }); } } // Slot release untuk user cancellation. Organizer cancel di-handle // closeTrip (participant + trip sudah di-CANCELLED/CLOSED di sana). if (refund.reason === "USER_CANCELLATION") { await tx.tripParticipant.updateMany({ where: { id: booking.participantId, status: { not: "CANCELLED" }, }, data: { status: "CANCELLED", markedPaidAt: null, paymentConfirmedAt: null, }, }); if (booking.trip.status === "FULL") { const remaining = await tx.tripParticipant.count({ where: { tripId: booking.tripId, status: { not: "CANCELLED" } }, }); if (remaining < booking.trip.maxParticipants) { await tx.trip.update({ where: { id: booking.tripId }, data: { status: "OPEN" }, }); } } } return { ok: true as const }; }); }, /** * Peserta cancel booking sendiri. Hitung refund pakai policy default * (lib/refund-policy.ts) — hardcoded MVP, akan jadi data-driven di R-5. * * Behaviour: * - Kalau hasil hitung = 0 (di luar window): cancel participant + booking * langsung, tanpa Refund row (uang tidak balik). * - Kalau hasil hitung > 0: buat Refund PENDING (initiatedBy=USER, * reportedBy=PARTICIPANT, reason=USER_CANCELLATION). Participant + booking * TETAP CONFIRMED/PAID sampai admin mark SUCCEEDED — slot baru bebas saat * refund tuntas. Cegah double-request via hasActiveRefund. */ async requestUserCancellation(input: { bookingId: string; userId: string; }) { return runSerializable(async (tx) => { const booking = await tx.booking.findUnique({ where: { id: input.bookingId }, include: { trip: { select: { id: true, date: true, status: true, maxParticipants: true, }, }, payments: { where: { status: "PAID" }, orderBy: { paidAt: "desc" }, take: 1, }, }, }); if (!booking) { throw new Error("Booking tidak ditemukan"); } if (booking.userId !== input.userId) { throw new Error("Booking ini bukan milikmu"); } if (booking.status !== "PAID") { throw new Error( "Hanya booking PAID yang bisa cancel dengan refund. Booking yang belum lunas bisa cancel dari tombol 'Batal Ikut'." ); } if (isTripDepartureDayPast(booking.trip.date)) { throw new Error( "Trip sudah lewat tanggal berangkat — pembatalan ditutup" ); } const paid = booking.payments[0]; if (!paid) { throw new Error( "Tidak ada Payment dengan status PAID di booking ini" ); } const days = daysUntilDeparture(booking.trip.date); const refundAmount = calculateRefundAmount(paid.amount, days); if (refundAmount === 0) { await tx.tripParticipant.update({ where: { id: booking.participantId }, data: { status: "CANCELLED", markedPaidAt: null, paymentConfirmedAt: null, }, }); await tx.booking.update({ where: { id: booking.id }, data: { status: "CANCELLED" }, }); if (booking.trip.status === "FULL") { const remaining = await tx.tripParticipant.count({ where: { tripId: booking.tripId, status: { not: "CANCELLED" } }, }); if (remaining < booking.trip.maxParticipants) { await tx.trip.update({ where: { id: booking.tripId }, data: { status: "OPEN" }, }); } } return { kind: "CANCELLED_NO_REFUND" as const, days, refundAmount: 0, }; } const hasActive = await refundRepo.hasActiveRefund(input.bookingId, tx); if (hasActive) { throw new Error( "Booking ini sudah punya refund yang sedang diproses" ); } const percentage = Math.floor((refundAmount * 100) / paid.amount); const refund = await refundRepo.create( { bookingId: booking.id, paymentId: paid.id, amount: refundAmount, reason: "USER_CANCELLATION", reportedBy: "PARTICIPANT", reportNote: `Self-service cancel oleh peserta — H-${days} dari tanggal berangkat (refund ${percentage}%).`, initiatedBy: "USER", idempotencyKey: newIdempotencyKey(), }, tx ); return { kind: "REFUND_PENDING" as const, refundId: refund.id, days, refundAmount, }; }); }, /** * Dipanggil dari tripService.closeTrip (organizer cancel trip) dengan tx * yang sama. Buat Refund auto-approved untuk satu booking PAID. Tidak * mengecek hasActiveRefund (caller harus filter dulu) supaya batch closeTrip * idempotent dengan retry-safe. * * Refund langsung APPROVED — policy jelas (organizer cancel = 100% refund), * tapi eksekusi (SUCCEEDED) tetap manual oleh admin. */ async createSystemRefundForClosedTrip( tx: Prisma.TransactionClient, input: { bookingId: string; paymentId: string; amount: number; } ) { const now = new Date(); return tx.refund.create({ data: { bookingId: input.bookingId, paymentId: input.paymentId, amount: input.amount, reason: "ORGANIZER_CANCELLED", reportedBy: "ORGANIZER", reportNote: "Organizer membatalkan trip — auto-create oleh SYSTEM.", initiatedBy: "SYSTEM", idempotencyKey: newIdempotencyKey(), status: "APPROVED", reviewedAt: now, adminNote: "Auto-approved (SYSTEM): organizer cancel = full refund.", }, }); }, /** * APPROVED/PROCESSING → FAILED. Catatan wajib (alasan gagal). * Tidak mengubah Booking/Payment — uang belum keluar. */ async markFailed(input: { refundId: string; adminId: string; adminNote: string }) { if (!input.adminNote.trim()) { throw new Error("Alasan gagal wajib diisi"); } return runSerializable(async (tx) => { const refund = await tx.refund.findUnique({ where: { id: input.refundId } }); if (!refund) { throw new Error("Refund tidak ditemukan"); } if (refund.status !== "APPROVED" && refund.status !== "PROCESSING") { throw new Error( "Hanya refund APPROVED atau PROCESSING yang bisa ditandai FAILED" ); } return refundRepo.update( input.refundId, { status: "FAILED", failedAt: new Date(), reviewedById: input.adminId, reviewedAt: new Date(), adminNote: input.adminNote.trim(), }, tx ); }); }, };