refund roadmap pr-1 and pr-2

This commit is contained in:
2026-05-11 13:04:20 +07:00
parent d2b0a780d5
commit 54f4569107
36 changed files with 5750 additions and 19 deletions
+174
View File
@@ -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.");
},
};