Files
setrip/server/services/trip.service.ts
T
2026-05-11 13:04:20 +07:00

600 lines
19 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 { 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);
} 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.");
},
};