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'."
+132 -130
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,11 +93,7 @@ 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++) {
try {
return await prisma.$transaction(
async (tx) => {
const todayCount = await tx.trip.count({ const todayCount = await tx.trip.count({
where: { where: {
organizerId: input.organizerId, organizerId: input.organizerId,
@@ -123,32 +106,11 @@ export const tripService = {
); );
} }
return tx.trip.create({ data: tripData }); return tx.trip.create({ data: tripData });
}, }, "Gagal membuat trip. Coba lagi sebentar.");
{
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.");
}, },
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++) {
try {
return await prisma.$transaction(
async (tx) => {
const trip = await tx.trip.findUnique({ const trip = await tx.trip.findUnique({
where: { id: tripId }, where: { id: tripId },
select: { select: {
@@ -231,57 +193,57 @@ export const tripService = {
} }
return participant; return participant;
}, }, "Pendaftaran sedang ramai. Coba lagi sebentar.");
{
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.");
}, },
/**
* 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) => {
const trip = await tx.trip.findUnique({
where: { id: tripId },
select: { id: true, status: true, date: true, maxParticipants: true },
});
if (!trip) { if (!trip) {
throw new Error("Trip tidak ditemukan"); throw new Error("Trip tidak ditemukan");
} }
if (isTripDepartureDayPast(trip.date)) { if (isTripDepartureDayPast(trip.date)) {
throw new Error( throw new Error(
"Tanggal berangkat trip sudah lewat — pendaftaran tidak bisa dibatalkan. Jika trip sudah selesai, gunakan bagian ulasan." "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") { if (!existing || existing.status === "CANCELLED") {
throw new Error("Kamu tidak terdaftar di trip ini"); throw new Error("Kamu tidak terdaftar di trip ini");
} }
// Safety: kalau booking sudah PAID, paksa lewat refund flow supaya tidak // Safety: kalau booking sudah PAID/PARTIALLY_REFUNDED, paksa lewat refund
// ada uang menggantung tanpa Refund record. // flow supaya tidak ada uang menggantung tanpa Refund record. Dicek di
const existingBooking = await bookingRepo.findByTripAndUser(tripId, userId); // dalam tx supaya konsisten dengan webhook pembayaran.
const booking = await tx.booking.findUnique({
where: { participantId: existing.id },
select: { status: true },
});
if ( if (
existingBooking && booking &&
(existingBooking.status === "PAID" || (booking.status === "PAID" || booking.status === "PARTIALLY_REFUNDED")
existingBooking.status === "PARTIALLY_REFUNDED")
) { ) {
throw new Error( throw new Error(
"Booking kamu sudah lunas — pakai tombol 'Cancel & Request Refund' supaya uang bisa dikembalikan." "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,15 +286,24 @@ export const tripService = {
participantId: string, participantId: string,
organizerId: 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) { if (!trip) {
throw new Error("Trip tidak ditemukan"); throw new Error("Trip tidak ditemukan");
} }
if (trip.organizerId !== organizerId) { 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) { if (!participant || participant.tripId !== tripId) {
throw new Error("Peserta tidak ditemukan"); throw new Error("Peserta tidak ditemukan");
} }
@@ -328,17 +311,28 @@ export const tripService = {
throw new Error("Peserta ini tidak dalam status menunggu persetujuan"); 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) => {
const trip = await tx.trip.findUnique({
where: { id: tripId },
select: {
id: true,
status: true,
organizerId: true,
maxParticipants: true,
},
});
if (!trip) { if (!trip) {
throw new Error("Trip tidak ditemukan"); throw new Error("Trip tidak ditemukan");
} }
if (trip.organizerId !== organizerId) { 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) { if (!participant || participant.tripId !== tripId) {
throw new Error("Peserta tidak ditemukan"); throw new Error("Peserta tidak ditemukan");
} }
if (participant.status !== "PENDING") { 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) => { // Update kondisional pada status PENDING — aman dari race dengan
await tx.tripParticipant.update({ // confirm/cancel yang berjalan bersamaan.
where: { id: participantId }, 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" },
}); });
});
// Slot kembali kosong → re-open trip, kondisional `status: "FULL"`.
if (trip.status === "FULL") { if (trip.status === "FULL") {
const count = await participantRepo.countByTrip(tripId); const activeCount = await tx.tripParticipant.count({
if (count < trip.maxParticipants) { where: { tripId, status: { not: "CANCELLED" } },
await tripRepo.updateStatus(tripId, "OPEN"); });
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,11 +455,7 @@ 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++) {
try {
return await prisma.$transaction(
async (tx) => {
const trip = await tx.trip.findUnique({ const trip = await tx.trip.findUnique({
where: { id: tripId }, where: { id: tripId },
select: { id: true, status: true, organizerId: true, date: true }, select: { id: true, status: true, organizerId: true, date: true },
@@ -439,13 +463,8 @@ export const tripService = {
if (!trip) { if (!trip) {
throw new Error("Trip tidak ditemukan"); throw new Error("Trip tidak ditemukan");
} }
if ( if (actor.type === "ORGANIZER" && trip.organizerId !== actor.userId) {
actor.type === "ORGANIZER" && throw new Error("Hanya organizer trip ini yang bisa membatalkan trip");
trip.organizerId !== actor.userId
) {
throw new Error(
"Hanya organizer trip ini yang bisa membatalkan trip"
);
} }
if (trip.status === "CLOSED") { if (trip.status === "CLOSED") {
throw new Error("Trip sudah dibatalkan"); throw new Error("Trip sudah dibatalkan");
@@ -568,23 +587,6 @@ export const tripService = {
cancelledBookings, cancelledBookings,
skippedBookings, skippedBookings,
}; };
}, }, "Gagal membatalkan trip. Coba lagi sebentar.");
{
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.");
}, },
}; };