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; /** * 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 { const body: Record = { 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. * * | Midtrans | fraud_status | PaymentStatus | * |---------------------|--------------|---------------| * | capture | accept | PAID | * | capture | challenge | AWAITING | * | settlement | — | PAID | * | pending | — | AWAITING | * | deny | — | FAILED | * | expire | — | EXPIRED | * | cancel | — | CANCELLED | * | refund / partial | — | REFUNDED | */ 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"; } }