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
+13
View File
@@ -71,4 +71,17 @@ export const bookingRepo = {
data: { status },
});
},
/**
* Jumlah booking PAID/PARTIALLY_REFUNDED di trip. Dipakai untuk preview
* dampak cancel-trip (berapa peserta yang akan dapat auto-refund).
*/
async countSettledForTrip(tripId: string) {
return prisma.booking.count({
where: {
tripId,
status: { in: ["PAID", "PARTIALLY_REFUNDED"] },
},
});
},
};
+111
View File
@@ -0,0 +1,111 @@
import { prisma } from "@/lib/prisma";
import { Prisma } from "@/app/generated/prisma/client";
const refundListInclude = {
booking: {
include: {
trip: { select: { id: true, title: true, date: true, organizerId: true } },
user: { select: { id: true, name: true, email: true, image: true } },
payments: {
orderBy: { createdAt: "desc" },
select: {
id: true,
provider: true,
method: true,
amount: true,
status: true,
paidAt: true,
},
},
},
},
payment: {
select: {
id: true,
provider: true,
method: true,
amount: true,
status: true,
},
},
reviewedBy: { select: { id: true, name: true, email: true } },
} satisfies Prisma.RefundInclude;
export const refundRepo = {
async findById(id: string) {
return prisma.refund.findUnique({
where: { id },
include: refundListInclude,
});
},
async listByStatus(
status?: "PENDING" | "APPROVED" | "REJECTED" | "PROCESSING" | "SUCCEEDED" | "FAILED"
) {
return prisma.refund.findMany({
where: status ? { status } : undefined,
orderBy: { createdAt: "desc" },
include: refundListInclude,
});
},
async listByBooking(bookingId: string) {
return prisma.refund.findMany({
where: { bookingId },
orderBy: { createdAt: "desc" },
});
},
/** Total nominal yang sudah SUCCEEDED untuk satu booking. Dipakai service untuk
* validasi `SUM(SUCCEEDED) + new.amount <= payment.amount`. */
async sumSucceededAmount(bookingId: string, tx?: Prisma.TransactionClient): Promise<number> {
const client = tx ?? prisma;
const agg = await client.refund.aggregate({
where: { bookingId, status: "SUCCEEDED" },
_sum: { amount: true },
});
return agg._sum.amount ?? 0;
},
/** Pending + approved + processing — refund yang "in-flight" (belum settled).
* Dipakai untuk cek apakah booking masih punya refund aktif. */
async hasActiveRefund(bookingId: string, tx?: Prisma.TransactionClient): Promise<boolean> {
const client = tx ?? prisma;
const count = await client.refund.count({
where: {
bookingId,
status: { in: ["PENDING", "APPROVED", "PROCESSING"] },
},
});
return count > 0;
},
async create(
data: Pick<
Prisma.RefundUncheckedCreateInput,
| "bookingId"
| "paymentId"
| "amount"
| "reason"
| "reportedBy"
| "reportNote"
| "initiatedBy"
| "idempotencyKey"
>,
tx?: Prisma.TransactionClient
) {
const client = tx ?? prisma;
return client.refund.create({ data });
},
async update(
id: string,
data: Prisma.RefundUncheckedUpdateInput,
tx?: Prisma.TransactionClient
) {
const client = tx ?? prisma;
return client.refund.update({ where: { id }, data });
},
};
export type RefundWithRelations = Awaited<ReturnType<typeof refundRepo.findById>>;
+502
View File
@@ -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
);
});
},
};
+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.");
},
};