diff --git a/common/api.js b/common/api.js index 41b9504..88ddbd2 100644 --- a/common/api.js +++ b/common/api.js @@ -633,7 +633,7 @@ var default_api = 'https://api.global-interaction.com/'; }; // H5 充值 API 只做路径和参数名封装;token、app_code、env、错误 envelope 仍由 request 统一处理。 - // 金额、金币数、普通/币商钱包、MiFaPay 参数和 USDT 链上校验都以后端订单快照为准。 + // 金额、金币数、普通/币商钱包、三方支付参数和 USDT 链上校验都以后端订单快照为准。 function isAslanWebPay() { return activeAppConfig && activeAppConfig.adapter === 'aslan-web-pay'; } @@ -701,52 +701,92 @@ var default_api = 'https://api.global-interaction.com/'; return query; } - function aslanPaymentMethods(options) { + function legacyProviderPaymentMethods(options) { var providers = (activeAppConfig && activeAppConfig.providers) || {}; - var mifapay = providers.mifapay || {}; - var countries = mifapay.countries || []; var countryCode = String( options.country_code || options.countryCode || '' ).toUpperCase(); - var matchedCountries = countries.filter(function (country) { - return countryMatchesCode(country, countryCode); + var matchedMethods = []; + 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 matchedMethods = collectMifaPayMethods( - matchedCountries, - mifapay, - options, - false - ); - var methods = matchedMethods.concat( - collectMifaPayMethods(globalCountries, mifapay, options, false) - ); + + var methods = matchedMethods.concat(globalMethods); if (!matchedMethods.length && countryCode) { // 用户国家在 app JSON 里没有具体支付方式时,商品仍按用户国家查询,支付方式降级展示 JSON 中全部方式。 // 兜底方式只影响前端展示和用户选择,下单仍带当前用户国家的 payCountryId 供后端校验金额和币种。 - methods = collectMifaPayMethods(countries, mifapay, options, true); + methods = allProviderMethods; } return methods; } - function collectMifaPayMethods(countries, mifapay, options, fallbackAll) { + function collectLegacyProviderMethods( + countries, + provider, + providerKey, + options, + fallbackAll + ) { var methods = []; + var providerCode = provider.provider_code || providerKey; + var providerName = provider.provider_name || providerCode; (countries || []).forEach(function (country) { var countryCode = primaryCountryCode(country); (country.methods || []).forEach(function (method, index) { methods.push( Object.assign({}, method, { + // method_id 只用于同页稳定区分卡片,不能复制 Lalu 数据库 method_id; + // 旧网关真正下单依赖 provider/payWay/payType/channelCode,而不是该前端合成值。 method_id: [ - mifapay.provider_code || 'mifapay', + providerCode, countryCode, - method.pay_way, - method.pay_type, + method.channel_code || method.pay_way, + method.pay_type || method.channel_code, index, ].join(':'), - provider_code: mifapay.provider_code || 'mifapay', - provider_name: mifapay.provider_name || 'MiFaPay', + provider_code: providerCode, + provider_name: providerName, country_code: countryCode, country_name: country.country_name, currency_code: @@ -761,7 +801,7 @@ var default_api = 'https://api.global-interaction.com/'; options.pay_country_id || options.payCountryId || '', - status: mifapay.status || 'active', + status: provider.status || 'active', fallback_all_countries: !!fallbackAll, }) ); @@ -1058,28 +1098,48 @@ var default_api = 'https://api.global-interaction.com/'; channel.channel_code, details.channelCode, details.channel_code, + details.factoryChannel, + details.factory_channel, ].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 = method.channel_code || [method.pay_way, method.pay_type].filter(Boolean).join('_'); 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) { order = order || {}; var factoryCode = String( - order.factoryCode || order.factory_code || '' + order.factoryCode || + order.factory_code || + order.providerCode || + order.provider_code || + '' ).toUpperCase(); return { order_id: order.orderId || order.order_id || '', trade_no: order.tradeNo || order.trade_no || '', - provider_code: factoryCode === 'MIFA_PAY' ? 'mifapay' : factoryCode, + provider_code: providerCodeFromFactory(factoryCode), factory_code: factoryCode, pay_url: order.requestUrl || order.request_url || order.pay_url || '', @@ -1101,12 +1161,16 @@ var default_api = 'https://api.global-interaction.com/'; ? 'failed' : 'pending'; var factoryCode = String( - order.factoryCode || order.factory_code || '' + order.factoryCode || + order.factory_code || + order.providerCode || + order.provider_code || + '' ).toUpperCase(); return { order_id: order.orderId || order.order_id || '', trade_no: order.tradeNo || order.trade_no || '', - provider_code: factoryCode === 'MIFA_PAY' ? 'mifapay' : factoryCode, + provider_code: providerCodeFromFactory(factoryCode), factory_code: factoryCode, currency_code: order.currency || order.currency_code || '', 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; return options; }); @@ -1219,7 +1284,17 @@ var default_api = 'https://api.global-interaction.com/'; goodsId: payload && payload.product_id, payCountryId: payload && payload.pay_country_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, + 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, appVersion: 'h5', }, diff --git a/package.json b/package.json index 113ad15..a35086c 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "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", "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:check": "prettier --check \"**/*.{js,html,css,json,md}\"" }, diff --git a/recharge/config/aslan.json b/recharge/config/aslan.json index d7c085a..0cc95c4 100644 --- a/recharge/config/aslan.json +++ b/recharge/config/aslan.json @@ -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": "" + } + ] + } + ] } } } diff --git a/recharge/config/yumi.json b/recharge/config/yumi.json index c192243..f7dbaec 100644 --- a/recharge/config/yumi.json +++ b/recharge/config/yumi.json @@ -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": "" + } + ] + } + ] } } } diff --git a/recharge/index.html b/recharge/index.html index cca6196..9381a02 100644 --- a/recharge/index.html +++ b/recharge/index.html @@ -1252,7 +1252,7 @@ var selectedPayCountry = selectPayCountryFromConfig(context); if ( - configuredMifaPayCountries().length && + configuredProviderCountries().length && !selectedPayCountry ) { return Promise.resolve( @@ -2341,7 +2341,7 @@ return configured; } - if (configuredMifaPayCountries().length) { + if (configuredProviderCountries().length) { // Aslan H5 只按接口返回的用户国家匹配本地支付 JSON;没有对应国家时不兜底到其它国家。 state.selectedPayCountryID = ''; state.selectedCountryCode = @@ -2382,7 +2382,7 @@ } function selectedConfiguredPayCountry(context) { - var countries = configuredMifaPayCountries(); + var countries = configuredProviderCountries(); if (!countries.length) return null; var userCountryCode = userCountryCodeFromContext(context); if (!userCountryCode) return null; @@ -2396,13 +2396,22 @@ ); } - function configuredMifaPayCountries() { + function configuredProviderCountries() { var config = state.appConfig || {}; var providers = config.providers || {}; - var mifapay = providers.mifapay || {}; - return Array.isArray(mifapay.countries) - ? mifapay.countries - : []; + // 国家开关属于整个 legacy provider 配置,而不是 MiFaPay 私有配置。 + // 合并所有 provider 后,TR/MX/BR 等仅由 V5Pay 开通的国家才能继续请求商品和支付方式。 + return Object.keys(providers).reduce(function ( + countries, + providerKey + ) { + var provider = providers[providerKey] || {}; + return countries.concat( + Array.isArray(provider.countries) + ? provider.countries + : [] + ); + }, []); } function primaryConfiguredCountryCode(country) { diff --git a/scripts/test_recharge_v5pay.js b/scripts/test_recharge_v5pay.js new file mode 100644 index 0000000..45b86c8 --- /dev/null +++ b/scripts/test_recharge_v5pay.js @@ -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; +});