fix race condition issue

This commit is contained in:
2026-05-20 13:16:25 +07:00
parent 43ea725107
commit da217c2946
3 changed files with 445 additions and 421 deletions
+47
View File
@@ -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<T>(
fn: (tx: Prisma.TransactionClient) => Promise<T>,
fallbackMessage = "Operasi sedang ramai. Coba lagi sebentar."
): Promise<T> {
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);
}
+9 -34
View File
@@ -1,43 +1,10 @@
import { randomBytes } from "crypto"; import { randomBytes } from "crypto";
import { Prisma } from "@/app/generated/prisma/client"; import { Prisma } from "@/app/generated/prisma/client";
import { prisma } from "@/lib/prisma";
import { refundRepo } from "@/server/repositories/refund.repo"; import { refundRepo } from "@/server/repositories/refund.repo";
import { calculateRefundAmount, daysUntilDeparture } from "@/lib/refund-policy"; import { calculateRefundAmount, daysUntilDeparture } from "@/lib/refund-policy";
import { isTripDepartureDayPast } from "@/lib/trip-dates"; import { isTripDepartureDayPast } from "@/lib/trip-dates";
import { payoutService } from "@/server/services/payout.service"; import { payoutService } from "@/server/services/payout.service";
import { runSerializable } from "@/lib/serializable-tx";
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<T>(fn: (tx: Prisma.TransactionClient) => Promise<T>): Promise<T> {
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 { function newIdempotencyKey(): string {
return `refund_${randomBytes(16).toString("hex")}`; return `refund_${randomBytes(16).toString("hex")}`;
@@ -358,6 +325,14 @@ export const refundService = {
if (booking.userId !== input.userId) { if (booking.userId !== input.userId) {
throw new Error("Booking ini bukan milikmu"); 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") { if (booking.status !== "PAID") {
throw new Error( throw new Error(
"Hanya booking PAID yang bisa cancel dengan refund. Booking yang belum lunas bisa cancel dari tombol 'Batal Ikut'." "Hanya booking PAID yang bisa cancel dengan refund. Booking yang belum lunas bisa cancel dari tombol 'Batal Ikut'."
+389 -387
View File
@@ -1,9 +1,6 @@
import { Prisma } from "@/app/generated/prisma/client"; import { Prisma } from "@/app/generated/prisma/client";
import type { ActivityCategory, Vibe } from "@/app/generated/prisma/enums"; import type { ActivityCategory, Vibe } from "@/app/generated/prisma/enums";
import { prisma } from "@/lib/prisma";
import { tripRepo, type TripFilters } from "@/server/repositories/trip.repo"; 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 { refundService } from "@/server/services/refund.service";
import { payoutService } from "@/server/services/payout.service"; import { payoutService } from "@/server/services/payout.service";
import { payoutRepo } from "@/server/repositories/payout.repo"; import { payoutRepo } from "@/server/repositories/payout.repo";
@@ -11,17 +8,7 @@ import { LIMITS } from "@/lib/limits";
import { utcStartOfDay, isTripDepartureDayPast } from "@/lib/trip-dates"; import { utcStartOfDay, isTripDepartureDayPast } from "@/lib/trip-dates";
import { isFreeTrip } from "@/lib/trip-pricing"; import { isFreeTrip } from "@/lib/trip-pricing";
import type { ItineraryItemInput } from "@/lib/itinerary"; import type { ItineraryItemInput } from "@/lib/itinerary";
import { runSerializable } from "@/lib/serializable-tx";
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"
);
}
interface CreateTripInput { interface CreateTripInput {
category: ActivityCategory; category: ActivityCategory;
@@ -106,182 +93,157 @@ export const tripService = {
itineraryItems, itineraryItems,
} satisfies Prisma.TripCreateInput; } satisfies Prisma.TripCreateInput;
let lastErr: unknown; return runSerializable(async (tx) => {
for (let attempt = 0; attempt < SERIAL_TX_ATTEMPTS; attempt++) { const todayCount = await tx.trip.count({
try { where: {
return await prisma.$transaction( organizerId: input.organizerId,
async (tx) => { createdAt: { gte: since },
const todayCount = await tx.trip.count({ },
where: { });
organizerId: input.organizerId, if (todayCount >= LIMITS.MAX_TRIPS_PER_ORGANIZER_PER_DAY) {
createdAt: { gte: since }, throw new Error(
}, `Batas harian: maksimal ${LIMITS.MAX_TRIPS_PER_ORGANIZER_PER_DAY} trip per hari. Coba lagi besok.`
});
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,
}
); );
} catch (e) {
lastErr = e;
if (isSerializationConflict(e) && attempt < SERIAL_TX_ATTEMPTS - 1) {
continue;
}
throw e;
} }
} return tx.trip.create({ data: tripData });
throw lastErr instanceof Error }, "Gagal membuat trip. Coba lagi sebentar.");
? lastErr
: new Error("Gagal membuat trip. Coba lagi sebentar.");
}, },
async joinTrip(tripId: string, userId: string) { async joinTrip(tripId: string, userId: string) {
let lastErr: unknown; return runSerializable(async (tx) => {
for (let attempt = 0; attempt < SERIAL_TX_ATTEMPTS; attempt++) { const trip = await tx.trip.findUnique({
try { where: { id: tripId },
return await prisma.$transaction( select: {
async (tx) => { id: true,
const trip = await tx.trip.findUnique({ status: true,
where: { id: tripId }, date: true,
select: { organizerId: true,
id: true, maxParticipants: true,
status: true, price: true,
date: true, },
organizerId: true, });
maxParticipants: true,
price: true,
},
});
if (!trip) { if (!trip) {
throw new Error("Trip tidak ditemukan"); 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.status !== "OPEN") {
throw lastErr instanceof Error throw new Error("Trip tidak tersedia untuk pendaftaran");
? lastErr }
: new Error("Pendaftaran sedang ramai. Coba lagi sebentar."); 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) { async cancelJoin(tripId: string, userId: string) {
const trip = await tripRepo.findById(tripId); return runSerializable(async (tx) => {
if (!trip) { const trip = await tx.trip.findUnique({
throw new Error("Trip tidak ditemukan"); 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)) { const existing = await tx.tripParticipant.findUnique({
throw new Error( where: { tripId_userId: { tripId, userId } },
"Tanggal berangkat trip sudah lewat — pendaftaran tidak bisa dibatalkan. Jika trip sudah selesai, gunakan bagian ulasan." });
); if (!existing || existing.status === "CANCELLED") {
} throw new Error("Kamu tidak terdaftar di trip ini");
}
const existing = await participantRepo.findByTripAndUser(tripId, userId); // Safety: kalau booking sudah PAID/PARTIALLY_REFUNDED, paksa lewat refund
if (!existing || existing.status === "CANCELLED") { // flow supaya tidak ada uang menggantung tanpa Refund record. Dicek di
throw new Error("Kamu tidak terdaftar di trip ini"); // 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({ const cancelled = await tx.tripParticipant.update({
where: { tripId_userId: { tripId, userId } }, where: { tripId_userId: { tripId, userId } },
data: { data: {
@@ -290,21 +252,33 @@ export const tripService = {
paymentConfirmedAt: null, 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({ await tx.booking.updateMany({
where: { participantId: existing.id }, where: {
participantId: existing.id,
status: { in: ["PENDING", "AWAITING_PAY"] },
},
data: { status: "CANCELLED" }, 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; 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( async confirmParticipant(
@@ -312,33 +286,53 @@ export const tripService = {
participantId: string, participantId: string,
organizerId: string organizerId: string
) { ) {
const trip = await tripRepo.findById(tripId); return runSerializable(async (tx) => {
if (!trip) { const trip = await tx.trip.findUnique({
throw new Error("Trip tidak ditemukan"); where: { id: tripId },
} select: { id: true, organizerId: true, price: true },
if (trip.organizerId !== organizerId) { });
throw new Error("Hanya organizer trip ini yang bisa mengonfirmasi peserta"); 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); const participant = await tx.tripParticipant.findUnique({
if (!participant || participant.tripId !== tripId) { where: { id: participantId },
throw new Error("Peserta tidak ditemukan"); select: { id: true, tripId: true, status: true },
} });
if (participant.status !== "PENDING") { if (!participant || participant.tripId !== tripId) {
throw new Error("Peserta ini tidak dalam status menunggu persetujuan"); 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). // Trip gratis: Booking langsung PAID. Trip berbayar: AWAITING_PAY.
const nextBookingStatus = isFreeTrip(trip) ? "PAID" : "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({ await tx.booking.updateMany({
where: { participantId }, where: { participantId, status: { not: "CANCELLED" } },
data: { status: nextBookingStatus }, data: { status: nextBookingStatus },
}); });
return tx.tripParticipant.update({
return tx.tripParticipant.findUniqueOrThrow({
where: { id: participantId }, where: { id: participantId },
data: { status: "CONFIRMED" },
}); });
}); });
}, },
@@ -348,45 +342,79 @@ export const tripService = {
participantId: string, participantId: string,
organizerId: string organizerId: string
) { ) {
const trip = await tripRepo.findById(tripId); return runSerializable(async (tx) => {
if (!trip) { const trip = await tx.trip.findUnique({
throw new Error("Trip tidak ditemukan"); where: { id: tripId },
} select: {
if (trip.organizerId !== organizerId) { id: true,
throw new Error("Hanya organizer trip ini yang bisa menolak permintaan bergabung"); 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); const participant = await tx.tripParticipant.findUnique({
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({
where: { id: participantId }, 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: { data: {
status: "CANCELLED", status: "CANCELLED",
markedPaidAt: null, markedPaidAt: null,
paymentConfirmedAt: 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({ await tx.booking.updateMany({
where: { participantId }, where: {
participantId,
status: { in: ["PENDING", "AWAITING_PAY"] },
},
data: { status: "CANCELLED" }, data: { status: "CANCELLED" },
}); });
});
if (trip.status === "FULL") { // Slot kembali kosong → re-open trip, kondisional `status: "FULL"`.
const count = await participantRepo.countByTrip(tripId); if (trip.status === "FULL") {
if (count < trip.maxParticipants) { const activeCount = await tx.tripParticipant.count({
await tripRepo.updateStatus(tripId, "OPEN"); 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: "ORGANIZER"; userId: string }
| { type: "ADMIN"; adminId: string; reason: string } | { type: "ADMIN"; adminId: string; reason: string }
) { ) {
let lastErr: unknown; return runSerializable(async (tx) => {
for (let attempt = 0; attempt < SERIAL_TX_ATTEMPTS; attempt++) { const trip = await tx.trip.findUnique({
try { where: { id: tripId },
return await prisma.$transaction( select: { id: true, status: true, organizerId: true, date: true },
async (tx) => { });
const trip = await tx.trip.findUnique({ if (!trip) {
where: { id: tripId }, throw new Error("Trip tidak ditemukan");
select: { id: true, status: true, organizerId: true, date: true }, }
}); if (actor.type === "ORGANIZER" && trip.organizerId !== actor.userId) {
if (!trip) { throw new Error("Hanya organizer trip ini yang bisa membatalkan trip");
throw new Error("Trip tidak ditemukan"); }
} if (trip.status === "CLOSED") {
if ( throw new Error("Trip sudah dibatalkan");
actor.type === "ORGANIZER" && }
trip.organizerId !== actor.userId if (trip.status === "COMPLETED") {
) { throw new Error(
throw new Error( "Trip sudah selesai (COMPLETED) — tidak bisa dibatalkan"
"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,
}
); );
} catch (e) { }
lastErr = e; if (isTripDepartureDayPast(trip.date)) {
if (isSerializationConflict(e) && attempt < SERIAL_TX_ATTEMPTS - 1) { 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; 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 // Semua participant aktif → CANCELLED (apapun status booking-nya).
? lastErr await tx.tripParticipant.updateMany({
: new Error("Gagal membatalkan trip. Coba lagi sebentar."); 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.");
}, },
}; };