refund roadmap pr-1 and pr-2
This commit is contained in:
@@ -0,0 +1,502 @@
|
||||
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
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user