543 lines
16 KiB
TypeScript
543 lines
16 KiB
TypeScript
import { Prisma } from "@/app/generated/prisma/client";
|
|
import { prisma } from "@/lib/prisma";
|
|
import {
|
|
MIDTRANS,
|
|
createSnapTransaction,
|
|
fetchMidtransTransactionStatus,
|
|
mapMidtransStatus,
|
|
verifyMidtransSignature,
|
|
type MidtransTransactionStatus,
|
|
type MidtransWebhookBody,
|
|
} from "@/lib/midtrans";
|
|
import { isTripDepartureDayPast } from "@/lib/trip-dates";
|
|
import { payoutService } from "@/server/services/payout.service";
|
|
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"
|
|
);
|
|
}
|
|
|
|
function midtransOrderId(bookingId: string, attempt: number): string {
|
|
return `midtrans-${bookingId}-${attempt}`;
|
|
}
|
|
|
|
export interface StartMidtransResult {
|
|
paymentId: string;
|
|
snapToken: string;
|
|
snapJsUrl: string;
|
|
clientKey: string;
|
|
expiresAt: Date;
|
|
orderId: string;
|
|
}
|
|
|
|
export type ApplyOutcome =
|
|
| { ok: true; status: "updated" | "skipped" | "ignored" | "booking_conflict" }
|
|
| { 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";
|
|
|
|
// Notif email user kalau payment benar-benar berhasil di-apply ke booking.
|
|
if (newStatus === "PAID" && !isConflict) {
|
|
void notifyPaymentPaid(payment.id);
|
|
}
|
|
// E3.3 — pembayaran kadaluarsa/gagal: kabari user supaya bisa retry.
|
|
if (newStatus === "EXPIRED" || newStatus === "FAILED") {
|
|
void notifyPaymentFailed(payment.id);
|
|
}
|
|
|
|
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 = {
|
|
/**
|
|
* Mulai pembayaran Midtrans untuk Booking. Idempotent — kalau ada Payment
|
|
* MIDTRANS aktif (status PENDING/AWAITING), reuse token yang sudah ada
|
|
* selama belum expired.
|
|
*/
|
|
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");
|
|
}
|
|
|
|
const booking = await prisma.booking.findUnique({
|
|
where: { id: bookingId },
|
|
include: {
|
|
user: { select: { id: true, name: true, email: true } },
|
|
trip: { select: { title: true, date: true } },
|
|
payments: {
|
|
where: { provider: "MIDTRANS" },
|
|
orderBy: { createdAt: "desc" },
|
|
},
|
|
},
|
|
});
|
|
|
|
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 gratis tidak butuh pembayaran online");
|
|
}
|
|
if (booking.status === "PAID") {
|
|
throw new Error("Booking sudah lunas");
|
|
}
|
|
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"
|
|
);
|
|
}
|
|
|
|
// Reuse Payment AWAITING/PENDING yang belum kadaluarsa, kalau ada.
|
|
const now = new Date();
|
|
const reusable = booking.payments.find(
|
|
(p) =>
|
|
(p.status === "PENDING" || p.status === "AWAITING") &&
|
|
p.snapToken &&
|
|
(!p.expiresAt || p.expiresAt.getTime() > now.getTime())
|
|
);
|
|
|
|
const snapJsUrl = MIDTRANS.snapJsUrl();
|
|
|
|
if (reusable && reusable.snapToken) {
|
|
return {
|
|
paymentId: reusable.id,
|
|
snapToken: reusable.snapToken,
|
|
snapJsUrl,
|
|
clientKey,
|
|
expiresAt: reusable.expiresAt ?? new Date(now.getTime() + 24 * 3600 * 1000),
|
|
orderId: reusable.externalOrderId,
|
|
};
|
|
}
|
|
|
|
// Tutup attempt lama yang sudah expired (housekeeping ringan)
|
|
const expiredIds = booking.payments
|
|
.filter(
|
|
(p) =>
|
|
(p.status === "PENDING" || p.status === "AWAITING") &&
|
|
p.expiresAt &&
|
|
p.expiresAt.getTime() <= now.getTime()
|
|
)
|
|
.map((p) => p.id);
|
|
if (expiredIds.length > 0) {
|
|
await prisma.payment.updateMany({
|
|
where: { id: { in: expiredIds } },
|
|
data: { status: "EXPIRED" },
|
|
});
|
|
}
|
|
|
|
const attemptNumber = booking.payments.length + 1;
|
|
const orderId = midtransOrderId(booking.id, attemptNumber);
|
|
const expirySeconds = 24 * 3600; // 24 jam, default Midtrans
|
|
const expiresAt = new Date(now.getTime() + expirySeconds * 1000);
|
|
|
|
// Create Payment row dulu (PENDING) supaya kalau call gagal, kita tetap punya audit trail.
|
|
const payment = await prisma.payment.create({
|
|
data: {
|
|
bookingId: booking.id,
|
|
provider: "MIDTRANS",
|
|
externalOrderId: orderId,
|
|
amount: booking.amount,
|
|
status: "PENDING",
|
|
expiresAt,
|
|
},
|
|
});
|
|
|
|
let snapResult;
|
|
try {
|
|
snapResult = await createSnapTransaction({
|
|
orderId,
|
|
grossAmount: booking.amount,
|
|
customer: {
|
|
name: booking.user.name,
|
|
email: booking.user.email,
|
|
},
|
|
itemName: booking.trip.title,
|
|
expirySeconds,
|
|
finishUrl: options?.finishUrl,
|
|
});
|
|
} catch (err) {
|
|
// Roll back Payment ke FAILED supaya orderId tidak nyangkut PENDING selamanya.
|
|
await prisma.payment.update({
|
|
where: { id: payment.id },
|
|
data: {
|
|
status: "FAILED",
|
|
failedAt: new Date(),
|
|
rejectionReason: err instanceof Error ? err.message : "Snap API error",
|
|
},
|
|
});
|
|
throw err;
|
|
}
|
|
|
|
const updated = await prisma.payment.update({
|
|
where: { id: payment.id },
|
|
data: {
|
|
snapToken: snapResult.token,
|
|
status: "AWAITING",
|
|
},
|
|
});
|
|
|
|
return {
|
|
paymentId: updated.id,
|
|
snapToken: snapResult.token,
|
|
snapJsUrl,
|
|
clientKey,
|
|
expiresAt,
|
|
orderId,
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Handle webhook callback dari Midtrans. Idempotent — boleh dipanggil berulang.
|
|
* Verifikasi signature dulu, lalu delegate ke `applyGatewayStatus`.
|
|
*/
|
|
async handleMidtransWebhook(
|
|
payload: MidtransWebhookBody
|
|
): Promise<WebhookOutcome> {
|
|
const signatureValid = verifyMidtransSignature(
|
|
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.
|
|
*/
|
|
/**
|
|
* 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
|
|
): 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: { 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,
|
|
});
|
|
|
|
return result;
|
|
},
|
|
};
|
|
|
|
async function notifyPaymentPaid(paymentId: string) {
|
|
const payment = await prisma.payment.findUnique({
|
|
where: { id: paymentId },
|
|
include: {
|
|
booking: {
|
|
include: {
|
|
user: { select: { email: true, name: true } },
|
|
trip: { select: { id: true, title: true } },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
if (!payment) return;
|
|
await emailService.send({
|
|
to: payment.booking.user.email,
|
|
idempotencyKey: `payment_paid-${payment.id}`,
|
|
template: {
|
|
template: "payment_paid",
|
|
data: {
|
|
userName: payment.booking.user.name,
|
|
tripTitle: payment.booking.trip.title,
|
|
tripId: payment.booking.trip.id,
|
|
amount: payment.amount,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
/** E3.3 — kabari user kalau pembayaran expired/gagal supaya bisa retry. */
|
|
async function notifyPaymentFailed(paymentId: string) {
|
|
const payment = await prisma.payment.findUnique({
|
|
where: { id: paymentId },
|
|
include: {
|
|
booking: {
|
|
include: {
|
|
user: { select: { email: true, name: true } },
|
|
trip: { select: { id: true, title: true } },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
if (!payment) return;
|
|
await emailService.send({
|
|
to: payment.booking.user.email,
|
|
idempotencyKey: `payment_expired-${payment.id}`,
|
|
template: {
|
|
template: "payment_expired",
|
|
data: {
|
|
userName: payment.booking.user.name,
|
|
tripTitle: payment.booking.trip.title,
|
|
tripId: payment.booking.trip.id,
|
|
amount: payment.amount,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
// Re-export untuk testing langsung kalau perlu (tetap private dari modul lain).
|
|
export const _internal = { applyGatewayStatus };
|
|
export type { MidtransTransactionStatus };
|