import { Prisma } from "@/app/generated/prisma/client"; import { prisma } from "@/lib/prisma"; import { MIDTRANS, createSnapTransaction, fetchMidtransTransactionStatus, mapMidtransStatus, verifyMidtransSignature, type MidtransTransactionStatus, type MidtransWebhookBody, } from "@/lib/midtrans"; import { isTripDepartureDayPast } from "@/lib/trip-dates"; import { payoutService } from "@/server/services/payout.service"; 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" ); } function midtransOrderId(bookingId: string, attempt: number): string { return `midtrans-${bookingId}-${attempt}`; } export interface StartMidtransResult { paymentId: string; snapToken: string; snapJsUrl: string; clientKey: string; expiresAt: Date; orderId: string; } export type ApplyOutcome = | { ok: true; status: "updated" | "skipped" | "ignored" | "booking_conflict" } | { ok: false; reason: "amount_mismatch" }; export type WebhookOutcome = | ApplyOutcome | { ok: false; reason: "signature_mismatch" }; /** * Bentuk minimum yang dibutuhkan oleh `applyGatewayStatus` — bisa berasal dari * webhook callback (Midtrans → kita) atau dari hasil GET /v2/{order_id}/status * (kita → Midtrans saat rekonsiliasi). Bedanya cuma asal dan apakah signature * perlu dicek. */ interface GatewayUpdatePayload { order_id: string; gross_amount: string; transaction_status: string; fraud_status?: string | null; transaction_id?: string; payment_type?: string; /** Snapshot mentah untuk audit trail di `Payment.rawCallback`. */ rawSource: Prisma.InputJsonValue; } /** * State machine: terjemahkan status dari gateway ke perubahan Payment + Booking * di DB. Idempotent: kalau payment sudah final, hanya update rawCallback. * Tidak melakukan auth — caller wajib pastikan source-nya terpercaya * (signature webhook valid, atau response Midtrans Core API). */ async function applyGatewayStatus( update: GatewayUpdatePayload ): Promise { const payment = await prisma.payment.findUnique({ where: { externalOrderId: update.order_id }, include: { booking: true }, }); if (!payment) { return { ok: true, status: "ignored" }; } const amountFromGateway = Math.round(Number(update.gross_amount)); if ( Number.isNaN(amountFromGateway) || amountFromGateway !== payment.amount ) { await prisma.payment.update({ where: { id: payment.id }, data: { rawCallback: update.rawSource, rejectionReason: `Amount mismatch: gateway=${update.gross_amount}, expected=${payment.amount}`, }, }); return { ok: false, reason: "amount_mismatch" }; } const finalStatuses = new Set([ "PAID", "FAILED", "EXPIRED", "CANCELLED", "REFUNDED", ]); if (finalStatuses.has(payment.status)) { await prisma.payment.update({ where: { id: payment.id }, data: { rawCallback: update.rawSource }, }); return { ok: true, status: "skipped" }; } const newStatus = mapMidtransStatus( update.transaction_status, update.fraud_status ?? null ); let lastErr: unknown; for (let attempt = 0; attempt < SERIAL_TX_ATTEMPTS; attempt++) { try { await prisma.$transaction( async (tx) => { const now = new Date(); const currentBooking = await tx.booking.findUnique({ where: { id: payment.bookingId }, select: { status: true, participantId: true }, }); const bookingInConflictState = currentBooking?.status === "CANCELLED" || currentBooking?.status === "REFUNDED" || currentBooking?.status === "EXPIRED"; const conflictNote = bookingInConflictState && newStatus === "PAID" ? `Gateway PAID but Booking is ${currentBooking.status}. Manual review required (potential refund).` : null; await tx.payment.update({ where: { id: payment.id }, data: { status: newStatus, externalTxId: update.transaction_id ?? null, method: update.payment_type ?? null, rawCallback: update.rawSource, paidAt: newStatus === "PAID" ? now : null, failedAt: newStatus === "FAILED" || newStatus === "EXPIRED" ? now : null, rejectionReason: conflictNote, }, }); if (newStatus === "PAID" && !bookingInConflictState) { await tx.booking.update({ where: { id: payment.bookingId }, data: { status: "PAID" }, }); await tx.tripParticipant.update({ where: { id: payment.booking.participantId }, data: { paymentConfirmedAt: now, markedPaidAt: now }, }); await payoutService.createForPaidBooking(tx, { bookingId: payment.bookingId, }); } }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable, maxWait: 5000, timeout: 15000, } ); const finalBooking = await prisma.booking.findUnique({ where: { id: payment.bookingId }, select: { status: true }, }); const isConflict = newStatus === "PAID" && finalBooking?.status !== "PAID"; // Notif email user kalau payment benar-benar berhasil di-apply ke booking. if (newStatus === "PAID" && !isConflict) { void notifyPaymentPaid(payment.id); } return { ok: true, status: isConflict ? "booking_conflict" : "updated", }; } catch (e) { lastErr = e; if (isSerializationConflict(e) && attempt < SERIAL_TX_ATTEMPTS - 1) { continue; } throw e; } } throw lastErr instanceof Error ? lastErr : new Error("Gagal apply status gateway karena konflik transaksi"); } export const paymentService = { /** * Mulai pembayaran Midtrans untuk Booking. Idempotent — kalau ada Payment * MIDTRANS aktif (status PENDING/AWAITING), reuse token yang sudah ada * selama belum expired. */ async startMidtransPayment( bookingId: string, userId: string, options?: { finishUrl?: string } ): Promise { const clientKey = process.env.NEXT_PUBLIC_MIDTRANS_CLIENT_KEY; if (!clientKey) { throw new Error("NEXT_PUBLIC_MIDTRANS_CLIENT_KEY belum di-set"); } const booking = await prisma.booking.findUnique({ where: { id: bookingId }, include: { user: { select: { id: true, name: true, email: true } }, trip: { select: { title: true, date: true } }, payments: { where: { provider: "MIDTRANS" }, orderBy: { createdAt: "desc" }, }, }, }); 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 gratis tidak butuh pembayaran online"); } if (booking.status === "PAID") { throw new Error("Booking sudah lunas"); } 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" ); } // Reuse Payment AWAITING/PENDING yang belum kadaluarsa, kalau ada. const now = new Date(); const reusable = booking.payments.find( (p) => (p.status === "PENDING" || p.status === "AWAITING") && p.snapToken && (!p.expiresAt || p.expiresAt.getTime() > now.getTime()) ); const snapJsUrl = MIDTRANS.snapJsUrl(); if (reusable && reusable.snapToken) { return { paymentId: reusable.id, snapToken: reusable.snapToken, snapJsUrl, clientKey, expiresAt: reusable.expiresAt ?? new Date(now.getTime() + 24 * 3600 * 1000), orderId: reusable.externalOrderId, }; } // Tutup attempt lama yang sudah expired (housekeeping ringan) const expiredIds = booking.payments .filter( (p) => (p.status === "PENDING" || p.status === "AWAITING") && p.expiresAt && p.expiresAt.getTime() <= now.getTime() ) .map((p) => p.id); if (expiredIds.length > 0) { await prisma.payment.updateMany({ where: { id: { in: expiredIds } }, data: { status: "EXPIRED" }, }); } const attemptNumber = booking.payments.length + 1; const orderId = midtransOrderId(booking.id, attemptNumber); const expirySeconds = 24 * 3600; // 24 jam, default Midtrans const expiresAt = new Date(now.getTime() + expirySeconds * 1000); // Create Payment row dulu (PENDING) supaya kalau call gagal, kita tetap punya audit trail. const payment = await prisma.payment.create({ data: { bookingId: booking.id, provider: "MIDTRANS", externalOrderId: orderId, amount: booking.amount, status: "PENDING", expiresAt, }, }); let snapResult; try { snapResult = await createSnapTransaction({ orderId, grossAmount: booking.amount, customer: { name: booking.user.name, email: booking.user.email, }, itemName: booking.trip.title, expirySeconds, finishUrl: options?.finishUrl, }); } catch (err) { // Roll back Payment ke FAILED supaya orderId tidak nyangkut PENDING selamanya. await prisma.payment.update({ where: { id: payment.id }, data: { status: "FAILED", failedAt: new Date(), rejectionReason: err instanceof Error ? err.message : "Snap API error", }, }); throw err; } const updated = await prisma.payment.update({ where: { id: payment.id }, data: { snapToken: snapResult.token, status: "AWAITING", }, }); return { paymentId: updated.id, snapToken: snapResult.token, snapJsUrl, clientKey, expiresAt, orderId, }; }, /** * Handle webhook callback dari Midtrans. Idempotent — boleh dipanggil berulang. * Verifikasi signature dulu, lalu delegate ke `applyGatewayStatus`. */ async handleMidtransWebhook( payload: MidtransWebhookBody ): Promise { const signatureValid = verifyMidtransSignature( payload.order_id, payload.status_code, payload.gross_amount, payload.signature_key ); if (!signatureValid) { return { ok: false, reason: "signature_mismatch" }; } return applyGatewayStatus({ order_id: payload.order_id, gross_amount: payload.gross_amount, transaction_status: payload.transaction_status, fraud_status: payload.fraud_status ?? null, transaction_id: payload.transaction_id, payment_type: payload.payment_type, rawSource: payload as unknown as Prisma.InputJsonValue, }); }, /** * Rekonsiliasi server-side: tarik status terkini dari Midtrans Core API, * lalu apply ke DB. Dipakai saat user kembali dari Snap (redirect flow) atau * saat webhook belum sampai (mis. dev di localhost). Aman dipanggil * berulang — idempotent via `applyGatewayStatus`. * * Auth: caller harus pastikan `userId` adalah owner booking; kita verifikasi * di sini lewat lookup payment → booking.userId. */ /** * Admin variant `reconcileFromGateway` — skip ownership check (admin bypass). * Dipakai dari `/admin/bookings/[id]` saat user lapor "sudah bayar tapi * status belum update". Idempotent: aman dipanggil berulang. */ async adminReconcile( orderId: string ): Promise< | { ok: true; status: | "updated" | "skipped" | "ignored" | "booking_conflict" | "not_found"; } | { ok: false; reason: "amount_mismatch" | "not_found" } > { const payment = await prisma.payment.findUnique({ where: { externalOrderId: orderId }, select: { id: true }, }); if (!payment) { return { ok: false, reason: "not_found" }; } const status = await fetchMidtransTransactionStatus(orderId); if (!status) { return { ok: true, status: "not_found" }; } return applyGatewayStatus({ order_id: status.order_id, gross_amount: status.gross_amount, transaction_status: status.transaction_status, fraud_status: status.fraud_status ?? null, transaction_id: status.transaction_id, payment_type: status.payment_type, rawSource: status as unknown as Prisma.InputJsonValue, }); }, async reconcileFromGateway( orderId: string, userId: string ): Promise< | { ok: true; status: | "updated" | "skipped" | "ignored" | "booking_conflict" | "not_found"; } | { ok: false; reason: "amount_mismatch" | "forbidden" | "not_found" } > { const payment = await prisma.payment.findUnique({ where: { externalOrderId: orderId }, include: { booking: { select: { userId: true } } }, }); if (!payment) { return { ok: false, reason: "not_found" }; } if (payment.booking.userId !== userId) { return { ok: false, reason: "forbidden" }; } const status = await fetchMidtransTransactionStatus(orderId); if (!status) { return { ok: true, status: "not_found" }; } const result = await applyGatewayStatus({ order_id: status.order_id, gross_amount: status.gross_amount, transaction_status: status.transaction_status, fraud_status: status.fraud_status ?? null, transaction_id: status.transaction_id, payment_type: status.payment_type, rawSource: status as unknown as Prisma.InputJsonValue, }); return result; }, }; async function notifyPaymentPaid(paymentId: string) { const payment = await prisma.payment.findUnique({ where: { id: paymentId }, include: { booking: { include: { user: { select: { email: true, name: true } }, trip: { select: { id: true, title: true } }, }, }, }, }); if (!payment) return; await emailService.send({ to: payment.booking.user.email, idempotencyKey: `payment_paid-${payment.id}`, template: { template: "payment_paid", data: { userName: payment.booking.user.name, tripTitle: payment.booking.trip.title, tripId: payment.booking.trip.id, amount: payment.amount, }, }, }); } // Re-export untuk testing langsung kalau perlu (tetap private dari modul lain). export const _internal = { applyGatewayStatus }; export type { MidtransTransactionStatus };