// FlatRender Pay — drop-in Node client for the ZarinPal broker (pay.flatrender.ir). // Zero dependencies: Node 18+ (global fetch) + built-in crypto. CommonJS. // // const { FlatPay } = require("./flatpay"); // const pay = new FlatPay({ apiKey: process.env.FLATPAY_KEY, secret: process.env.FLATPAY_SECRET }); // // // 1. create + redirect // const r = await pay.createPayment({ amount: 50000, currency: "IRT", // description: "اشتراک", clientRef: order.id, returnUrl: "https://meezi.ir/pay/return", // metadata: { userId: user.id } }); // res.redirect(r.payment_url); // // // 2. on your return_url handler — confirm authoritatively // const txn = await pay.inquire(req.query.id); // if (txn.status === "Paid") { /* grant */ } // // // 3. webhook (recommended) — Express: // app.post("/flatpay/webhook", express.raw({ type: "*/*" }), (req, res) => { // if (!pay.verifyWebhook(req.body, req.get("X-FlatPay-Signature"))) return res.sendStatus(401); // const ev = JSON.parse(req.body.toString("utf8")); // if (ev.status === "Paid") { /* idempotent grant keyed on ev.id / ev.client_ref */ } // res.sendStatus(200); // }); const crypto = require("crypto"); const DEFAULT_BASE = "https://pay.flatrender.ir"; function hmac(secret, message) { return crypto.createHmac("sha256", secret).update(message).digest("hex"); } function timingSafeEqualHex(a, b) { try { const ba = Buffer.from(a, "hex"); const bb = Buffer.from(b, "hex"); return ba.length === bb.length && crypto.timingSafeEqual(ba, bb); } catch { return false; } } class FlatPay { constructor({ apiKey, secret, baseUrl = DEFAULT_BASE } = {}) { if (!apiKey || !secret) throw new Error("FlatPay: apiKey and secret are required"); this.apiKey = apiKey; this.secret = secret; this.baseUrl = baseUrl.replace(/\/+$/, ""); } async _signedPost(path, payload) { const body = JSON.stringify(payload); const res = await fetch(this.baseUrl + path, { method: "POST", headers: { "Content-Type": "application/json", "X-Api-Key": this.apiKey, "X-Signature": hmac(this.secret, body), }, body, }); const data = await res.json().catch(() => ({})); if (!res.ok) { const err = new Error(data.message || `FlatPay ${path} failed (${res.status})`); err.code = data.code; err.status = res.status; throw err; } return data; } /** Create a payment. Returns { id, status, payment_url, authority, amount_rial }. */ createPayment({ amount, currency = "IRR", description, clientRef, returnUrl, mobile, email, metadata }) { if (!returnUrl) throw new Error("FlatPay: returnUrl is required"); return this._signedPost("/v1/pay/request", { amount, currency, description, client_ref: clientRef, return_url: returnUrl, mobile, email, metadata, }); } /** Authoritative server-side status check. Returns the full transaction. */ inquire(id) { return this._signedPost("/v1/pay/inquiry", { id }); } /** * Verify the signed return-redirect query. * Pass the query params { id, status, ref_id, sign } AND the amount_rial you got * from createPayment/inquire (the redirect itself doesn't carry the amount). */ verifyRedirect({ id, status, ref_id = "", sign }, amountRial) { const message = `${id}.${status}.${ref_id}.${amountRial}`; return !!sign && timingSafeEqualHex(hmac(this.secret, message), sign); } /** * Verify a webhook. `rawBody` MUST be the exact bytes received (Buffer or string — * do not re-stringify a parsed object, signatures won't match). */ verifyWebhook(rawBody, signatureHeader) { const msg = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody), "utf8"); return !!signatureHeader && timingSafeEqualHex(hmac(this.secret, msg), signatureHeader); } } module.exports = { FlatPay, hmac };