feat(recharge): add V5Pay for Yumi and Aslan

This commit is contained in:
zhx 2026-07-10 19:59:12 +08:00
parent b96b5d934b
commit f457803881
6 changed files with 975 additions and 42 deletions

View File

@ -633,7 +633,7 @@ var default_api = 'https://api.global-interaction.com/';
}; };
// H5 充值 API 只做路径和参数名封装token、app_code、env、错误 envelope 仍由 request 统一处理。 // H5 充值 API 只做路径和参数名封装token、app_code、env、错误 envelope 仍由 request 统一处理。
// 金额、金币数、普通/币商钱包、MiFaPay 参数和 USDT 链上校验都以后端订单快照为准。 // 金额、金币数、普通/币商钱包、三方支付参数和 USDT 链上校验都以后端订单快照为准。
function isAslanWebPay() { function isAslanWebPay() {
return activeAppConfig && activeAppConfig.adapter === 'aslan-web-pay'; return activeAppConfig && activeAppConfig.adapter === 'aslan-web-pay';
} }
@ -701,52 +701,92 @@ var default_api = 'https://api.global-interaction.com/';
return query; return query;
} }
function aslanPaymentMethods(options) { function legacyProviderPaymentMethods(options) {
var providers = (activeAppConfig && activeAppConfig.providers) || {}; var providers = (activeAppConfig && activeAppConfig.providers) || {};
var mifapay = providers.mifapay || {};
var countries = mifapay.countries || [];
var countryCode = String( var countryCode = String(
options.country_code || options.countryCode || '' options.country_code || options.countryCode || ''
).toUpperCase(); ).toUpperCase();
var matchedCountries = countries.filter(function (country) { var matchedMethods = [];
return countryMatchesCode(country, countryCode); var globalMethods = [];
var allProviderMethods = [];
// Aslan/Yumi 的 app JSON 是旧支付网关的前端控制面;这里必须逐 provider 收集,
// 否则新增 V5Pay 后仍只读取 providers.mifapay会出现配置存在但页面永远不展示的假开通。
Object.keys(providers).forEach(function (providerKey) {
var provider = providers[providerKey] || {};
var countries = Array.isArray(provider.countries)
? provider.countries
: [];
var matchedCountries = countries.filter(function (country) {
return countryMatchesCode(country, countryCode);
});
var globalCountries = countries.filter(function (country) {
return countryMatchesCode(country, 'GLOBAL');
});
matchedMethods = matchedMethods.concat(
collectLegacyProviderMethods(
matchedCountries,
provider,
providerKey,
options,
false
)
);
globalMethods = globalMethods.concat(
collectLegacyProviderMethods(
globalCountries,
provider,
providerKey,
options,
false
)
);
allProviderMethods = allProviderMethods.concat(
collectLegacyProviderMethods(
countries,
provider,
providerKey,
options,
true
)
);
}); });
var globalCountries = countries.filter(function (country) {
return countryMatchesCode(country, 'GLOBAL'); var methods = matchedMethods.concat(globalMethods);
});
var matchedMethods = collectMifaPayMethods(
matchedCountries,
mifapay,
options,
false
);
var methods = matchedMethods.concat(
collectMifaPayMethods(globalCountries, mifapay, options, false)
);
if (!matchedMethods.length && countryCode) { if (!matchedMethods.length && countryCode) {
// 用户国家在 app JSON 里没有具体支付方式时,商品仍按用户国家查询,支付方式降级展示 JSON 中全部方式。 // 用户国家在 app JSON 里没有具体支付方式时,商品仍按用户国家查询,支付方式降级展示 JSON 中全部方式。
// 兜底方式只影响前端展示和用户选择,下单仍带当前用户国家的 payCountryId 供后端校验金额和币种。 // 兜底方式只影响前端展示和用户选择,下单仍带当前用户国家的 payCountryId 供后端校验金额和币种。
methods = collectMifaPayMethods(countries, mifapay, options, true); methods = allProviderMethods;
} }
return methods; return methods;
} }
function collectMifaPayMethods(countries, mifapay, options, fallbackAll) { function collectLegacyProviderMethods(
countries,
provider,
providerKey,
options,
fallbackAll
) {
var methods = []; var methods = [];
var providerCode = provider.provider_code || providerKey;
var providerName = provider.provider_name || providerCode;
(countries || []).forEach(function (country) { (countries || []).forEach(function (country) {
var countryCode = primaryCountryCode(country); var countryCode = primaryCountryCode(country);
(country.methods || []).forEach(function (method, index) { (country.methods || []).forEach(function (method, index) {
methods.push( methods.push(
Object.assign({}, method, { Object.assign({}, method, {
// method_id 只用于同页稳定区分卡片,不能复制 Lalu 数据库 method_id
// 旧网关真正下单依赖 provider/payWay/payType/channelCode而不是该前端合成值。
method_id: [ method_id: [
mifapay.provider_code || 'mifapay', providerCode,
countryCode, countryCode,
method.pay_way, method.channel_code || method.pay_way,
method.pay_type, method.pay_type || method.channel_code,
index, index,
].join(':'), ].join(':'),
provider_code: mifapay.provider_code || 'mifapay', provider_code: providerCode,
provider_name: mifapay.provider_name || 'MiFaPay', provider_name: providerName,
country_code: countryCode, country_code: countryCode,
country_name: country.country_name, country_name: country.country_name,
currency_code: currency_code:
@ -761,7 +801,7 @@ var default_api = 'https://api.global-interaction.com/';
options.pay_country_id || options.pay_country_id ||
options.payCountryId || options.payCountryId ||
'', '',
status: mifapay.status || 'active', status: provider.status || 'active',
fallback_all_countries: !!fallbackAll, fallback_all_countries: !!fallbackAll,
}) })
); );
@ -1058,28 +1098,48 @@ var default_api = 'https://api.global-interaction.com/';
channel.channel_code, channel.channel_code,
details.channelCode, details.channelCode,
details.channel_code, details.channel_code,
details.factoryChannel,
details.factory_channel,
].forEach(function (code) { ].forEach(function (code) {
if (code) supported[String(code)] = item; if (code) supported[String(code).toUpperCase()] = item;
}); });
}); });
return aslanPaymentMethods(options).filter(function (method) { return legacyProviderPaymentMethods(options).filter(function (method) {
if (method.provider_code === 'v5pay') {
// Yumi 的旧 options 接口只返回数据库渠道明细V5Pay 由服务端在下单时根据
// provider/payType 合成受控快照,因此不能拿 MiFaPay 的旧渠道集合误删 V5Pay 入口。
return true;
}
var channelCode = var channelCode =
method.channel_code || method.channel_code ||
[method.pay_way, method.pay_type].filter(Boolean).join('_'); [method.pay_way, method.pay_type].filter(Boolean).join('_');
if (!Object.keys(supported).length) return true; if (!Object.keys(supported).length) return true;
return !!supported[channelCode]; return !!supported[String(channelCode || '').toUpperCase()];
}); });
} }
function providerCodeFromFactory(factoryCode) {
// Java 旧网关返回 factory code而 H5 状态机统一消费小写 provider code
// 显式映射能避免 V5_PAY 被当成未知的重定向渠道并以大写值持久化。
if (factoryCode === 'MIFA_PAY' || factoryCode === 'MIFAPAY')
return 'mifapay';
if (factoryCode === 'V5_PAY' || factoryCode === 'V5PAY') return 'v5pay';
return factoryCode;
}
function mapYumiOrder(order) { function mapYumiOrder(order) {
order = order || {}; order = order || {};
var factoryCode = String( var factoryCode = String(
order.factoryCode || order.factory_code || '' order.factoryCode ||
order.factory_code ||
order.providerCode ||
order.provider_code ||
''
).toUpperCase(); ).toUpperCase();
return { return {
order_id: order.orderId || order.order_id || '', order_id: order.orderId || order.order_id || '',
trade_no: order.tradeNo || order.trade_no || '', trade_no: order.tradeNo || order.trade_no || '',
provider_code: factoryCode === 'MIFA_PAY' ? 'mifapay' : factoryCode, provider_code: providerCodeFromFactory(factoryCode),
factory_code: factoryCode, factory_code: factoryCode,
pay_url: pay_url:
order.requestUrl || order.request_url || order.pay_url || '', order.requestUrl || order.request_url || order.pay_url || '',
@ -1101,12 +1161,16 @@ var default_api = 'https://api.global-interaction.com/';
? 'failed' ? 'failed'
: 'pending'; : 'pending';
var factoryCode = String( var factoryCode = String(
order.factoryCode || order.factory_code || '' order.factoryCode ||
order.factory_code ||
order.providerCode ||
order.provider_code ||
''
).toUpperCase(); ).toUpperCase();
return { return {
order_id: order.orderId || order.order_id || '', order_id: order.orderId || order.order_id || '',
trade_no: order.tradeNo || order.trade_no || '', trade_no: order.tradeNo || order.trade_no || '',
provider_code: factoryCode === 'MIFA_PAY' ? 'mifapay' : factoryCode, provider_code: providerCodeFromFactory(factoryCode),
factory_code: factoryCode, factory_code: factoryCode,
currency_code: order.currency || order.currency_code || '', currency_code: order.currency || order.currency_code || '',
provider_amount_minor: decimalToMinor(order.amount), provider_amount_minor: decimalToMinor(order.amount),
@ -1176,7 +1240,8 @@ var default_api = 'https://api.global-interaction.com/';
}) })
); );
} }
options.payment_methods = aslanPaymentMethods(options); options.payment_methods =
legacyProviderPaymentMethods(options);
options.usdt_trc20_enabled = false; options.usdt_trc20_enabled = false;
return options; return options;
}); });
@ -1219,7 +1284,17 @@ var default_api = 'https://api.global-interaction.com/';
goodsId: payload && payload.product_id, goodsId: payload && payload.product_id,
payCountryId: payload && payload.pay_country_id, payCountryId: payload && payload.pay_country_id,
userId: payload && payload.user_id, userId: payload && payload.user_id,
// Yumi 旧接口以前只收到 channelCode无法区分同一渠道下的 MiFaPay/V5Pay。
// provider 与 V5Pay productType/productMode 必须原样透传,让后端策略选择和签名字段都以用户选择为准。
providerCode: payload && payload.provider_code,
payWay: payload && payload.pay_way,
payType: payload && payload.pay_type,
channelCode: payload && payload.channel_code, channelCode: payload && payload.channel_code,
countryCode: payload && payload.country_code,
currencyCode: payload && payload.currency_code,
returnUrl: payload && payload.return_url,
commandId: payload && payload.command_id,
language: readLanguageQuery() || 'en',
newVersion: true, newVersion: true,
appVersion: 'h5', appVersion: 'h5',
}, },

View File

@ -7,6 +7,7 @@
"dev:manager-center": "python3 scripts/h5_dev_server.py --host 127.0.0.1 --port 8788 --directory .", "dev:manager-center": "python3 scripts/h5_dev_server.py --host 127.0.0.1 --port 8788 --directory .",
"aslan:webview": "node scripts/aslan_flutter_webview.js", "aslan:webview": "node scripts/aslan_flutter_webview.js",
"test:manager-center": "node scripts/test_manager_center_resource_pagination.js", "test:manager-center": "node scripts/test_manager_center_resource_pagination.js",
"test:recharge-v5pay": "node scripts/test_recharge_v5pay.js",
"format": "prettier --write \"**/*.{js,html,css,json,md}\"", "format": "prettier --write \"**/*.{js,html,css,json,md}\"",
"format:check": "prettier --check \"**/*.{js,html,css,json,md}\"" "format:check": "prettier --check \"**/*.{js,html,css,json,md}\""
}, },

View File

@ -361,6 +361,236 @@
] ]
} }
] ]
},
"v5pay": {
"provider_code": "v5pay",
"provider_name": "V5Pay",
"status": "active",
"countries": [
{
"country_code": "EG",
"country_codes": ["EG", "EGY"],
"country_name": "Egypt",
"currency_code": "EGP",
"methods": [
{
"pay_way": "Cash",
"pay_type": "FAWRY",
"channel_code": "FAWRY",
"method_name": "Fawry",
"logo_url": ""
},
{
"pay_way": "Ewallet",
"pay_type": "VODAFONE",
"channel_code": "VODAFONE",
"method_name": "Vodafone",
"logo_url": ""
}
]
},
{
"country_code": "IN",
"country_codes": ["IN", "IND"],
"country_name": "India",
"currency_code": "INR",
"methods": [
{
"pay_way": "WEB_ONLINE",
"pay_type": "UPI",
"channel_code": "UPI",
"method_name": "UPI",
"logo_url": ""
}
]
},
{
"country_code": "PH",
"country_codes": ["PH", "PHL"],
"country_name": "Philippines",
"currency_code": "PHP",
"methods": [
{
"pay_way": "WEB_ONLINE",
"pay_type": "GCASH_ONLINE",
"channel_code": "GCASH_ONLINE",
"method_name": "GCash",
"logo_url": ""
},
{
"pay_way": "WEB_ONLINE",
"pay_type": "COINS_ONLINE",
"channel_code": "COINS_ONLINE",
"method_name": "Coins.ph",
"logo_url": ""
},
{
"pay_way": "FIXED_CODE",
"pay_type": "INSTAPAY_QR_STATIC",
"channel_code": "INSTAPAY_QR_STATIC",
"method_name": "QR Ph",
"logo_url": ""
},
{
"pay_way": "DYNAMIC_CODE",
"pay_type": "INSTAPAY_QR",
"channel_code": "INSTAPAY_QR",
"method_name": "QR Ph",
"logo_url": ""
},
{
"pay_way": "QR",
"pay_type": "QRPH_GCASH",
"channel_code": "QRPH_GCASH",
"method_name": "QR Ph (GCash)",
"logo_url": ""
},
{
"pay_way": "WEB_ONLINE",
"pay_type": "PESONET",
"channel_code": "PESONET",
"method_name": "PESONet",
"logo_url": ""
},
{
"pay_way": "WEB_ONLINE",
"pay_type": "UB_ONLINE",
"channel_code": "UB_ONLINE",
"method_name": "UnionBank",
"logo_url": ""
},
{
"pay_way": "WEB_ONLINE",
"pay_type": "BPI_ONLINE",
"channel_code": "BPI_ONLINE",
"method_name": "BPI",
"logo_url": ""
},
{
"pay_way": "WEB_ONLINE",
"pay_type": "RCBC_ONLINE",
"channel_code": "RCBC_ONLINE",
"method_name": "RCBC",
"logo_url": ""
},
{
"pay_way": "FIXED_CODE",
"pay_type": "BANKVA",
"channel_code": "BANKVA",
"method_name": "Bank Transfer",
"logo_url": ""
},
{
"pay_way": "FIXED_CODE",
"pay_type": "BILL",
"channel_code": "BILL",
"method_name": "Paybills",
"logo_url": ""
}
]
},
{
"country_code": "PK",
"country_codes": ["PK", "PAK"],
"country_name": "Pakistan",
"currency_code": "PKR",
"methods": [
{
"pay_way": "WEB_ONLINE",
"pay_type": "JAZZCASH",
"channel_code": "JAZZCASH",
"method_name": "JazzCash",
"logo_url": ""
}
]
},
{
"country_code": "TR",
"country_codes": ["TR", "TUR"],
"country_name": "Turkey",
"currency_code": "TRY",
"methods": [
{
"pay_way": "WEB_ONLINE",
"pay_type": "GPAY",
"channel_code": "GPAY",
"method_name": "GPay",
"logo_url": ""
},
{
"pay_way": "WEB_ONLINE",
"pay_type": "BKM_EXPRESS",
"channel_code": "BKM_EXPRESS",
"method_name": "BKM Express",
"logo_url": ""
},
{
"pay_way": "WEB_ONLINE",
"pay_type": "BANK_TRANSFER",
"channel_code": "BANK_TRANSFER",
"method_name": "Bank Transfer",
"logo_url": ""
},
{
"pay_way": "WEB_ONLINE",
"pay_type": "CREDIT_CARD",
"channel_code": "CREDIT_CARD",
"method_name": "Credit Card",
"logo_url": ""
}
]
},
{
"country_code": "BR",
"country_codes": ["BR", "BRA"],
"country_name": "Brazil",
"currency_code": "BRL",
"methods": [
{
"pay_way": "WEB_ONLINE",
"pay_type": "PICPAY",
"channel_code": "PICPAY",
"method_name": "PicPay",
"logo_url": ""
},
{
"pay_way": "WEB_ONLINE",
"pay_type": "PIX",
"channel_code": "PIX",
"method_name": "PIX",
"logo_url": ""
},
{
"pay_way": "WEB_ONLINE",
"pay_type": "BOLETO",
"channel_code": "BOLETO",
"method_name": "Boleto",
"logo_url": ""
},
{
"pay_way": "BANK_TRANSFER",
"pay_type": "BANK_TRANSFER",
"channel_code": "BANK_TRANSFER",
"method_name": "Transferência Bancária",
"logo_url": ""
},
{
"pay_way": "WEB_ONLINE",
"pay_type": "CREDIT_CARD",
"channel_code": "CREDIT_CARD",
"method_name": "Credit Card",
"logo_url": ""
},
{
"pay_way": "WEB_ONLINE",
"pay_type": "BINANCE",
"channel_code": "BINANCE",
"method_name": "Binance Pay",
"logo_url": ""
}
]
}
]
} }
} }
} }

View File

@ -362,6 +362,258 @@
] ]
} }
] ]
},
"v5pay": {
"provider_code": "v5pay",
"provider_name": "V5Pay",
"status": "active",
"countries": [
{
"country_code": "EG",
"country_codes": ["EG", "EGY"],
"country_name": "Egypt",
"currency_code": "EGP",
"methods": [
{
"pay_way": "Cash",
"pay_type": "FAWRY",
"channel_code": "FAWRY",
"method_name": "Fawry",
"logo_url": ""
},
{
"pay_way": "Ewallet",
"pay_type": "VODAFONE",
"channel_code": "VODAFONE",
"method_name": "Vodafone",
"logo_url": ""
}
]
},
{
"country_code": "IN",
"country_codes": ["IN", "IND"],
"country_name": "India",
"currency_code": "INR",
"methods": [
{
"pay_way": "WEB_ONLINE",
"pay_type": "UPI",
"channel_code": "UPI",
"method_name": "UPI",
"logo_url": ""
}
]
},
{
"country_code": "PH",
"country_codes": ["PH", "PHL"],
"country_name": "Philippines",
"currency_code": "PHP",
"methods": [
{
"pay_way": "WEB_ONLINE",
"pay_type": "GCASH_ONLINE",
"channel_code": "GCASH_ONLINE",
"method_name": "GCash",
"logo_url": ""
},
{
"pay_way": "WEB_ONLINE",
"pay_type": "COINS_ONLINE",
"channel_code": "COINS_ONLINE",
"method_name": "Coins.ph",
"logo_url": ""
},
{
"pay_way": "FIXED_CODE",
"pay_type": "INSTAPAY_QR_STATIC",
"channel_code": "INSTAPAY_QR_STATIC",
"method_name": "QR Ph",
"logo_url": ""
},
{
"pay_way": "DYNAMIC_CODE",
"pay_type": "INSTAPAY_QR",
"channel_code": "INSTAPAY_QR",
"method_name": "QR Ph",
"logo_url": ""
},
{
"pay_way": "QR",
"pay_type": "QRPH_GCASH",
"channel_code": "QRPH_GCASH",
"method_name": "QR Ph (GCash)",
"logo_url": ""
},
{
"pay_way": "WEB_ONLINE",
"pay_type": "PESONET",
"channel_code": "PESONET",
"method_name": "PESONet",
"logo_url": ""
},
{
"pay_way": "WEB_ONLINE",
"pay_type": "UB_ONLINE",
"channel_code": "UB_ONLINE",
"method_name": "UnionBank",
"logo_url": ""
},
{
"pay_way": "WEB_ONLINE",
"pay_type": "BPI_ONLINE",
"channel_code": "BPI_ONLINE",
"method_name": "BPI",
"logo_url": ""
},
{
"pay_way": "WEB_ONLINE",
"pay_type": "RCBC_ONLINE",
"channel_code": "RCBC_ONLINE",
"method_name": "RCBC",
"logo_url": ""
},
{
"pay_way": "FIXED_CODE",
"pay_type": "BANKVA",
"channel_code": "BANKVA",
"method_name": "Bank Transfer",
"logo_url": ""
},
{
"pay_way": "FIXED_CODE",
"pay_type": "BILL",
"channel_code": "BILL",
"method_name": "Paybills",
"logo_url": ""
}
]
},
{
"country_code": "PK",
"country_codes": ["PK", "PAK"],
"country_name": "Pakistan",
"currency_code": "PKR",
"methods": [
{
"pay_way": "WEB_ONLINE",
"pay_type": "JAZZCASH",
"channel_code": "JAZZCASH",
"method_name": "JazzCash",
"logo_url": ""
}
]
},
{
"country_code": "VN",
"country_codes": ["VN", "VNM"],
"country_name": "Vietnam",
"currency_code": "VND",
"methods": [
{
"pay_way": "WEB_ONLINE",
"pay_type": "BANK_TRANSFER",
"channel_code": "BANK_TRANSFER",
"method_name": "Bank Transfer",
"logo_url": ""
},
{
"pay_way": "WEB_ONLINE",
"pay_type": "QR",
"channel_code": "QR",
"method_name": "VietQR",
"logo_url": ""
}
]
},
{
"country_code": "TR",
"country_codes": ["TR", "TUR", "T2", "TU2"],
"country_name": "Turkey",
"currency_code": "TRY",
"methods": [
{
"pay_way": "WEB_ONLINE",
"pay_type": "GPAY",
"channel_code": "GPAY",
"method_name": "GPay",
"logo_url": ""
},
{
"pay_way": "WEB_ONLINE",
"pay_type": "BKM_EXPRESS",
"channel_code": "BKM_EXPRESS",
"method_name": "BKM Express",
"logo_url": ""
},
{
"pay_way": "WEB_ONLINE",
"pay_type": "BANK_TRANSFER",
"channel_code": "BANK_TRANSFER",
"method_name": "Bank Transfer",
"logo_url": ""
},
{
"pay_way": "WEB_ONLINE",
"pay_type": "CREDIT_CARD",
"channel_code": "CREDIT_CARD",
"method_name": "Credit Card",
"logo_url": ""
}
]
},
{
"country_code": "BR",
"country_codes": ["BR", "BRA"],
"country_name": "Brazil",
"currency_code": "BRL",
"methods": [
{
"pay_way": "WEB_ONLINE",
"pay_type": "PICPAY",
"channel_code": "PICPAY",
"method_name": "PicPay",
"logo_url": ""
},
{
"pay_way": "WEB_ONLINE",
"pay_type": "PIX",
"channel_code": "PIX",
"method_name": "PIX",
"logo_url": ""
},
{
"pay_way": "WEB_ONLINE",
"pay_type": "BOLETO",
"channel_code": "BOLETO",
"method_name": "Boleto",
"logo_url": ""
},
{
"pay_way": "BANK_TRANSFER",
"pay_type": "BANK_TRANSFER",
"channel_code": "BANK_TRANSFER",
"method_name": "Transferência Bancária",
"logo_url": ""
},
{
"pay_way": "WEB_ONLINE",
"pay_type": "CREDIT_CARD",
"channel_code": "CREDIT_CARD",
"method_name": "Credit Card",
"logo_url": ""
},
{
"pay_way": "WEB_ONLINE",
"pay_type": "BINANCE",
"channel_code": "BINANCE",
"method_name": "Binance Pay",
"logo_url": ""
}
]
}
]
} }
} }
} }

View File

@ -1252,7 +1252,7 @@
var selectedPayCountry = var selectedPayCountry =
selectPayCountryFromConfig(context); selectPayCountryFromConfig(context);
if ( if (
configuredMifaPayCountries().length && configuredProviderCountries().length &&
!selectedPayCountry !selectedPayCountry
) { ) {
return Promise.resolve( return Promise.resolve(
@ -2341,7 +2341,7 @@
return configured; return configured;
} }
if (configuredMifaPayCountries().length) { if (configuredProviderCountries().length) {
// Aslan H5 只按接口返回的用户国家匹配本地支付 JSON没有对应国家时不兜底到其它国家。 // Aslan H5 只按接口返回的用户国家匹配本地支付 JSON没有对应国家时不兜底到其它国家。
state.selectedPayCountryID = ''; state.selectedPayCountryID = '';
state.selectedCountryCode = state.selectedCountryCode =
@ -2382,7 +2382,7 @@
} }
function selectedConfiguredPayCountry(context) { function selectedConfiguredPayCountry(context) {
var countries = configuredMifaPayCountries(); var countries = configuredProviderCountries();
if (!countries.length) return null; if (!countries.length) return null;
var userCountryCode = userCountryCodeFromContext(context); var userCountryCode = userCountryCodeFromContext(context);
if (!userCountryCode) return null; if (!userCountryCode) return null;
@ -2396,13 +2396,22 @@
); );
} }
function configuredMifaPayCountries() { function configuredProviderCountries() {
var config = state.appConfig || {}; var config = state.appConfig || {};
var providers = config.providers || {}; var providers = config.providers || {};
var mifapay = providers.mifapay || {}; // 国家开关属于整个 legacy provider 配置,而不是 MiFaPay 私有配置。
return Array.isArray(mifapay.countries) // 合并所有 provider 后TR/MX/BR 等仅由 V5Pay 开通的国家才能继续请求商品和支付方式。
? mifapay.countries return Object.keys(providers).reduce(function (
: []; countries,
providerKey
) {
var provider = providers[providerKey] || {};
return countries.concat(
Array.isArray(provider.countries)
? provider.countries
: []
);
}, []);
} }
function primaryConfiguredCountryCode(country) { function primaryConfiguredCountryCode(country) {

View File

@ -0,0 +1,366 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const root = path.join(__dirname, '..');
const configPaths = {
aslan: path.join(root, 'recharge', 'config', 'aslan.json'),
yumi: path.join(root, 'recharge', 'config', 'yumi.json'),
};
const currentRuntimeTypes = {
EG: ['FAWRY', 'VODAFONE'],
IN: ['UPI'],
PH: [
'GCASH_ONLINE',
'COINS_ONLINE',
'INSTAPAY_QR_STATIC',
'INSTAPAY_QR',
'QRPH_GCASH',
'PESONET',
'UB_ONLINE',
'BPI_ONLINE',
'RCBC_ONLINE',
'BANKVA',
'BILL',
],
PK: ['JAZZCASH'],
VN: ['BANK_TRANSFER', 'QR'],
TR: ['GPAY', 'BKM_EXPRESS', 'BANK_TRANSFER', 'CREDIT_CARD'],
MX: ['SPEI', 'CODI', 'SEVEN_ELEVEN', 'OXXO', 'CREDIT_CARD'],
BR: ['PICPAY', 'PIX', 'BOLETO', 'BANK_TRANSFER', 'CREDIT_CARD', 'BINANCE'],
};
// V5Pay 商户虽已开放 8 个国家,但各应用只有同时存在有效币种汇率和上架商品时才可展示;
// 这里锁定线上真实交集,防止前端把无法生成金额快照的空入口误放给用户。
const expectedTypesByApp = {
aslan: Object.fromEntries(
['EG', 'IN', 'PH', 'PK', 'TR', 'BR'].map((code) => [
code,
currentRuntimeTypes[code],
])
),
yumi: Object.fromEntries(
['EG', 'IN', 'PH', 'PK', 'VN', 'TR', 'BR'].map((code) => [
code,
currentRuntimeTypes[code],
])
),
};
function response(payload) {
return {
headers: { get: () => '' },
ok: true,
status: 200,
statusText: 'OK',
text: () =>
Promise.resolve(
JSON.stringify({ code: 'OK', message: 'ok', data: payload })
),
};
}
function createAPIHarness() {
const requests = [];
const storage = new Map();
let nextPayload = {};
const window = {
document: { documentElement: { setAttribute: () => {} } },
fetch: (rawURL, options = {}) => {
// 每次调用都记录真实序列化后的 URL/body测试的是浏览器最终发出的契约而不是内部 helper 的中间对象。
requests.push({
url: new URL(rawURL),
options,
body: options.body ? JSON.parse(options.body) : null,
});
return Promise.resolve(response(nextPayload));
},
localStorage: {
getItem: (key) => storage.get(key) || null,
removeItem: (key) => storage.delete(key),
setItem: (key, value) => storage.set(key, String(value)),
},
location: {
hash: '',
hostname: 'h5.global-interaction.com',
href: 'https://h5.global-interaction.com/recharge/index.html',
origin: 'https://h5.global-interaction.com',
pathname: '/recharge/index.html',
search: '?language=tr',
},
};
const context = vm.createContext({
FormData,
JSON,
Math,
Number,
Object,
Promise,
String,
URL,
URLSearchParams,
Uint32Array,
window,
});
vm.runInContext(
fs.readFileSync(path.join(root, 'common', 'api.js'), 'utf8'),
context,
{ filename: 'common/api.js' }
);
return {
api: window.HyAppAPI,
requests,
respondWith(payload) {
nextPayload = payload;
},
};
}
function readConfigs() {
return Object.fromEntries(
Object.entries(configPaths).map(([app, file]) => [
app,
JSON.parse(fs.readFileSync(file, 'utf8')),
])
);
}
function assertRuntimeConfig(config, app) {
const provider = config.providers && config.providers.v5pay;
assert.ok(provider, `${app} should configure V5Pay`);
assert.equal(provider.provider_code, 'v5pay');
assert.equal(provider.provider_name, 'V5Pay');
assert.equal(provider.status, 'active');
assert.deepEqual(
Object.fromEntries(
provider.countries.map((country) => [
country.country_code,
country.methods.map((method) => method.pay_type),
])
),
expectedTypesByApp[app],
`${app} should expose only V5Pay countries backed by current app products`
);
provider.countries.forEach((country) => {
country.methods.forEach((method) => {
assert.equal(method.channel_code, method.pay_type);
assert.equal(method.logo_url, '');
assert.ok(method.pay_way, `${country.country_code} needs pay_way`);
assert.ok(method.method_name, `${method.pay_type} needs a name`);
// Lalu 的 method_id 和商户密钥属于后端数据,复制到 app JSON 会造成跨应用错误绑定或敏感信息泄露。
assert.equal(Object.hasOwn(method, 'method_id'), false);
assert.equal(Object.hasOwn(method, 'secret_key'), false);
});
});
}
async function assertProviderParsing(harness, config, app) {
harness.api.setAppConfig(config);
const currencies = Object.fromEntries(
config.providers.v5pay.countries.map((country) => [
country.country_code,
country.currency_code,
])
);
for (const [countryCode, types] of Object.entries(
expectedTypesByApp[app]
)) {
if (app === 'aslan') {
harness.respondWith({
country_code: countryCode,
currency_code: currencies[countryCode],
products: [{ product_id: 'legacy-product' }],
});
} else {
harness.respondWith({
countryCode,
currencyCode: currencies[countryCode],
payCountryId: 'pay-country-1',
commodity: [
{
id: 'legacy-product',
amountUsd: 1,
amount: 1,
currency: currencies[countryCode],
},
],
// 生产旧接口当前只返回 MiFaPay 数据库渠道。V5Pay 使用下单时的安全合成快照,
// 必须证明它不会被一个完全无关的旧渠道集合误过滤掉。
channels: [
{
channel: { channelCode: 'MIFA_ONLY_CHANNEL' },
details: { factoryChannel: 'MIFA_ONLY_TYPE' },
},
],
});
}
const options = await harness.api.recharge.options('100000001', {
country_code: countryCode,
currency_code: currencies[countryCode],
pay_country_id: 'pay-country-1',
user_id: '100000001',
});
const v5Methods = Array.from(options.payment_methods).filter(
(method) => method.provider_code === 'v5pay'
);
const v5Types = v5Methods.map((method) => method.pay_type);
assert.deepEqual(
v5Types,
types,
`${app} should parse ${countryCode} V5Pay methods`
);
v5Methods.forEach((method) => {
assert.equal(method.provider_name, 'V5Pay');
assert.equal(method.channel_code, method.pay_type);
assert.match(method.method_id, /^v5pay:/);
});
}
}
async function assertLegacyOrderPayloads(harness, configs) {
const legacyProductID = '90071992547409931234';
for (const app of ['aslan', 'yumi']) {
harness.api.setAppConfig(configs[app]);
harness.respondWith({
orderId: `${app}-order-1`,
factoryCode: app === 'yumi' ? 'V5_PAY' : undefined,
providerCode: app === 'aslan' ? 'v5pay' : undefined,
requestUrl: `https://pay.example/${app}-order-1`,
});
const order = await harness.api.recharge.createOrder({
product_id: legacyProductID,
pay_country_id: 'pay-country-1',
user_id: '100000001',
provider_code: 'v5pay',
pay_way: 'Cash',
pay_type: 'FAWRY',
channel_code: 'FAWRY',
country_code: 'EG',
currency_code: 'EGP',
command_id: `${app}-command-1`,
return_url: 'https://h5.global-interaction.com/recharge/index.html',
});
const body = harness.requests.at(-1).body;
assert.equal(body.goodsId, legacyProductID);
assert.equal(typeof body.goodsId, 'string');
assert.equal(body.providerCode, 'v5pay');
assert.equal(body.payWay, 'Cash');
assert.equal(body.payType, 'FAWRY');
assert.equal(body.channelCode, 'FAWRY');
if (app === 'yumi') {
assert.equal(body.countryCode, 'EG');
assert.equal(body.currencyCode, 'EGP');
assert.equal(body.commandId, 'yumi-command-1');
assert.equal(body.language, 'tr');
assert.equal(order.provider_code, 'v5pay');
harness.respondWith({
orderId: 'yumi-order-1',
factoryCode: 'V5PAY',
status: 'SUCCESS',
amount: '2.00',
});
const status = await harness.api.recharge.getOrder(
'yumi-order-1',
'100000001'
);
assert.equal(status.provider_code, 'v5pay');
assert.equal(status.status, 'credited');
}
}
}
async function assertLaluNumericPayload(harness) {
harness.api.setAppConfig(null);
harness.respondWith({ order_id: 'lalu-order-1' });
await harness.api.recharge.createOrder({
product_id: 123456,
provider_code: 'v5pay',
});
const body = harness.requests.at(-1).body;
assert.equal(body.product_id, 123456);
assert.equal(typeof body.product_id, 'number');
}
function extractPageFunction(source, name) {
const start = source.indexOf(`function ${name}(`);
assert.notEqual(start, -1, `missing recharge page function ${name}`);
const next = source.indexOf('\n function ', start + 1);
return source.slice(start, next === -1 ? source.length : next);
}
function assertPageProductIDCompatibility() {
const pageSource = fs.readFileSync(
path.join(root, 'recharge', 'index.html'),
'utf8'
);
const source = [
extractPageFunction(pageSource, 'productID'),
extractPageFunction(pageSource, 'createOrderProductID'),
extractPageFunction(pageSource, 'isLegacyWebPayApp'),
].join('\n');
const context = vm.createContext({ isFinite, Number, String, state: {} });
vm.runInContext(source, context, { filename: 'recharge/index.html' });
context.state.appConfig = { adapter: 'yumi-web-pay' };
assert.equal(
context.createOrderProductID({ product_id: '90071992547409931234' }),
'90071992547409931234'
);
context.state.appConfig = { adapter: 'aslan-web-pay' };
assert.equal(
context.createOrderProductID({ product_id: '123456' }),
'123456'
);
context.state.appConfig = null;
assert.equal(
context.createOrderProductID({ product_id: '123456' }),
123456
);
}
function assertPageProviderCountries(configs) {
const pageSource = fs.readFileSync(
path.join(root, 'recharge', 'index.html'),
'utf8'
);
const source = extractPageFunction(
pageSource,
'configuredProviderCountries'
);
const context = vm.createContext({ Object, Array, state: {} });
vm.runInContext(source, context, { filename: 'recharge/index.html' });
Object.entries(configs).forEach(([app, config]) => {
context.state.appConfig = config;
const codes = Array.from(context.configuredProviderCountries()).map(
(country) => country.country_code
);
Object.keys(expectedTypesByApp[app]).forEach((countryCode) => {
assert.ok(
codes.includes(countryCode),
`${app} page country selection should include ${countryCode}`
);
});
});
}
async function main() {
const configs = readConfigs();
Object.entries(configs).forEach(([app, config]) =>
assertRuntimeConfig(config, app)
);
const harness = createAPIHarness();
await assertProviderParsing(harness, configs.aslan, 'aslan');
await assertProviderParsing(harness, configs.yumi, 'yumi');
await assertLegacyOrderPayloads(harness, configs);
await assertLaluNumericPayload(harness);
assertPageProductIDCompatibility();
assertPageProviderCountries(configs);
console.log('recharge V5Pay compatibility tests passed');
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});