MovixOpenSource/API/Mainapi/utils/vipPayblis.js
Movix 1401dc20f3 tird release
mon compte github a été détecté comme du spam je crois
2026-04-28 17:55:00 +02:00

328 lines
10 KiB
JavaScript

// API/Mainapi/utils/vipPayblis.js
'use strict';
const crypto = require('crypto');
const axios = require('axios');
/**
* PHP-compatible serialize() for objects with string keys and string values.
* Preserves insertion order. Encodes string lengths in BYTES (UTF-8), not chars.
*
* Example: phpSerialize({ a: '1', b: 'éé' })
* => 'a:2:{s:1:"a";s:1:"1";s:1:"b";s:4:"éé";}'
* ^ 4 bytes, not 2 chars
*/
function phpSerialize(obj) {
if (obj === null || typeof obj !== 'object') {
throw new TypeError('phpSerialize expects a plain object');
}
const entries = Object.entries(obj);
const parts = [`a:${entries.length}:{`];
for (const [key, rawValue] of entries) {
if (typeof key !== 'string') {
throw new TypeError(`phpSerialize: non-string key "${String(key)}"`);
}
const value = String(rawValue ?? '');
const keyBytes = Buffer.byteLength(key, 'utf8');
const valueBytes = Buffer.byteLength(value, 'utf8');
parts.push(`s:${keyBytes}:"${key}";s:${valueBytes}:"${value}";`);
}
parts.push('}');
return parts.join('');
}
/**
* Encodes the serialized PHP string as base64, matching PHP's
* base64_encode(serialize($arr)) pipeline.
*/
function encodePayblisToken(payload) {
const serialized = phpSerialize(payload);
return Buffer.from(serialized, 'utf8').toString('base64');
}
const PAYBLIS_DEFAULT_DOMAIN = 'pay.payblis.com';
const PAYBLIS_DEFAULT_STORE_NAME = 'Movix';
function isPayblisEnabled() {
const raw = String(process.env.VIP_PAYBLIS_ENABLED || '').trim().toLowerCase();
return raw === 'true' || raw === '1' || raw === 'yes' || raw === 'on';
}
function isPayblisSandbox() {
const raw = String(process.env.VIP_PAYBLIS_SANDBOX || '').trim().toLowerCase();
return raw === 'true' || raw === '1' || raw === 'yes' || raw === 'on';
}
function getPayblisMerchantKey() {
return String(process.env.VIP_PAYBLIS_MERCHANT_KEY || '').trim();
}
function getPayblisSecretKey() {
return String(process.env.VIP_PAYBLIS_SECRET_KEY || '').trim();
}
function getPayblisStoreName() {
return String(process.env.VIP_PAYBLIS_STORE_NAME || PAYBLIS_DEFAULT_STORE_NAME).trim();
}
function getPayblisDomain() {
return String(process.env.VIP_PAYBLIS_DOMAIN || PAYBLIS_DEFAULT_DOMAIN)
.trim()
.replace(/^https?:\/\//i, '')
.replace(/\/+$/, '');
}
function getPayblisIpnBaseUrl(context = {}) {
const envBaseUrl = String(process.env.VIP_PAYBLIS_IPN_BASE_URL || '')
.trim()
.replace(/\/+$/, '');
if (envBaseUrl) return envBaseUrl;
return String(context.callbackBaseUrl || '').trim().replace(/\/+$/, '');
}
function getPayblisFrontendBaseUrl() {
return String(process.env.FRONTEND_BASE_URL || '').trim().replace(/\/+$/, '');
}
const PAYBLIS_SIGNED_FIELDS = [
'MerchantKey',
'sandbox',
'amount',
'currency',
'product_name',
'method',
'RefOrder',
'Customer_Email',
'Customer_FirstName',
'Customer_Name',
'country',
'userIP',
'lang',
'store_name',
'urlOK',
'urlKO',
'ipnURL'
];
function computeSignatureConcatDeclared(payload, secretKey) {
const data = PAYBLIS_SIGNED_FIELDS
.map((k) => String(payload[k] ?? ''))
.join('');
return crypto.createHmac('sha256', secretKey).update(data, 'utf8').digest('hex');
}
function computeSignatureConcatAlphabetical(payload, secretKey) {
const data = Object.keys(payload)
.filter((k) => k !== 'signature')
.sort((a, b) => a.localeCompare(b))
.map((k) => `${k}=${String(payload[k] ?? '')}`)
.join('&');
return crypto.createHmac('sha256', secretKey).update(data, 'utf8').digest('hex');
}
function computeSignatureOverSerialized(payload, secretKey) {
const { signature, ...fields } = payload;
const serialized = phpSerialize(fields);
return crypto.createHmac('sha256', secretKey).update(serialized, 'utf8').digest('hex');
}
const SIGNATURE_STRATEGIES = {
concat_declared: computeSignatureConcatDeclared,
concat_alphabetical: computeSignatureConcatAlphabetical,
over_serialized: computeSignatureOverSerialized
};
function computePayblisSignature(payload, secretKey, strategyName) {
const name = strategyName
|| String(process.env.VIP_PAYBLIS_SIGNATURE_STRATEGY || 'concat_declared').trim().toLowerCase();
const strategy = SIGNATURE_STRATEGIES[name];
if (!strategy) {
throw new Error(`Stratégie de signature Payblis inconnue: ${name}`);
}
return strategy(payload, secretKey);
}
function verifyPayblisSignature(payload, providedSignature, secretKey, strategyName) {
if (!providedSignature || typeof providedSignature !== 'string') return false;
const expected = computePayblisSignature(payload, secretKey, strategyName);
const providedBuf = Buffer.from(providedSignature, 'hex');
const expectedBuf = Buffer.from(expected, 'hex');
if (providedBuf.length !== expectedBuf.length || providedBuf.length === 0) return false;
return crypto.timingSafeEqual(providedBuf, expectedBuf);
}
/**
* Verifies the signature on an incoming Payblis IPN webhook.
*
* Payblis signs IPNs using HMAC-SHA256 over the compact JSON body with the
* `signature` field removed. Verified against live Payblis sandbox on
* 2026-04-21: a captured IPN with signature `5c1150b9...` matched exactly
* when the signed payload was `JSON.stringify(body_minus_signature)`.
*
* @param {object} parsedBody - the already-parsed IPN JSON object
* @param {string} providedSignature - the 64-char hex signature provided by Payblis
* @param {string} secretKey - the merchant Secret Key
* @returns {boolean} true iff the signature is valid
*/
function verifyPayblisIpnSignature(parsedBody, providedSignature, secretKey) {
if (!providedSignature || typeof providedSignature !== 'string') return false;
if (!parsedBody || typeof parsedBody !== 'object') return false;
const { signature, ...fields } = parsedBody;
const signedData = JSON.stringify(fields);
const expected = crypto.createHmac('sha256', secretKey).update(signedData, 'utf8').digest('hex');
const providedBuf = Buffer.from(providedSignature, 'hex');
const expectedBuf = Buffer.from(expected, 'hex');
if (providedBuf.length !== expectedBuf.length || providedBuf.length === 0) return false;
return crypto.timingSafeEqual(providedBuf, expectedBuf);
}
function normalizeLang(value) {
const normalized = String(value || '').trim().toLowerCase();
return normalized === 'en' ? 'en' : 'fr';
}
function normalizeCountryCode(value) {
const normalized = String(value || '').trim().toUpperCase();
return /^[A-Z]{2}$/.test(normalized) ? normalized : 'FR';
}
function derivePseudo({ auth, payerEmail }) {
const rawPseudo = auth?.pseudo || auth?.username || auth?.displayName || '';
const fromPseudo = String(rawPseudo).trim().slice(0, 60);
if (fromPseudo) return fromPseudo;
const email = String(payerEmail || auth?.email || '').trim();
const local = email.split('@')[0] || '';
const sanitized = local.replace(/[^a-zA-Z0-9_\-.]/g, '').slice(0, 60);
return sanitized || 'guest';
}
/**
* Builds the complete payload (including signature) for the Payblis
* payment_gateway.php endpoint. All values are coerced to strings.
*/
function buildPayblisPayload({
merchantKey,
secretKey,
sandbox,
amountEur,
productName,
refOrder,
payerEmail,
customerName,
countryCode,
userIp,
lang,
storeName,
urlOK,
urlKO,
ipnURL,
signatureStrategy
}) {
if (!merchantKey) throw new Error('Payblis MerchantKey manquant');
if (!secretKey) throw new Error('Payblis Secret Key manquant');
if (!refOrder) throw new Error('Payblis RefOrder manquant');
if (!payerEmail) throw new Error('Payblis Customer_Email manquant');
if (!urlOK || !urlKO || !ipnURL) throw new Error('Payblis URLs manquantes');
const payload = {
MerchantKey: merchantKey,
sandbox: sandbox ? 'true' : 'false',
amount: Number(amountEur).toFixed(2),
currency: 'EUR',
product_name: String(productName || '').slice(0, 120),
method: '',
RefOrder: refOrder,
Customer_Email: payerEmail,
Customer_FirstName: customerName,
Customer_Name: customerName,
country: normalizeCountryCode(countryCode),
userIP: String(userIp || ''),
lang: normalizeLang(lang),
store_name: storeName,
urlOK,
urlKO,
ipnURL
};
payload.signature = computePayblisSignature(payload, secretKey, signatureStrategy);
return payload;
}
function normalizePayblisHost(domain) {
return (domain || getPayblisDomain())
.replace(/^https?:\/\//i, '')
.replace(/\/+$/, '');
}
function buildPayblisCheckoutUrl(payload, domain) {
const token = encodePayblisToken(payload);
const host = normalizePayblisHost(domain);
return `https://${host}/api/payment_gateway.php?token=${token}`;
}
/**
* Normalises an incoming IPN payload into { normalized, raw }.
* Accepts JSON objects, form-encoded bodies (already parsed by express),
* and strips whitespace from string fields.
*/
function normalizeIpnPayload(body) {
if (!body || typeof body !== 'object') {
return { normalized: {}, raw: {} };
}
const raw = { ...body };
const normalized = {};
for (const [k, v] of Object.entries(body)) {
normalized[k] = typeof v === 'string' ? v.trim() : v;
}
return { normalized, raw };
}
function normalizeIpnStatus(value) {
const upper = String(value || '').trim().toUpperCase();
if (['SUCCESS', 'PAID', 'COMPLETED'].includes(upper)) return 'success';
if (['FAILED', 'DECLINED', 'ERROR'].includes(upper)) return 'failed';
if (['PENDING', 'AWAITING', 'PROCESSING'].includes(upper)) return 'pending';
return 'unknown';
}
/**
* Calls GET /api/REST/transaction-status to fetch the latest state for
* a given RefOrder. Used as a backup poll when the IPN is delayed or lost.
*/
async function fetchPayblisTransactionStatus(refOrder, { merchantKey, domain } = {}) {
const host = normalizePayblisHost(domain);
const response = await axios.request({
method: 'GET',
url: `https://${host}/api/REST/transaction-status`,
headers: {
'X-Merchant-Key': merchantKey || getPayblisMerchantKey(),
'Content-Type': 'application/json'
},
data: { ref_order: refOrder },
timeout: 20000
});
return response.data;
}
module.exports = {
phpSerialize,
encodePayblisToken,
isPayblisEnabled,
isPayblisSandbox,
getPayblisMerchantKey,
getPayblisSecretKey,
getPayblisStoreName,
getPayblisDomain,
getPayblisIpnBaseUrl,
getPayblisFrontendBaseUrl,
computePayblisSignature,
verifyPayblisSignature,
verifyPayblisIpnSignature,
SIGNATURE_STRATEGIES,
PAYBLIS_SIGNED_FIELDS,
derivePseudo,
buildPayblisPayload,
buildPayblisCheckoutUrl,
normalizeIpnPayload,
normalizeIpnStatus,
fetchPayblisTransactionStatus
};