admin roadmap trips ops and payment ops
This commit is contained in:
@@ -20,6 +20,46 @@ export const bookingRepo = {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Detail booking lengkap untuk admin investigation: payments (with raw
|
||||
* callback), refunds, payout, trip + organizer, user. Dipakai oleh
|
||||
* `/admin/bookings/[id]` untuk timeline lengkap money flow.
|
||||
*/
|
||||
async findByIdForAdmin(id: string) {
|
||||
return prisma.booking.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
trip: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
destination: true,
|
||||
location: true,
|
||||
date: true,
|
||||
endDate: true,
|
||||
price: true,
|
||||
status: true,
|
||||
organizer: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
},
|
||||
user: { select: { id: true, name: true, email: true, image: true } },
|
||||
participant: true,
|
||||
payments: { orderBy: { createdAt: "asc" } },
|
||||
refunds: {
|
||||
orderBy: { createdAt: "asc" },
|
||||
include: {
|
||||
reviewedBy: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
},
|
||||
payout: {
|
||||
include: {
|
||||
processedBy: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async create(
|
||||
data: Pick<
|
||||
Prisma.BookingUncheckedCreateInput,
|
||||
|
||||
@@ -191,6 +191,45 @@ export const tripRepo = {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Admin search lintas trip. Tidak filter berdasarkan status departure (admin
|
||||
* boleh lihat semua: OPEN/FULL/CLOSED/COMPLETED). Include _count peserta
|
||||
* aktif untuk indikator cepat.
|
||||
*/
|
||||
async searchForAdmin(filters: {
|
||||
q?: string;
|
||||
status?: "OPEN" | "FULL" | "CLOSED" | "COMPLETED";
|
||||
}) {
|
||||
const where: Prisma.TripWhereInput = {};
|
||||
if (filters.status) {
|
||||
where.status = filters.status;
|
||||
}
|
||||
if (filters.q) {
|
||||
where.OR = [
|
||||
{ title: { contains: filters.q, mode: "insensitive" } },
|
||||
{ destination: { contains: filters.q, mode: "insensitive" } },
|
||||
{ location: { contains: filters.q, mode: "insensitive" } },
|
||||
{ organizer: { name: { contains: filters.q, mode: "insensitive" } } },
|
||||
{ organizer: { email: { contains: filters.q, mode: "insensitive" } } },
|
||||
];
|
||||
}
|
||||
return prisma.trip.findMany({
|
||||
where,
|
||||
include: {
|
||||
organizer: { select: { id: true, name: true, email: true } },
|
||||
images: { orderBy: { order: "asc" }, take: 1 },
|
||||
_count: {
|
||||
select: {
|
||||
participants: { where: { status: { not: "CANCELLED" } } },
|
||||
bookings: { where: { status: "PAID" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { date: "desc" },
|
||||
take: 100,
|
||||
});
|
||||
},
|
||||
|
||||
/** Semua trip yang dibuat user (semua status), terbaru dulu — untuk profil. */
|
||||
async findByOrganizerId(organizerId: string) {
|
||||
return prisma.trip.findMany({
|
||||
|
||||
@@ -381,6 +381,49 @@ export const paymentService = {
|
||||
* Auth: caller harus pastikan `userId` adalah owner booking; kita verifikasi
|
||||
* di sini lewat lookup payment → booking.userId.
|
||||
*/
|
||||
/**
|
||||
* Admin variant `reconcileFromGateway` — skip ownership check (admin bypass).
|
||||
* Dipakai dari `/admin/bookings/[id]` saat user lapor "sudah bayar tapi
|
||||
* status belum update". Idempotent: aman dipanggil berulang.
|
||||
*/
|
||||
async adminReconcile(
|
||||
orderId: string
|
||||
): Promise<
|
||||
| {
|
||||
ok: true;
|
||||
status:
|
||||
| "updated"
|
||||
| "skipped"
|
||||
| "ignored"
|
||||
| "booking_conflict"
|
||||
| "not_found";
|
||||
}
|
||||
| { ok: false; reason: "amount_mismatch" | "not_found" }
|
||||
> {
|
||||
const payment = await prisma.payment.findUnique({
|
||||
where: { externalOrderId: orderId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!payment) {
|
||||
return { ok: false, reason: "not_found" };
|
||||
}
|
||||
|
||||
const status = await fetchMidtransTransactionStatus(orderId);
|
||||
if (!status) {
|
||||
return { ok: true, status: "not_found" };
|
||||
}
|
||||
|
||||
return applyGatewayStatus({
|
||||
order_id: status.order_id,
|
||||
gross_amount: status.gross_amount,
|
||||
transaction_status: status.transaction_status,
|
||||
fraud_status: status.fraud_status ?? null,
|
||||
transaction_id: status.transaction_id,
|
||||
payment_type: status.payment_type,
|
||||
rawSource: status as unknown as Prisma.InputJsonValue,
|
||||
});
|
||||
},
|
||||
|
||||
async reconcileFromGateway(
|
||||
orderId: string,
|
||||
userId: string
|
||||
|
||||
@@ -404,9 +404,10 @@ export const tripService = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Organizer batalkan trip (Trip.status = CLOSED). Atomic dalam satu
|
||||
* serializable transaction:
|
||||
* - Set Trip.status = CLOSED.
|
||||
* Batalkan trip (Trip.status = CLOSED). Bisa dipicu organizer sendiri ATAU
|
||||
* admin (intervensi). Atomic dalam satu serializable transaction:
|
||||
* - Set Trip.status = CLOSED (+ `cancelledByAdminId` & `cancelledReason`
|
||||
* kalau actor = ADMIN).
|
||||
* - Untuk setiap peserta aktif:
|
||||
* - Booking PAID → buat Refund ORGANIZER_CANCELLED (auto-approved, full
|
||||
* amount). Booking tetap PAID sampai admin mark SUCCEEDED — jejak
|
||||
@@ -420,7 +421,12 @@ export const tripService = {
|
||||
* Idempotent: trip yang sudah CLOSED/COMPLETED akan ditolak supaya tidak
|
||||
* dobel-buat refund.
|
||||
*/
|
||||
async closeTrip(tripId: string, organizerId: string) {
|
||||
async closeTrip(
|
||||
tripId: string,
|
||||
actor:
|
||||
| { type: "ORGANIZER"; userId: string }
|
||||
| { type: "ADMIN"; adminId: string; reason: string }
|
||||
) {
|
||||
let lastErr: unknown;
|
||||
for (let attempt = 0; attempt < SERIAL_TX_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
@@ -433,7 +439,10 @@ export const tripService = {
|
||||
if (!trip) {
|
||||
throw new Error("Trip tidak ditemukan");
|
||||
}
|
||||
if (trip.organizerId !== organizerId) {
|
||||
if (
|
||||
actor.type === "ORGANIZER" &&
|
||||
trip.organizerId !== actor.userId
|
||||
) {
|
||||
throw new Error(
|
||||
"Hanya organizer trip ini yang bisa membatalkan trip"
|
||||
);
|
||||
@@ -544,7 +553,13 @@ export const tripService = {
|
||||
|
||||
await tx.trip.update({
|
||||
where: { id: tripId },
|
||||
data: { status: "CLOSED" },
|
||||
data: {
|
||||
status: "CLOSED",
|
||||
...(actor.type === "ADMIN" && {
|
||||
cancelledByAdminId: actor.adminId,
|
||||
cancelledReason: actor.reason,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user