Files

626 lines
20 KiB
TypeScript

import { cache } from "react";
import { Prisma } from "@/app/generated/prisma/client";
import type { ActivityCategory, Vibe } from "@/app/generated/prisma/enums";
import { tripRepo, type TripFilters } from "@/server/repositories/trip.repo";
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";
import type { ItineraryItemInput } from "@/lib/itinerary";
import { runSerializable } from "@/lib/serializable-tx";
interface CreateTripInput {
category: ActivityCategory;
title: string;
description?: string;
destination: string;
location: string;
meetingPoint?: string;
whatsIncluded?: string;
whatsExcluded?: string;
date: Date;
endDate?: Date;
maxParticipants: number;
price: number;
vibe?: Vibe;
organizerId: string;
imageUrls?: string[];
itineraryItems?: ItineraryItemInput[];
}
export const tripService = {
async getOpenTrips(filters?: TripFilters) {
return tripRepo.findOpen(filters);
},
async getAllTrips() {
return tripRepo.findAll();
},
/**
* Ambil trip by id. Dibungkus `React.cache()` — `generateMetadata`, body
* halaman, dan `opengraph-image` memanggil ini untuk id yang sama dalam satu
* request, jadi query Prisma yang berat ini cukup jalan sekali per request.
*/
getTripById: cache(async (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 itineraryItems = input.itineraryItems?.length
? {
create: input.itineraryItems.map((item, i) => ({
day: item.day,
startTime: item.startTime,
endTime: item.endTime ?? null,
activity: item.activity.trim(),
order: i,
})),
}
: undefined;
const tripData = {
category: input.category,
title: input.title,
description: input.description,
destination: input.destination,
location: input.location,
meetingPoint: input.meetingPoint,
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,
itineraryItems,
} satisfies Prisma.TripCreateInput;
return runSerializable(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 });
}, "Gagal membuat trip. Coba lagi sebentar.");
},
async joinTrip(tripId: string, userId: string) {
return runSerializable(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;
}, "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) {
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 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/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 (
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 cancelled = await tx.tripParticipant.update({
where: { tripId_userId: { tripId, userId } },
data: {
status: "CANCELLED",
markedPaidAt: 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({
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;
});
},
async confirmParticipant(
tripId: string,
participantId: string,
organizerId: string
) {
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"
);
}
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("Peserta ini tidak dalam status menunggu persetujuan");
}
// Trip gratis: Booking langsung PAID. Trip berbayar: AWAITING_PAY.
const nextBookingStatus = isFreeTrip(trip) ? "PAID" : "AWAITING_PAY";
// 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, status: { not: "CANCELLED" } },
data: { status: nextBookingStatus },
});
return tx.tripParticipant.findUniqueOrThrow({
where: { id: participantId },
});
});
},
async rejectParticipant(
tripId: string,
participantId: string,
organizerId: string
) {
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"
);
}
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"
);
}
// 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,
status: { in: ["PENDING", "AWAITING_PAY"] },
},
data: { status: "CANCELLED" },
});
// Slot kembali kosong → re-open trip, kondisional `status: "FULL"`.
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 { ok: true as const };
});
},
/**
* 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);
},
/**
* Batalkan trip (Trip.status = CLOSED). Bisa dipicu organizer sendiri ATAU
* admin (intervensi). Atomic dalam satu serializable transaction:
* - Set Trip.status = CLOSED (+ `cancelledByAdminId` & `cancelledReason`
* kalau actor = ADMIN).
* - 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,
actor:
| { type: "ORGANIZER"; userId: string }
| { type: "ADMIN"; adminId: string; reason: string }
) {
return runSerializable(async (tx) => {
const trip = await tx.trip.findUnique({
where: { id: tripId },
select: {
id: true,
status: true,
organizerId: true,
date: true,
title: true,
organizer: { select: { id: true, email: true, name: true } },
},
});
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 (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[] = [];
// userId → nominal refund yang dibuat untuk dia (untuk email pembatalan).
const refundByUser = new Map<string, number>();
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);
refundByUser.set(b.userId, remaining);
// 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);
}
}
// Snapshot peserta aktif SEBELUM di-cancel — untuk notifikasi email.
const activeParticipants = await tx.tripParticipant.findMany({
where: { tripId, status: { not: "CANCELLED" } },
select: { user: { select: { id: true, email: true, name: true } } },
});
// 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,
// Data penerima notifikasi — email dikirim oleh action setelah tx commit.
notify: {
tripTitle: trip.title,
organizer: trip.organizer,
participants: activeParticipants.map((p) => ({
userId: p.user.id,
email: p.user.email,
name: p.user.name,
refundAmount: refundByUser.get(p.user.id) ?? 0,
})),
},
};
}, "Gagal membatalkan trip. Coba lagi sebentar.");
},
};