Files
setrip/lib/midtrans.ts
T
2026-05-09 00:55:40 +07:00

204 lines
5.9 KiB
TypeScript

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.
*
* | 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";
}
}