612 lines
20 KiB
TypeScript
612 lines
20 KiB
TypeScript
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 { bookingService } from "@/server/services/booking.service";
|
|
import { refundService } from "@/server/services/refund.service";
|
|
import { payoutService } from "@/server/services/payout.service";
|
|
import { payoutRepo } from "@/server/repositories/payout.repo";
|
|
import { LIMITS } from "@/lib/limits";
|
|
import { utcStartOfDay, isTripDepartureDayPast } from "@/lib/trip-dates";
|
|
import { isFreeTrip } from "@/lib/trip-pricing";
|
|
|
|
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 {
|
|
category: ActivityCategory;
|
|
title: string;
|
|
description?: string;
|
|
destination: string;
|
|
location: string;
|
|
meetingPoint?: string;
|
|
itinerary?: string;
|
|
whatsIncluded?: string;
|
|
whatsExcluded?: string;
|
|
date: Date;
|
|
endDate?: Date;
|
|
maxParticipants: number;
|
|
price: number;
|
|
vibe?: Vibe;
|
|
organizerId: string;
|
|
imageUrls?: string[];
|
|
}
|
|
|
|
export const tripService = {
|
|
async getOpenTrips(filters?: TripFilters) {
|
|
return tripRepo.findOpen(filters);
|
|
},
|
|
|
|
async getAllTrips() {
|
|
return tripRepo.findAll();
|
|
},
|
|
|
|
async getTripById(id: string) {
|
|
const trip = await tripRepo.findById(id);
|
|
if (!trip) {
|
|
throw new Error("Trip tidak ditemukan");
|
|
}
|
|
return trip;
|
|
},
|
|
|
|
async createTrip(input: CreateTripInput) {
|
|
if (isTripDepartureDayPast(input.date)) {
|
|
throw new Error("Tanggal berangkat tidak boleh di masa lalu");
|
|
}
|
|
|
|
if (input.endDate && input.endDate.getTime() < input.date.getTime()) {
|
|
throw new Error("Tanggal pulang tidak boleh sebelum tanggal berangkat");
|
|
}
|
|
|
|
const since = utcStartOfDay(new Date());
|
|
const images = input.imageUrls?.length
|
|
? {
|
|
create: input.imageUrls.map((url, i) => ({ url, order: i })),
|
|
}
|
|
: undefined;
|
|
|
|
const tripData = {
|
|
category: input.category,
|
|
title: input.title,
|
|
description: input.description,
|
|
destination: input.destination,
|
|
location: input.location,
|
|
meetingPoint: input.meetingPoint,
|
|
itinerary: input.itinerary,
|
|
whatsIncluded: input.whatsIncluded,
|
|
whatsExcluded: input.whatsExcluded,
|
|
date: input.date,
|
|
endDate: input.endDate,
|
|
maxParticipants: input.maxParticipants,
|
|
price: input.price,
|
|
vibe: input.vibe,
|
|
organizer: { connect: { id: input.organizerId } },
|
|
images,
|
|
} satisfies Prisma.TripCreateInput;
|
|
|
|
let lastErr: unknown;
|
|
for (let attempt = 0; attempt < SERIAL_TX_ATTEMPTS; attempt++) {
|
|
try {
|
|
return await prisma.$transaction(
|
|
async (tx) => {
|
|
const todayCount = await tx.trip.count({
|
|
where: {
|
|
organizerId: input.organizerId,
|
|
createdAt: { gte: since },
|
|
},
|
|
});
|
|
if (todayCount >= LIMITS.MAX_TRIPS_PER_ORGANIZER_PER_DAY) {
|
|
throw new Error(
|
|
`Batas harian: maksimal ${LIMITS.MAX_TRIPS_PER_ORGANIZER_PER_DAY} trip per hari. Coba lagi besok.`
|
|
);
|
|
}
|
|
return tx.trip.create({ data: tripData });
|
|
},
|
|
{
|
|
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
|
|
maxWait: 5000,
|
|
timeout: 15000,
|
|
}
|
|
);
|
|
} 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) {
|
|
let lastErr: unknown;
|
|
for (let attempt = 0; attempt < SERIAL_TX_ATTEMPTS; attempt++) {
|
|
try {
|
|
return await prisma.$transaction(
|
|
async (tx) => {
|
|
const trip = await tx.trip.findUnique({
|
|
where: { id: tripId },
|
|
select: {
|
|
id: true,
|
|
status: true,
|
|
date: true,
|
|
organizerId: true,
|
|
maxParticipants: true,
|
|
price: true,
|
|
},
|
|
});
|
|
|
|
if (!trip) {
|
|
throw new Error("Trip tidak ditemukan");
|
|
}
|
|
if (trip.status !== "OPEN") {
|
|
throw new Error("Trip tidak tersedia untuk pendaftaran");
|
|
}
|
|
if (isTripDepartureDayPast(trip.date)) {
|
|
throw new Error(
|
|
"Trip sudah melewati tanggal berangkat, tidak bisa mendaftar"
|
|
);
|
|
}
|
|
if (trip.organizerId === userId) {
|
|
throw new Error("Organizer tidak bisa join trip sendiri");
|
|
}
|
|
|
|
const existing = await tx.tripParticipant.findUnique({
|
|
where: { tripId_userId: { tripId, userId } },
|
|
});
|
|
if (existing && existing.status !== "CANCELLED") {
|
|
throw new Error("Kamu sudah terdaftar di trip ini");
|
|
}
|
|
|
|
const participantCount = await tx.tripParticipant.count({
|
|
where: { tripId, status: { not: "CANCELLED" } },
|
|
});
|
|
if (participantCount >= trip.maxParticipants) {
|
|
throw new Error("Trip sudah penuh");
|
|
}
|
|
|
|
const participant =
|
|
existing?.status === "CANCELLED"
|
|
? await tx.tripParticipant.update({
|
|
where: { tripId_userId: { tripId, userId } },
|
|
data: {
|
|
status: "PENDING",
|
|
markedPaidAt: null,
|
|
paymentConfirmedAt: null,
|
|
},
|
|
})
|
|
: await tx.tripParticipant.create({
|
|
data: { tripId, userId, status: "PENDING" },
|
|
});
|
|
|
|
// Booking 1-1 ke participant. Upsert untuk handle re-join setelah CANCELLED.
|
|
await tx.booking.upsert({
|
|
where: { participantId: participant.id },
|
|
create: {
|
|
tripId,
|
|
userId,
|
|
participantId: participant.id,
|
|
amount: trip.price,
|
|
status: "PENDING",
|
|
},
|
|
update: {
|
|
status: "PENDING",
|
|
amount: trip.price,
|
|
},
|
|
});
|
|
|
|
const newCount = await tx.tripParticipant.count({
|
|
where: { tripId, status: { not: "CANCELLED" } },
|
|
});
|
|
if (newCount >= trip.maxParticipants) {
|
|
await tx.trip.update({
|
|
where: { id: tripId },
|
|
data: { status: "FULL" },
|
|
});
|
|
}
|
|
|
|
return participant;
|
|
},
|
|
{
|
|
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
|
|
maxWait: 5000,
|
|
timeout: 15000,
|
|
}
|
|
);
|
|
} catch (e) {
|
|
lastErr = e;
|
|
if (isSerializationConflict(e) && attempt < SERIAL_TX_ATTEMPTS - 1) {
|
|
continue;
|
|
}
|
|
throw e;
|
|
}
|
|
}
|
|
throw lastErr instanceof Error
|
|
? lastErr
|
|
: new Error("Pendaftaran sedang ramai. Coba lagi sebentar.");
|
|
},
|
|
|
|
async cancelJoin(tripId: string, userId: string) {
|
|
const trip = await tripRepo.findById(tripId);
|
|
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);
|
|
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);
|
|
if (
|
|
existingBooking &&
|
|
(existingBooking.status === "PAID" ||
|
|
existingBooking.status === "PARTIALLY_REFUNDED")
|
|
) {
|
|
throw new Error(
|
|
"Booking kamu sudah lunas — pakai tombol 'Cancel & Request Refund' supaya uang bisa dikembalikan."
|
|
);
|
|
}
|
|
|
|
const result = await prisma.$transaction(async (tx) => {
|
|
const cancelled = await tx.tripParticipant.update({
|
|
where: { tripId_userId: { tripId, userId } },
|
|
data: {
|
|
status: "CANCELLED",
|
|
markedPaidAt: null,
|
|
paymentConfirmedAt: null,
|
|
},
|
|
});
|
|
await tx.booking.updateMany({
|
|
where: { participantId: existing.id },
|
|
data: { status: "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(
|
|
tripId: string,
|
|
participantId: string,
|
|
organizerId: string
|
|
) {
|
|
const trip = await tripRepo.findById(tripId);
|
|
if (!trip) {
|
|
throw new Error("Trip tidak ditemukan");
|
|
}
|
|
if (trip.organizerId !== organizerId) {
|
|
throw new Error("Hanya organizer trip ini yang bisa mengonfirmasi peserta");
|
|
}
|
|
|
|
const participant = await participantRepo.findById(participantId);
|
|
if (!participant || participant.tripId !== tripId) {
|
|
throw new Error("Peserta tidak ditemukan");
|
|
}
|
|
if (participant.status !== "PENDING") {
|
|
throw new Error("Peserta ini tidak dalam status menunggu persetujuan");
|
|
}
|
|
|
|
// Trip gratis: Booking langsung PAID. Trip berbayar: AWAITING_PAY (tinggal bayar).
|
|
const nextBookingStatus = isFreeTrip(trip) ? "PAID" : "AWAITING_PAY";
|
|
|
|
return prisma.$transaction(async (tx) => {
|
|
await tx.booking.updateMany({
|
|
where: { participantId },
|
|
data: { status: nextBookingStatus },
|
|
});
|
|
return tx.tripParticipant.update({
|
|
where: { id: participantId },
|
|
data: { status: "CONFIRMED" },
|
|
});
|
|
});
|
|
},
|
|
|
|
async rejectParticipant(
|
|
tripId: string,
|
|
participantId: string,
|
|
organizerId: string
|
|
) {
|
|
const trip = await tripRepo.findById(tripId);
|
|
if (!trip) {
|
|
throw new Error("Trip tidak ditemukan");
|
|
}
|
|
if (trip.organizerId !== organizerId) {
|
|
throw new Error("Hanya organizer trip ini yang bisa menolak permintaan bergabung");
|
|
}
|
|
|
|
const participant = await participantRepo.findById(participantId);
|
|
if (!participant || participant.tripId !== tripId) {
|
|
throw new Error("Peserta tidak ditemukan");
|
|
}
|
|
if (participant.status !== "PENDING") {
|
|
throw new Error("Hanya permintaan yang masih menunggu yang bisa ditolak");
|
|
}
|
|
|
|
await prisma.$transaction(async (tx) => {
|
|
await tx.tripParticipant.update({
|
|
where: { id: participantId },
|
|
data: {
|
|
status: "CANCELLED",
|
|
markedPaidAt: null,
|
|
paymentConfirmedAt: null,
|
|
},
|
|
});
|
|
await tx.booking.updateMany({
|
|
where: { participantId },
|
|
data: { status: "CANCELLED" },
|
|
});
|
|
});
|
|
|
|
if (trip.status === "FULL") {
|
|
const count = await participantRepo.countByTrip(tripId);
|
|
if (count < trip.maxParticipants) {
|
|
await tripRepo.updateStatus(tripId, "OPEN");
|
|
}
|
|
}
|
|
|
|
return { ok: true as const };
|
|
},
|
|
|
|
async markParticipantPayment(tripId: string, userId: string) {
|
|
const trip = await tripRepo.findById(tripId);
|
|
if (!trip) {
|
|
throw new Error("Trip tidak ditemukan");
|
|
}
|
|
|
|
if (isFreeTrip(trip)) {
|
|
throw new Error(
|
|
"Trip ini gratis — tidak ada pembayaran yang perlu ditandai"
|
|
);
|
|
}
|
|
|
|
if (isTripDepartureDayPast(trip.date)) {
|
|
throw new Error("Trip sudah lewat — pembayaran tidak perlu ditandai");
|
|
}
|
|
|
|
const booking = await bookingRepo.findByTripAndUser(tripId, userId);
|
|
if (!booking || booking.status === "CANCELLED") {
|
|
throw new Error("Kamu tidak terdaftar di trip ini");
|
|
}
|
|
|
|
return bookingService.markPaidManual(booking.id, userId);
|
|
},
|
|
|
|
/**
|
|
* Auto-complete trip yang sudah lewat. Dipakai cron harian.
|
|
*
|
|
* Cutoff = start of today UTC. Trip dengan endDate < cutoff (atau, kalau
|
|
* endDate null, date < cutoff) di-set status COMPLETED — selama statusnya
|
|
* masih OPEN/FULL. CLOSED trip tidak disentuh (organizer eksplisit batalkan).
|
|
*
|
|
* Idempotent: dua kali run di hari sama, run kedua nge-match 0 row.
|
|
*/
|
|
async autoCompletePastTrips() {
|
|
const cutoff = utcStartOfDay(new Date());
|
|
return tripRepo.bulkCompletePastTrips(cutoff);
|
|
},
|
|
|
|
async confirmParticipantPayment(
|
|
tripId: string,
|
|
participantId: string,
|
|
organizerId: string
|
|
) {
|
|
const trip = await tripRepo.findById(tripId);
|
|
if (!trip) {
|
|
throw new Error("Trip tidak ditemukan");
|
|
}
|
|
if (trip.organizerId !== organizerId) {
|
|
throw new Error("Hanya organizer yang bisa mengonfirmasi pembayaran");
|
|
}
|
|
if (isFreeTrip(trip)) {
|
|
throw new Error("Trip ini gratis — tidak ada pembayaran yang perlu dikonfirmasi");
|
|
}
|
|
|
|
const booking = await bookingRepo.findByParticipantId(participantId);
|
|
if (!booking || booking.tripId !== tripId) {
|
|
throw new Error("Booking tidak ditemukan");
|
|
}
|
|
|
|
return bookingService.confirmPaidManual(booking.id, organizerId);
|
|
},
|
|
|
|
/**
|
|
* Organizer batalkan trip (Trip.status = CLOSED). Atomic dalam satu
|
|
* serializable transaction:
|
|
* - Set Trip.status = CLOSED.
|
|
* - Untuk setiap peserta aktif:
|
|
* - Booking PAID → buat Refund ORGANIZER_CANCELLED (auto-approved, full
|
|
* amount). Booking tetap PAID sampai admin mark SUCCEEDED — jejak
|
|
* finansial harus terjaga.
|
|
* - Booking PENDING/AWAITING_PAY → set CANCELLED langsung (uang belum
|
|
* masuk, tidak ada refund).
|
|
* - Booking PARTIALLY_REFUNDED / dengan refund aktif → di-skip (admin
|
|
* handle manual supaya tidak double-refund).
|
|
* - Semua TripParticipant aktif → CANCELLED.
|
|
*
|
|
* Idempotent: trip yang sudah CLOSED/COMPLETED akan ditolak supaya tidak
|
|
* dobel-buat refund.
|
|
*/
|
|
async closeTrip(tripId: string, organizerId: string) {
|
|
let lastErr: unknown;
|
|
for (let attempt = 0; attempt < SERIAL_TX_ATTEMPTS; attempt++) {
|
|
try {
|
|
return await prisma.$transaction(
|
|
async (tx) => {
|
|
const trip = await tx.trip.findUnique({
|
|
where: { id: tripId },
|
|
select: { id: true, status: true, organizerId: true, date: true },
|
|
});
|
|
if (!trip) {
|
|
throw new Error("Trip tidak ditemukan");
|
|
}
|
|
if (trip.organizerId !== organizerId) {
|
|
throw new Error(
|
|
"Hanya organizer trip ini yang bisa membatalkan trip"
|
|
);
|
|
}
|
|
if (trip.status === "CLOSED") {
|
|
throw new Error("Trip sudah dibatalkan");
|
|
}
|
|
if (trip.status === "COMPLETED") {
|
|
throw new Error(
|
|
"Trip sudah selesai (COMPLETED) — tidak bisa dibatalkan"
|
|
);
|
|
}
|
|
if (isTripDepartureDayPast(trip.date)) {
|
|
throw new Error(
|
|
"Tanggal berangkat sudah lewat — gunakan flow pelaporan biasa ke admin"
|
|
);
|
|
}
|
|
|
|
const bookings = await tx.booking.findMany({
|
|
where: { tripId },
|
|
include: {
|
|
payments: {
|
|
where: { status: "PAID" },
|
|
orderBy: { paidAt: "desc" },
|
|
take: 1,
|
|
},
|
|
refunds: {
|
|
where: {
|
|
status: { in: ["PENDING", "APPROVED", "PROCESSING"] },
|
|
},
|
|
select: { id: true },
|
|
},
|
|
},
|
|
});
|
|
|
|
const refundsCreated: string[] = [];
|
|
const cancelledBookings: string[] = [];
|
|
const skippedBookings: string[] = [];
|
|
|
|
for (const b of bookings) {
|
|
if (b.status === "CANCELLED" || b.status === "EXPIRED") {
|
|
continue;
|
|
}
|
|
if (b.status === "REFUNDED") {
|
|
continue;
|
|
}
|
|
if (b.refunds.length > 0) {
|
|
// Sudah ada refund aktif (mis. user request cancel). Admin
|
|
// handle manual supaya tidak konflik dengan refund existing.
|
|
skippedBookings.push(b.id);
|
|
continue;
|
|
}
|
|
|
|
if (b.status === "PAID" || b.status === "PARTIALLY_REFUNDED") {
|
|
const paid = b.payments[0];
|
|
if (!paid) {
|
|
// Payment tidak konsisten dgn booking status — skip + flag.
|
|
skippedBookings.push(b.id);
|
|
continue;
|
|
}
|
|
// Untuk PARTIALLY_REFUNDED, hitung sisa refundable.
|
|
const alreadyRefunded = await tx.refund.aggregate({
|
|
where: { bookingId: b.id, status: "SUCCEEDED" },
|
|
_sum: { amount: true },
|
|
});
|
|
const remaining = paid.amount - (alreadyRefunded._sum.amount ?? 0);
|
|
if (remaining <= 0) {
|
|
continue;
|
|
}
|
|
const refund = await refundService.createSystemRefundForClosedTrip(
|
|
tx,
|
|
{
|
|
bookingId: b.id,
|
|
paymentId: paid.id,
|
|
amount: remaining,
|
|
}
|
|
);
|
|
refundsCreated.push(refund.id);
|
|
|
|
// Trip dibatalkan → cancel payout HELD/RELEASED organizer untuk
|
|
// booking ini. Payout PAID di-flag clawback otomatis.
|
|
const payout = await payoutRepo.findByBookingId(b.id, tx);
|
|
if (payout) {
|
|
await payoutService.cancel(tx, {
|
|
payoutId: payout.id,
|
|
reason: "Trip dibatalkan organizer.",
|
|
});
|
|
}
|
|
} else {
|
|
// PENDING / AWAITING_PAY → uang belum masuk → langsung CANCELLED.
|
|
await tx.booking.update({
|
|
where: { id: b.id },
|
|
data: { status: "CANCELLED" },
|
|
});
|
|
cancelledBookings.push(b.id);
|
|
}
|
|
}
|
|
|
|
// Semua participant aktif → CANCELLED (apapun status booking-nya).
|
|
await tx.tripParticipant.updateMany({
|
|
where: { tripId, status: { not: "CANCELLED" } },
|
|
data: {
|
|
status: "CANCELLED",
|
|
markedPaidAt: null,
|
|
paymentConfirmedAt: null,
|
|
},
|
|
});
|
|
|
|
await tx.trip.update({
|
|
where: { id: tripId },
|
|
data: { status: "CLOSED" },
|
|
});
|
|
|
|
return {
|
|
ok: true as const,
|
|
refundsCreated,
|
|
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.");
|
|
},
|
|
};
|