c4efe4453b
- ✅ - ✅ - ✅
277 lines
8.2 KiB
TypeScript
277 lines
8.2 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",
|
|
/** Core API base — dipakai untuk GET /v2/{order_id}/status (rekonsiliasi). */
|
|
coreApiBase: () =>
|
|
isProduction()
|
|
? "https://api.midtrans.com/v2"
|
|
: "https://api.sandbox.midtrans.com/v2",
|
|
};
|
|
|
|
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;
|
|
/// URL absolut untuk redirect user setelah selesai bayar (success / pending / error).
|
|
/// Tanpa ini, Midtrans pakai default `example.com`.
|
|
finishUrl?: string;
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
if (payload.finishUrl) {
|
|
body.callbacks = { finish: payload.finishUrl };
|
|
}
|
|
|
|
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 ?? "",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Bentuk minimal response dari Midtrans Core API GET /v2/{order_id}/status.
|
|
* Sub-set field yang kita pakai untuk rekonsiliasi (sama dengan field webhook).
|
|
* https://docs.midtrans.com/reference/get-transaction-status
|
|
*/
|
|
export interface MidtransTransactionStatus {
|
|
order_id: string;
|
|
status_code: string;
|
|
transaction_status: string;
|
|
gross_amount: string;
|
|
transaction_id?: string;
|
|
payment_type?: string;
|
|
fraud_status?: string | null;
|
|
}
|
|
|
|
/**
|
|
* Fetch status transaksi langsung dari Midtrans untuk rekonsiliasi server-side.
|
|
* Dipakai saat kita tidak bisa mengandalkan webhook (mis. dev di localhost,
|
|
* atau webhook tertunda). Auth pakai server key — response sudah terpercaya
|
|
* karena datang dari Midtrans atas request kita, jadi tidak perlu verifikasi
|
|
* signature.
|
|
*
|
|
* Return null kalau Midtrans tidak menemukan order (404).
|
|
*/
|
|
export async function fetchMidtransTransactionStatus(
|
|
orderId: string
|
|
): Promise<MidtransTransactionStatus | null> {
|
|
const res = await fetch(
|
|
`${MIDTRANS.coreApiBase()}/${encodeURIComponent(orderId)}/status`,
|
|
{
|
|
method: "GET",
|
|
headers: {
|
|
Accept: "application/json",
|
|
Authorization: basicAuthHeader(),
|
|
},
|
|
cache: "no-store",
|
|
}
|
|
);
|
|
|
|
if (res.status === 404) return null;
|
|
|
|
const json = (await res.json().catch(() => null)) as
|
|
| (Partial<MidtransTransactionStatus> & { status_message?: string })
|
|
| null;
|
|
|
|
if (!res.ok || !json?.order_id || !json.transaction_status) {
|
|
const reason = json?.status_message ?? `HTTP ${res.status}`;
|
|
throw new Error(`Midtrans status fetch gagal: ${reason}`);
|
|
}
|
|
|
|
return {
|
|
order_id: json.order_id,
|
|
status_code: json.status_code ?? String(res.status),
|
|
transaction_status: json.transaction_status,
|
|
gross_amount: json.gross_amount ?? "0",
|
|
transaction_id: json.transaction_id,
|
|
payment_type: json.payment_type,
|
|
fraud_status: json.fraud_status ?? null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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";
|
|
}
|
|
}
|