create public layout and admin and fix escrow and refund
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
import { Prisma } from "@/app/generated/prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { payoutRepo } from "@/server/repositories/payout.repo";
|
||||
|
||||
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 payout. Coba lagi sebentar.");
|
||||
}
|
||||
|
||||
/** Buffer hari setelah trip selesai sebelum payout boleh ditransfer. */
|
||||
export const PAYOUT_HOLD_BUFFER_DAYS = 3;
|
||||
|
||||
/** Hitung heldUntil dari trip date. Pakai endDate kalau ada, kalau tidak pakai date. */
|
||||
function computeHeldUntil(tripDate: Date, tripEndDate: Date | null): Date {
|
||||
const baseDate = tripEndDate ?? tripDate;
|
||||
const result = new Date(baseDate);
|
||||
result.setUTCDate(result.getUTCDate() + PAYOUT_HOLD_BUFFER_DAYS);
|
||||
return result;
|
||||
}
|
||||
|
||||
export const payoutService = {
|
||||
/**
|
||||
* Dipanggil saat Booking → PAID (webhook Midtrans atau organizer confirm manual).
|
||||
* Idempotent: kalau Payout untuk booking ini sudah ada, no-op (return existing).
|
||||
*
|
||||
* Snapshot bank info dari OrganizerVerification (kalau ada) supaya audit-friendly
|
||||
* walau organizer ganti bank nanti.
|
||||
*/
|
||||
async createForPaidBooking(
|
||||
tx: Prisma.TransactionClient,
|
||||
input: { bookingId: string }
|
||||
) {
|
||||
const booking = await tx.booking.findUnique({
|
||||
where: { id: input.bookingId },
|
||||
select: {
|
||||
id: true,
|
||||
amount: true,
|
||||
status: true,
|
||||
userId: true,
|
||||
trip: {
|
||||
select: {
|
||||
id: true,
|
||||
organizerId: true,
|
||||
date: true,
|
||||
endDate: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!booking) {
|
||||
throw new Error("Booking tidak ditemukan saat membuat payout");
|
||||
}
|
||||
if (booking.amount <= 0) {
|
||||
// Trip gratis — tidak ada uang yang perlu di-payout.
|
||||
return null;
|
||||
}
|
||||
const existing = await payoutRepo.findByBookingId(booking.id, tx);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const bankInfo = await tx.organizerVerification.findUnique({
|
||||
where: { userId: booking.trip.organizerId },
|
||||
select: {
|
||||
status: true,
|
||||
bankName: true,
|
||||
bankAccountNumber: true,
|
||||
bankAccountName: true,
|
||||
},
|
||||
});
|
||||
|
||||
const heldUntil = computeHeldUntil(booking.trip.date, booking.trip.endDate);
|
||||
|
||||
return payoutRepo.create(
|
||||
{
|
||||
bookingId: booking.id,
|
||||
tripId: booking.trip.id,
|
||||
organizerId: booking.trip.organizerId,
|
||||
amount: booking.amount,
|
||||
heldUntil,
|
||||
bankName:
|
||||
bankInfo?.status === "APPROVED" ? bankInfo.bankName : null,
|
||||
bankAccountNumber:
|
||||
bankInfo?.status === "APPROVED" ? bankInfo.bankAccountNumber : null,
|
||||
bankAccountName:
|
||||
bankInfo?.status === "APPROVED" ? bankInfo.bankAccountName : null,
|
||||
},
|
||||
tx
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Cron-callable: cari semua HELD payout yang sudah lewat heldUntil & trip-nya
|
||||
* COMPLETED, lalu flip ke RELEASED. Idempotent.
|
||||
*/
|
||||
async releaseEligible() {
|
||||
const now = new Date();
|
||||
const eligible = await payoutRepo.findEligibleForRelease(now);
|
||||
if (eligible.length === 0) {
|
||||
return { releasedIds: [] as string[] };
|
||||
}
|
||||
const ids = eligible.map((p) => p.id);
|
||||
await prisma.payout.updateMany({
|
||||
where: { id: { in: ids }, status: "HELD" },
|
||||
data: { status: "RELEASED", releasedAt: now },
|
||||
});
|
||||
return { releasedIds: ids };
|
||||
},
|
||||
|
||||
/** RELEASED → PAID. Catatan/referensi transfer wajib (audit trail). */
|
||||
async markPaid(input: { payoutId: string; adminId: string; adminNote: string }) {
|
||||
if (!input.adminNote.trim()) {
|
||||
throw new Error("Catatan/referensi transfer wajib diisi");
|
||||
}
|
||||
return runSerializable(async (tx) => {
|
||||
const payout = await tx.payout.findUnique({
|
||||
where: { id: input.payoutId },
|
||||
});
|
||||
if (!payout) {
|
||||
throw new Error("Payout tidak ditemukan");
|
||||
}
|
||||
if (payout.status !== "RELEASED") {
|
||||
throw new Error(
|
||||
"Hanya payout RELEASED yang bisa ditandai PAID. Tunggu trip selesai + buffer."
|
||||
);
|
||||
}
|
||||
return payoutRepo.update(
|
||||
input.payoutId,
|
||||
{
|
||||
status: "PAID",
|
||||
paidAt: new Date(),
|
||||
processedById: input.adminId,
|
||||
adminNote: input.adminNote.trim(),
|
||||
},
|
||||
tx
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Cancel payout — biasanya dipanggil internal saat refund SUCCEEDED penuh
|
||||
* atau trip dibatalkan. Tidak boleh untuk payout yang sudah PAID (uang sudah
|
||||
* keluar ke organizer; admin perlu clawback manual).
|
||||
*/
|
||||
async cancel(
|
||||
tx: Prisma.TransactionClient,
|
||||
input: { payoutId: string; reason: string; adminId?: string | null }
|
||||
) {
|
||||
const payout = await tx.payout.findUnique({
|
||||
where: { id: input.payoutId },
|
||||
});
|
||||
if (!payout) return null;
|
||||
if (payout.status === "CANCELLED") return payout;
|
||||
if (payout.status === "PAID") {
|
||||
// Uang sudah ditransfer — tidak bisa undo otomatis. Catat note saja.
|
||||
return payoutRepo.update(
|
||||
input.payoutId,
|
||||
{
|
||||
adminNote:
|
||||
(payout.adminNote ? `${payout.adminNote}\n---\n` : "") +
|
||||
`[!] ${input.reason} setelah PAID — perlu clawback manual.`,
|
||||
},
|
||||
tx
|
||||
);
|
||||
}
|
||||
return payoutRepo.update(
|
||||
input.payoutId,
|
||||
{
|
||||
status: "CANCELLED",
|
||||
cancelledAt: new Date(),
|
||||
processedById: input.adminId ?? null,
|
||||
adminNote: input.reason,
|
||||
},
|
||||
tx
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Refund SUCCEEDED — kurangi nominal payout sesuai nominal refund. Kalau
|
||||
* jatuh ke 0 atau lebih, cancel payout. Dipanggil dari refund.service.
|
||||
*/
|
||||
async applyRefundDelta(
|
||||
tx: Prisma.TransactionClient,
|
||||
input: { bookingId: string; refundAmount: number }
|
||||
) {
|
||||
const payout = await payoutRepo.findByBookingId(input.bookingId, tx);
|
||||
if (!payout) return null;
|
||||
if (payout.status === "CANCELLED") return payout;
|
||||
if (payout.status === "PAID") {
|
||||
// Uang sudah ditransfer ke organizer — flag untuk clawback manual.
|
||||
return payoutRepo.update(
|
||||
payout.id,
|
||||
{
|
||||
adminNote:
|
||||
(payout.adminNote ? `${payout.adminNote}\n---\n` : "") +
|
||||
`[!] Refund Rp${input.refundAmount.toLocaleString("id-ID")} terjadi setelah payout PAID. Perlu clawback manual dari organizer.`,
|
||||
},
|
||||
tx
|
||||
);
|
||||
}
|
||||
|
||||
const nextAmount = payout.amount - input.refundAmount;
|
||||
if (nextAmount <= 0) {
|
||||
return payoutRepo.update(
|
||||
payout.id,
|
||||
{
|
||||
status: "CANCELLED",
|
||||
cancelledAt: new Date(),
|
||||
adminNote:
|
||||
(payout.adminNote ? `${payout.adminNote}\n---\n` : "") +
|
||||
"Dibatalkan otomatis karena refund penuh.",
|
||||
},
|
||||
tx
|
||||
);
|
||||
}
|
||||
return payoutRepo.update(payout.id, { amount: nextAmount }, tx);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user