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 { 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<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.");
}
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'."
+132 -130
View File
@@ -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,11 +93,7 @@ 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) => {
return runSerializable(async (tx) => {
const todayCount = await tx.trip.count({
where: {
organizerId: input.organizerId,
@@ -123,32 +106,11 @@ export const tripService = {
);
}
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;
}
}
throw lastErr instanceof Error
? lastErr
: new Error("Gagal membuat trip. Coba lagi sebentar.");
}, "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) => {
return runSerializable(async (tx) => {
const trip = await tx.trip.findUnique({
where: { id: tripId },
select: {
@@ -231,57 +193,57 @@ export const tripService = {
}
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;
}
}
throw lastErr instanceof Error
? lastErr
: new Error("Pendaftaran sedang ramai. Coba lagi sebentar.");
}, "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);
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."
);
}
const existing = await participantRepo.findByTripAndUser(tripId, userId);
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");
}
// Safety: kalau booking sudah PAID, paksa lewat refund flow supaya tidak
// ada uang menggantung tanpa Refund record.
const existingBooking = await bookingRepo.findByTripAndUser(tripId, userId);
// 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 (
existingBooking &&
(existingBooking.status === "PAID" ||
existingBooking.status === "PARTIALLY_REFUNDED")
booking &&
(booking.status === "PAID" || booking.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,15 +286,24 @@ export const tripService = {
participantId: string,
organizerId: string
) {
const trip = await tripRepo.findById(tripId);
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");
throw new Error(
"Hanya organizer trip ini yang bisa mengonfirmasi peserta"
);
}
const participant = await participantRepo.findById(participantId);
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");
}
@@ -328,17 +311,28 @@ export const tripService = {
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";
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);
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");
throw new Error(
"Hanya organizer trip ini yang bisa menolak permintaan bergabung"
);
}
const participant = await participantRepo.findById(participantId);
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");
throw new Error(
"Hanya permintaan yang masih menunggu yang bisa ditolak"
);
}
await prisma.$transaction(async (tx) => {
await tx.tripParticipant.update({
where: { id: participantId },
// 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" },
});
});
// Slot kembali kosong → re-open trip, kondisional `status: "FULL"`.
if (trip.status === "FULL") {
const count = await participantRepo.countByTrip(tripId);
if (count < trip.maxParticipants) {
await tripRepo.updateStatus(tripId, "OPEN");
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 };
});
},
/**
@@ -427,11 +455,7 @@ 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) => {
return runSerializable(async (tx) => {
const trip = await tx.trip.findUnique({
where: { id: tripId },
select: { id: true, status: true, organizerId: true, date: true },
@@ -439,13 +463,8 @@ export const tripService = {
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 (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");
@@ -568,23 +587,6 @@ export const tripService = {
cancelledBookings,
skippedBookings,
};
},
{
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 membatalkan trip. Coba lagi sebentar.");
}, "Gagal membatalkan trip. Coba lagi sebentar.");
},
};