refund roadmap pr-1 and pr-2
This commit is contained in:
@@ -5,6 +5,7 @@ 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";
|
||||
@@ -253,6 +254,19 @@ export const tripService = {
|
||||
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 } },
|
||||
@@ -422,4 +436,164 @@ export const tripService = {
|
||||
|
||||
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.");
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user