Files
wordpress-theme-aksrt/src/Settings/PaymentRuntime.php
T
kate522 ce21cc2b9b revert fa476af70e
revert refactor(pay): 充值商品以充值金额为唯一真值,移除销售金额与折扣

充值商品行此前是双值模型:price 充值金额决定到账、pay_price 销售金额决定支付,
pay_price 为 0 时回落到 price,否则即「付更少拿更多」的折扣。该模型把三处耦合在
一起——后台 schema 暴露销售金额输入、线格式首段携带支付额、下单时计价与到账分离。
只改其中一处就会出现「后台不再可配但前端仍在展示折扣」或「展示已合并但下单仍按
旧价扣款」的静默不一致,故本次三层一并收口,充值金额即实际支付金额。

后台 schema 有两份且必须同步:SPA 侧 recharge.js 负责渲染表单,PHP 侧 SchemaPayment
负责默认值与清洗。只改一份会让界面不再可配而默认值仍在写 pay_price。

线格式三段 "支付|到账|标题" 保持不动,仅为兼容历史已存的字符串配置:支付段不再读取
任何配置来源,恒等于充值金额,故历史配置里的折扣价会被忽略。旧数据无需迁移,pay_price
键留在库中只是不再被读取,从重建后的后台保存一次即自然消失。

行为变化(需知悉):
- 已配置折扣的站点,其用户将按充值金额全额支付,即对这些用户是涨价。这是「不做优惠
  和促销」的必然结果。
- parse_packages 的校验口径随之变松:有效性只由到账段决定,故 "abc|10|x" 这类首段
  非法、到账段合法的行会被解析为 10 元档,而旧实现两段都作金额校验、会丢弃。理由是
  支付段已不表达任何含义,因一个无用字段是垃圾就丢弃一条本来有效的档位只会让运营困惑。
  既有数据不受影响——旧格式化器写出的首段恒为数字,只有手改出的脏数据才可能命中。
  该取舍已写入方法文档。

- recharge.js / SchemaPayment:删除 pay_price 字段与三档默认值中的该键,help 去掉
  「同时支持设置折扣」
- PaymentRuntime::format_product_lines:不再读取 pay_price,支付段由充值金额充当
- BalanceServiceCharge::parse_packages:不再产出 pay_price 键,金额唯一来源为到账段
- BalanceServiceCharge::create_charge_order:支付额与到账额同源于 price
- credits-wallet.php:移除销售金额行、「省X」折扣徽标及仅服务折扣的差额计算
- BalanceServiceRest:仅注释改写,说明积分路径的 pay_price 是售价而非折扣
- 新增 tests/widget-system/recharge-product-price-contract.test.cjs:5 条跨层契约

范围边界:积分商品 pay_points_product 的 pay_price 是「积分|支付|标签」里的实际售价,
本身不含折扣语义,保留不动。契约测试以正向断言固定该边界,避免日后被误当作同类问题
一并删掉。

验证:新增契约测试 5 项通过;node --test 全量 375 项 / 375 通过(原 370 项);PHPUnit
44 项 / 106 断言通过;改动的 PHP 文件 php -l 全部通过。变异验证:把 create_charge_order
改回按 pay_price 计价、把 pay_price 重新注入模板,对应断言分别转红,证明回归测试拦得
住回退。另以反射调用真实代码做端到端执行验证 11 项全过,覆盖结构化数组 -> 线格式 ->
商品数组全链路、数组内残存 pay_price 被忽略、历史折扣字符串(98|100|旧折扣档 取 100)、
到账段为 0 或非法时跳过等边界。

部署提示:assets/admin/build 为部署时构建、未入库,后台表单的改动需在目标机执行
cd assets/admin && npm ci && npm run build 后才会生效。前端 credits.js 走 filemtime
版本号,无需手工清缓存。
2026-09-19 03:25:12 +00:00

649 lines
32 KiB
PHP

<?php
/**
* @package AKSRT
*/
namespace AKSRT\Settings;
defined('ABSPATH') || exit;
class PaymentRuntime {
/**
* Paid content / download policy.
*
* @return array<string,mixed>
*/
public static function get_paid_content_settings(): array {
$settings = Settings::get_all();
$box_position = sanitize_key((string) ($settings['pay_box_position'] ?? 'top'));
// legacy: box_top | top | bottom | box_bottom
if (!in_array($box_position, ['box_top', 'top', 'bottom', 'box_bottom'], true)) {
$box_position = 'top';
}
return [
// 下载门槛永远支持积分购买模式(不再由页面开关控制)。
'download_credit' => Settings::credits_enabled(),
'default_cost' => Settings::download_default_cost(),
// 付费内容仅文章。
'paid_post_types' => ['post'],
'vip_free_download' => false,
'vip_exempt_roles' => [],
'catalog_note' => '',
'manual_delivery_note' => __('付费或人工确认内容请在订单备注中记录交付方式。', 'aksrt'),
'no_logged_in' => !isset($settings['pay_no_logged_in']) || !empty($settings['pay_no_logged_in']),
'free_logged_show' => !isset($settings['pay_free_logged_show']) || !empty($settings['pay_free_logged_show']),
'down_alone_page' => !empty($settings['down_alone_page']),
'display' => [
'mark' => sanitize_text_field((string) ($settings['pay_mark'] ?? '付费')),
'currency_symbol' => sanitize_text_field((string) ($settings['pay_currency_mark'] ?? '¥')),
'currency_unit' => sanitize_text_field((string) ($settings['pay_currency_unit'] ?? '元')),
'show_user' => !isset($settings['pay_show_user']) || !empty($settings['pay_show_user']),
'show_paycount' => !isset($settings['pay_show_paycount']) || !empty($settings['pay_show_paycount']),
'box_position' => $box_position,
],
'download' => [
'enabled' => true,
'remote' => !isset($settings['down_remote_to_local_s']) || !empty($settings['down_remote_to_local_s']),
'verify_nonce' => !isset($settings['down_verify_nonce']) || !empty($settings['down_verify_nonce']),
'user_log' => max(0, (int) ($settings['down_user_log'] ?? 50)),
'post_log' => max(0, (int) ($settings['down_post_log'] ?? 50)),
'remote_size' => max(0, (int) ($settings['down_remote_to_local_size'] ?? 2048)),
'remote_ext' => sanitize_text_field((string) ($settings['down_remote_to_local_ext'] ?? 'zip,rar,7z')),
'alone_page' => !empty($settings['down_alone_page']),
],
// 内容收银台优惠码:全局开关 + 默认说明。
'coupon_post_s' => !empty($settings['coupon_post_s']),
'coupon_desc' => sanitize_textarea_field((string) ($settings['coupon_desc'] ?? '')),
];
}
/**
* Content coupon global switches (legacy coupon_post_s).
*
* @return array{enabled:bool,desc:string}
*/
public static function get_content_coupon_settings(): array {
$settings = self::get_paid_content_settings();
return [
'enabled' => !empty($settings['coupon_post_s']),
'desc' => (string) ($settings['coupon_desc'] ?? ''),
];
}
/**
* Balance recharge settings (legacy pay_balance_* contract).
*
* @return array<string,mixed>
*/
public static function get_payment_recharge_settings(): array {
$settings = Settings::get_all();
// 该选项可能存字符串(旧格式)或数组(结构化保存):先判类型再转换,
// 避免对数组做强转触发 "Array to string conversion" 警告。
$raw_packages = $settings['pay_balance_product'] ?? null;
$packages = is_array($raw_packages)
? self::format_product_lines($raw_packages)
: (string) ($raw_packages ?? "30|30|入门包\n98|108|推荐包");
$desc = sanitize_textarea_field((string) ($settings['pay_balance_desc'] ?? ''));
return [
'enabled' => !empty($settings['pay_balance_s']),
'withdraw_enabled' => !empty($settings['pay_balance_withdraw_s']),
'desc' => $desc,
'packages_raw' => $packages,
'package_count' => self::count_structured_lines($packages),
'help_text' => $desc !== '' ? $desc : __('当前支持人工确认与测试联调;接入真实网关时复用同一组字段。', 'aksrt'),
'product' => [
'name' => sanitize_text_field((string) ($settings['pay_balance_product_name'] ?? '')),
'price' => self::money_decimal($settings['pay_balance_price'] ?? '0.00', '0.00'),
'tag' => sanitize_text_field((string) ($settings['pay_balance_tag'] ?? '元')),
'tag_class' => sanitize_text_field((string) ($settings['pay_balance_tag_class'] ?? '')),
'custom_s' => !isset($settings['pay_balance_product_custom_s']) || !empty($settings['pay_balance_product_custom_s']),
'custom_limit' => max(1, min(99999, (int) ($settings['pay_balance_product_custom_limit'] ?? 9999))),
],
'transfer' => [
'enabled' => !empty($settings['pay_balance_transfer_s']),
'service_charge' => max(0, min(50, (int) ($settings['pay_balance_transfer_service_charge'] ?? 0))),
'desc' => sanitize_textarea_field((string) ($settings['pay_balance_transfer_desc'] ?? '')),
],
'pass_charge' => [
// 三个独立开关:各类型卡密只进入各自的购买弹窗(收款接口同款拆分)。
'balance' => [
'enabled' => !empty($settings['pay_balance_pass_charge_s']),
'desc' => sanitize_textarea_field((string) ($settings['pay_balance_pass_charge_desc'] ?? '')),
'only_password' => !empty($settings['pay_balance_pass_charge_only_password']),
],
'credits' => [
'enabled' => !empty($settings['points_pass_exchange_s']),
'desc' => sanitize_textarea_field((string) ($settings['points_pass_exchange_desc'] ?? '')),
'only_password' => !empty($settings['points_pass_exchange_only_password']),
],
'vip' => [
'enabled' => !empty($settings['pay_vip_pass_charge_s']),
'desc' => sanitize_textarea_field((string) ($settings['pay_vip_pass_charge_desc'] ?? '')),
// 默认非单密码(卡号+密码),对齐参考主题 pay_vip_pass_charge_only_password 默认 false。
'only_password' => !empty($settings['pay_vip_pass_charge_only_password']),
],
],
'points_exchange' => [
'enabled' => !empty($settings['pay_balance_points_exchange_s']),
'rate' => max(1, min(10000, (int) ($settings['pay_balance_points_exchange_rate'] ?? 100))),
'min_points' => max(1, min(10000000, (int) ($settings['pay_balance_points_exchange_min_points'] ?? 100))),
'daily_max_points' => max(1, min(10000000, (int) ($settings['pay_balance_points_exchange_daily_max_points'] ?? 100000))),
'desc' => sanitize_textarea_field((string) ($settings['pay_balance_points_exchange_desc'] ?? '')),
'products_raw' => is_array($settings['pay_points_product'] ?? null)
? self::format_points_product_lines($settings['pay_points_product'])
: (string) ($settings['pay_points_product'] ?? ''),
],
];
}
/**
* Resolve a configured VIP icon, falling back to the bundled level asset.
*/
private static function get_vip_icon_url(int $level, string $configured): string {
$configured = esc_url_raw($configured);
if ($configured !== '') {
return $configured;
}
$filename = 'vip-' . $level . '.svg';
if (defined('AKSRT_DIR') && is_file(AKSRT_DIR . '/assets/img/user/vip/' . $filename)) {
return defined('AKSRT_URI') ? AKSRT_URI . '/assets/img/user/vip/' . $filename : '';
}
return '';
}
/**
* Dual-level VIP settings (legacy pay_user_vip_1 / pay_user_vip_2).
*
* @return array<string,mixed>
*/
public static function get_payment_vip_settings(): array {
$settings = Settings::get_all();
$vip1_enabled = !empty($settings['pay_user_vip_1_s']);
$vip2_enabled = !empty($settings['pay_user_vip_2_s']);
$enabled = $vip1_enabled || $vip2_enabled;
$name1 = trim((string) ($settings['pay_user_vip_1_name'] ?? ''));
$name2 = trim((string) ($settings['pay_user_vip_2_name'] ?? ''));
if ($name1 === '') {
$name1 = __('一级会员', 'aksrt');
}
if ($name2 === '') {
$name2 = __('二级会员', 'aksrt');
}
$badge = $vip2_enabled ? $name2 : ($vip1_enabled ? $name1 : __('VIP', 'aksrt'));
$renew_price_type = sanitize_key((string) ($settings['vip_renew_price_type'] ?? 'discount'));
if (!in_array($renew_price_type, ['same', 'discount', 'reduce'], true)) {
$renew_price_type = 'discount';
}
$default_l1 = "30|59|1|month|限时|月卡\n98|199|3|month|特惠|季卡\n365|599|12|month|超值|年卡\n999|1299|0|day|永久|永久卡";
$default_l2 = "58|99|1|month|限时|月卡\n168|299|3|month|特惠|季卡\n598|999|12|month|超值|年卡\n1299|1999|0|day|永久|永久卡";
$products_1 = sanitize_textarea_field((string) ($settings['payment_vip_1_products'] ?? $default_l1));
$products_2 = sanitize_textarea_field((string) ($settings['payment_vip_2_products'] ?? $default_l2));
// Restore newlines lost only if empty after sanitize — sanitize_textarea_field keeps \n.
if (trim($products_1) === '') {
$products_1 = $default_l1;
}
if (trim($products_2) === '') {
$products_2 = $default_l2;
}
$desc = sanitize_textarea_field((string) ($settings['pay_user_vip_desc'] ?? ''));
$more = (string) ($settings['pay_user_vip_more'] ?? '');
$allowed_equity = [
'li' => [],
'ul' => [],
'ol' => [],
'strong' => [],
'b' => [],
'em' => [],
'i' => ['class' => true],
'span' => ['class' => true],
'br' => [],
'a' => ['href' => true, 'target' => true, 'rel' => true, 'class' => true],
];
return [
'enabled' => $enabled,
'nav_pay_vip' => !isset($settings['nav_pay_vip']) || !empty($settings['nav_pay_vip']),
'nav_user_pay_vip' => !isset($settings['nav_user_pay_vip']) || !empty($settings['nav_user_pay_vip']),
'name' => $vip1_enabled ? $name1 : $badge,
'badge_text' => $badge,
'desc' => $desc,
'more' => wp_kses($more, $allowed_equity + ['div' => ['class' => true], 'p' => ['class' => true]]),
'help_text' => $desc !== '' ? $desc : __('会员权益以免扣下载和身份展示为主,开通后立即生效。', 'aksrt'),
'levels' => [
1 => [
'enabled' => $vip1_enabled,
'name' => $name1,
'desc' => sanitize_text_field((string) ($settings['pay_user_vip_1_desc'] ?? __('尊享一级会员特权', 'aksrt'))),
'equity' => wp_kses((string) (($settings['pay_user_vip_1_equity'] ?? '') !== '' ? $settings['pay_user_vip_1_equity'] : '<li>免费下载部分资源</li><li>会员专属身份标识</li><li>付费内容会员价</li>'), $allowed_equity),
'img_icon' => self::get_vip_icon_url(1, (string) ($settings['vip_1_img_icon'] ?? '')),
'product_s' => !isset($settings['vip_1_product_s']) || !empty($settings['vip_1_product_s']),
'products_raw' => $products_1,
'download_limit' => max(0, min(9999, (int) ($settings['pay_download_limit_vip_1'] ?? 0))),
],
2 => [
'enabled' => $vip2_enabled,
'name' => $name2,
'desc' => sanitize_text_field((string) ($settings['pay_user_vip_2_desc'] ?? __('尊享二级会员特权', 'aksrt'))),
'equity' => wp_kses((string) (($settings['pay_user_vip_2_equity'] ?? '') !== '' ? $settings['pay_user_vip_2_equity'] : '<li>更高免费下载额度</li><li>更高等级身份标识</li><li>更多付费内容会员价</li>'), $allowed_equity),
'img_icon' => self::get_vip_icon_url(2, (string) ($settings['vip_2_img_icon'] ?? '')),
'product_s' => !isset($settings['vip_2_product_s']) || !empty($settings['vip_2_product_s']),
'products_raw' => $products_2,
'download_limit' => max(0, min(9999, (int) ($settings['pay_download_limit_vip_2'] ?? 0))),
],
],
'benefit' => [
'download_limit' => max(0, min(9999, (int) ($settings['pay_download_limit'] ?? 0))),
],
'renew' => [
'enabled' => !isset($settings['vip_renew']) || !empty($settings['vip_renew']),
'desc' => sanitize_textarea_field((string) ($settings['vip_renew_desc'] ?? '')),
'price_type' => $renew_price_type,
'discount' => max(1, min(100, (int) ($settings['vip_renew_discount'] ?? 8))),
'reduce' => self::money_decimal($settings['vip_renew_reduce'] ?? '20.00', '20.00', 999900),
],
'upgrade' => [
'enabled' => !isset($settings['vip_upgrade']) || !empty($settings['vip_upgrade']),
'desc' => sanitize_textarea_field((string) ($settings['vip_upgrade_desc'] ?? '')),
// Multi-line upgrade product rules — must not collapse newlines.
'product' => sanitize_textarea_field((string) ($settings['vip_upgrade_product'] ?? '')),
],
'points_exchange' => [
'enabled' => !empty($settings['pay_vip_points_exchange_s']),
'desc' => sanitize_textarea_field((string) ($settings['pay_vip_points_exchange_desc'] ?? '')),
'packages_raw' => sanitize_textarea_field((string) ($settings['payment_vip_points_packages'] ?? "1|500|30|积分月卡\n1|5000|0|积分永久")),
],
];
}
/**
* Creator-income policy. Cash and points intentionally use separate ratio sets.
*
* @return array{enabled:bool,cycle_days:int,ratios:array<string,array<string,int>>,note:string}
*/
public static function get_payment_revenue_settings(): array {
$settings = Settings::get_all();
$note = trim((string) ($settings['payment_revenue_note'] ?? ''));
$clamp_ratio = static fn ($value): int => max(0, min(100, (int) $value));
return [
'enabled' => !empty($settings['pay_income_s']),
'cycle_days' => max(1, min(365, (int) ($settings['payment_revenue_cycle_days'] ?? 30))),
'ratios' => [
'cash' => [
'ordinary' => $clamp_ratio($settings['payment_revenue_cash_ratio_user'] ?? 60),
'vip1' => $clamp_ratio($settings['payment_revenue_cash_ratio_vip_1'] ?? 70),
'vip2' => $clamp_ratio($settings['payment_revenue_cash_ratio_vip_2'] ?? 80),
],
'points' => [
'ordinary' => $clamp_ratio($settings['payment_revenue_points_ratio_user'] ?? 0),
'vip1' => $clamp_ratio($settings['payment_revenue_points_ratio_vip_1'] ?? 0),
'vip2' => $clamp_ratio($settings['payment_revenue_points_ratio_vip_2'] ?? 0),
],
],
'note' => $note !== '' ? wp_kses_post($note) : __('<p>订单完成并经过结算周期后,作者分成会自动转入对应的钱包。</p>', 'aksrt'),
];
}
/**
* Affiliate policy. Layout follows the reference theme: one rebate rule
* set per user tier (pay_rebate_user_s / _1 / _2 + pay_rebate_ratio*).
*
* @return array<string,mixed>
*/
public static function get_payment_affiliate_settings(): array {
$settings = Settings::get_all();
$clamp_ratio = static fn($value): int => max(0, min(100, (int) $value));
$resolve_types = static function (array $types): array {
$types = array_values(array_intersect(['all', 'membership', 'content', 'credits'], array_map('sanitize_key', $types)));
// 「全部订单」展开为全部可返佣订单类型。
if (in_array('all', $types, true)) {
return ['membership', 'content', 'credits'];
}
return $types;
};
$types_by_level = [
0 => $resolve_types((array) ($settings['pay_rebate_user_s'] ?? [])),
1 => $resolve_types((array) ($settings['pay_rebate_user_s_1'] ?? ['all'])),
2 => $resolve_types((array) ($settings['pay_rebate_user_s_2'] ?? ['all'])),
];
$judgment = sanitize_key((string) ($settings['pay_rebate_judgment'] ?? 'all'));
if (!in_array($judgment, ['all', 'link'], true)) {
$judgment = 'all';
}
return [
'enabled' => !empty($settings['pay_rebate_s']),
'judgment' => $judgment,
'show_users' => !isset($settings['pay_rebate_show_users']) || !empty($settings['pay_rebate_show_users']),
'self_rebate' => !isset($settings['pay_rebate_self_s']) || !empty($settings['pay_rebate_self_s']),
'ratios' => [
'user' => $clamp_ratio($settings['pay_rebate_ratio'] ?? 5),
'vip_1' => $clamp_ratio($settings['pay_rebate_ratio_vip_1'] ?? 10),
'vip_2' => $clamp_ratio($settings['pay_rebate_ratio_vip_2'] ?? 20),
],
'order_types' => $types_by_level[0],
'order_types_by_level' => $types_by_level,
'window_days' => (int) CreditsRuntime::get_referral_settings()['window_days'],
'settlement_days' => max(0, min(90, (int) ($settings['affiliate_settlement_days'] ?? 7))),
'promotion_text' => sanitize_textarea_field((string) ($settings['pay_rebate_text_details'] ?? '')),
];
}
/**
* 某等级推广者可获返佣的订单类型;空数组代表该等级用户不参与推广返佣。
*
* @return array<int,string>
*/
public static function affiliate_order_types_for_level(int $level): array {
$settings = self::get_payment_affiliate_settings();
$key = $level >= 2 ? 2 : ($level >= 1 ? 1 : 0);
return (array) ($settings['order_types_by_level'][$key] ?? []);
}
public static function affiliate_enabled(): bool { return !empty(self::get_payment_affiliate_settings()['enabled']); }
public static function affiliate_ratio_for_user(int $user_id): int {
$level = function_exists('aksrt_get_user_vip_level') ? (int) aksrt_get_user_vip_level($user_id) : 0;
return self::affiliate_ratio_for_level($level);
}
public static function affiliate_ratio_for_level(int $level): int {
$settings = self::get_payment_affiliate_settings();
$key = $level >= 2 ? 'vip_2' : ($level >= 1 ? 'vip_1' : 'user');
return (int) ($settings['ratios'][$key] ?? 0);
}
public static function get_affiliate_settings(): array { return self::get_payment_affiliate_settings(); }
public static function get_settlement_settings(): array {
$s = self::get_payment_affiliate_settings();
return ['observation_days' => (int) $s['settlement_days'], 'retry_limit' => 100];
}
/**
* Gateway channel selection. WeChat / Alipay each pick between the
* official SDK and 易支付; PayPal / Stripe are standalone toggles.
*
* @return array<string,mixed>
*/
public static function get_payment_gateway_settings(): array {
$settings = Settings::get_all();
$wechat = sanitize_key((string) ($settings['pay_wechat_sdk_options'] ?? 'null'));
if (!in_array($wechat, ['official_wechat', 'epay', 'null'], true)) {
$wechat = 'null';
}
$alipay = sanitize_key((string) ($settings['pay_alipay_sdk_options'] ?? 'null'));
if (!in_array($alipay, ['official_alipay', 'epay', 'null'], true)) {
$alipay = 'null';
}
$paypal_enabled = !empty($settings['pay_paypal_sdk_s']);
$stripe_enabled = !empty($settings['pay_stripe_sdk_s']);
$balance_enabled = !empty($settings['pay_balance_s']);
// 卡密收款接口随三个兑换开关任一生效(余额 / 积分 / 会员)。
$card_pass_enabled = !empty($settings['pay_balance_pass_charge_s'])
|| !empty($settings['points_pass_exchange_s'])
|| !empty($settings['pay_vip_pass_charge_s']);
return [
'help_text' => __('微信 / 支付宝可在官方与易支付之间选择;PayPal、Stripe 独立开关。异步回调由各网关验签。', 'aksrt'),
'wechat_sdk_options' => $wechat,
'alipay_sdk_options' => $alipay,
'paypal_enabled' => $paypal_enabled,
'stripe_enabled' => $stripe_enabled,
'balance_enabled' => $balance_enabled,
'card_pass_enabled' => $card_pass_enabled,
'gateways' => [
'wechat' => $wechat === 'official_wechat',
'alipay' => $alipay === 'official_alipay',
'paypal' => $paypal_enabled,
'stripe' => $stripe_enabled,
'balance' => $balance_enabled,
'card_pass' => $card_pass_enabled,
'epay' => $wechat === 'epay' || $alipay === 'epay',
],
'currency' => [
'mark' => sanitize_text_field((string) ($settings['pay_currency_mark'] ?? '¥')),
'unit' => sanitize_text_field((string) ($settings['pay_currency_unit'] ?? '元')),
],
'custom_title_s' => !empty($settings['pay_sdk_custom_title_s']),
'custom_title' => sanitize_text_field((string) ($settings['pay_sdk_custom_title'] ?? '')),
'epay' => [
'apiurl' => esc_url_raw((string) ($settings['epay_apiurl'] ?? '')),
'pid' => sanitize_text_field((string) ($settings['epay_pid'] ?? '')),
'key' => sanitize_text_field((string) ($settings['epay_key'] ?? '')),
'device' => sanitize_key((string) ($settings['epay_device'] ?? 'auto')),
],
];
}
/**
* Order display / checkout policy (legacy pay_* + AKSRT order ops).
*
* @return array<string,mixed>
*/
public static function get_order_settings(): array {
$s = Settings::get_all();
$expiry = max(5, min(1440, (int) ($s['payment_order_expiry'] ?? 30)));
$cookie_day = max(1, min(31, (int) ($s['pay_cookie_day'] ?? 15)));
$box_position = sanitize_key((string) ($s['pay_box_position'] ?? 'top'));
if (!in_array($box_position, ['box_top', 'top', 'bottom', 'box_bottom'], true)) {
$box_position = 'top';
}
$status_keys = ['pending', 'paid', 'refunding', 'refunded', 'closed'];
$status_defaults = ['待支付', '已支付', '退款中', '已退款', '已关闭'];
$status_texts = [];
foreach ($status_keys as $i => $key) {
$val = trim((string) ($s["payment_order_status_{$key}"] ?? ''));
$status_texts[$key] = $val !== '' ? $val : $status_defaults[$i];
}
return [
'expiry' => $expiry,
'show_user' => !isset($s['pay_show_user']) || !empty($s['pay_show_user']),
'show_paycount' => !isset($s['pay_show_paycount']) || !empty($s['pay_show_paycount']),
'no_logged_in' => !isset($s['pay_no_logged_in']) || !empty($s['pay_no_logged_in']),
'cookie_day' => $cookie_day,
'no_logged_remind' => sanitize_textarea_field((string) ($s['pay_no_logged_remind'] ?? '')),
'wait_float' => !isset($s['pay_wait_float_s']) || !empty($s['pay_wait_float_s']),
'mark' => sanitize_text_field((string) ($s['pay_mark'] ?? '付费')),
'box_position' => $box_position,
'show_ip' => !empty($s['payment_show_ip']),
'show_device' => !empty($s['payment_show_device']),
'show_order_no' => !isset($s['payment_show_order_no']) || !empty($s['payment_show_order_no']),
'show_create_time' => !isset($s['payment_show_create_time']) || !empty($s['payment_show_create_time']),
'show_pay_time' => !isset($s['payment_show_pay_time']) || !empty($s['payment_show_pay_time']),
'status_texts' => $status_texts,
'service' => self::format_service_items((array) ($s['pay_service'] ?? [])),
];
}
/**
* 客户服务:参考主题为 {value, icon} 数组;首项同时回填旧单值键,兼容既有消费方。
*
* @param array<int,mixed> $items
* @return array<string,mixed>
*/
private static function format_service_items(array $items): array {
$clean = [];
foreach ($items as $row) {
if (!is_array($row)) {
continue;
}
$value = sanitize_textarea_field((string) ($row['value'] ?? ''));
if ($value === '') {
continue;
}
$clean[] = [
'value' => $value,
'icon' => sanitize_text_field((string) ($row['icon'] ?? '')),
];
}
$first = $clean[0] ?? [];
return [
'items' => $clean,
'text' => (string) ($first['value'] ?? ''),
'icon' => (string) ($first['icon'] ?? ''),
'value' => (string) ($first['value'] ?? ''),
'qq' => '',
'wechat' => '',
];
}
/**
* Return the UTC expiry time for a newly created payable order.
*
* Keeping this in the payment runtime prevents individual checkout
* flows from drifting away from the admin "payment_order_expiry" setting.
*/
public static function get_order_expiry_at(?int $now = null): string {
$settings = self::get_order_settings();
$timestamp = $now ?? time();
$minutes = max(5, min(1440, (int) ($settings['expiry'] ?? 30)));
return gmdate('Y-m-d H:i:s', $timestamp + $minutes * MINUTE_IN_SECONDS);
}
/**
* @return array{min_amount:string,service_charge_rate:int,daily_count:int,daily_limit:string,reset_time:string,payout_channels:string[]}
*/
public static function get_withdrawal_settings(): array {
$s = Settings::get_all();
$channels = array_values(array_intersect(['alipay', 'wechat'], array_map('sanitize_key', (array) ($s['affiliate_payout_channels'] ?? ['alipay', 'wechat']))));
if (!$channels) { $channels = ['alipay', 'wechat']; }
return [
'min_amount' => self::money_decimal($s['affiliate_min_withdrawal'] ?? '50.00', '50.00', 99999900),
'service_charge_rate' => max(0, min(100, (int) ($s['affiliate_withdrawal_fee'] ?? 5))),
'daily_count' => max(0, min(99999, (int) ($s['affiliate_daily_withdrawal_count'] ?? 3))),
'daily_limit' => self::money_decimal($s['affiliate_daily_withdrawal_limit'] ?? '500.00', '500.00', 99999900),
'reset_time' => preg_match('/^(?:[01]\\d|2[0-3]):[0-5]\\d$/', (string) ($s['affiliate_withdrawal_reset_time'] ?? '00:00'))
? (string) $s['affiliate_withdrawal_reset_time']
: '00:00',
'payout_channels' => $channels,
];
}
/**
* Whether a registered adapter is admin-enabled.
* Gateway ids (wechatpay) map to the reference-theme channel selectors:
* pay_wechat_sdk_options / pay_alipay_sdk_options / pay_paypal_sdk_s / pay_stripe_sdk_s.
*/
public static function is_gateway_enabled(string $gateway_id): bool {
$settings = Settings::get_all();
$gateway_id = sanitize_key($gateway_id);
$wechat = sanitize_key((string) ($settings['pay_wechat_sdk_options'] ?? 'null'));
if (!in_array($wechat, ['official_wechat', 'epay', 'null'], true)) {
$wechat = 'null';
}
$alipay = sanitize_key((string) ($settings['pay_alipay_sdk_options'] ?? 'null'));
if (!in_array($alipay, ['official_alipay', 'epay', 'null'], true)) {
$alipay = 'null';
}
switch ($gateway_id) {
case 'alipay':
return $alipay === 'official_alipay';
case 'wechatpay':
return $wechat === 'official_wechat';
case 'paypal':
return !empty($settings['pay_paypal_sdk_s']);
case 'stripe':
return !empty($settings['pay_stripe_sdk_s']);
case 'balance':
return !empty($settings['pay_balance_s']);
case 'card_pass':
// 收款接口随三个兑换开关任一生效(余额 / 积分 / 会员)。
return !empty($settings['pay_balance_pass_charge_s'])
|| !empty($settings['points_pass_exchange_s'])
|| !empty($settings['pay_vip_pass_charge_s']);
case 'epay':
return $wechat === 'epay' || $alipay === 'epay';
case 'epay_alipay':
return $alipay === 'epay';
case 'epay_wxpay':
return $wechat === 'epay';
}
return false;
}
private static function count_structured_lines(string $raw): int {
$lines = preg_split('/[\r\n]+/', $raw, -1, PREG_SPLIT_NO_EMPTY);
$count = 0;
foreach ((array) $lines as $line) {
if (trim((string) $line) !== '') {
$count++;
}
}
return $count;
}
private static function money_decimal(mixed $value, string $default, int $max_minor = PHP_INT_MAX): string {
try {
if (!is_string($value)) {
throw new \InvalidArgumentException('money_decimal_must_be_string');
}
$amount = \AKSRT\Ledger\AssetAmount::balance(trim($value));
return $amount->minor() <= $max_minor ? $amount->decimal() : $default;
} catch (\InvalidArgumentException $e) {
return $default;
}
}
/**
* Format recharge product rows (reference-theme group shape:
* price 充值金额 / pay_price 销售金额 / tag 小标签) into "支付|到账|标题" lines.
*
* @param array<int,mixed> $products
*/
private static function format_product_lines(array $products): string {
$lines = [];
foreach ($products as $row) {
if (!is_array($row)) {
continue;
}
$price = max(0, (float) ($row['price'] ?? 0));
$pay_price = max(0, (float) ($row['pay_price'] ?? 0));
$pay = $pay_price > 0 ? $pay_price : $price;
$title = trim((string) ($row['tag'] ?? ''));
$lines[] = $pay . '|' . $price . '|' . $title;
}
return implode("\n", $lines);
}
/**
* Format points product rows (reference-theme group shape:
* points 积分 / pay_price 销售金额 / tag 小标签) into "积分|支付|标签" lines.
*
* @param array<int,mixed> $products
*/
private static function format_points_product_lines(array $products): string {
$lines = [];
foreach ($products as $row) {
if (!is_array($row)) {
continue;
}
$points = max(0, (int) ($row['points'] ?? 0));
$pay_price = max(0, (float) ($row['pay_price'] ?? 0));
$title = trim((string) ($row['tag'] ?? ''));
$lines[] = $points . '|' . $pay_price . '|' . $title;
}
return implode("\n", $lines);
}
}