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(); }, 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 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(); 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."); }, };