277 lines
8.2 KiB
TypeScript
277 lines
8.2 KiB
TypeScript
import { Prisma } from "@/app/generated/prisma/client";
|
|
import { prisma } from "@/lib/prisma";
|
|
import { payoutRepo } from "@/server/repositories/payout.repo";
|
|
import { emailService } from "@/lib/email/send";
|
|
|
|
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 },
|
|
});
|
|
|
|
// E3.6 — kabari organizer payout-nya sudah lepas hold & masuk antrian
|
|
// transfer. Pin ke `releasedAt: now` supaya hanya yang baru di-release.
|
|
const released = await prisma.payout.findMany({
|
|
where: { id: { in: ids }, status: "RELEASED", releasedAt: now },
|
|
select: {
|
|
id: true,
|
|
amount: true,
|
|
organizer: { select: { email: true, name: true } },
|
|
trip: { select: { title: true } },
|
|
},
|
|
});
|
|
for (const p of released) {
|
|
void emailService.send({
|
|
to: p.organizer.email,
|
|
idempotencyKey: `payout_released-${p.id}`,
|
|
template: {
|
|
template: "payout_released",
|
|
data: {
|
|
organizerName: p.organizer.name,
|
|
tripTitle: p.trip.title,
|
|
amount: p.amount,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
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);
|
|
},
|
|
};
|