Files
setrip/server/services/payout.service.ts
T
2026-05-20 15:25:32 +07:00

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);
},
};