create public layout and admin and fix escrow and refund
This commit is contained in:
@@ -36,6 +36,25 @@ export const organizerRepo = {
|
||||
});
|
||||
},
|
||||
|
||||
async countByStatus(status: "PENDING" | "APPROVED" | "REJECTED") {
|
||||
return prisma.organizerVerification.count({ where: { status } });
|
||||
},
|
||||
|
||||
/** Verifikasi terbaru (default PENDING) untuk preview di dashboard admin. */
|
||||
async listRecent(status: "PENDING" | "APPROVED" | "REJECTED", limit = 3) {
|
||||
return prisma.organizerVerification.findMany({
|
||||
where: { status },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
select: {
|
||||
id: true,
|
||||
fullName: true,
|
||||
createdAt: true,
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async updateReview(
|
||||
id: string,
|
||||
data: {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { Prisma } from "@/app/generated/prisma/client";
|
||||
import type { PayoutStatus } from "@/app/generated/prisma/enums";
|
||||
|
||||
const payoutListInclude = {
|
||||
booking: {
|
||||
select: {
|
||||
id: true,
|
||||
amount: true,
|
||||
status: true,
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
},
|
||||
trip: {
|
||||
select: { id: true, title: true, date: true, endDate: true, status: true },
|
||||
},
|
||||
organizer: { select: { id: true, name: true, email: true } },
|
||||
processedBy: { select: { id: true, name: true, email: true } },
|
||||
} satisfies Prisma.PayoutInclude;
|
||||
|
||||
export const payoutRepo = {
|
||||
async findById(id: string) {
|
||||
return prisma.payout.findUnique({
|
||||
where: { id },
|
||||
include: payoutListInclude,
|
||||
});
|
||||
},
|
||||
|
||||
async findByBookingId(bookingId: string, tx?: Prisma.TransactionClient) {
|
||||
const client = tx ?? prisma;
|
||||
return client.payout.findUnique({ where: { bookingId } });
|
||||
},
|
||||
|
||||
async listByStatus(status: PayoutStatus) {
|
||||
return prisma.payout.findMany({
|
||||
where: { status },
|
||||
orderBy: { heldUntil: "asc" },
|
||||
include: payoutListInclude,
|
||||
});
|
||||
},
|
||||
|
||||
async listForOrganizer(organizerId: string) {
|
||||
return prisma.payout.findMany({
|
||||
where: { organizerId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
trip: {
|
||||
select: { id: true, title: true, date: true, endDate: true, status: true },
|
||||
},
|
||||
booking: {
|
||||
select: {
|
||||
id: true,
|
||||
amount: true,
|
||||
user: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async countByStatus(status: PayoutStatus) {
|
||||
return prisma.payout.count({ where: { status } });
|
||||
},
|
||||
|
||||
/** Payout terbaru untuk satu status — dipakai dashboard admin. */
|
||||
async listRecent(status: PayoutStatus, limit = 3) {
|
||||
return prisma.payout.findMany({
|
||||
where: { status },
|
||||
orderBy: status === "HELD" ? { heldUntil: "asc" } : { updatedAt: "desc" },
|
||||
take: limit,
|
||||
select: {
|
||||
id: true,
|
||||
amount: true,
|
||||
heldUntil: true,
|
||||
releasedAt: true,
|
||||
organizer: { select: { id: true, name: true } },
|
||||
trip: { select: { id: true, title: true } },
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async create(
|
||||
data: Pick<
|
||||
Prisma.PayoutUncheckedCreateInput,
|
||||
| "bookingId"
|
||||
| "tripId"
|
||||
| "organizerId"
|
||||
| "amount"
|
||||
| "heldUntil"
|
||||
| "bankName"
|
||||
| "bankAccountNumber"
|
||||
| "bankAccountName"
|
||||
>,
|
||||
tx?: Prisma.TransactionClient
|
||||
) {
|
||||
const client = tx ?? prisma;
|
||||
return client.payout.create({ data });
|
||||
},
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
data: Prisma.PayoutUncheckedUpdateInput,
|
||||
tx?: Prisma.TransactionClient
|
||||
) {
|
||||
const client = tx ?? prisma;
|
||||
return client.payout.update({ where: { id }, data });
|
||||
},
|
||||
|
||||
/** Cari semua HELD payout yang sudah lewat heldUntil & trip-nya COMPLETED. */
|
||||
async findEligibleForRelease(now: Date) {
|
||||
return prisma.payout.findMany({
|
||||
where: {
|
||||
status: "HELD",
|
||||
heldUntil: { lte: now },
|
||||
trip: { status: "COMPLETED" },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export type PayoutWithRelations = Awaited<ReturnType<typeof payoutRepo.findById>>;
|
||||
@@ -49,6 +49,36 @@ export const refundRepo = {
|
||||
});
|
||||
},
|
||||
|
||||
async countByStatus(
|
||||
status: "PENDING" | "APPROVED" | "REJECTED" | "PROCESSING" | "SUCCEEDED" | "FAILED"
|
||||
) {
|
||||
return prisma.refund.count({ where: { status } });
|
||||
},
|
||||
|
||||
/** Refund terbaru untuk satu status — dipakai dashboard admin. */
|
||||
async listRecent(
|
||||
status: "PENDING" | "APPROVED" | "REJECTED" | "PROCESSING" | "SUCCEEDED" | "FAILED",
|
||||
limit = 3
|
||||
) {
|
||||
return prisma.refund.findMany({
|
||||
where: { status },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
select: {
|
||||
id: true,
|
||||
amount: true,
|
||||
reason: true,
|
||||
createdAt: true,
|
||||
booking: {
|
||||
select: {
|
||||
user: { select: { id: true, name: true } },
|
||||
trip: { select: { id: true, title: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async listByBooking(bookingId: string) {
|
||||
return prisma.refund.findMany({
|
||||
where: { bookingId },
|
||||
|
||||
@@ -3,6 +3,7 @@ import { prisma } from "@/lib/prisma";
|
||||
import { bookingRepo } from "@/server/repositories/booking.repo";
|
||||
import { paymentRepo } from "@/server/repositories/payment.repo";
|
||||
import { isTripDepartureDayPast } from "@/lib/trip-dates";
|
||||
import { payoutService } from "@/server/services/payout.service";
|
||||
|
||||
const SERIAL_TX_ATTEMPTS = 6;
|
||||
|
||||
@@ -191,6 +192,9 @@ export const bookingService = {
|
||||
data: { paymentConfirmedAt: now },
|
||||
});
|
||||
|
||||
// Escrow: tahan uang di Payout HELD sampai trip selesai + buffer.
|
||||
await payoutService.createForPaidBooking(tx, { bookingId });
|
||||
|
||||
return { ok: true as const };
|
||||
},
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type MidtransWebhookBody,
|
||||
} from "@/lib/midtrans";
|
||||
import { isTripDepartureDayPast } from "@/lib/trip-dates";
|
||||
import { payoutService } from "@/server/services/payout.service";
|
||||
|
||||
const SERIAL_TX_ATTEMPTS = 6;
|
||||
|
||||
@@ -299,6 +300,10 @@ export const paymentService = {
|
||||
where: { id: payment.booking.participantId },
|
||||
data: { paymentConfirmedAt: now, markedPaidAt: now },
|
||||
});
|
||||
// Escrow: tahan uang di Payout HELD sampai trip selesai + buffer.
|
||||
await payoutService.createForPaidBooking(tx, {
|
||||
bookingId: payment.bookingId,
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -4,6 +4,7 @@ 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";
|
||||
import { payoutService } from "@/server/services/payout.service";
|
||||
|
||||
const SERIAL_TX_ATTEMPTS = 6;
|
||||
|
||||
@@ -236,6 +237,12 @@ export const refundService = {
|
||||
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
|
||||
|
||||
@@ -6,6 +6,8 @@ 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 { payoutService } from "@/server/services/payout.service";
|
||||
import { payoutRepo } from "@/server/repositories/payout.repo";
|
||||
import { LIMITS } from "@/lib/limits";
|
||||
import { utcStartOfDay, isTripDepartureDayPast } from "@/lib/trip-dates";
|
||||
import { isFreeTrip } from "@/lib/trip-pricing";
|
||||
@@ -546,6 +548,16 @@ export const tripService = {
|
||||
}
|
||||
);
|
||||
refundsCreated.push(refund.id);
|
||||
|
||||
// Trip dibatalkan → cancel payout HELD/RELEASED organizer untuk
|
||||
// booking ini. Payout PAID di-flag clawback otomatis.
|
||||
const payout = await payoutRepo.findByBookingId(b.id, tx);
|
||||
if (payout) {
|
||||
await payoutService.cancel(tx, {
|
||||
payoutId: payout.id,
|
||||
reason: "Trip dibatalkan organizer.",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// PENDING / AWAITING_PAY → uang belum masuk → langsung CANCELLED.
|
||||
await tx.booking.update({
|
||||
|
||||
Reference in New Issue
Block a user