add payment and integration with midtrans
This commit is contained in:
@@ -2,6 +2,7 @@ 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";
|
||||
|
||||
const SERIAL_TX_ATTEMPTS = 6;
|
||||
|
||||
@@ -39,7 +40,7 @@ export const bookingService = {
|
||||
async (tx) => {
|
||||
const booking = await tx.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
include: { trip: { select: { price: true } } },
|
||||
include: { trip: { select: { price: true, date: true } } },
|
||||
});
|
||||
if (!booking) {
|
||||
throw new Error("Booking tidak ditemukan");
|
||||
@@ -60,6 +61,11 @@ export const bookingService = {
|
||||
"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: {
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
import { Prisma } from "@/app/generated/prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
MIDTRANS,
|
||||
createSnapTransaction,
|
||||
mapMidtransStatus,
|
||||
verifyMidtransSignature,
|
||||
type MidtransWebhookBody,
|
||||
} from "@/lib/midtrans";
|
||||
import { isTripDepartureDayPast } from "@/lib/trip-dates";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export type WebhookOutcome =
|
||||
| { ok: true; status: "updated" | "skipped" | "ignored" | "booking_conflict" }
|
||||
| { ok: false; reason: "signature_mismatch" | "amount_mismatch" };
|
||||
|
||||
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): 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),
|
||||
};
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
} 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,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle webhook callback dari Midtrans. Idempotent — boleh dipanggil berulang.
|
||||
* Selalu return result yang webhook handler bisa terjemahkan ke HTTP status.
|
||||
*/
|
||||
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
|
||||
);
|
||||
if (!signatureValid) {
|
||||
return { ok: false, reason: "signature_mismatch" };
|
||||
}
|
||||
|
||||
const payment = await prisma.payment.findUnique({
|
||||
where: { externalOrderId: orderId },
|
||||
include: { booking: true },
|
||||
});
|
||||
|
||||
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 },
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
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");
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user