Files
setrip/server/services/refund.service.ts
T
2026-05-20 13:16:25 +07:00

485 lines
15 KiB
TypeScript

import { randomBytes } from "crypto";
import { Prisma } from "@/app/generated/prisma/client";
import { refundRepo } from "@/server/repositories/refund.repo";
import { calculateRefundAmount, daysUntilDeparture } from "@/lib/refund-policy";
import { isTripDepartureDayPast } from "@/lib/trip-dates";
import { payoutService } from "@/server/services/payout.service";
import { runSerializable } from "@/lib/serializable-tx";
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
);
// Escrow: kurangi (atau cancel) payout organizer sesuai nominal refund.
await payoutService.applyRefundDelta(tx, {
bookingId: refund.bookingId,
refundAmount: refund.amount,
});
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.trip.status === "CLOSED" ||
booking.trip.status === "COMPLETED"
) {
throw new Error(
"Trip sudah dibatalkan/selesai — pembatalan mandiri ditutup. Hubungi admin untuk proses refund."
);
}
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
);
});
},
};