create public layout and admin and fix escrow and refund

This commit is contained in:
arifal
2026-05-12 00:05:30 +07:00
parent a07942c4b4
commit 958514d575
48 changed files with 1928 additions and 18 deletions
+19
View File
@@ -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: {
+122
View File
@@ -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>>;
+30
View File
@@ -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 },
+4
View File
@@ -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 };
},
{
+5
View File
@@ -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,
});
}
},
{
+248
View File
@@ -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);
},
};
+7
View File
@@ -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
+12
View File
@@ -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({