mirror of
https://github.com/movixcorp/MovixOpenSource.git
synced 2026-07-26 16:32:07 +00:00
328 lines
10 KiB
JavaScript
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
|
|
};
|