add payment and integration with midtrans
This commit is contained in:
+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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user