import { Prisma } from "@/app/generated/prisma/client"; import { prisma } from "@/lib/prisma"; import { payoutRepo } from "@/server/repositories/payout.repo"; import { emailService } from "@/lib/email/send"; 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 payout. Coba lagi sebentar."); } /** Buffer hari setelah trip selesai sebelum payout boleh ditransfer. */ export const PAYOUT_HOLD_BUFFER_DAYS = 3; /** Hitung heldUntil dari trip date. Pakai endDate kalau ada, kalau tidak pakai date. */ function computeHeldUntil(tripDate: Date, tripEndDate: Date | null): Date { const baseDate = tripEndDate ?? tripDate; const result = new Date(baseDate); result.setUTCDate(result.getUTCDate() + PAYOUT_HOLD_BUFFER_DAYS); return result; } export const payoutService = { /** * Dipanggil saat Booking → PAID (webhook Midtrans atau organizer confirm manual). * Idempotent: kalau Payout untuk booking ini sudah ada, no-op (return existing). * * Snapshot bank info dari OrganizerVerification (kalau ada) supaya audit-friendly * walau organizer ganti bank nanti. */ async createForPaidBooking( tx: Prisma.TransactionClient, input: { bookingId: string } ) { const booking = await tx.booking.findUnique({ where: { id: input.bookingId }, select: { id: true, amount: true, status: true, userId: true, trip: { select: { id: true, organizerId: true, date: true, endDate: true, }, }, }, }); if (!booking) { throw new Error("Booking tidak ditemukan saat membuat payout"); } if (booking.amount <= 0) { // Trip gratis — tidak ada uang yang perlu di-payout. return null; } const existing = await payoutRepo.findByBookingId(booking.id, tx); if (existing) { return existing; } const bankInfo = await tx.organizerVerification.findUnique({ where: { userId: booking.trip.organizerId }, select: { status: true, bankName: true, bankAccountNumber: true, bankAccountName: true, }, }); const heldUntil = computeHeldUntil(booking.trip.date, booking.trip.endDate); return payoutRepo.create( { bookingId: booking.id, tripId: booking.trip.id, organizerId: booking.trip.organizerId, amount: booking.amount, heldUntil, bankName: bankInfo?.status === "APPROVED" ? bankInfo.bankName : null, bankAccountNumber: bankInfo?.status === "APPROVED" ? bankInfo.bankAccountNumber : null, bankAccountName: bankInfo?.status === "APPROVED" ? bankInfo.bankAccountName : null, }, tx ); }, /** * Cron-callable: cari semua HELD payout yang sudah lewat heldUntil & trip-nya * COMPLETED, lalu flip ke RELEASED. Idempotent. */ async releaseEligible() { const now = new Date(); const eligible = await payoutRepo.findEligibleForRelease(now); if (eligible.length === 0) { return { releasedIds: [] as string[] }; } const ids = eligible.map((p) => p.id); await prisma.payout.updateMany({ where: { id: { in: ids }, status: "HELD" }, data: { status: "RELEASED", releasedAt: now }, }); // E3.6 — kabari organizer payout-nya sudah lepas hold & masuk antrian // transfer. Pin ke `releasedAt: now` supaya hanya yang baru di-release. const released = await prisma.payout.findMany({ where: { id: { in: ids }, status: "RELEASED", releasedAt: now }, select: { id: true, amount: true, organizer: { select: { email: true, name: true } }, trip: { select: { title: true } }, }, }); for (const p of released) { void emailService.send({ to: p.organizer.email, idempotencyKey: `payout_released-${p.id}`, template: { template: "payout_released", data: { organizerName: p.organizer.name, tripTitle: p.trip.title, amount: p.amount, }, }, }); } return { releasedIds: ids }; }, /** RELEASED → PAID. Catatan/referensi transfer wajib (audit trail). */ async markPaid(input: { payoutId: string; adminId: string; adminNote: string }) { if (!input.adminNote.trim()) { throw new Error("Catatan/referensi transfer wajib diisi"); } return runSerializable(async (tx) => { const payout = await tx.payout.findUnique({ where: { id: input.payoutId }, }); if (!payout) { throw new Error("Payout tidak ditemukan"); } if (payout.status !== "RELEASED") { throw new Error( "Hanya payout RELEASED yang bisa ditandai PAID. Tunggu trip selesai + buffer." ); } return payoutRepo.update( input.payoutId, { status: "PAID", paidAt: new Date(), processedById: input.adminId, adminNote: input.adminNote.trim(), }, tx ); }); }, /** * Cancel payout — biasanya dipanggil internal saat refund SUCCEEDED penuh * atau trip dibatalkan. Tidak boleh untuk payout yang sudah PAID (uang sudah * keluar ke organizer; admin perlu clawback manual). */ async cancel( tx: Prisma.TransactionClient, input: { payoutId: string; reason: string; adminId?: string | null } ) { const payout = await tx.payout.findUnique({ where: { id: input.payoutId }, }); if (!payout) return null; if (payout.status === "CANCELLED") return payout; if (payout.status === "PAID") { // Uang sudah ditransfer — tidak bisa undo otomatis. Catat note saja. return payoutRepo.update( input.payoutId, { adminNote: (payout.adminNote ? `${payout.adminNote}\n---\n` : "") + `[!] ${input.reason} setelah PAID — perlu clawback manual.`, }, tx ); } return payoutRepo.update( input.payoutId, { status: "CANCELLED", cancelledAt: new Date(), processedById: input.adminId ?? null, adminNote: input.reason, }, tx ); }, /** * Refund SUCCEEDED — kurangi nominal payout sesuai nominal refund. Kalau * jatuh ke 0 atau lebih, cancel payout. Dipanggil dari refund.service. */ async applyRefundDelta( tx: Prisma.TransactionClient, input: { bookingId: string; refundAmount: number } ) { const payout = await payoutRepo.findByBookingId(input.bookingId, tx); if (!payout) return null; if (payout.status === "CANCELLED") return payout; if (payout.status === "PAID") { // Uang sudah ditransfer ke organizer — flag untuk clawback manual. return payoutRepo.update( payout.id, { adminNote: (payout.adminNote ? `${payout.adminNote}\n---\n` : "") + `[!] Refund Rp${input.refundAmount.toLocaleString("id-ID")} terjadi setelah payout PAID. Perlu clawback manual dari organizer.`, }, tx ); } const nextAmount = payout.amount - input.refundAmount; if (nextAmount <= 0) { return payoutRepo.update( payout.id, { status: "CANCELLED", cancelledAt: new Date(), adminNote: (payout.adminNote ? `${payout.adminNote}\n---\n` : "") + "Dibatalkan otomatis karena refund penuh.", }, tx ); } return payoutRepo.update(payout.id, { amount: nextAmount }, tx); }, };