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
|
### Persiapan akun & env
|
||||||
|
|
||||||
| Env | Keterangan |
|
| Env | Keterangan |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `MIDTRANS_SERVER_KEY` | Server key dari dashboard Midtrans (sandbox/production sesuai mode). Rahasia. |
|
| `MIDTRANS_SERVER_KEY` | Server key dari dashboard Midtrans (sandbox/production sesuai mode). Rahasia. Server-side only. |
|
||||||
| `MIDTRANS_CLIENT_KEY` | Client key. Boleh di expose ke frontend (untuk Snap script). |
|
| `NEXT_PUBLIC_MIDTRANS_CLIENT_KEY` | Client key untuk Snap.js. Aman di-expose ke frontend (NEXT_PUBLIC_). |
|
||||||
| `MIDTRANS_IS_PRODUCTION` | `true`/`false` — pilih endpoint sandbox vs production. |
|
| `NEXT_PUBLIC_MIDTRANS_IS_PRODUCTION` | `true` untuk production, `false` (atau kosong) untuk sandbox. NEXT_PUBLIC_ supaya client tahu URL Snap.js yang benar. |
|
||||||
| `MIDTRANS_NOTIFICATION_URL` | URL callback publik kita, mis. `https://setrip.id/api/webhooks/midtrans`. Didaftarkan di dashboard Midtrans. |
|
|
||||||
|
|
||||||
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
|
### Tugas
|
||||||
|
|
||||||
| # | Item | Status | Catatan |
|
| # | Item | Status | File |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| C1 | Update [env.example](env.example) + dokumentasi env | ⏳ | 4 env baru. |
|
| C1 | Update [env.example](env.example) + 3 env baru + komentar webhook URL | ✅ | [env.example](env.example) |
|
||||||
| C2 | `lib/midtrans.ts` — client tipis: `createSnapTransaction`, `verifySignature`, `mapStatus` | ⏳ | Pakai `fetch` + `crypto.createHash('sha512')`. Tidak butuh dependency baru. |
|
| C2 | `lib/midtrans.ts` — `createSnapTransaction`, `verifyMidtransSignature` (timing-safe compare), `MIDTRANS` config helper | ✅ | [lib/midtrans.ts](lib/midtrans.ts) |
|
||||||
| C3 | Status mapping helper | ⏳ | `transaction_status` + `fraud_status` Midtrans → `PaymentStatus` internal. Tabel mapping ada di README PR ini. |
|
| C3 | Status mapping `mapMidtransStatus(transaction_status, fraud_status)` → `PaymentStatus` | ✅ | [lib/midtrans.ts](lib/midtrans.ts) |
|
||||||
| C4 | Service `paymentService.startMidtransPayment(bookingId)` | ⏳ | Bikin Payment row provider=MIDTRANS, kirim ke Midtrans, simpan `snapToken` + `expiresAt`. Kalau Booking sudah PAID → reject. |
|
| 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 (Midtrans)" untuk trip berbayar | ⏳ | Fallback "Transfer manual" tetap ada (provider MANUAL). User pilih sebelum lanjut. |
|
| 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 | Frontend: load Snap script + invoke `window.snap.pay(token)` | ⏳ | Loaded conditional di halaman payment, bukan global. Pakai client key dari env publik. |
|
| 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 (sha512). Lookup Payment by `externalOrderId`. Update idempotent. Selalu return 200. |
|
| 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 | Booking status sync setelah webhook PAID | ⏳ | `Booking.status = PAID`. Sync `TripParticipant.paymentConfirmedAt` untuk kompatibilitas. Concurrency: gunakan DB transaction. |
|
| C8 | `paymentService.handleMidtransWebhook` — verifikasi signature, amount check, transaction (`Payment` + `Booking` + `TripParticipant.paymentConfirmedAt` backcompat) | ✅ | [server/services/payment.service.ts](server/services/payment.service.ts) |
|
||||||
| 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 update kalau Payment sudah final (PAID/FAILED/EXPIRED/CANCELLED/REFUNDED) | ✅ | [payment.service.ts](server/services/payment.service.ts) |
|
||||||
| C10 | Anti-replay: skip kalau `Payment.status` sudah final (PAID/FAILED/EXPIRED) | ⏳ | Webhook bisa diretry oleh Midtrans. |
|
| C11 | Simpan callback mentah ke `Payment.rawCallback` (audit & dispute), termasuk untuk callback yang di-skip | ✅ | [payment.service.ts](server/services/payment.service.ts) |
|
||||||
| C11 | Logging callback mentah ke `Payment.rawCallback` (Json) | ⏳ | Audit & dispute. |
|
| C+ | Server action `startMidtransPaymentAction` (resolve booking dari tripId, bridge ke client) | ✅ | [features/booking/actions.ts](features/booking/actions.ts) |
|
||||||
| C12 | Test scenario di sandbox | ⏳ | Settlement BCA VA, gopay, deny (kartu fraud), expire, cancel. |
|
| 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) |
|
||||||
| C13 | Status badge di halaman payment | ⏳ | Tampil real-time tanpa polling agresif (refresh manual atau interval longgar 10s). |
|
| 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. |
|
||||||
| C14 | Email/in-app notification setelah PAID | ⏳ | Optional Phase ini, bisa Phase berikutnya. |
|
| 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`
|
### 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.
|
5. Pakai DB transaction untuk update Payment + Booking + TripParticipant bersamaan.
|
||||||
6. Selalu return 200 kalau request valid (mismatch signature → 401, sisanya → 200 + log).
|
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
|
### 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).
|
- **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.
|
- **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. Idempotency key = `Payment.externalOrderId` + status terkini.
|
- **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**: simulator Midtrans akan kirim callback ke `MIDTRANS_NOTIFICATION_URL`. Pastikan URL sandbox bisa diakses publik (tunneling kalau dev lokal — ngrok / cloudflared).
|
- **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<{
|
export type BookingWhereUniqueInput = Prisma.AtLeast<{
|
||||||
id?: string
|
id?: string
|
||||||
participantId?: string
|
participantId?: string
|
||||||
|
tripId_userId?: Prisma.BookingTripIdUserIdCompoundUniqueInput
|
||||||
AND?: Prisma.BookingWhereInput | Prisma.BookingWhereInput[]
|
AND?: Prisma.BookingWhereInput | Prisma.BookingWhereInput[]
|
||||||
OR?: Prisma.BookingWhereInput[]
|
OR?: Prisma.BookingWhereInput[]
|
||||||
NOT?: Prisma.BookingWhereInput | Prisma.BookingWhereInput[]
|
NOT?: Prisma.BookingWhereInput | Prisma.BookingWhereInput[]
|
||||||
@@ -292,7 +293,7 @@ export type BookingWhereUniqueInput = Prisma.AtLeast<{
|
|||||||
user?: Prisma.XOR<Prisma.UserScalarRelationFilter, Prisma.UserWhereInput>
|
user?: Prisma.XOR<Prisma.UserScalarRelationFilter, Prisma.UserWhereInput>
|
||||||
participant?: Prisma.XOR<Prisma.TripParticipantScalarRelationFilter, Prisma.TripParticipantWhereInput>
|
participant?: Prisma.XOR<Prisma.TripParticipantScalarRelationFilter, Prisma.TripParticipantWhereInput>
|
||||||
payments?: Prisma.PaymentListRelationFilter
|
payments?: Prisma.PaymentListRelationFilter
|
||||||
}, "id" | "participantId">
|
}, "id" | "participantId" | "tripId_userId">
|
||||||
|
|
||||||
export type BookingOrderByWithAggregationInput = {
|
export type BookingOrderByWithAggregationInput = {
|
||||||
id?: Prisma.SortOrder
|
id?: Prisma.SortOrder
|
||||||
@@ -426,6 +427,11 @@ export type BookingNullableScalarRelationFilter = {
|
|||||||
isNot?: Prisma.BookingWhereInput | null
|
isNot?: Prisma.BookingWhereInput | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BookingTripIdUserIdCompoundUniqueInput = {
|
||||||
|
tripId: string
|
||||||
|
userId: string
|
||||||
|
}
|
||||||
|
|
||||||
export type BookingCountOrderByAggregateInput = {
|
export type BookingCountOrderByAggregateInput = {
|
||||||
id?: Prisma.SortOrder
|
id?: Prisma.SortOrder
|
||||||
tripId?: Prisma.SortOrder
|
tripId?: Prisma.SortOrder
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates";
|
|||||||
import { isFreeTrip } from "@/lib/trip-pricing";
|
import { isFreeTrip } from "@/lib/trip-pricing";
|
||||||
import { categoryMeta } from "@/lib/activity-category";
|
import { categoryMeta } from "@/lib/activity-category";
|
||||||
import { MarkPaidButton } from "@/features/booking/components/mark-paid-button";
|
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";
|
import { CopyButton } from "@/features/booking/components/copy-button";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -293,8 +294,22 @@ async function PaidTripSection({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canMarkPaid && bankAvailable && (
|
{canMarkPaid && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{bankAvailable && (
|
||||||
|
<>
|
||||||
<MarkPaidButton tripId={tripId} />
|
<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 && (
|
{hasMarkedPaid && (
|
||||||
|
|||||||
+12
@@ -15,3 +15,15 @@ KYC_UPLOAD_DIR=
|
|||||||
|
|
||||||
GOOGLE_CLIENT_ID="xxxxxxxx"
|
GOOGLE_CLIENT_ID="xxxxxxxx"
|
||||||
GOOGLE_CLIENT_SECRET="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 { getServerSession } from "next-auth";
|
||||||
import { authOptions } from "@/lib/auth";
|
import { authOptions } from "@/lib/auth";
|
||||||
import { tripService } from "@/server/services/trip.service";
|
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";
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
export async function markParticipantPaidAction(tripId: string) {
|
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(
|
export async function confirmParticipantPaymentAction(
|
||||||
tripId: string,
|
tripId: string,
|
||||||
participantId: 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",
|
"name": "setrip",
|
||||||
"version": "0.9.0",
|
"version": "0.10.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "setrip",
|
"name": "setrip",
|
||||||
"version": "0.9.0",
|
"version": "0.10.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next-auth/prisma-adapter": "^1.0.7",
|
"@next-auth/prisma-adapter": "^1.0.7",
|
||||||
"@prisma/adapter-pg": "^7.7.0",
|
"@prisma/adapter-pg": "^7.7.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "setrip",
|
"name": "setrip",
|
||||||
"version": "0.9.0",
|
"version": "0.10.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"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())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
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([tripId, status])
|
||||||
@@index([userId])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum BookingStatus {
|
enum BookingStatus {
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ export const bookingRepo = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async findByTripAndUser(tripId: string, userId: string) {
|
async findByTripAndUser(tripId: string, userId: string) {
|
||||||
return prisma.booking.findFirst({
|
return prisma.booking.findUnique({
|
||||||
where: { tripId, userId },
|
where: { tripId_userId: { tripId, userId } },
|
||||||
include: { payments: { orderBy: { createdAt: "desc" } } },
|
include: { payments: { orderBy: { createdAt: "desc" } } },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Prisma } from "@/app/generated/prisma/client";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { bookingRepo } from "@/server/repositories/booking.repo";
|
import { bookingRepo } from "@/server/repositories/booking.repo";
|
||||||
import { paymentRepo } from "@/server/repositories/payment.repo";
|
import { paymentRepo } from "@/server/repositories/payment.repo";
|
||||||
|
import { isTripDepartureDayPast } from "@/lib/trip-dates";
|
||||||
|
|
||||||
const SERIAL_TX_ATTEMPTS = 6;
|
const SERIAL_TX_ATTEMPTS = 6;
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ export const bookingService = {
|
|||||||
async (tx) => {
|
async (tx) => {
|
||||||
const booking = await tx.booking.findUnique({
|
const booking = await tx.booking.findUnique({
|
||||||
where: { id: bookingId },
|
where: { id: bookingId },
|
||||||
include: { trip: { select: { price: true } } },
|
include: { trip: { select: { price: true, date: true } } },
|
||||||
});
|
});
|
||||||
if (!booking) {
|
if (!booking) {
|
||||||
throw new Error("Booking tidak ditemukan");
|
throw new Error("Booking tidak ditemukan");
|
||||||
@@ -60,6 +61,11 @@ export const bookingService = {
|
|||||||
"Booking belum siap menerima pembayaran (tunggu approve organizer)"
|
"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({
|
const existing = await tx.payment.findFirst({
|
||||||
where: {
|
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