- ✅
- ✅ - ✅ - ✅
This commit is contained in:
@@ -1,24 +1,4 @@
|
||||
import { Prisma } from "@/app/generated/prisma/client";
|
||||
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;
|
||||
|
||||
function isSerializationConflict(err: unknown): boolean {
|
||||
return (
|
||||
typeof err === "object" &&
|
||||
err !== null &&
|
||||
"code" in err &&
|
||||
(err as { code: string }).code === "P2034"
|
||||
);
|
||||
}
|
||||
|
||||
function manualOrderId(bookingId: string): string {
|
||||
return `manual-${bookingId}`;
|
||||
}
|
||||
|
||||
export const bookingService = {
|
||||
async getByParticipantId(participantId: string) {
|
||||
@@ -28,199 +8,4 @@ export const bookingService = {
|
||||
async getByTripAndUser(tripId: string, userId: string) {
|
||||
return bookingRepo.findByTripAndUser(tripId, userId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Peserta tandai sudah transfer manual. Idempotent: kalau sudah ada Payment
|
||||
* MANUAL aktif, biarkan; kalau Booking sudah PAID, tolak.
|
||||
*/
|
||||
async markPaidManual(bookingId: string, userId: string) {
|
||||
let lastErr: unknown;
|
||||
for (let attempt = 0; attempt < SERIAL_TX_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
return await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const booking = await tx.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
include: { trip: { select: { price: true, date: true } } },
|
||||
});
|
||||
if (!booking) {
|
||||
throw new Error("Booking tidak ditemukan");
|
||||
}
|
||||
if (booking.userId !== userId) {
|
||||
throw new Error("Booking ini bukan milikmu");
|
||||
}
|
||||
if (booking.amount <= 0) {
|
||||
throw new Error(
|
||||
"Booking ini tidak butuh pembayaran (gratis)"
|
||||
);
|
||||
}
|
||||
if (booking.status === "PAID") {
|
||||
throw new Error("Pembayaran sudah dikonfirmasi");
|
||||
}
|
||||
if (booking.status !== "AWAITING_PAY") {
|
||||
throw new Error(
|
||||
"Booking belum siap menerima pembayaran (tunggu approve organizer)"
|
||||
);
|
||||
}
|
||||
if (isTripDepartureDayPast(booking.trip.date)) {
|
||||
throw new Error(
|
||||
"Trip sudah lewat tanggal berangkat — pembayaran ditutup"
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await tx.payment.findFirst({
|
||||
where: {
|
||||
bookingId,
|
||||
provider: "MANUAL",
|
||||
status: { in: ["PENDING", "AWAITING"] },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
if (existing && existing.status === "AWAITING") {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const payment = existing
|
||||
? await tx.payment.update({
|
||||
where: { id: existing.id },
|
||||
data: { status: "AWAITING" },
|
||||
})
|
||||
: await tx.payment.create({
|
||||
data: {
|
||||
bookingId,
|
||||
provider: "MANUAL",
|
||||
externalOrderId: manualOrderId(bookingId),
|
||||
amount: booking.amount,
|
||||
status: "AWAITING",
|
||||
},
|
||||
});
|
||||
|
||||
// Backward-compat: tetap update timestamp di TripParticipant
|
||||
// selama UI lama masih membaca kolom ini.
|
||||
await tx.tripParticipant.update({
|
||||
where: { id: booking.participantId },
|
||||
data: { markedPaidAt: new Date() },
|
||||
});
|
||||
|
||||
return payment;
|
||||
},
|
||||
{
|
||||
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 menandai pembayaran. Coba lagi sebentar.");
|
||||
},
|
||||
|
||||
/**
|
||||
* Organizer konfirmasi pembayaran manual masuk.
|
||||
* Idempotent: kalau sudah PAID, tolak (UI lama bisa muncul tombol dua kali).
|
||||
*/
|
||||
async confirmPaidManual(bookingId: string, organizerId: string) {
|
||||
let lastErr: unknown;
|
||||
for (let attempt = 0; attempt < SERIAL_TX_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
return await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const booking = await tx.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
include: { trip: { select: { organizerId: true, price: true } } },
|
||||
});
|
||||
if (!booking) {
|
||||
throw new Error("Booking tidak ditemukan");
|
||||
}
|
||||
if (booking.trip.organizerId !== organizerId) {
|
||||
throw new Error(
|
||||
"Hanya organizer trip ini yang bisa mengonfirmasi pembayaran"
|
||||
);
|
||||
}
|
||||
if (booking.amount <= 0) {
|
||||
throw new Error(
|
||||
"Booking ini gratis — tidak ada pembayaran yang perlu dikonfirmasi"
|
||||
);
|
||||
}
|
||||
if (booking.status === "PAID") {
|
||||
throw new Error("Pembayaran sudah dikonfirmasi sebelumnya");
|
||||
}
|
||||
|
||||
const awaitingPayment = await tx.payment.findFirst({
|
||||
where: {
|
||||
bookingId,
|
||||
provider: "MANUAL",
|
||||
status: "AWAITING",
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
if (!awaitingPayment) {
|
||||
throw new Error(
|
||||
"Peserta belum menandai sudah membayar"
|
||||
);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
await tx.payment.update({
|
||||
where: { id: awaitingPayment.id },
|
||||
data: {
|
||||
status: "PAID",
|
||||
paidAt: now,
|
||||
method: "manual_transfer",
|
||||
},
|
||||
});
|
||||
|
||||
await tx.booking.update({
|
||||
where: { id: bookingId },
|
||||
data: { status: "PAID" },
|
||||
});
|
||||
|
||||
// Backward-compat: tetap update timestamp di TripParticipant.
|
||||
await tx.tripParticipant.update({
|
||||
where: { id: booking.participantId },
|
||||
data: { paymentConfirmedAt: now },
|
||||
});
|
||||
|
||||
// Escrow: tahan uang di Payout HELD sampai trip selesai + buffer.
|
||||
await payoutService.createForPaidBooking(tx, { bookingId });
|
||||
|
||||
return { ok: true as const };
|
||||
},
|
||||
{
|
||||
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 mengonfirmasi pembayaran. Coba lagi sebentar.");
|
||||
},
|
||||
|
||||
/**
|
||||
* Daftar booking yang masih menunggu konfirmasi organizer di trip tertentu.
|
||||
* Dipakai OrganizerPaymentQueue.
|
||||
*/
|
||||
async getAwaitingManualForTrip(tripId: string) {
|
||||
return bookingRepo.findAwaitingManualConfirmation(tripId);
|
||||
},
|
||||
};
|
||||
|
||||
+234
-143
@@ -3,8 +3,10 @@ import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
MIDTRANS,
|
||||
createSnapTransaction,
|
||||
fetchMidtransTransactionStatus,
|
||||
mapMidtransStatus,
|
||||
verifyMidtransSignature,
|
||||
type MidtransTransactionStatus,
|
||||
type MidtransWebhookBody,
|
||||
} from "@/lib/midtrans";
|
||||
import { isTripDepartureDayPast } from "@/lib/trip-dates";
|
||||
@@ -31,11 +33,166 @@ export interface StartMidtransResult {
|
||||
snapJsUrl: string;
|
||||
clientKey: string;
|
||||
expiresAt: Date;
|
||||
orderId: string;
|
||||
}
|
||||
|
||||
export type WebhookOutcome =
|
||||
export type ApplyOutcome =
|
||||
| { ok: true; status: "updated" | "skipped" | "ignored" | "booking_conflict" }
|
||||
| { ok: false; reason: "signature_mismatch" | "amount_mismatch" };
|
||||
| { ok: false; reason: "amount_mismatch" };
|
||||
|
||||
export type WebhookOutcome =
|
||||
| ApplyOutcome
|
||||
| { ok: false; reason: "signature_mismatch" };
|
||||
|
||||
/**
|
||||
* Bentuk minimum yang dibutuhkan oleh `applyGatewayStatus` — bisa berasal dari
|
||||
* webhook callback (Midtrans → kita) atau dari hasil GET /v2/{order_id}/status
|
||||
* (kita → Midtrans saat rekonsiliasi). Bedanya cuma asal dan apakah signature
|
||||
* perlu dicek.
|
||||
*/
|
||||
interface GatewayUpdatePayload {
|
||||
order_id: string;
|
||||
gross_amount: string;
|
||||
transaction_status: string;
|
||||
fraud_status?: string | null;
|
||||
transaction_id?: string;
|
||||
payment_type?: string;
|
||||
/** Snapshot mentah untuk audit trail di `Payment.rawCallback`. */
|
||||
rawSource: Prisma.InputJsonValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* State machine: terjemahkan status dari gateway ke perubahan Payment + Booking
|
||||
* di DB. Idempotent: kalau payment sudah final, hanya update rawCallback.
|
||||
* Tidak melakukan auth — caller wajib pastikan source-nya terpercaya
|
||||
* (signature webhook valid, atau response Midtrans Core API).
|
||||
*/
|
||||
async function applyGatewayStatus(
|
||||
update: GatewayUpdatePayload
|
||||
): Promise<ApplyOutcome> {
|
||||
const payment = await prisma.payment.findUnique({
|
||||
where: { externalOrderId: update.order_id },
|
||||
include: { booking: true },
|
||||
});
|
||||
|
||||
if (!payment) {
|
||||
return { ok: true, status: "ignored" };
|
||||
}
|
||||
|
||||
const amountFromGateway = Math.round(Number(update.gross_amount));
|
||||
if (
|
||||
Number.isNaN(amountFromGateway) ||
|
||||
amountFromGateway !== payment.amount
|
||||
) {
|
||||
await prisma.payment.update({
|
||||
where: { id: payment.id },
|
||||
data: {
|
||||
rawCallback: update.rawSource,
|
||||
rejectionReason: `Amount mismatch: gateway=${update.gross_amount}, expected=${payment.amount}`,
|
||||
},
|
||||
});
|
||||
return { ok: false, reason: "amount_mismatch" };
|
||||
}
|
||||
|
||||
const finalStatuses = new Set([
|
||||
"PAID",
|
||||
"FAILED",
|
||||
"EXPIRED",
|
||||
"CANCELLED",
|
||||
"REFUNDED",
|
||||
]);
|
||||
if (finalStatuses.has(payment.status)) {
|
||||
await prisma.payment.update({
|
||||
where: { id: payment.id },
|
||||
data: { rawCallback: update.rawSource },
|
||||
});
|
||||
return { ok: true, status: "skipped" };
|
||||
}
|
||||
|
||||
const newStatus = mapMidtransStatus(
|
||||
update.transaction_status,
|
||||
update.fraud_status ?? null
|
||||
);
|
||||
|
||||
let lastErr: unknown;
|
||||
for (let attempt = 0; attempt < SERIAL_TX_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const now = new Date();
|
||||
|
||||
const currentBooking = await tx.booking.findUnique({
|
||||
where: { id: payment.bookingId },
|
||||
select: { status: true, participantId: true },
|
||||
});
|
||||
|
||||
const bookingInConflictState =
|
||||
currentBooking?.status === "CANCELLED" ||
|
||||
currentBooking?.status === "REFUNDED" ||
|
||||
currentBooking?.status === "EXPIRED";
|
||||
|
||||
const conflictNote =
|
||||
bookingInConflictState && newStatus === "PAID"
|
||||
? `Gateway PAID but Booking is ${currentBooking.status}. Manual review required (potential refund).`
|
||||
: null;
|
||||
|
||||
await tx.payment.update({
|
||||
where: { id: payment.id },
|
||||
data: {
|
||||
status: newStatus,
|
||||
externalTxId: update.transaction_id ?? null,
|
||||
method: update.payment_type ?? null,
|
||||
rawCallback: update.rawSource,
|
||||
paidAt: newStatus === "PAID" ? now : null,
|
||||
failedAt:
|
||||
newStatus === "FAILED" || newStatus === "EXPIRED" ? now : null,
|
||||
rejectionReason: conflictNote,
|
||||
},
|
||||
});
|
||||
|
||||
if (newStatus === "PAID" && !bookingInConflictState) {
|
||||
await tx.booking.update({
|
||||
where: { id: payment.bookingId },
|
||||
data: { status: "PAID" },
|
||||
});
|
||||
await tx.tripParticipant.update({
|
||||
where: { id: payment.booking.participantId },
|
||||
data: { paymentConfirmedAt: now, markedPaidAt: now },
|
||||
});
|
||||
await payoutService.createForPaidBooking(tx, {
|
||||
bookingId: payment.bookingId,
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
|
||||
maxWait: 5000,
|
||||
timeout: 15000,
|
||||
}
|
||||
);
|
||||
|
||||
const finalBooking = await prisma.booking.findUnique({
|
||||
where: { id: payment.bookingId },
|
||||
select: { status: true },
|
||||
});
|
||||
const isConflict =
|
||||
newStatus === "PAID" && finalBooking?.status !== "PAID";
|
||||
return {
|
||||
ok: true,
|
||||
status: isConflict ? "booking_conflict" : "updated",
|
||||
};
|
||||
} catch (e) {
|
||||
lastErr = e;
|
||||
if (isSerializationConflict(e) && attempt < SERIAL_TX_ATTEMPTS - 1) {
|
||||
continue;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
throw lastErr instanceof Error
|
||||
? lastErr
|
||||
: new Error("Gagal apply status gateway karena konflik transaksi");
|
||||
}
|
||||
|
||||
export const paymentService = {
|
||||
/**
|
||||
@@ -43,7 +200,11 @@ export const paymentService = {
|
||||
* MIDTRANS aktif (status PENDING/AWAITING), reuse token yang sudah ada
|
||||
* selama belum expired.
|
||||
*/
|
||||
async startMidtransPayment(bookingId: string, userId: string): Promise<StartMidtransResult> {
|
||||
async startMidtransPayment(
|
||||
bookingId: string,
|
||||
userId: string,
|
||||
options?: { finishUrl?: string }
|
||||
): Promise<StartMidtransResult> {
|
||||
const clientKey = process.env.NEXT_PUBLIC_MIDTRANS_CLIENT_KEY;
|
||||
if (!clientKey) {
|
||||
throw new Error("NEXT_PUBLIC_MIDTRANS_CLIENT_KEY belum di-set");
|
||||
@@ -102,6 +263,7 @@ export const paymentService = {
|
||||
snapJsUrl,
|
||||
clientKey,
|
||||
expiresAt: reusable.expiresAt ?? new Date(now.getTime() + 24 * 3600 * 1000),
|
||||
orderId: reusable.externalOrderId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -149,6 +311,7 @@ export const paymentService = {
|
||||
},
|
||||
itemName: booking.trip.title,
|
||||
expirySeconds,
|
||||
finishUrl: options?.finishUrl,
|
||||
});
|
||||
} catch (err) {
|
||||
// Roll back Payment ke FAILED supaya orderId tidak nyangkut PENDING selamanya.
|
||||
@@ -177,164 +340,92 @@ export const paymentService = {
|
||||
snapJsUrl,
|
||||
clientKey,
|
||||
expiresAt,
|
||||
orderId,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle webhook callback dari Midtrans. Idempotent — boleh dipanggil berulang.
|
||||
* Selalu return result yang webhook handler bisa terjemahkan ke HTTP status.
|
||||
* Verifikasi signature dulu, lalu delegate ke `applyGatewayStatus`.
|
||||
*/
|
||||
async handleMidtransWebhook(
|
||||
payload: MidtransWebhookBody
|
||||
): Promise<WebhookOutcome> {
|
||||
const { order_id: orderId, status_code: statusCode, gross_amount: grossAmount, signature_key: signatureKey } = payload;
|
||||
|
||||
const signatureValid = verifyMidtransSignature(
|
||||
orderId,
|
||||
statusCode,
|
||||
grossAmount,
|
||||
signatureKey
|
||||
payload.order_id,
|
||||
payload.status_code,
|
||||
payload.gross_amount,
|
||||
payload.signature_key
|
||||
);
|
||||
if (!signatureValid) {
|
||||
return { ok: false, reason: "signature_mismatch" };
|
||||
}
|
||||
|
||||
return applyGatewayStatus({
|
||||
order_id: payload.order_id,
|
||||
gross_amount: payload.gross_amount,
|
||||
transaction_status: payload.transaction_status,
|
||||
fraud_status: payload.fraud_status ?? null,
|
||||
transaction_id: payload.transaction_id,
|
||||
payment_type: payload.payment_type,
|
||||
rawSource: payload as unknown as Prisma.InputJsonValue,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Rekonsiliasi server-side: tarik status terkini dari Midtrans Core API,
|
||||
* lalu apply ke DB. Dipakai saat user kembali dari Snap (redirect flow) atau
|
||||
* saat webhook belum sampai (mis. dev di localhost). Aman dipanggil
|
||||
* berulang — idempotent via `applyGatewayStatus`.
|
||||
*
|
||||
* Auth: caller harus pastikan `userId` adalah owner booking; kita verifikasi
|
||||
* di sini lewat lookup payment → booking.userId.
|
||||
*/
|
||||
async reconcileFromGateway(
|
||||
orderId: string,
|
||||
userId: string
|
||||
): Promise<
|
||||
| {
|
||||
ok: true;
|
||||
status:
|
||||
| "updated"
|
||||
| "skipped"
|
||||
| "ignored"
|
||||
| "booking_conflict"
|
||||
| "not_found";
|
||||
}
|
||||
| { ok: false; reason: "amount_mismatch" | "forbidden" | "not_found" }
|
||||
> {
|
||||
const payment = await prisma.payment.findUnique({
|
||||
where: { externalOrderId: orderId },
|
||||
include: { booking: true },
|
||||
include: { booking: { select: { userId: true } } },
|
||||
});
|
||||
if (!payment) {
|
||||
return { ok: false, reason: "not_found" };
|
||||
}
|
||||
if (payment.booking.userId !== userId) {
|
||||
return { ok: false, reason: "forbidden" };
|
||||
}
|
||||
|
||||
const status = await fetchMidtransTransactionStatus(orderId);
|
||||
if (!status) {
|
||||
return { ok: true, status: "not_found" };
|
||||
}
|
||||
|
||||
const result = await 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,
|
||||
});
|
||||
|
||||
if (!payment) {
|
||||
// Order tidak dikenal — return ok supaya Midtrans tidak retry forever.
|
||||
return { ok: true, status: "ignored" };
|
||||
}
|
||||
|
||||
// Cek amount cocok. gross_amount dari Midtrans format "100000.00".
|
||||
const amountFromGateway = Math.round(Number(grossAmount));
|
||||
if (
|
||||
Number.isNaN(amountFromGateway) ||
|
||||
amountFromGateway !== payment.amount
|
||||
) {
|
||||
// Tetap simpan callback mentah untuk audit, tapi jangan ubah status.
|
||||
await prisma.payment.update({
|
||||
where: { id: payment.id },
|
||||
data: {
|
||||
rawCallback: payload as unknown as Prisma.InputJsonValue,
|
||||
rejectionReason: `Amount mismatch: gateway=${grossAmount}, expected=${payment.amount}`,
|
||||
},
|
||||
});
|
||||
return { ok: false, reason: "amount_mismatch" };
|
||||
}
|
||||
|
||||
const finalStatuses = new Set([
|
||||
"PAID",
|
||||
"FAILED",
|
||||
"EXPIRED",
|
||||
"CANCELLED",
|
||||
"REFUNDED",
|
||||
]);
|
||||
if (finalStatuses.has(payment.status)) {
|
||||
// Idempotent: skip update tapi tetap log callback baru.
|
||||
await prisma.payment.update({
|
||||
where: { id: payment.id },
|
||||
data: {
|
||||
rawCallback: payload as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
return { ok: true, status: "skipped" };
|
||||
}
|
||||
|
||||
const newStatus = mapMidtransStatus(
|
||||
payload.transaction_status,
|
||||
payload.fraud_status ?? null
|
||||
);
|
||||
|
||||
let lastErr: unknown;
|
||||
for (let attempt = 0; attempt < SERIAL_TX_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const now = new Date();
|
||||
|
||||
// Re-baca booking di dalam transaksi — bisa berubah (mis. user cancel) sejak
|
||||
// outer fetch. Lock-free read tetap aman karena isolasi serializable.
|
||||
const currentBooking = await tx.booking.findUnique({
|
||||
where: { id: payment.bookingId },
|
||||
select: { status: true, participantId: true },
|
||||
});
|
||||
|
||||
const bookingInConflictState =
|
||||
currentBooking?.status === "CANCELLED" ||
|
||||
currentBooking?.status === "REFUNDED" ||
|
||||
currentBooking?.status === "EXPIRED";
|
||||
|
||||
// Selalu simpan callback Payment (truth dari gateway). Kalau booking
|
||||
// di state konflik dan webhook mau set PAID, payment tetap PAID
|
||||
// (uang masuk) tapi tidak propagate ke booking — flag untuk manual review/refund.
|
||||
const conflictNote =
|
||||
bookingInConflictState && newStatus === "PAID"
|
||||
? `Webhook PAID arrived but Booking is ${currentBooking.status}. Manual review required (potential refund).`
|
||||
: null;
|
||||
|
||||
await tx.payment.update({
|
||||
where: { id: payment.id },
|
||||
data: {
|
||||
status: newStatus,
|
||||
externalTxId: payload.transaction_id ?? null,
|
||||
method: payload.payment_type ?? null,
|
||||
rawCallback: payload as unknown as Prisma.InputJsonValue,
|
||||
paidAt: newStatus === "PAID" ? now : null,
|
||||
failedAt:
|
||||
newStatus === "FAILED" || newStatus === "EXPIRED" ? now : null,
|
||||
rejectionReason: conflictNote,
|
||||
},
|
||||
});
|
||||
|
||||
if (newStatus === "PAID" && !bookingInConflictState) {
|
||||
await tx.booking.update({
|
||||
where: { id: payment.bookingId },
|
||||
data: { status: "PAID" },
|
||||
});
|
||||
// Backward-compat: sync timestamp di TripParticipant.
|
||||
await tx.tripParticipant.update({
|
||||
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,
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
|
||||
maxWait: 5000,
|
||||
timeout: 15000,
|
||||
}
|
||||
);
|
||||
|
||||
// Recheck post-tx untuk reporting outcome.
|
||||
const finalBooking = await prisma.booking.findUnique({
|
||||
where: { id: payment.bookingId },
|
||||
select: { status: true },
|
||||
});
|
||||
const isConflict =
|
||||
newStatus === "PAID" &&
|
||||
finalBooking?.status !== "PAID";
|
||||
return {
|
||||
ok: true,
|
||||
status: isConflict ? "booking_conflict" : "updated",
|
||||
};
|
||||
} catch (e) {
|
||||
lastErr = e;
|
||||
if (isSerializationConflict(e) && attempt < SERIAL_TX_ATTEMPTS - 1) {
|
||||
continue;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
throw lastErr instanceof Error
|
||||
? lastErr
|
||||
: new Error("Webhook gagal diproses karena konflik transaksi");
|
||||
return result;
|
||||
},
|
||||
};
|
||||
|
||||
// Re-export untuk testing langsung kalau perlu (tetap private dari modul lain).
|
||||
export const _internal = { applyGatewayStatus };
|
||||
export type { MidtransTransactionStatus };
|
||||
|
||||
@@ -4,13 +4,13 @@ import { prisma } from "@/lib/prisma";
|
||||
import { tripRepo, type TripFilters } from "@/server/repositories/trip.repo";
|
||||
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";
|
||||
import type { ItineraryItemInput } from "@/lib/itinerary";
|
||||
|
||||
const SERIAL_TX_ATTEMPTS = 6;
|
||||
|
||||
@@ -30,7 +30,6 @@ interface CreateTripInput {
|
||||
destination: string;
|
||||
location: string;
|
||||
meetingPoint?: string;
|
||||
itinerary?: string;
|
||||
whatsIncluded?: string;
|
||||
whatsExcluded?: string;
|
||||
date: Date;
|
||||
@@ -40,6 +39,7 @@ interface CreateTripInput {
|
||||
vibe?: Vibe;
|
||||
organizerId: string;
|
||||
imageUrls?: string[];
|
||||
itineraryItems?: ItineraryItemInput[];
|
||||
}
|
||||
|
||||
export const tripService = {
|
||||
@@ -75,6 +75,18 @@ export const tripService = {
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const itineraryItems = input.itineraryItems?.length
|
||||
? {
|
||||
create: input.itineraryItems.map((item, i) => ({
|
||||
day: item.day,
|
||||
startTime: item.startTime,
|
||||
endTime: item.endTime ?? null,
|
||||
activity: item.activity.trim(),
|
||||
order: i,
|
||||
})),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const tripData = {
|
||||
category: input.category,
|
||||
title: input.title,
|
||||
@@ -82,7 +94,6 @@ export const tripService = {
|
||||
destination: input.destination,
|
||||
location: input.location,
|
||||
meetingPoint: input.meetingPoint,
|
||||
itinerary: input.itinerary,
|
||||
whatsIncluded: input.whatsIncluded,
|
||||
whatsExcluded: input.whatsExcluded,
|
||||
date: input.date,
|
||||
@@ -92,6 +103,7 @@ export const tripService = {
|
||||
vibe: input.vibe,
|
||||
organizer: { connect: { id: input.organizerId } },
|
||||
images,
|
||||
itineraryItems,
|
||||
} satisfies Prisma.TripCreateInput;
|
||||
|
||||
let lastErr: unknown;
|
||||
@@ -377,30 +389,6 @@ export const tripService = {
|
||||
return { ok: true as const };
|
||||
},
|
||||
|
||||
async markParticipantPayment(tripId: string, userId: string) {
|
||||
const trip = await tripRepo.findById(tripId);
|
||||
if (!trip) {
|
||||
throw new Error("Trip tidak ditemukan");
|
||||
}
|
||||
|
||||
if (isFreeTrip(trip)) {
|
||||
throw new Error(
|
||||
"Trip ini gratis — tidak ada pembayaran yang perlu ditandai"
|
||||
);
|
||||
}
|
||||
|
||||
if (isTripDepartureDayPast(trip.date)) {
|
||||
throw new Error("Trip sudah lewat — pembayaran tidak perlu ditandai");
|
||||
}
|
||||
|
||||
const booking = await bookingRepo.findByTripAndUser(tripId, userId);
|
||||
if (!booking || booking.status === "CANCELLED") {
|
||||
throw new Error("Kamu tidak terdaftar di trip ini");
|
||||
}
|
||||
|
||||
return bookingService.markPaidManual(booking.id, userId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Auto-complete trip yang sudah lewat. Dipakai cron harian.
|
||||
*
|
||||
@@ -415,30 +403,6 @@ export const tripService = {
|
||||
return tripRepo.bulkCompletePastTrips(cutoff);
|
||||
},
|
||||
|
||||
async confirmParticipantPayment(
|
||||
tripId: string,
|
||||
participantId: string,
|
||||
organizerId: string
|
||||
) {
|
||||
const trip = await tripRepo.findById(tripId);
|
||||
if (!trip) {
|
||||
throw new Error("Trip tidak ditemukan");
|
||||
}
|
||||
if (trip.organizerId !== organizerId) {
|
||||
throw new Error("Hanya organizer yang bisa mengonfirmasi pembayaran");
|
||||
}
|
||||
if (isFreeTrip(trip)) {
|
||||
throw new Error("Trip ini gratis — tidak ada pembayaran yang perlu dikonfirmasi");
|
||||
}
|
||||
|
||||
const booking = await bookingRepo.findByParticipantId(participantId);
|
||||
if (!booking || booking.tripId !== tripId) {
|
||||
throw new Error("Booking tidak ditemukan");
|
||||
}
|
||||
|
||||
return bookingService.confirmPaidManual(booking.id, organizerId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Organizer batalkan trip (Trip.status = CLOSED). Atomic dalam satu
|
||||
* serializable transaction:
|
||||
|
||||
Reference in New Issue
Block a user