Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 57f7764bf5 | |||
| da217c2946 |
@@ -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);
|
||||||
|
}
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "setrip",
|
"name": "setrip",
|
||||||
"version": "0.16.1",
|
"version": "0.16.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "setrip",
|
"name": "setrip",
|
||||||
"version": "0.16.1",
|
"version": "0.16.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next-auth/prisma-adapter": "^1.0.7",
|
"@next-auth/prisma-adapter": "^1.0.7",
|
||||||
"@prisma/adapter-pg": "^7.7.0",
|
"@prisma/adapter-pg": "^7.7.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "setrip",
|
"name": "setrip",
|
||||||
"version": "0.16.1",
|
"version": "0.16.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
@@ -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
@@ -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.");
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user