Compare commits
2 Commits
ecd4dc2ef4
...
5e0232d909
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e0232d909 | |||
| 68ffaf2f69 |
+41
-26
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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";
|
||||
}
|
||||
}
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "setrip",
|
||||
"version": "0.9.0",
|
||||
"version": "0.10.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "setrip",
|
||||
"version": "0.9.0",
|
||||
"version": "0.10.0",
|
||||
"dependencies": {
|
||||
"@next-auth/prisma-adapter": "^1.0.7",
|
||||
"@prisma/adapter-pg": "^7.7.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "setrip",
|
||||
"version": "0.9.0",
|
||||
"version": "0.10.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
@@ -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");
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" } } },
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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