add payment and integration with midtrans

This commit is contained in:
2026-05-08 21:44:34 +07:00
parent ecd4dc2ef4
commit 68ffaf2f69
14 changed files with 886 additions and 36 deletions
+41 -26
View File
@@ -158,39 +158,42 @@ enum PaymentStatus {
---
## PR C — Midtrans integration (Snap + webhook)
## PR C — Midtrans integration (Snap + webhook)
Tambah provider MIDTRANS ke pipeline yang sudah dibuat di PR B. Test di sandbox dulu.
Selesai. `tsc --noEmit` lulus. Belum test live ke sandbox Midtrans — perlu env diisi + tunneling kalau dev lokal.
### Persiapan akun & env
| Env | Keterangan |
|---|---|
| `MIDTRANS_SERVER_KEY` | Server key dari dashboard Midtrans (sandbox/production sesuai mode). Rahasia. |
| `MIDTRANS_CLIENT_KEY` | Client key. Boleh di expose ke frontend (untuk Snap script). |
| `MIDTRANS_IS_PRODUCTION` | `true`/`false` — pilih endpoint sandbox vs production. |
| `MIDTRANS_NOTIFICATION_URL` | URL callback publik kita, mis. `https://setrip.id/api/webhooks/midtrans`. Didaftarkan di dashboard Midtrans. |
| `MIDTRANS_SERVER_KEY` | Server key dari dashboard Midtrans (sandbox/production sesuai mode). Rahasia. Server-side only. |
| `NEXT_PUBLIC_MIDTRANS_CLIENT_KEY` | Client key untuk Snap.js. Aman di-expose ke frontend (NEXT_PUBLIC_). |
| `NEXT_PUBLIC_MIDTRANS_IS_PRODUCTION` | `true` untuk production, `false` (atau kosong) untuk sandbox. NEXT_PUBLIC_ supaya client tahu URL Snap.js yang benar. |
Tambah ke [env.example](env.example) dengan komentar.
`MIDTRANS_NOTIFICATION_URL` **tidak** di env — diset langsung di dashboard Midtrans ke `<NEXT_PUBLIC_SITE_URL>/api/webhooks/midtrans`.
Sudah ditambah ke [env.example](env.example).
### Tugas
| # | Item | Status | Catatan |
| # | Item | Status | File |
|---|---|---|---|
| C1 | Update [env.example](env.example) + dokumentasi env | | 4 env baru. |
| C2 | `lib/midtrans.ts` client tipis: `createSnapTransaction`, `verifySignature`, `mapStatus` | ⏳ | Pakai `fetch` + `crypto.createHash('sha512')`. Tidak butuh dependency baru. |
| C3 | Status mapping helper | ⏳ | `transaction_status` + `fraud_status` Midtrans`PaymentStatus` internal. Tabel mapping ada di README PR ini. |
| C4 | Service `paymentService.startMidtransPayment(bookingId)` | ⏳ | Bikin Payment row provider=MIDTRANS, kirim ke Midtrans, simpan `snapToken` + `expiresAt`. Kalau Booking sudah PAID → reject. |
| C5 | Halaman payment: tombol "Bayar online (Midtrans)" untuk trip berbayar | ⏳ | Fallback "Transfer manual" tetap ada (provider MANUAL). User pilih sebelum lanjut. |
| C6 | Frontend: load Snap script + invoke `window.snap.pay(token)` | | Loaded conditional di halaman payment, bukan global. Pakai client key dari env publik. |
| C7 | Webhook endpoint `app/api/webhooks/midtrans/route.ts` | ⏳ | POST. Verify signature (sha512). Lookup Payment by `externalOrderId`. Update idempotent. Selalu return 200. |
| C8 | Booking status sync setelah webhook PAID | ⏳ | `Booking.status = PAID`. Sync `TripParticipant.paymentConfirmedAt` untuk kompatibilitas. Concurrency: gunakan DB transaction. |
| C9 | Cron / scheduled job: expire Payment lama | ⏳ | Midtrans default expire 24 jam, tapi DB-side juga harus bersih supaya UI status akurat. Bisa dijalankan via Vercel cron atau manual scheduler. |
| C10 | Anti-replay: skip kalau `Payment.status` sudah final (PAID/FAILED/EXPIRED) | | Webhook bisa diretry oleh Midtrans. |
| C11 | Logging callback mentah ke `Payment.rawCallback` (Json) | | Audit & dispute. |
| C12 | Test scenario di sandbox | ⏳ | Settlement BCA VA, gopay, deny (kartu fraud), expire, cancel. |
| C13 | Status badge di halaman payment | ⏳ | Tampil real-time tanpa polling agresif (refresh manual atau interval longgar 10s). |
| C14 | Email/in-app notification setelah PAID | ⏳ | Optional Phase ini, bisa Phase berikutnya. |
| C1 | Update [env.example](env.example) + 3 env baru + komentar webhook URL | | [env.example](env.example) |
| C2 | `lib/midtrans.ts``createSnapTransaction`, `verifyMidtransSignature` (timing-safe compare), `MIDTRANS` config helper | ✅ | [lib/midtrans.ts](lib/midtrans.ts) |
| C3 | Status mapping `mapMidtransStatus(transaction_status, fraud_status)``PaymentStatus` | ✅ | [lib/midtrans.ts](lib/midtrans.ts) |
| C4 | `paymentService.startMidtransPayment(bookingId, userId)` — validate, reuse Payment AWAITING aktif (idempotent re-attempt), atau buat Payment baru + call Snap API + simpan token + expiresAt | ✅ | [server/services/payment.service.ts](server/services/payment.service.ts) |
| C5 | Halaman payment: tombol "Bayar online via Midtrans" + divider "atau" + tombol manual lama | ✅ | [app/trips/[id]/payment/page.tsx](app/trips/%5Bid%5D/payment/page.tsx) |
| C6 | `MidtransPayButton` client component — load Snap.js dengan `data-client-key` dinamis, `window.snap.pay(token, callbacks)`, refresh page setelah Snap close | | [features/booking/components/midtrans-pay-button.tsx](features/booking/components/midtrans-pay-button.tsx) |
| C7 | Webhook endpoint `app/api/webhooks/midtrans/route.ts` POST, verify signature, lookup, idempotent, return 200/401 sesuai outcome | ✅ | [app/api/webhooks/midtrans/route.ts](app/api/webhooks/midtrans/route.ts) |
| C8 | `paymentService.handleMidtransWebhook` — verifikasi signature, amount check, transaction (`Payment` + `Booking` + `TripParticipant.paymentConfirmedAt` backcompat) | ✅ | [server/services/payment.service.ts](server/services/payment.service.ts) |
| C10 | Anti-replay: skip update kalau Payment sudah final (PAID/FAILED/EXPIRED/CANCELLED/REFUNDED) | ✅ | [payment.service.ts](server/services/payment.service.ts) |
| C11 | Simpan callback mentah ke `Payment.rawCallback` (audit & dispute), termasuk untuk callback yang di-skip | | [payment.service.ts](server/services/payment.service.ts) |
| C+ | Server action `startMidtransPaymentAction` (resolve booking dari tripId, bridge ke client) | | [features/booking/actions.ts](features/booking/actions.ts) |
| C+ | Retry handling: Payment row baru dengan `midtrans-{bookingId}-{retryN}` kalau attempt lama expired/failed; idempotent reuse kalau masih AWAITING | ✅ | [payment.service.ts](server/services/payment.service.ts) |
| C9 | Cron expire Payment lama | ⏸️ skipped | Housekeeping di-handle saat user start payment (auto-expire attempt yang lewat `expiresAt`). Cron formal bisa ditambah kalau perlu cleanup massal. |
| C12 | Test scenario sandbox (settlement, deny, expire) | ⏸️ manual | Perlu env Midtrans diisi + tunneling untuk dev lokal (ngrok/cloudflared). Tidak bisa otomatis dari sini. |
| C13 | Status badge real-time | ⏸️ partial | Page refresh setelah Snap close + halaman SSR pull state baru tiap reload. Polling otomatis belum diimplementasi. |
| C14 | Email/in-app notification setelah PAID | ⏳ pending | Diluar scope PR C — masuk Phase berikutnya. |
### Mapping `transaction_status` Midtrans → `PaymentStatus`
@@ -214,13 +217,25 @@ Tambah ke [env.example](env.example) dengan komentar.
5. Pakai DB transaction untuk update Payment + Booking + TripParticipant bersamaan.
6. Selalu return 200 kalau request valid (mismatch signature → 401, sisanya → 200 + log).
### Hardening pasca-audit (sebelum Midtrans live) ✅
Empat fix tambahan dari audit security/correctness:
| Fix | Issue | Solusi | File |
|---|---|---|---|
| 1 | Webhook bisa overwrite Booking CANCELLED/REFUNDED/EXPIRED jadi PAID | Re-fetch Booking di dalam serializable transaction; kalau state konflik, Payment tetap PAID (uang masuk) tapi Booking tidak di-update + `Payment.rejectionReason` di-flag untuk manual review/refund. Webhook outcome `booking_conflict` di-log warning. | [payment.service.ts](server/services/payment.service.ts), [route.ts](app/api/webhooks/midtrans/route.ts) |
| 2 | `startMidtransPayment` lupa cek trip departure date | Tambah `isTripDepartureDayPast` guard, juga di `bookingService.markPaidManual` untuk konsistensi | [payment.service.ts](server/services/payment.service.ts), [booking.service.ts](server/services/booking.service.ts) |
| 3 | `Booking` tidak punya constraint `(tripId, userId)` unique | Tambah `@@unique([tripId, userId])` + migration `20260508160000_booking_unique_trip_user`. `findByTripAndUser` switch dari `findFirst` ke `findUnique` (lebih efisien) | [schema.prisma](prisma/schema.prisma), [migration](prisma/migrations/20260508160000_booking_unique_trip_user/migration.sql), [booking.repo.ts](server/repositories/booking.repo.ts) |
| 4 | Webhook payload tidak schema-validated | Zod `midtransWebhookSchema` (passthrough untuk forward-compat). Webhook route `safeParse` → 400 kalau shape invalid. Service signature pakai type yang inferred dari schema. | [lib/midtrans.ts](lib/midtrans.ts), [route.ts](app/api/webhooks/midtrans/route.ts), [payment.service.ts](server/services/payment.service.ts) |
### Edge cases yang gampang lupa
- **Quota race**: dua user bayar bersamaan untuk slot terakhir → slot harus di-hold saat Booking dibuat (status AWAITING_PAY masih hitung kuota), release otomatis saat Payment EXPIRED.
- **Quota race**: dua user bayar bersamaan untuk slot terakhir → slot di-hold saat Booking dibuat (status AWAITING_PAY masih hitung kuota via `TripParticipant.status`). Release belum otomatis saat Payment EXPIRED — kalau perlu, tambah cron (lihat C9 yang di-skip).
- **Trip dibatalkan organizer setelah peserta bayar** → `Booking.status = REFUNDED` setelah dana balik. Implementasi refund Midtrans = PR terpisah (tidak di scope PR C ini).
- **User retry pembayaran setelah gagal** → bikin Payment baru (bukan reuse), `externalOrderId` baru (`setrip-{bookingId}-{retry}`). Booking status tetap AWAITING_PAY.
- **Webhook duplicate**: Midtrans bisa kirim notifikasi yang sama 2-3 kali. Idempotency key = `Payment.externalOrderId` + status terkini.
- **Sandbox vs production**: simulator Midtrans akan kirim callback ke `MIDTRANS_NOTIFICATION_URL`. Pastikan URL sandbox bisa diakses publik (tunneling kalau dev lokal — ngrok / cloudflared).
- **User retry pembayaran setelah gagal** → bikin Payment baru, `externalOrderId` baru (`midtrans-{bookingId}-{retryN}`). Reuse kalau masih AWAITING & belum expired.
- **Webhook duplicate**: Midtrans bisa kirim notifikasi yang sama 2-3 kali. Idempotent: skip update kalau Payment sudah final, tapi tetap simpan callback ke `rawCallback` untuk audit.
- **Sandbox vs production**: webhook URL diset di dashboard Midtrans = `<NEXT_PUBLIC_SITE_URL>/api/webhooks/midtrans`. Dev lokal perlu tunneling (ngrok / cloudflared) supaya endpoint bisa di-reach Midtrans.
- **Booking belum approved (`PENDING`) tapi user coba bayar** — `paymentService.startMidtransPayment` reject dengan pesan jelas. UI sudah hide tombol di state ini.
---
+81
View File
@@ -0,0 +1,81 @@
import { NextRequest, NextResponse } from "next/server";
import { midtransWebhookSchema } from "@/lib/midtrans";
import { paymentService } from "@/server/services/payment.service";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Webhook callback dari Midtrans.
*
* Aturan response:
* - Body bukan JSON / shape tidak valid → 400 (Midtrans tetap retry, tapi mereka pasti
* kirim shape valid; 400 di sini = bug bukan dari Midtrans).
* - Signature mismatch → 401 (Midtrans tidak retry untuk auth error).
* - Sudah final / unknown order / amount mismatch → 200 OK + log
* (kita tidak mau Midtrans retry forever untuk kasus yang server-side perlu manual review).
* - Sukses update → 200 OK.
*
* URL ini harus didaftarkan di dashboard Midtrans:
* `<NEXT_PUBLIC_SITE_URL>/api/webhooks/midtrans`.
*/
export async function POST(req: NextRequest) {
let raw: unknown;
try {
raw = await req.json();
} catch {
return NextResponse.json({ error: "Body bukan JSON valid" }, { status: 400 });
}
const parsed = midtransWebhookSchema.safeParse(raw);
if (!parsed.success) {
console.warn(
"[midtrans-webhook] payload schema invalid",
parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`)
);
return NextResponse.json(
{ error: "Payload schema invalid" },
{ status: 400 }
);
}
const body = parsed.data;
let outcome;
try {
outcome = await paymentService.handleMidtransWebhook(body);
} catch (err) {
console.error("[midtrans-webhook] gagal proses callback", err, {
order_id: body.order_id,
});
return NextResponse.json(
{ error: "Gagal memproses callback" },
{ status: 500 }
);
}
if (!outcome.ok) {
if (outcome.reason === "signature_mismatch") {
console.warn("[midtrans-webhook] signature mismatch", {
order_id: body.order_id,
});
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}
if (outcome.reason === "amount_mismatch") {
console.warn("[midtrans-webhook] amount mismatch", {
order_id: body.order_id,
gross_amount: body.gross_amount,
});
// Return 200 supaya Midtrans tidak retry; investigasi via log.
return NextResponse.json({ status: "amount_mismatch_logged" });
}
}
if (outcome.ok && outcome.status === "booking_conflict") {
console.warn(
"[midtrans-webhook] PAID arrived for booking in conflict state — manual review required",
{ order_id: body.order_id, transaction_id: body.transaction_id }
);
}
return NextResponse.json({ status: outcome.ok ? outcome.status : "error" });
}
File diff suppressed because one or more lines are too long
+7 -1
View File
@@ -278,6 +278,7 @@ export type BookingOrderByWithRelationInput = {
export type BookingWhereUniqueInput = Prisma.AtLeast<{
id?: string
participantId?: string
tripId_userId?: Prisma.BookingTripIdUserIdCompoundUniqueInput
AND?: Prisma.BookingWhereInput | Prisma.BookingWhereInput[]
OR?: Prisma.BookingWhereInput[]
NOT?: Prisma.BookingWhereInput | Prisma.BookingWhereInput[]
@@ -292,7 +293,7 @@ export type BookingWhereUniqueInput = Prisma.AtLeast<{
user?: Prisma.XOR<Prisma.UserScalarRelationFilter, Prisma.UserWhereInput>
participant?: Prisma.XOR<Prisma.TripParticipantScalarRelationFilter, Prisma.TripParticipantWhereInput>
payments?: Prisma.PaymentListRelationFilter
}, "id" | "participantId">
}, "id" | "participantId" | "tripId_userId">
export type BookingOrderByWithAggregationInput = {
id?: Prisma.SortOrder
@@ -426,6 +427,11 @@ export type BookingNullableScalarRelationFilter = {
isNot?: Prisma.BookingWhereInput | null
}
export type BookingTripIdUserIdCompoundUniqueInput = {
tripId: string
userId: string
}
export type BookingCountOrderByAggregateInput = {
id?: Prisma.SortOrder
tripId?: Prisma.SortOrder
+17 -2
View File
@@ -11,6 +11,7 @@ import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
import { isFreeTrip } from "@/lib/trip-pricing";
import { categoryMeta } from "@/lib/activity-category";
import { MarkPaidButton } from "@/features/booking/components/mark-paid-button";
import { MidtransPayButton } from "@/features/booking/components/midtrans-pay-button";
import { CopyButton } from "@/features/booking/components/copy-button";
export const metadata: Metadata = {
@@ -293,8 +294,22 @@ async function PaidTripSection({
</div>
)}
{canMarkPaid && bankAvailable && (
<MarkPaidButton tripId={tripId} />
{canMarkPaid && (
<div className="space-y-3">
{bankAvailable && (
<>
<MarkPaidButton tripId={tripId} />
<div className="flex items-center gap-3">
<span className="h-px flex-1 bg-neutral-200" />
<span className="text-[11px] font-semibold uppercase tracking-wide text-neutral-400">
atau
</span>
<span className="h-px flex-1 bg-neutral-200" />
</div>
</>
)}
<MidtransPayButton tripId={tripId} />
</div>
)}
{hasMarkedPaid && (
+12
View File
@@ -15,3 +15,15 @@ KYC_UPLOAD_DIR=
GOOGLE_CLIENT_ID="xxxxxxxx"
GOOGLE_CLIENT_SECRET="xxxxxxxx"
# === Midtrans payment gateway (Phase C) ===
# Server key dari dashboard Midtrans (sandbox: SB-Mid-server-..., production: Mid-server-...).
# RAHASIA — server-side only, jangan commit nilai aslinya.
MIDTRANS_SERVER_KEY=
# Client key untuk init Snap.js di browser (sandbox: SB-Mid-client-..., production: Mid-client-...).
# Aman diekspos via NEXT_PUBLIC_ — bukan rahasia.
NEXT_PUBLIC_MIDTRANS_CLIENT_KEY=
# 'true' untuk production, 'false' atau kosong untuk sandbox.
# Dibaca di server (untuk Snap API endpoint) DAN client (untuk Snap.js URL).
NEXT_PUBLIC_MIDTRANS_IS_PRODUCTION=false
# Webhook URL di Midtrans dashboard harus diset ke: <NEXT_PUBLIC_SITE_URL>/api/webhooks/midtrans
+47
View File
@@ -3,6 +3,8 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { tripService } from "@/server/services/trip.service";
import { paymentService } from "@/server/services/payment.service";
import { bookingService } from "@/server/services/booking.service";
import { revalidatePath } from "next/cache";
export async function markParticipantPaidAction(tripId: string) {
@@ -23,6 +25,51 @@ export async function markParticipantPaidAction(tripId: string) {
}
}
export type StartMidtransResponse =
| { error: string }
| {
success: true;
snapToken: string;
snapJsUrl: string;
clientKey: string;
};
/**
* Mulai pembayaran online via Midtrans untuk trip tertentu. Resolve booking
* dari (tripId, userId) supaya client tidak perlu tahu bookingId.
*/
export async function startMidtransPaymentAction(
tripId: string
): Promise<StartMidtransResponse> {
const session = await getServerSession(authOptions);
if (!session?.user) {
return { error: "Kamu harus login terlebih dahulu" };
}
try {
const booking = await bookingService.getByTripAndUser(
tripId,
session.user.id
);
if (!booking || booking.status === "CANCELLED") {
return { error: "Kamu tidak terdaftar di trip ini" };
}
const result = await paymentService.startMidtransPayment(
booking.id,
session.user.id
);
return {
success: true,
snapToken: result.snapToken,
snapJsUrl: result.snapJsUrl,
clientKey: result.clientKey,
};
} catch (err) {
return { error: (err as Error).message };
}
}
export async function confirmParticipantPaymentAction(
tripId: string,
participantId: string
@@ -0,0 +1,132 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { startMidtransPaymentAction } from "@/features/booking/actions";
interface SnapCallbacks {
onSuccess?: (result: unknown) => void;
onPending?: (result: unknown) => void;
onError?: (result: unknown) => void;
onClose?: () => void;
}
interface SnapApi {
pay: (token: string, callbacks?: SnapCallbacks) => void;
}
declare global {
interface Window {
snap?: SnapApi;
}
}
const SCRIPT_ID = "midtrans-snap-script";
function loadSnapScript(snapJsUrl: string, clientKey: string): Promise<void> {
return new Promise((resolve, reject) => {
if (typeof window === "undefined") {
reject(new Error("Snap hanya bisa dimuat di browser"));
return;
}
if (window.snap) {
resolve();
return;
}
const existing = document.getElementById(SCRIPT_ID);
if (existing) {
existing.addEventListener("load", () => resolve());
existing.addEventListener("error", () =>
reject(new Error("Gagal memuat Snap.js"))
);
return;
}
const script = document.createElement("script");
script.id = SCRIPT_ID;
script.src = snapJsUrl;
script.async = true;
script.setAttribute("data-client-key", clientKey);
script.onload = () => resolve();
script.onerror = () => reject(new Error("Gagal memuat Snap.js"));
document.body.appendChild(script);
});
}
interface MidtransPayButtonProps {
tripId: string;
}
export function MidtransPayButton({ tripId }: MidtransPayButtonProps) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
async function handleClick() {
setLoading(true);
setError("");
const result = await startMidtransPaymentAction(tripId);
if ("error" in result) {
setError(result.error);
setLoading(false);
return;
}
try {
await loadSnapScript(result.snapJsUrl, result.clientKey);
} catch (e) {
setError(e instanceof Error ? e.message : "Gagal memuat pembayaran");
setLoading(false);
return;
}
if (!window.snap) {
setError("Snap belum siap, refresh halaman dan coba lagi");
setLoading(false);
return;
}
window.snap.pay(result.snapToken, {
onSuccess: () => {
// Webhook server akan tetap jadi sumber kebenaran. Refresh page untuk pull state baru.
router.refresh();
},
onPending: () => router.refresh(),
onError: () => {
setError(
"Pembayaran gagal diproses. Coba lagi atau pakai metode lain."
);
router.refresh();
},
onClose: () => {
// User menutup popup tanpa menyelesaikan. Refresh saja, kalau status berubah
// (mis. user sudah bayar VA) callback dari Midtrans akan datang ke webhook.
router.refresh();
},
});
setLoading(false);
}
return (
<div>
{error && (
<div className="mb-3 rounded-xl bg-red-50 px-4 py-3 text-sm font-medium text-red-600">
{error}
</div>
)}
<button
type="button"
onClick={handleClick}
disabled={loading}
className="w-full rounded-xl bg-secondary-600 py-3 text-sm font-bold text-white shadow-lg shadow-secondary-600/20 transition-colors hover:bg-secondary-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{loading ? "Memuat Snap..." : "Bayar online via Midtrans"}
</button>
<p className="mt-2 text-center text-[11px] text-neutral-500">
Bayar pakai BCA VA, GoPay, QRIS, kartu, dan lainnya. Status terupdate
otomatis setelah pembayaran terkonfirmasi gateway.
</p>
</div>
);
}
+193
View File
@@ -0,0 +1,193 @@
import crypto from "node:crypto";
import { z } from "zod/v4";
import type { PaymentStatus } from "@/app/generated/prisma/enums";
/**
* Schema validasi callback Midtrans. Field minimum yang kita pakai +
* passthrough untuk field lain (Midtrans bisa tambah field tanpa breaking).
*/
export const midtransWebhookSchema = z.object({
order_id: z.string().min(1, "order_id wajib"),
status_code: z.string().min(1, "status_code wajib"),
gross_amount: z.string().min(1, "gross_amount wajib"),
signature_key: z.string().min(1, "signature_key wajib"),
transaction_status: z.string().min(1, "transaction_status wajib"),
transaction_id: z.string().optional(),
payment_type: z.string().optional(),
fraud_status: z.string().nullable().optional(),
}).passthrough();
export type MidtransWebhookBody = z.infer<typeof midtransWebhookSchema>;
/**
* Thin client untuk Midtrans Snap.
*
* Tidak pakai library `midtrans-client` — kita cukup `fetch` + `crypto.createHash`
* untuk verifikasi signature. Lebih ringkas dan dependency-free.
*/
function isProduction(): boolean {
return process.env.NEXT_PUBLIC_MIDTRANS_IS_PRODUCTION === "true";
}
export const MIDTRANS = {
isProduction,
/** Server-side base URL untuk Snap REST API. */
snapApiBase: () =>
isProduction()
? "https://app.midtrans.com/snap/v1"
: "https://app.sandbox.midtrans.com/snap/v1",
/** Public URL Snap.js untuk client. */
snapJsUrl: () =>
isProduction()
? "https://app.midtrans.com/snap/snap.js"
: "https://app.sandbox.midtrans.com/snap/snap.js",
};
function requireServerKey(): string {
const key = process.env.MIDTRANS_SERVER_KEY;
if (!key) {
throw new Error(
"MIDTRANS_SERVER_KEY belum di-set. Cek env.example."
);
}
return key;
}
function basicAuthHeader(): string {
// Midtrans pakai Basic auth dengan serverKey di username, password kosong.
const token = Buffer.from(`${requireServerKey()}:`).toString("base64");
return `Basic ${token}`;
}
interface SnapTransactionPayload {
orderId: string;
grossAmount: number;
customer: {
name: string;
email: string;
};
itemName: string;
/// Berapa detik sampai expire. Default Midtrans 24 jam, kita pakai itu kalau undefined.
expirySeconds?: number;
}
export interface SnapTransactionResult {
token: string;
redirectUrl: string;
}
/**
* Buat transaksi Snap di Midtrans, dapatkan token untuk dipakai window.snap.pay().
* https://docs.midtrans.com/reference/charge-transactions-1
*/
export async function createSnapTransaction(
payload: SnapTransactionPayload
): Promise<SnapTransactionResult> {
const body: Record<string, unknown> = {
transaction_details: {
order_id: payload.orderId,
gross_amount: payload.grossAmount,
},
customer_details: {
first_name: payload.customer.name,
email: payload.customer.email,
},
item_details: [
{
id: payload.orderId,
price: payload.grossAmount,
quantity: 1,
name: payload.itemName.slice(0, 50),
},
],
};
if (payload.expirySeconds) {
body.expiry = {
unit: "second",
duration: payload.expirySeconds,
};
}
const res = await fetch(`${MIDTRANS.snapApiBase()}/transactions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: basicAuthHeader(),
},
body: JSON.stringify(body),
cache: "no-store",
});
const json = (await res.json().catch(() => null)) as
| { token?: string; redirect_url?: string; error_messages?: string[] }
| null;
if (!res.ok || !json?.token) {
const reason =
json?.error_messages?.join(", ") ?? `HTTP ${res.status}`;
throw new Error(`Midtrans Snap gagal: ${reason}`);
}
return {
token: json.token,
redirectUrl: json.redirect_url ?? "",
};
}
/**
* Verifikasi signature webhook Midtrans.
* Formula: SHA512(order_id + status_code + gross_amount + serverKey).
* https://docs.midtrans.com/reference/notification-webhooks-1
*/
export function verifyMidtransSignature(
orderId: string,
statusCode: string,
grossAmount: string,
signatureKey: string
): boolean {
const expected = crypto
.createHash("sha512")
.update(`${orderId}${statusCode}${grossAmount}${requireServerKey()}`)
.digest("hex");
// Konstanta-time compare untuk hindari timing attack.
const a = Buffer.from(expected, "hex");
const b = Buffer.from(signatureKey, "hex");
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
/**
* Map kombinasi `transaction_status` + `fraud_status` Midtrans ke `PaymentStatus` internal.
* Tabel rujukan ada di PAYMENT_ROADMAP.md PR C.
*/
export function mapMidtransStatus(
transactionStatus: string,
fraudStatus?: string | null
): PaymentStatus {
switch (transactionStatus) {
case "capture":
// Khusus kartu kredit. fraud_status menentukan apakah sudah aman atau perlu review.
if (fraudStatus === "challenge") return "AWAITING";
if (fraudStatus === "accept") return "PAID";
return "AWAITING";
case "settlement":
return "PAID";
case "pending":
return "AWAITING";
case "deny":
return "FAILED";
case "expire":
return "EXPIRED";
case "cancel":
return "CANCELLED";
case "refund":
case "partial_refund":
return "REFUNDED";
default:
return "AWAITING";
}
}
@@ -0,0 +1,5 @@
-- DropIndex (replace solo userId index dengan unique compound yang lebih berguna)
DROP INDEX "Booking_userId_idx";
-- CreateIndex (unique compound, juga jadi index untuk lookup by tripId+userId)
CREATE UNIQUE INDEX "Booking_tripId_userId_key" ON "Booking"("tripId", "userId");
+4 -1
View File
@@ -262,8 +262,11 @@ model Booking {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
/// Konsistensi: 1-1 ke participant via participantId, dan participant unique
/// per (tripId, userId). Constraint ini eksplisit + jadi index untuk query
/// `findByTripAndUser`.
@@unique([tripId, userId])
@@index([tripId, status])
@@index([userId])
}
enum BookingStatus {
+2 -2
View File
@@ -14,8 +14,8 @@ export const bookingRepo = {
},
async findByTripAndUser(tripId: string, userId: string) {
return prisma.booking.findFirst({
where: { tripId, userId },
return prisma.booking.findUnique({
where: { tripId_userId: { tripId, userId } },
include: { payments: { orderBy: { createdAt: "desc" } } },
});
},
+7 -1
View File
@@ -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: {
+335
View File
@@ -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");
},
};