auth-app-api / src /utils /payoneerCheckout.js
AndreaNotes1's picture
Upload 46 files
4c6b1e8 verified
Raw History Blame Contribute Delete
7.23 kB
/**
* 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 };