503 lines
16 KiB
TypeScript
503 lines
16 KiB
TypeScript
import { randomBytes } from "crypto";
|
|
import { Prisma } from "@/app/generated/prisma/client";
|
|
import { prisma } from "@/lib/prisma";
|
|
import { refundRepo } from "@/server/repositories/refund.repo";
|
|
import { calculateRefundAmount, daysUntilDeparture } from "@/lib/refund-policy";
|
|
import { isTripDepartureDayPast } from "@/lib/trip-dates";
|
|
|
|
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"
|
|
);
|
|
}
|
|
|
|
async function runSerializable<T>(fn: (tx: Prisma.TransactionClient) => Promise<T>): Promise<T> {
|
|
let lastErr: unknown;
|
|
for (let attempt = 0; attempt < SERIAL_TX_ATTEMPTS; attempt++) {
|
|
try {
|
|
return await prisma.$transaction(fn, {
|
|
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 memproses refund. Coba lagi sebentar.");
|
|
}
|
|
|
|
function newIdempotencyKey(): string {
|
|
return `refund_${randomBytes(16).toString("hex")}`;
|
|
}
|
|
|
|
type RequestRefundInput = {
|
|
bookingId: string;
|
|
reason:
|
|
| "USER_CANCELLATION"
|
|
| "ORGANIZER_CANCELLED"
|
|
| "TRIP_ISSUE"
|
|
| "ADMIN_ADJUSTMENT"
|
|
| "DISPUTE_RESOLVED"
|
|
| "OTHER";
|
|
reportedBy: "PARTICIPANT" | "ORGANIZER";
|
|
reportNote: string;
|
|
/** Nominal refund (IDR). Kalau tidak diisi → service akan pakai sisa
|
|
* refundable amount (payment.amount - sudah-di-refund). */
|
|
amount?: number;
|
|
/** Admin yang memasukkan laporan ke sistem. */
|
|
initiatedByAdminId: string;
|
|
};
|
|
|
|
export const refundService = {
|
|
/**
|
|
* Admin mencatat laporan refund dari peserta atau organizer ke sistem.
|
|
* Status awal: PENDING. Belum mengubah Booking/Payment status.
|
|
*
|
|
* Idempotency: kalau booking masih punya refund PENDING/APPROVED/PROCESSING,
|
|
* tolak — admin harus selesaikan yang lama dulu (reject atau succeeded).
|
|
*/
|
|
async requestRefund(input: RequestRefundInput) {
|
|
return runSerializable(async (tx) => {
|
|
const booking = await tx.booking.findUnique({
|
|
where: { id: input.bookingId },
|
|
include: {
|
|
payments: {
|
|
where: { status: "PAID" },
|
|
orderBy: { paidAt: "desc" },
|
|
take: 1,
|
|
},
|
|
},
|
|
});
|
|
if (!booking) {
|
|
throw new Error("Booking tidak ditemukan");
|
|
}
|
|
if (booking.amount <= 0) {
|
|
throw new Error("Booking gratis — tidak ada nominal untuk di-refund");
|
|
}
|
|
if (booking.status === "CANCELLED" || booking.status === "EXPIRED") {
|
|
throw new Error(
|
|
"Booking sudah dibatalkan/expired — tidak ada pembayaran untuk di-refund"
|
|
);
|
|
}
|
|
if (booking.status === "REFUNDED") {
|
|
throw new Error("Booking sudah refund penuh");
|
|
}
|
|
|
|
const paidPayment = booking.payments[0];
|
|
if (!paidPayment) {
|
|
throw new Error(
|
|
"Tidak ada Payment dengan status PAID di booking ini — tidak bisa di-refund"
|
|
);
|
|
}
|
|
|
|
const hasActive = await refundRepo.hasActiveRefund(input.bookingId, tx);
|
|
if (hasActive) {
|
|
throw new Error(
|
|
"Booking ini masih punya refund yang sedang diproses. Selesaikan dulu sebelum membuat yang baru."
|
|
);
|
|
}
|
|
|
|
const alreadyRefunded = await refundRepo.sumSucceededAmount(input.bookingId, tx);
|
|
const remaining = paidPayment.amount - alreadyRefunded;
|
|
if (remaining <= 0) {
|
|
throw new Error("Seluruh nominal sudah di-refund");
|
|
}
|
|
|
|
const amount = input.amount ?? remaining;
|
|
if (!Number.isInteger(amount) || amount <= 0) {
|
|
throw new Error("Nominal refund harus bilangan bulat positif");
|
|
}
|
|
if (amount > remaining) {
|
|
throw new Error(
|
|
`Nominal refund (Rp ${amount.toLocaleString("id-ID")}) melebihi sisa yang bisa di-refund (Rp ${remaining.toLocaleString("id-ID")})`
|
|
);
|
|
}
|
|
|
|
return refundRepo.create(
|
|
{
|
|
bookingId: input.bookingId,
|
|
paymentId: paidPayment.id,
|
|
amount,
|
|
reason: input.reason,
|
|
reportedBy: input.reportedBy,
|
|
reportNote: input.reportNote,
|
|
initiatedBy: "ADMIN",
|
|
idempotencyKey: newIdempotencyKey(),
|
|
},
|
|
tx
|
|
);
|
|
});
|
|
},
|
|
|
|
/** PENDING → APPROVED. Boleh menambah catatan admin (opsional). */
|
|
async approveRefund(input: { refundId: string; adminId: string; adminNote?: string }) {
|
|
return runSerializable(async (tx) => {
|
|
const refund = await tx.refund.findUnique({ where: { id: input.refundId } });
|
|
if (!refund) {
|
|
throw new Error("Refund tidak ditemukan");
|
|
}
|
|
if (refund.status !== "PENDING") {
|
|
throw new Error("Hanya refund berstatus PENDING yang bisa disetujui");
|
|
}
|
|
return refundRepo.update(
|
|
input.refundId,
|
|
{
|
|
status: "APPROVED",
|
|
reviewedById: input.adminId,
|
|
reviewedAt: new Date(),
|
|
adminNote: input.adminNote ?? refund.adminNote,
|
|
},
|
|
tx
|
|
);
|
|
});
|
|
},
|
|
|
|
/** PENDING → REJECTED. Alasan wajib supaya audit trail jelas. */
|
|
async rejectRefund(input: { refundId: string; adminId: string; adminNote: string }) {
|
|
if (!input.adminNote.trim()) {
|
|
throw new Error("Alasan tolak wajib diisi");
|
|
}
|
|
return runSerializable(async (tx) => {
|
|
const refund = await tx.refund.findUnique({ where: { id: input.refundId } });
|
|
if (!refund) {
|
|
throw new Error("Refund tidak ditemukan");
|
|
}
|
|
if (refund.status !== "PENDING") {
|
|
throw new Error("Hanya refund berstatus PENDING yang bisa ditolak");
|
|
}
|
|
return refundRepo.update(
|
|
input.refundId,
|
|
{
|
|
status: "REJECTED",
|
|
reviewedById: input.adminId,
|
|
reviewedAt: new Date(),
|
|
adminNote: input.adminNote.trim(),
|
|
},
|
|
tx
|
|
);
|
|
});
|
|
},
|
|
|
|
/**
|
|
* APPROVED → SUCCEEDED untuk manual transfer (admin sudah transfer manual
|
|
* ke rekening peserta). adminNote diharapkan berisi referensi transfer.
|
|
*
|
|
* Side effects:
|
|
* - Update Payment.status → REFUNDED hanya saat full refund.
|
|
* - Update Booking.status → REFUNDED (full) atau PARTIALLY_REFUNDED (partial).
|
|
* - Untuk USER_CANCELLATION: bebaskan slot — set TripParticipant → CANCELLED
|
|
* dan re-open Trip (FULL → OPEN) kalau peserta aktif < maxParticipants.
|
|
* Untuk ORGANIZER_CANCELLED slot tidak perlu dibebaskan (trip sudah CLOSED).
|
|
*/
|
|
async markSucceededManual(input: {
|
|
refundId: string;
|
|
adminId: string;
|
|
adminNote: string;
|
|
}) {
|
|
if (!input.adminNote.trim()) {
|
|
throw new Error("Catatan/referensi transfer wajib diisi");
|
|
}
|
|
return runSerializable(async (tx) => {
|
|
const refund = await tx.refund.findUnique({
|
|
where: { id: input.refundId },
|
|
});
|
|
if (!refund) {
|
|
throw new Error("Refund tidak ditemukan");
|
|
}
|
|
if (refund.status !== "APPROVED") {
|
|
throw new Error(
|
|
"Hanya refund APPROVED yang bisa ditandai SUCCEEDED. Setujui dulu."
|
|
);
|
|
}
|
|
|
|
const now = new Date();
|
|
|
|
await refundRepo.update(
|
|
input.refundId,
|
|
{
|
|
status: "SUCCEEDED",
|
|
succeededAt: now,
|
|
reviewedById: input.adminId,
|
|
reviewedAt: now,
|
|
adminNote: input.adminNote.trim(),
|
|
},
|
|
tx
|
|
);
|
|
|
|
const totalRefunded = await refundRepo.sumSucceededAmount(
|
|
refund.bookingId,
|
|
tx
|
|
);
|
|
|
|
if (refund.paymentId) {
|
|
const payment = await tx.payment.findUnique({
|
|
where: { id: refund.paymentId },
|
|
});
|
|
if (payment && totalRefunded >= payment.amount) {
|
|
await tx.payment.update({
|
|
where: { id: payment.id },
|
|
data: { status: "REFUNDED" },
|
|
});
|
|
}
|
|
}
|
|
|
|
const booking = await tx.booking.findUnique({
|
|
where: { id: refund.bookingId },
|
|
include: {
|
|
trip: { select: { id: true, status: true, maxParticipants: true } },
|
|
payments: {
|
|
where: { status: { in: ["PAID", "REFUNDED"] } },
|
|
orderBy: { paidAt: "desc" },
|
|
take: 1,
|
|
},
|
|
},
|
|
});
|
|
if (!booking) {
|
|
throw new Error("Booking tidak ditemukan saat menutup refund");
|
|
}
|
|
const paid = booking.payments[0];
|
|
if (paid) {
|
|
const nextStatus =
|
|
totalRefunded >= paid.amount ? "REFUNDED" : "PARTIALLY_REFUNDED";
|
|
if (booking.status !== nextStatus) {
|
|
await tx.booking.update({
|
|
where: { id: booking.id },
|
|
data: { status: nextStatus },
|
|
});
|
|
}
|
|
}
|
|
|
|
// Slot release untuk user cancellation. Organizer cancel di-handle
|
|
// closeTrip (participant + trip sudah di-CANCELLED/CLOSED di sana).
|
|
if (refund.reason === "USER_CANCELLATION") {
|
|
await tx.tripParticipant.updateMany({
|
|
where: {
|
|
id: booking.participantId,
|
|
status: { not: "CANCELLED" },
|
|
},
|
|
data: {
|
|
status: "CANCELLED",
|
|
markedPaidAt: null,
|
|
paymentConfirmedAt: null,
|
|
},
|
|
});
|
|
if (booking.trip.status === "FULL") {
|
|
const remaining = await tx.tripParticipant.count({
|
|
where: { tripId: booking.tripId, status: { not: "CANCELLED" } },
|
|
});
|
|
if (remaining < booking.trip.maxParticipants) {
|
|
await tx.trip.update({
|
|
where: { id: booking.tripId },
|
|
data: { status: "OPEN" },
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return { ok: true as const };
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Peserta cancel booking sendiri. Hitung refund pakai policy default
|
|
* (lib/refund-policy.ts) — hardcoded MVP, akan jadi data-driven di R-5.
|
|
*
|
|
* Behaviour:
|
|
* - Kalau hasil hitung = 0 (di luar window): cancel participant + booking
|
|
* langsung, tanpa Refund row (uang tidak balik).
|
|
* - Kalau hasil hitung > 0: buat Refund PENDING (initiatedBy=USER,
|
|
* reportedBy=PARTICIPANT, reason=USER_CANCELLATION). Participant + booking
|
|
* TETAP CONFIRMED/PAID sampai admin mark SUCCEEDED — slot baru bebas saat
|
|
* refund tuntas. Cegah double-request via hasActiveRefund.
|
|
*/
|
|
async requestUserCancellation(input: {
|
|
bookingId: string;
|
|
userId: string;
|
|
}) {
|
|
return runSerializable(async (tx) => {
|
|
const booking = await tx.booking.findUnique({
|
|
where: { id: input.bookingId },
|
|
include: {
|
|
trip: {
|
|
select: {
|
|
id: true,
|
|
date: true,
|
|
status: true,
|
|
maxParticipants: true,
|
|
},
|
|
},
|
|
payments: {
|
|
where: { status: "PAID" },
|
|
orderBy: { paidAt: "desc" },
|
|
take: 1,
|
|
},
|
|
},
|
|
});
|
|
if (!booking) {
|
|
throw new Error("Booking tidak ditemukan");
|
|
}
|
|
if (booking.userId !== input.userId) {
|
|
throw new Error("Booking ini bukan milikmu");
|
|
}
|
|
if (booking.status !== "PAID") {
|
|
throw new Error(
|
|
"Hanya booking PAID yang bisa cancel dengan refund. Booking yang belum lunas bisa cancel dari tombol 'Batal Ikut'."
|
|
);
|
|
}
|
|
if (isTripDepartureDayPast(booking.trip.date)) {
|
|
throw new Error(
|
|
"Trip sudah lewat tanggal berangkat — pembatalan ditutup"
|
|
);
|
|
}
|
|
const paid = booking.payments[0];
|
|
if (!paid) {
|
|
throw new Error(
|
|
"Tidak ada Payment dengan status PAID di booking ini"
|
|
);
|
|
}
|
|
|
|
const days = daysUntilDeparture(booking.trip.date);
|
|
const refundAmount = calculateRefundAmount(paid.amount, days);
|
|
|
|
if (refundAmount === 0) {
|
|
await tx.tripParticipant.update({
|
|
where: { id: booking.participantId },
|
|
data: {
|
|
status: "CANCELLED",
|
|
markedPaidAt: null,
|
|
paymentConfirmedAt: null,
|
|
},
|
|
});
|
|
await tx.booking.update({
|
|
where: { id: booking.id },
|
|
data: { status: "CANCELLED" },
|
|
});
|
|
if (booking.trip.status === "FULL") {
|
|
const remaining = await tx.tripParticipant.count({
|
|
where: { tripId: booking.tripId, status: { not: "CANCELLED" } },
|
|
});
|
|
if (remaining < booking.trip.maxParticipants) {
|
|
await tx.trip.update({
|
|
where: { id: booking.tripId },
|
|
data: { status: "OPEN" },
|
|
});
|
|
}
|
|
}
|
|
return {
|
|
kind: "CANCELLED_NO_REFUND" as const,
|
|
days,
|
|
refundAmount: 0,
|
|
};
|
|
}
|
|
|
|
const hasActive = await refundRepo.hasActiveRefund(input.bookingId, tx);
|
|
if (hasActive) {
|
|
throw new Error(
|
|
"Booking ini sudah punya refund yang sedang diproses"
|
|
);
|
|
}
|
|
|
|
const percentage = Math.floor((refundAmount * 100) / paid.amount);
|
|
const refund = await refundRepo.create(
|
|
{
|
|
bookingId: booking.id,
|
|
paymentId: paid.id,
|
|
amount: refundAmount,
|
|
reason: "USER_CANCELLATION",
|
|
reportedBy: "PARTICIPANT",
|
|
reportNote: `Self-service cancel oleh peserta — H-${days} dari tanggal berangkat (refund ${percentage}%).`,
|
|
initiatedBy: "USER",
|
|
idempotencyKey: newIdempotencyKey(),
|
|
},
|
|
tx
|
|
);
|
|
|
|
return {
|
|
kind: "REFUND_PENDING" as const,
|
|
refundId: refund.id,
|
|
days,
|
|
refundAmount,
|
|
};
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Dipanggil dari tripService.closeTrip (organizer cancel trip) dengan tx
|
|
* yang sama. Buat Refund auto-approved untuk satu booking PAID. Tidak
|
|
* mengecek hasActiveRefund (caller harus filter dulu) supaya batch closeTrip
|
|
* idempotent dengan retry-safe.
|
|
*
|
|
* Refund langsung APPROVED — policy jelas (organizer cancel = 100% refund),
|
|
* tapi eksekusi (SUCCEEDED) tetap manual oleh admin.
|
|
*/
|
|
async createSystemRefundForClosedTrip(
|
|
tx: Prisma.TransactionClient,
|
|
input: {
|
|
bookingId: string;
|
|
paymentId: string;
|
|
amount: number;
|
|
}
|
|
) {
|
|
const now = new Date();
|
|
return tx.refund.create({
|
|
data: {
|
|
bookingId: input.bookingId,
|
|
paymentId: input.paymentId,
|
|
amount: input.amount,
|
|
reason: "ORGANIZER_CANCELLED",
|
|
reportedBy: "ORGANIZER",
|
|
reportNote: "Organizer membatalkan trip — auto-create oleh SYSTEM.",
|
|
initiatedBy: "SYSTEM",
|
|
idempotencyKey: newIdempotencyKey(),
|
|
status: "APPROVED",
|
|
reviewedAt: now,
|
|
adminNote: "Auto-approved (SYSTEM): organizer cancel = full refund.",
|
|
},
|
|
});
|
|
},
|
|
|
|
/**
|
|
* APPROVED/PROCESSING → FAILED. Catatan wajib (alasan gagal).
|
|
* Tidak mengubah Booking/Payment — uang belum keluar.
|
|
*/
|
|
async markFailed(input: { refundId: string; adminId: string; adminNote: string }) {
|
|
if (!input.adminNote.trim()) {
|
|
throw new Error("Alasan gagal wajib diisi");
|
|
}
|
|
return runSerializable(async (tx) => {
|
|
const refund = await tx.refund.findUnique({ where: { id: input.refundId } });
|
|
if (!refund) {
|
|
throw new Error("Refund tidak ditemukan");
|
|
}
|
|
if (refund.status !== "APPROVED" && refund.status !== "PROCESSING") {
|
|
throw new Error(
|
|
"Hanya refund APPROVED atau PROCESSING yang bisa ditandai FAILED"
|
|
);
|
|
}
|
|
return refundRepo.update(
|
|
input.refundId,
|
|
{
|
|
status: "FAILED",
|
|
failedAt: new Date(),
|
|
reviewedById: input.adminId,
|
|
reviewedAt: new Date(),
|
|
adminNote: input.adminNote.trim(),
|
|
},
|
|
tx
|
|
);
|
|
});
|
|
},
|
|
};
|