From da217c2946790ef04a189ec4d8ddbe9eed282baf Mon Sep 17 00:00:00 2001 From: arifal Date: Wed, 20 May 2026 13:16:25 +0700 Subject: [PATCH] fix race condition issue --- lib/serializable-tx.ts | 47 ++ server/services/refund.service.ts | 43 +- server/services/trip.service.ts | 776 +++++++++++++++--------------- 3 files changed, 445 insertions(+), 421 deletions(-) create mode 100644 lib/serializable-tx.ts diff --git a/lib/serializable-tx.ts b/lib/serializable-tx.ts new file mode 100644 index 0000000..3a0415a --- /dev/null +++ b/lib/serializable-tx.ts @@ -0,0 +1,47 @@ +/** + * Helper transaksi Serializable + retry. Single source of truth supaya semua + * operasi yang rawan race (join/cancel trip, refund, payment) konsisten: + * - Isolation level Serializable → Postgres SSI mendeteksi konflik. + * - Retry otomatis saat serialization failure (Prisma error code `P2034`). + */ +import { Prisma } from "@/app/generated/prisma/client"; +import { prisma } from "@/lib/prisma"; + +const SERIAL_TX_ATTEMPTS = 6; + +export function isSerializationConflict(err: unknown): boolean { + return ( + typeof err === "object" && + err !== null && + "code" in err && + (err as { code: string }).code === "P2034" + ); +} + +/** + * Jalankan `fn` dalam transaksi Serializable. Kalau gagal karena konflik + * serialisasi, ulang sampai `SERIAL_TX_ATTEMPTS` kali. Error bisnis (non-P2034) + * langsung dilempar tanpa retry. + */ +export async function runSerializable( + fn: (tx: Prisma.TransactionClient) => Promise, + fallbackMessage = "Operasi sedang ramai. Coba lagi sebentar." +): 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(fallbackMessage); +} diff --git a/server/services/refund.service.ts b/server/services/refund.service.ts index 1ab2990..bf5a3bf 100644 --- a/server/services/refund.service.ts +++ b/server/services/refund.service.ts @@ -1,43 +1,10 @@ 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"; 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" - ); -} - -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."); -} +import { runSerializable } from "@/lib/serializable-tx"; function newIdempotencyKey(): string { return `refund_${randomBytes(16).toString("hex")}`; @@ -358,6 +325,14 @@ export const refundService = { if (booking.userId !== input.userId) { throw new Error("Booking ini bukan milikmu"); } + if ( + booking.trip.status === "CLOSED" || + booking.trip.status === "COMPLETED" + ) { + throw new Error( + "Trip sudah dibatalkan/selesai — pembatalan mandiri ditutup. Hubungi admin untuk proses refund." + ); + } 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'." diff --git a/server/services/trip.service.ts b/server/services/trip.service.ts index 7b5e6b1..111ea31 100644 --- a/server/services/trip.service.ts +++ b/server/services/trip.service.ts @@ -1,9 +1,6 @@ import { Prisma } from "@/app/generated/prisma/client"; import type { ActivityCategory, Vibe } from "@/app/generated/prisma/enums"; -import { prisma } from "@/lib/prisma"; import { tripRepo, type TripFilters } from "@/server/repositories/trip.repo"; -import { participantRepo } from "@/server/repositories/participant.repo"; -import { bookingRepo } from "@/server/repositories/booking.repo"; import { refundService } from "@/server/services/refund.service"; import { payoutService } from "@/server/services/payout.service"; import { payoutRepo } from "@/server/repositories/payout.repo"; @@ -11,17 +8,7 @@ import { LIMITS } from "@/lib/limits"; import { utcStartOfDay, isTripDepartureDayPast } from "@/lib/trip-dates"; import { isFreeTrip } from "@/lib/trip-pricing"; import type { ItineraryItemInput } from "@/lib/itinerary"; - -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" - ); -} +import { runSerializable } from "@/lib/serializable-tx"; interface CreateTripInput { category: ActivityCategory; @@ -106,182 +93,157 @@ export const tripService = { itineraryItems, } satisfies Prisma.TripCreateInput; - let lastErr: unknown; - for (let attempt = 0; attempt < SERIAL_TX_ATTEMPTS; attempt++) { - try { - return await prisma.$transaction( - async (tx) => { - const todayCount = await tx.trip.count({ - where: { - organizerId: input.organizerId, - createdAt: { gte: since }, - }, - }); - if (todayCount >= LIMITS.MAX_TRIPS_PER_ORGANIZER_PER_DAY) { - throw new Error( - `Batas harian: maksimal ${LIMITS.MAX_TRIPS_PER_ORGANIZER_PER_DAY} trip per hari. Coba lagi besok.` - ); - } - return tx.trip.create({ data: tripData }); - }, - { - isolationLevel: Prisma.TransactionIsolationLevel.Serializable, - maxWait: 5000, - timeout: 15000, - } + return runSerializable(async (tx) => { + const todayCount = await tx.trip.count({ + where: { + organizerId: input.organizerId, + createdAt: { gte: since }, + }, + }); + if (todayCount >= LIMITS.MAX_TRIPS_PER_ORGANIZER_PER_DAY) { + throw new Error( + `Batas harian: maksimal ${LIMITS.MAX_TRIPS_PER_ORGANIZER_PER_DAY} trip per hari. Coba lagi besok.` ); - } catch (e) { - lastErr = e; - if (isSerializationConflict(e) && attempt < SERIAL_TX_ATTEMPTS - 1) { - continue; - } - throw e; } - } - throw lastErr instanceof Error - ? lastErr - : new Error("Gagal membuat trip. Coba lagi sebentar."); + return tx.trip.create({ data: tripData }); + }, "Gagal membuat trip. Coba lagi sebentar."); }, async joinTrip(tripId: string, userId: string) { - let lastErr: unknown; - for (let attempt = 0; attempt < SERIAL_TX_ATTEMPTS; attempt++) { - try { - return await prisma.$transaction( - async (tx) => { - const trip = await tx.trip.findUnique({ - where: { id: tripId }, - select: { - id: true, - status: true, - date: true, - organizerId: true, - maxParticipants: true, - price: true, - }, - }); + return runSerializable(async (tx) => { + const trip = await tx.trip.findUnique({ + where: { id: tripId }, + select: { + id: true, + status: true, + date: true, + organizerId: true, + maxParticipants: true, + price: true, + }, + }); - if (!trip) { - throw new Error("Trip tidak ditemukan"); - } - if (trip.status !== "OPEN") { - throw new Error("Trip tidak tersedia untuk pendaftaran"); - } - if (isTripDepartureDayPast(trip.date)) { - throw new Error( - "Trip sudah melewati tanggal berangkat, tidak bisa mendaftar" - ); - } - if (trip.organizerId === userId) { - throw new Error("Organizer tidak bisa join trip sendiri"); - } - - const existing = await tx.tripParticipant.findUnique({ - where: { tripId_userId: { tripId, userId } }, - }); - if (existing && existing.status !== "CANCELLED") { - throw new Error("Kamu sudah terdaftar di trip ini"); - } - - const participantCount = await tx.tripParticipant.count({ - where: { tripId, status: { not: "CANCELLED" } }, - }); - if (participantCount >= trip.maxParticipants) { - throw new Error("Trip sudah penuh"); - } - - const participant = - existing?.status === "CANCELLED" - ? await tx.tripParticipant.update({ - where: { tripId_userId: { tripId, userId } }, - data: { - status: "PENDING", - markedPaidAt: null, - paymentConfirmedAt: null, - }, - }) - : await tx.tripParticipant.create({ - data: { tripId, userId, status: "PENDING" }, - }); - - // Booking 1-1 ke participant. Upsert untuk handle re-join setelah CANCELLED. - await tx.booking.upsert({ - where: { participantId: participant.id }, - create: { - tripId, - userId, - participantId: participant.id, - amount: trip.price, - status: "PENDING", - }, - update: { - status: "PENDING", - amount: trip.price, - }, - }); - - const newCount = await tx.tripParticipant.count({ - where: { tripId, status: { not: "CANCELLED" } }, - }); - if (newCount >= trip.maxParticipants) { - await tx.trip.update({ - where: { id: tripId }, - data: { status: "FULL" }, - }); - } - - return participant; - }, - { - isolationLevel: Prisma.TransactionIsolationLevel.Serializable, - maxWait: 5000, - timeout: 15000, - } - ); - } catch (e) { - lastErr = e; - if (isSerializationConflict(e) && attempt < SERIAL_TX_ATTEMPTS - 1) { - continue; - } - throw e; + if (!trip) { + throw new Error("Trip tidak ditemukan"); } - } - throw lastErr instanceof Error - ? lastErr - : new Error("Pendaftaran sedang ramai. Coba lagi sebentar."); + if (trip.status !== "OPEN") { + throw new Error("Trip tidak tersedia untuk pendaftaran"); + } + if (isTripDepartureDayPast(trip.date)) { + throw new Error( + "Trip sudah melewati tanggal berangkat, tidak bisa mendaftar" + ); + } + if (trip.organizerId === userId) { + throw new Error("Organizer tidak bisa join trip sendiri"); + } + + const existing = await tx.tripParticipant.findUnique({ + where: { tripId_userId: { tripId, userId } }, + }); + if (existing && existing.status !== "CANCELLED") { + throw new Error("Kamu sudah terdaftar di trip ini"); + } + + const participantCount = await tx.tripParticipant.count({ + where: { tripId, status: { not: "CANCELLED" } }, + }); + if (participantCount >= trip.maxParticipants) { + throw new Error("Trip sudah penuh"); + } + + const participant = + existing?.status === "CANCELLED" + ? await tx.tripParticipant.update({ + where: { tripId_userId: { tripId, userId } }, + data: { + status: "PENDING", + markedPaidAt: null, + paymentConfirmedAt: null, + }, + }) + : await tx.tripParticipant.create({ + data: { tripId, userId, status: "PENDING" }, + }); + + // Booking 1-1 ke participant. Upsert untuk handle re-join setelah CANCELLED. + await tx.booking.upsert({ + where: { participantId: participant.id }, + create: { + tripId, + userId, + participantId: participant.id, + amount: trip.price, + status: "PENDING", + }, + update: { + status: "PENDING", + amount: trip.price, + }, + }); + + const newCount = await tx.tripParticipant.count({ + where: { tripId, status: { not: "CANCELLED" } }, + }); + if (newCount >= trip.maxParticipants) { + await tx.trip.update({ + where: { id: tripId }, + data: { status: "FULL" }, + }); + } + + return participant; + }, "Pendaftaran sedang ramai. Coba lagi sebentar."); }, + /** + * Peserta batal ikut trip (untuk booking yang BELUM lunas). Seluruh read + + * write dibungkus satu transaksi Serializable supaya aman dari race: + * - Status booking dicek di dalam tx, dan `booking.updateMany` difilter + * `PENDING/AWAITING_PAY` — kalau webhook pembayaran menandai booking PAID + * bersamaan, booking lunas TIDAK ikut di-cancel (cegah uang menggantung + * tanpa Refund record). + * - Re-open trip FULL → OPEN dilakukan di tx yang sama dan kondisional + * (`status: "FULL"`) supaya tidak menimpa trip yang sudah CLOSED. + */ async cancelJoin(tripId: string, userId: string) { - const trip = await tripRepo.findById(tripId); - if (!trip) { - throw new Error("Trip tidak ditemukan"); - } + return runSerializable(async (tx) => { + const trip = await tx.trip.findUnique({ + where: { id: tripId }, + select: { id: true, status: true, date: true, maxParticipants: true }, + }); + if (!trip) { + throw new Error("Trip tidak ditemukan"); + } + if (isTripDepartureDayPast(trip.date)) { + throw new Error( + "Tanggal berangkat trip sudah lewat — pendaftaran tidak bisa dibatalkan. Jika trip sudah selesai, gunakan bagian ulasan." + ); + } - if (isTripDepartureDayPast(trip.date)) { - throw new Error( - "Tanggal berangkat trip sudah lewat — pendaftaran tidak bisa dibatalkan. Jika trip sudah selesai, gunakan bagian ulasan." - ); - } + const existing = await tx.tripParticipant.findUnique({ + where: { tripId_userId: { tripId, userId } }, + }); + if (!existing || existing.status === "CANCELLED") { + throw new Error("Kamu tidak terdaftar di trip ini"); + } - const existing = await participantRepo.findByTripAndUser(tripId, userId); - if (!existing || existing.status === "CANCELLED") { - throw new Error("Kamu tidak terdaftar di trip ini"); - } + // Safety: kalau booking sudah PAID/PARTIALLY_REFUNDED, paksa lewat refund + // flow supaya tidak ada uang menggantung tanpa Refund record. Dicek di + // dalam tx supaya konsisten dengan webhook pembayaran. + const booking = await tx.booking.findUnique({ + where: { participantId: existing.id }, + select: { status: true }, + }); + if ( + booking && + (booking.status === "PAID" || booking.status === "PARTIALLY_REFUNDED") + ) { + throw new Error( + "Booking kamu sudah lunas — pakai tombol 'Cancel & Request Refund' supaya uang bisa dikembalikan." + ); + } - // Safety: kalau booking sudah PAID, paksa lewat refund flow supaya tidak - // ada uang menggantung tanpa Refund record. - const existingBooking = await bookingRepo.findByTripAndUser(tripId, userId); - if ( - existingBooking && - (existingBooking.status === "PAID" || - existingBooking.status === "PARTIALLY_REFUNDED") - ) { - throw new Error( - "Booking kamu sudah lunas — pakai tombol 'Cancel & Request Refund' supaya uang bisa dikembalikan." - ); - } - - const result = await prisma.$transaction(async (tx) => { const cancelled = await tx.tripParticipant.update({ where: { tripId_userId: { tripId, userId } }, data: { @@ -290,21 +252,33 @@ export const tripService = { paymentConfirmedAt: null, }, }); + + // Hanya cancel booking yang belum lunas — filter status menutup race + // dengan webhook pembayaran yang bisa menandai PAID secara bersamaan. await tx.booking.updateMany({ - where: { participantId: existing.id }, + where: { + participantId: existing.id, + status: { in: ["PENDING", "AWAITING_PAY"] }, + }, data: { status: "CANCELLED" }, }); + + // Slot kembali kosong → re-open trip. Kondisional `status: "FULL"` + // supaya tidak menghidupkan trip yang sudah CLOSED/COMPLETED. + if (trip.status === "FULL") { + const activeCount = await tx.tripParticipant.count({ + where: { tripId, status: { not: "CANCELLED" } }, + }); + if (activeCount < trip.maxParticipants) { + await tx.trip.updateMany({ + where: { id: tripId, status: "FULL" }, + data: { status: "OPEN" }, + }); + } + } + return cancelled; }); - - if (trip.status === "FULL") { - const count = await participantRepo.countByTrip(tripId); - if (count < trip.maxParticipants) { - await tripRepo.updateStatus(tripId, "OPEN"); - } - } - - return result; }, async confirmParticipant( @@ -312,33 +286,53 @@ export const tripService = { participantId: string, organizerId: string ) { - const trip = await tripRepo.findById(tripId); - if (!trip) { - throw new Error("Trip tidak ditemukan"); - } - if (trip.organizerId !== organizerId) { - throw new Error("Hanya organizer trip ini yang bisa mengonfirmasi peserta"); - } + return runSerializable(async (tx) => { + const trip = await tx.trip.findUnique({ + where: { id: tripId }, + select: { id: true, organizerId: true, price: true }, + }); + if (!trip) { + throw new Error("Trip tidak ditemukan"); + } + if (trip.organizerId !== organizerId) { + throw new Error( + "Hanya organizer trip ini yang bisa mengonfirmasi peserta" + ); + } - const participant = await participantRepo.findById(participantId); - if (!participant || participant.tripId !== tripId) { - throw new Error("Peserta tidak ditemukan"); - } - if (participant.status !== "PENDING") { - throw new Error("Peserta ini tidak dalam status menunggu persetujuan"); - } + const participant = await tx.tripParticipant.findUnique({ + where: { id: participantId }, + select: { id: true, tripId: true, status: true }, + }); + if (!participant || participant.tripId !== tripId) { + throw new Error("Peserta tidak ditemukan"); + } + if (participant.status !== "PENDING") { + throw new Error("Peserta ini tidak dalam status menunggu persetujuan"); + } - // Trip gratis: Booking langsung PAID. Trip berbayar: AWAITING_PAY (tinggal bayar). - const nextBookingStatus = isFreeTrip(trip) ? "PAID" : "AWAITING_PAY"; + // Trip gratis: Booking langsung PAID. Trip berbayar: AWAITING_PAY. + const nextBookingStatus = isFreeTrip(trip) ? "PAID" : "AWAITING_PAY"; - return prisma.$transaction(async (tx) => { + // Update kondisional — kalau peserta sudah berubah status (mis. user + // batal ikut bersamaan), count 0 → tolak, jangan resurrect peserta. + const confirmed = await tx.tripParticipant.updateMany({ + where: { id: participantId, status: "PENDING" }, + data: { status: "CONFIRMED" }, + }); + if (confirmed.count === 0) { + throw new Error("Peserta ini tidak dalam status menunggu persetujuan"); + } + + // Filter `not CANCELLED` supaya booking yang sudah dibatalkan tidak + // ikut "dihidupkan" kembali oleh konfirmasi yang balapan. await tx.booking.updateMany({ - where: { participantId }, + where: { participantId, status: { not: "CANCELLED" } }, data: { status: nextBookingStatus }, }); - return tx.tripParticipant.update({ + + return tx.tripParticipant.findUniqueOrThrow({ where: { id: participantId }, - data: { status: "CONFIRMED" }, }); }); }, @@ -348,45 +342,79 @@ export const tripService = { participantId: string, organizerId: string ) { - const trip = await tripRepo.findById(tripId); - if (!trip) { - throw new Error("Trip tidak ditemukan"); - } - if (trip.organizerId !== organizerId) { - throw new Error("Hanya organizer trip ini yang bisa menolak permintaan bergabung"); - } + return runSerializable(async (tx) => { + const trip = await tx.trip.findUnique({ + where: { id: tripId }, + select: { + id: true, + status: true, + organizerId: true, + maxParticipants: true, + }, + }); + if (!trip) { + throw new Error("Trip tidak ditemukan"); + } + if (trip.organizerId !== organizerId) { + throw new Error( + "Hanya organizer trip ini yang bisa menolak permintaan bergabung" + ); + } - const participant = await participantRepo.findById(participantId); - if (!participant || participant.tripId !== tripId) { - throw new Error("Peserta tidak ditemukan"); - } - if (participant.status !== "PENDING") { - throw new Error("Hanya permintaan yang masih menunggu yang bisa ditolak"); - } - - await prisma.$transaction(async (tx) => { - await tx.tripParticipant.update({ + const participant = await tx.tripParticipant.findUnique({ where: { id: participantId }, + select: { id: true, tripId: true, status: true }, + }); + if (!participant || participant.tripId !== tripId) { + throw new Error("Peserta tidak ditemukan"); + } + if (participant.status !== "PENDING") { + throw new Error( + "Hanya permintaan yang masih menunggu yang bisa ditolak" + ); + } + + // Update kondisional pada status PENDING — aman dari race dengan + // confirm/cancel yang berjalan bersamaan. + const rejected = await tx.tripParticipant.updateMany({ + where: { id: participantId, status: "PENDING" }, data: { status: "CANCELLED", markedPaidAt: null, paymentConfirmedAt: null, }, }); + if (rejected.count === 0) { + throw new Error( + "Hanya permintaan yang masih menunggu yang bisa ditolak" + ); + } + + // Peserta PENDING belum bisa punya booking lunas, tapi filter status + // tetap dipasang supaya tidak pernah menimpa booking PAID. await tx.booking.updateMany({ - where: { participantId }, + where: { + participantId, + status: { in: ["PENDING", "AWAITING_PAY"] }, + }, data: { status: "CANCELLED" }, }); - }); - if (trip.status === "FULL") { - const count = await participantRepo.countByTrip(tripId); - if (count < trip.maxParticipants) { - await tripRepo.updateStatus(tripId, "OPEN"); + // Slot kembali kosong → re-open trip, kondisional `status: "FULL"`. + if (trip.status === "FULL") { + const activeCount = await tx.tripParticipant.count({ + where: { tripId, status: { not: "CANCELLED" } }, + }); + if (activeCount < trip.maxParticipants) { + await tx.trip.updateMany({ + where: { id: tripId, status: "FULL" }, + data: { status: "OPEN" }, + }); + } } - } - return { ok: true as const }; + return { ok: true as const }; + }); }, /** @@ -427,164 +455,138 @@ export const tripService = { | { type: "ORGANIZER"; userId: string } | { type: "ADMIN"; adminId: string; reason: string } ) { - let lastErr: unknown; - for (let attempt = 0; attempt < SERIAL_TX_ATTEMPTS; attempt++) { - try { - return await prisma.$transaction( - async (tx) => { - const trip = await tx.trip.findUnique({ - where: { id: tripId }, - select: { id: true, status: true, organizerId: true, date: true }, - }); - if (!trip) { - throw new Error("Trip tidak ditemukan"); - } - if ( - actor.type === "ORGANIZER" && - trip.organizerId !== actor.userId - ) { - throw new Error( - "Hanya organizer trip ini yang bisa membatalkan trip" - ); - } - if (trip.status === "CLOSED") { - throw new Error("Trip sudah dibatalkan"); - } - if (trip.status === "COMPLETED") { - throw new Error( - "Trip sudah selesai (COMPLETED) — tidak bisa dibatalkan" - ); - } - if (isTripDepartureDayPast(trip.date)) { - throw new Error( - "Tanggal berangkat sudah lewat — gunakan flow pelaporan biasa ke admin" - ); - } - - const bookings = await tx.booking.findMany({ - where: { tripId }, - include: { - payments: { - where: { status: "PAID" }, - orderBy: { paidAt: "desc" }, - take: 1, - }, - refunds: { - where: { - status: { in: ["PENDING", "APPROVED", "PROCESSING"] }, - }, - select: { id: true }, - }, - }, - }); - - const refundsCreated: string[] = []; - const cancelledBookings: string[] = []; - const skippedBookings: string[] = []; - - for (const b of bookings) { - if (b.status === "CANCELLED" || b.status === "EXPIRED") { - continue; - } - if (b.status === "REFUNDED") { - continue; - } - if (b.refunds.length > 0) { - // Sudah ada refund aktif (mis. user request cancel). Admin - // handle manual supaya tidak konflik dengan refund existing. - skippedBookings.push(b.id); - continue; - } - - if (b.status === "PAID" || b.status === "PARTIALLY_REFUNDED") { - const paid = b.payments[0]; - if (!paid) { - // Payment tidak konsisten dgn booking status — skip + flag. - skippedBookings.push(b.id); - continue; - } - // Untuk PARTIALLY_REFUNDED, hitung sisa refundable. - const alreadyRefunded = await tx.refund.aggregate({ - where: { bookingId: b.id, status: "SUCCEEDED" }, - _sum: { amount: true }, - }); - const remaining = paid.amount - (alreadyRefunded._sum.amount ?? 0); - if (remaining <= 0) { - continue; - } - const refund = await refundService.createSystemRefundForClosedTrip( - tx, - { - bookingId: b.id, - paymentId: paid.id, - amount: remaining, - } - ); - refundsCreated.push(refund.id); - - // Trip dibatalkan → cancel payout HELD/RELEASED organizer untuk - // booking ini. Payout PAID di-flag clawback otomatis. - const payout = await payoutRepo.findByBookingId(b.id, tx); - if (payout) { - await payoutService.cancel(tx, { - payoutId: payout.id, - reason: "Trip dibatalkan organizer.", - }); - } - } else { - // PENDING / AWAITING_PAY → uang belum masuk → langsung CANCELLED. - await tx.booking.update({ - where: { id: b.id }, - data: { status: "CANCELLED" }, - }); - cancelledBookings.push(b.id); - } - } - - // Semua participant aktif → CANCELLED (apapun status booking-nya). - await tx.tripParticipant.updateMany({ - where: { tripId, status: { not: "CANCELLED" } }, - data: { - status: "CANCELLED", - markedPaidAt: null, - paymentConfirmedAt: null, - }, - }); - - await tx.trip.update({ - where: { id: tripId }, - data: { - status: "CLOSED", - ...(actor.type === "ADMIN" && { - cancelledByAdminId: actor.adminId, - cancelledReason: actor.reason, - }), - }, - }); - - return { - ok: true as const, - refundsCreated, - cancelledBookings, - skippedBookings, - }; - }, - { - isolationLevel: Prisma.TransactionIsolationLevel.Serializable, - maxWait: 5000, - timeout: 15000, - } + return runSerializable(async (tx) => { + const trip = await tx.trip.findUnique({ + where: { id: tripId }, + select: { id: true, status: true, organizerId: true, date: true }, + }); + if (!trip) { + throw new Error("Trip tidak ditemukan"); + } + if (actor.type === "ORGANIZER" && trip.organizerId !== actor.userId) { + throw new Error("Hanya organizer trip ini yang bisa membatalkan trip"); + } + if (trip.status === "CLOSED") { + throw new Error("Trip sudah dibatalkan"); + } + if (trip.status === "COMPLETED") { + throw new Error( + "Trip sudah selesai (COMPLETED) — tidak bisa dibatalkan" ); - } catch (e) { - lastErr = e; - if (isSerializationConflict(e) && attempt < SERIAL_TX_ATTEMPTS - 1) { + } + if (isTripDepartureDayPast(trip.date)) { + throw new Error( + "Tanggal berangkat sudah lewat — gunakan flow pelaporan biasa ke admin" + ); + } + + const bookings = await tx.booking.findMany({ + where: { tripId }, + include: { + payments: { + where: { status: "PAID" }, + orderBy: { paidAt: "desc" }, + take: 1, + }, + refunds: { + where: { + status: { in: ["PENDING", "APPROVED", "PROCESSING"] }, + }, + select: { id: true }, + }, + }, + }); + + const refundsCreated: string[] = []; + const cancelledBookings: string[] = []; + const skippedBookings: string[] = []; + + for (const b of bookings) { + if (b.status === "CANCELLED" || b.status === "EXPIRED") { continue; } - throw e; + if (b.status === "REFUNDED") { + continue; + } + if (b.refunds.length > 0) { + // Sudah ada refund aktif (mis. user request cancel). Admin + // handle manual supaya tidak konflik dengan refund existing. + skippedBookings.push(b.id); + continue; + } + + if (b.status === "PAID" || b.status === "PARTIALLY_REFUNDED") { + const paid = b.payments[0]; + if (!paid) { + // Payment tidak konsisten dgn booking status — skip + flag. + skippedBookings.push(b.id); + continue; + } + // Untuk PARTIALLY_REFUNDED, hitung sisa refundable. + const alreadyRefunded = await tx.refund.aggregate({ + where: { bookingId: b.id, status: "SUCCEEDED" }, + _sum: { amount: true }, + }); + const remaining = paid.amount - (alreadyRefunded._sum.amount ?? 0); + if (remaining <= 0) { + continue; + } + const refund = await refundService.createSystemRefundForClosedTrip( + tx, + { + bookingId: b.id, + paymentId: paid.id, + amount: remaining, + } + ); + refundsCreated.push(refund.id); + + // Trip dibatalkan → cancel payout HELD/RELEASED organizer untuk + // booking ini. Payout PAID di-flag clawback otomatis. + const payout = await payoutRepo.findByBookingId(b.id, tx); + if (payout) { + await payoutService.cancel(tx, { + payoutId: payout.id, + reason: "Trip dibatalkan organizer.", + }); + } + } else { + // PENDING / AWAITING_PAY → uang belum masuk → langsung CANCELLED. + await tx.booking.update({ + where: { id: b.id }, + data: { status: "CANCELLED" }, + }); + cancelledBookings.push(b.id); + } } - } - throw lastErr instanceof Error - ? lastErr - : new Error("Gagal membatalkan trip. Coba lagi sebentar."); + + // Semua participant aktif → CANCELLED (apapun status booking-nya). + await tx.tripParticipant.updateMany({ + where: { tripId, status: { not: "CANCELLED" } }, + data: { + status: "CANCELLED", + markedPaidAt: null, + paymentConfirmedAt: null, + }, + }); + + await tx.trip.update({ + where: { id: tripId }, + data: { + status: "CLOSED", + ...(actor.type === "ADMIN" && { + cancelledByAdminId: actor.adminId, + cancelledReason: actor.reason, + }), + }, + }); + + return { + ok: true as const, + refundsCreated, + cancelledBookings, + skippedBookings, + }; + }, "Gagal membatalkan trip. Coba lagi sebentar."); }, };