Spaces:
Running
Running
File size: 7,229 Bytes
4c6b1e8 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 | /**
* 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 };
|