/** * Adapter around Payoneer Checkout (the hosted-payment product that * lets a webstore accept card/wallet payments that settle directly * into a Payoneer account) — https://checkoutdocs.payoneer.com * * IMPORTANT — READ BEFORE GOING LIVE: * Payoneer Checkout is not a self-serve API like Stripe. Per Payoneer's * own documentation, credentials and the exact request/response * contract (endpoint paths, field names, webhook signature scheme) are * issued directly to you by Payoneer's integration team only after * your business applies and is approved as a Payoneer Checkout * merchant. That contract is account-specific, so it isn't something * that can be correctly guessed or hard-coded here in advance. * * What IS real and finished in this file: * - The env-driven config wiring (config/env.js -> payoneerCheckout) * - The calling convention the rest of the app uses (see * controllers/checkout.controller.js and webhook.controller.js) * - A standard HMAC-SHA256 webhook-signature check, which is the * common pattern most hosted-checkout providers use (Stripe, * Paysera, PayMongo, etc. all verify webhooks this way) — but you * MUST confirm the header name and signing scheme against your own * Payoneer Checkout webhook docs once you have them, since generic * industry convention isn't a guarantee it matches Payoneer's * specific implementation. * * What's a clearly-marked PLACEHOLDER, pending your approved * integration docs from Payoneer: * - The exact endpoint path for creating a checkout session * - The exact request body field names Payoneer's API expects * - The exact response field names for the session id / redirect URL * - The exact webhook payload field names / event-type values * - The exact webhook signature header name * * Search for "PAYONEER-DOCS-NEEDED" below for every spot that needs a * one-line fix once you have that contract in hand — the surrounding * logic (error handling, order matching, idempotency) doesn't need to * change. */ const crypto = require("crypto"); const { env } = require("../config/env"); function assertConfigured() { if (!env.payoneerCheckout.apiBase || !env.payoneerCheckout.apiKey || !env.payoneerCheckout.apiSecret) { throw Object.assign( new Error( "Payoneer Checkout is not configured yet. Set PAYONEER_CHECKOUT_API_BASE, " + "PAYONEER_CHECKOUT_API_KEY and PAYONEER_CHECKOUT_API_SECRET once your Payoneer " + "Checkout merchant account is approved." ), { status: 503 } ); } } /** * Creates a hosted checkout session for one order and returns where to * send the buyer to actually pay, plus any tax Payoneer's session * response reports (so the buyer can be shown a clear amount + tax + * total before being redirected — see checkout.controller.js). * * @param {object} params * @param {object} params.order - the just-created Order document * @param {object} params.course - the Course being purchased * @param {string} params.customerEmail * @param {string} params.customerName * @param {string} params.successUrl - where Payoneer redirects after a successful payment * @param {string} params.cancelUrl - where Payoneer redirects after a cancelled/failed payment */ async function createCheckoutSession({ order, course, customerEmail, customerName, successUrl, cancelUrl }) { assertConfigured(); // PAYONEER-DOCS-NEEDED: confirm this path against your approved // Payoneer Checkout API reference. const endpoint = `${env.payoneerCheckout.apiBase}/v1/checkout-sessions`; const response = await fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json", // PAYONEER-DOCS-NEEDED: confirm the auth header/scheme Payoneer // issues you (this assumes a bearer API key, a common pattern, // but Payoneer may use a different header or OAuth2 client- // credentials flow instead). Authorization: `Bearer ${env.payoneerCheckout.apiKey}`, }, body: JSON.stringify({ // PAYONEER-DOCS-NEEDED: field names below are our best-guess // shape for a hosted checkout session, modelled on the common // pattern across similar providers — confirm every field name // against Payoneer's reference before relying on this in // production. amount: order.amount, currency: order.currency, order_id: order.orderNumber, description: course.title, customer: { email: customerEmail, name: customerName }, success_url: successUrl, cancel_url: cancelUrl, }), }); if (!response.ok) { const body = await response.text().catch(() => ""); throw new Error(`Payoneer Checkout session creation failed (${response.status}): ${body}`); } const data = await response.json(); // PAYONEER-DOCS-NEEDED: confirm these response field names. return { sessionId: data.id || data.session_id, checkoutUrl: data.checkout_url || data.redirect_url, tax: data.tax ? { amount: data.tax.amount, currency: data.tax.currency || order.currency } : null, }; } /** * Verifies an incoming webhook's signature and normalizes it into * { sessionId, status, providerOrderId }. Throws if the signature is * missing or invalid — the caller (webhook.controller.js) treats a * throw as "reject this request," never as "treat it as paid." * * `rawBody` must be the exact, unparsed request body bytes/string used * to compute the signature — see routes/webhook.routes.js, which * registers this route with a raw body parser specifically so the * bytes here match exactly what Payoneer signed. */ function verifyAndParseWebhook({ rawBody, signatureHeader }) { if (!env.payoneerCheckout.webhookSecret) { throw Object.assign(new Error("PAYONEER_CHECKOUT_WEBHOOK_SECRET is not configured."), { status: 503 }); } if (!signatureHeader) { throw Object.assign(new Error("Missing webhook signature."), { status: 400 }); } const expectedSignature = crypto .createHmac("sha256", env.payoneerCheckout.webhookSecret) .update(rawBody) .digest("hex"); // PAYONEER-DOCS-NEEDED: confirm Payoneer's actual signature encoding // (hex vs base64) and whether the header carries just the signature // or a prefixed/versioned value (e.g. Stripe's "t=...,v1=..."). const signatureValid = signatureHeader.length === expectedSignature.length && crypto.timingSafeEqual(Buffer.from(signatureHeader), Buffer.from(expectedSignature)); if (!signatureValid) { throw Object.assign(new Error("Invalid webhook signature."), { status: 400 }); } const payload = JSON.parse(rawBody.toString("utf8")); // PAYONEER-DOCS-NEEDED: confirm the actual field/event names Payoneer // sends (this assumes something like { event: "payment.completed", // session_id, order_id }). const eventType = payload.event || payload.type; const status = eventType && eventType.includes("completed") ? "paid" : "other"; return { sessionId: payload.session_id || payload.id, providerOrderId: payload.order_id, status, raw: payload, }; } module.exports = { createCheckoutSession, verifyAndParseWebhook };