Files
wordpress-theme-aksrt/src/Credits/BalanceServiceCharge.php
T

459 lines
18 KiB
PHP

<?php
/**
* @package AKSRT
*/
namespace AKSRT\Credits;
defined('ABSPATH') || exit;
trait BalanceServiceCharge {
/**
* Atomically confirms a recharge order and credits its cash account.
*
* @param array<string,mixed> $order
*/
public function complete_recharge_payment(array $order, string $payment_method, string $provider_transaction_id, string $status_note = ''): bool {
$order_number = sanitize_text_field((string) ($order['order_number'] ?? ''));
$user_id = (int) ($order['user_id'] ?? 0);
if ($order_number === '' || $user_id <= 0 || sanitize_key((string) ($order['product_type'] ?? '')) !== \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE) {
return false;
}
$note = json_decode((string) ($order['note'] ?? ''), true);
$note = is_array($note) ? $note : [];
if (!isset($note['credit_amount']) || !is_string($note['credit_amount'])) {
return false;
}
$amount_raw = $note['credit_amount'];
try {
$credit = \AKSRT\Ledger\AssetAmount::balance($amount_raw);
} catch (\InvalidArgumentException $e) {
return false;
}
if ($credit->minor() <= 0) {
return false;
}
$payment_method = sanitize_key($payment_method);
$provider_transaction_id = sanitize_text_field($provider_transaction_id);
$locked_status = '';
$locked_note = [];
$locked_payment_method = '';
$locked_transaction_id = '';
$locked_paid_at = '';
$guard = function () use ($order_number, $user_id, $credit, $provider_transaction_id, &$locked_status, &$locked_note, &$locked_payment_method, &$locked_transaction_id, &$locked_paid_at) {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('order');
$current = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE order_number = %s FOR UPDATE", $order_number), ARRAY_A);
if (!is_array($current)
|| (int) ($current['user_id'] ?? 0) !== $user_id
|| sanitize_key((string) ($current['product_type'] ?? '')) !== \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE
|| !in_array((string) ($current['status'] ?? ''), [
\AKSRT\Payment\Order::STATUS_PENDING,
\AKSRT\Payment\Order::STATUS_PAID,
], true)
) {
return new \WP_Error('aksrt_order_confirm_failed', __('订单状态确认失败', 'aksrt'), ['status' => 409]);
}
$locked_status = (string) $current['status'];
$decoded = json_decode((string) ($current['note'] ?? ''), true);
$locked_note = is_array($decoded) ? $decoded : [];
$current_credit_raw = $locked_note['credit_amount'] ?? null;
try {
$current_credit = is_string($current_credit_raw)
? \AKSRT\Ledger\AssetAmount::balance($current_credit_raw)
: null;
} catch (\InvalidArgumentException $e) {
$current_credit = null;
}
if (!$current_credit || $current_credit->minor() !== $credit->minor()) {
return new \WP_Error('aksrt_recharge_amount_changed', __('充值到账金额已变化', 'aksrt'), ['status' => 409]);
}
$locked_payment_method = sanitize_key((string) ($current['payment_method'] ?? ''));
$locked_transaction_id = sanitize_text_field((string) ($current['payment_transaction_id'] ?? ''));
$locked_paid_at = trim((string) ($current['paid_at'] ?? ''));
if ($provider_transaction_id !== '' && $locked_transaction_id !== '' && !hash_equals($locked_transaction_id, $provider_transaction_id)) {
return new \WP_Error('aksrt_recharge_transaction_conflict', __('充值交易号不一致', 'aksrt'), ['status' => 409]);
}
return true;
};
$mutation = function () use ($order_number, $payment_method, $provider_transaction_id, $status_note, $credit, &$locked_status, &$locked_note, &$locked_payment_method, &$locked_transaction_id, &$locked_paid_at) {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('order');
$locked_note['ledger_credited'] = 1;
$locked_note['credited_amount'] = $credit->decimal();
if ($status_note !== '') {
$locked_note['status_note'] = sanitize_textarea_field($status_note);
}
$now = current_time('mysql');
$updated = $wpdb->update($table, [
'status' => \AKSRT\Payment\Order::STATUS_PAID,
'payment_method' => $payment_method !== '' ? $payment_method : $locked_payment_method,
'payment_transaction_id' => $provider_transaction_id !== '' ? $provider_transaction_id : $locked_transaction_id,
'paid_at' => $locked_paid_at !== '' ? $locked_paid_at : $now,
'note' => wp_json_encode($locked_note, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
], ['order_number' => $order_number, 'status' => $locked_status], ['%s', '%s', '%s', '%s', '%s'], ['%s', '%s']);
return $updated !== false ? true : new \WP_Error('aksrt_order_confirm_failed', __('订单状态确认失败', 'aksrt'), ['status' => 409]);
};
$result = \AKSRT\Ledger\LedgerService::get_instance()->credit(
$user_id,
\AKSRT\Ledger\AssetType::BALANCE,
$credit->minor(),
\AKSRT\Ledger\OperationKey::deterministic('recharge', [$order_number]),
'recharge',
['type' => 'order', 'order' => $order_number],
sprintf(__('余额充值订单 %s', 'aksrt'), $order_number),
$user_id,
['payment_method' => $payment_method, 'provider_transaction_id' => $provider_transaction_id],
$mutation,
$guard
);
if (is_wp_error($result)) {
return false;
}
if (!$result->replayed) {
$paid_order = array_merge($order, [
'status' => \AKSRT\Payment\Order::STATUS_PAID,
'payment_method' => $payment_method,
'payment_transaction_id' => $provider_transaction_id,
]);
do_action('aksrt_order_status_changed', $order_number, \AKSRT\Payment\Order::STATUS_PAID, $paid_order);
do_action('aksrt_order_paid', $order_number, $paid_order);
}
return true;
}
/**
* @return array<string,mixed>
*/
public function get_charge_view(int $user_id): array {
$cfg = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_payment_recharge_settings() : [];
$packages = $this->parse_packages((string) ($cfg['packages_raw'] ?? ''));
$custom_s = !isset($cfg['product']['custom_s']) || !empty($cfg['product']['custom_s']);
$custom_limit = max(1, (int) ($cfg['product']['custom_limit'] ?? 9999));
$methods = [];
if (function_exists('aksrt_list_cash_payment_methods')) {
// Exclude balance gateway for recharge (cannot pay wallet with wallet).
foreach (aksrt_list_cash_payment_methods(true) as $m) {
$id = is_array($m) ? (string) ($m['id'] ?? '') : (string) $m;
if ($id === '' || $id === 'balance') {
continue;
}
$methods[] = is_array($m) ? $m : ['id' => $id, 'name' => $id];
}
}
// 卡密支付渠道:受「启用卡密兑换余额功能」开关控制(开关内部已含收款接口主开关)。
$pass_charge = is_array($cfg['pass_charge'] ?? null) ? $cfg['pass_charge'] : [];
$card_pass_on = false;
if (class_exists('\AKSRT\Payment\ChargeCard')) {
$card_opts = \AKSRT\Payment\ChargeCard::get_instance()->get_public_options();
$card_pass_on = !empty($card_opts['balance']['enabled']);
$pass_charge = [
'balance' => is_array($card_opts['balance'] ?? null) ? $card_opts['balance'] : $pass_charge,
'credits' => is_array($card_opts['credits'] ?? null) ? $card_opts['credits'] : [],
'vip' => is_array($card_opts['vip'] ?? null) ? $card_opts['vip'] : [],
];
}
if ($card_pass_on) {
$methods[] = ['id' => 'card_pass', 'name' => __('卡密', 'aksrt')];
}
// 卡密渠道追加后需按「收款接口显示顺序」重新定位(含 card_pass 槽位)。
if (function_exists('aksrt_sort_payment_methods')) {
$methods = aksrt_sort_payment_methods($methods);
}
return [
'enabled' => !empty($cfg['enabled']),
'desc' => (string) ($cfg['desc'] ?? ''),
'wallet' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
'packages' => $packages,
'custom_s' => $custom_s,
'custom_limit' => $custom_limit,
'currency_mark' => (string) ($cfg['product']['tag'] ?? '元'),
'payment_methods' => $methods,
'transfer' => [
'enabled' => !empty($cfg['transfer']['enabled']),
'service_charge' => (int) ($cfg['transfer']['service_charge'] ?? 0),
'desc' => (string) ($cfg['transfer']['desc'] ?? ''),
],
'pass_charge' => $pass_charge,
];
}
/**
* @return array<int,array{key:string,price:string,pay_price:string,title:string,tag:string}>
*/
public function parse_packages(string $raw): array {
$out = [];
$lines = preg_split('/\r\n|\r|\n/', $raw) ?: [];
$i = 0;
foreach ($lines as $line) {
$line = trim((string) $line);
if ($line === '') {
continue;
}
$parts = array_map('trim', explode('|', $line));
if (count($parts) < 2) {
continue;
}
try {
$pay = \AKSRT\Ledger\AssetAmount::balance((string) $parts[0]);
$credit = \AKSRT\Ledger\AssetAmount::balance((string) $parts[1]);
} catch (\InvalidArgumentException $e) {
continue;
}
$title = $parts[2] ?? '';
if ($pay->minor() <= 0 || $credit->minor() <= 0) {
continue;
}
$out[] = [
'key' => (string) $i,
'pay_price' => $pay->decimal(),
'price' => $credit->decimal(),
'title' => $title !== '' ? $title : sprintf(__('充值 %s 元', 'aksrt'), $credit->decimal()),
'tag' => $title,
];
$i++;
}
return $out;
}
/**
* 积分商品行解析("积分|支付|标签",参考主题 pay_points_product 同款结构)。
*
* @return array<int,array{key:string,points:int,pay_price:string,title:string,tag:string}>
*/
public function parse_points_packages(string $raw): array {
$out = [];
$lines = preg_split('/\r\n|\r|\n/', $raw) ?: [];
$i = 0;
foreach ($lines as $line) {
$line = trim((string) $line);
if ($line === '') {
continue;
}
$parts = array_map('trim', explode('|', $line));
if (count($parts) < 2) {
continue;
}
$points = (int) $parts[0];
try {
$pay = \AKSRT\Ledger\AssetAmount::balance((string) $parts[1]);
} catch (\InvalidArgumentException $e) {
continue;
}
if ($points <= 0 || $pay->minor() <= 0) {
continue;
}
$title = $parts[2] ?? '';
$out[] = [
'key' => (string) $i,
'points' => $points,
'pay_price' => $pay->decimal(),
'title' => $title !== '' ? $title : sprintf(__('购买 %d 积分', 'aksrt'), $points),
'tag' => $title,
];
$i++;
}
return $out;
}
/**
* @return array<string,mixed>|\WP_Error
*/
public function create_charge_order(int $user_id, string $package_key, string $custom_price, string $payment_method, string $operation_key) {
if ($user_id <= 0) {
return new \WP_Error('aksrt_need_login', __('请先登录', 'aksrt'), ['status' => 401]);
}
if (!class_exists('\AKSRT\Payment\OrderRepository') || !class_exists('\AKSRT\Payment\Order')) {
return new \WP_Error('aksrt_pay_unavailable', __('订单系统不可用', 'aksrt'), ['status' => 500]);
}
$view = $this->get_charge_view($user_id);
$credit_amount = null;
$pay_amount = null;
$title = __('余额充值', 'aksrt');
if ($package_key === 'custom' || $package_key === '') {
if (empty($view['custom_s'])) {
return new \WP_Error('aksrt_custom_closed', __('未开放自定义金额', 'aksrt'), ['status' => 400]);
}
try {
$custom_amount = \AKSRT\Ledger\AssetAmount::balance($custom_price);
} catch (\InvalidArgumentException $e) {
return new \WP_Error('aksrt_invalid_price', __('金额格式无效', 'aksrt'), ['status' => 400]);
}
$max = max(1, (int) $view['custom_limit']);
if ($custom_amount->minor() < 100 || $custom_amount->minor() > $max * 100) {
return new \WP_Error('aksrt_custom_limit', sprintf(__('充值金额需在 1 ~ %d 之间', 'aksrt'), $max), ['status' => 400]);
}
$pay_amount = $custom_amount;
$credit_amount = $custom_amount;
$title = sprintf(__('自定义充值 %s 元', 'aksrt'), $custom_amount->decimal());
$package_key = 'custom';
} else {
$found = null;
foreach ((array) $view['packages'] as $pkg) {
if ((string) ($pkg['key'] ?? '') === (string) $package_key) {
$found = $pkg;
break;
}
}
if (!$found) {
return new \WP_Error('aksrt_invalid_package', __('充值套餐无效', 'aksrt'), ['status' => 400]);
}
try {
$pay_amount = \AKSRT\Ledger\AssetAmount::balance((string) $found['pay_price']);
$credit_amount = \AKSRT\Ledger\AssetAmount::balance((string) $found['price']);
} catch (\InvalidArgumentException $e) {
return new \WP_Error('aksrt_invalid_package', __('充值套餐金额无效', 'aksrt'), ['status' => 400]);
}
$title = (string) $found['title'];
}
if (!($pay_amount instanceof \AKSRT\Ledger\AssetAmount)
|| !($credit_amount instanceof \AKSRT\Ledger\AssetAmount)
|| $pay_amount->minor() <= 0
|| $credit_amount->minor() <= 0
) {
return new \WP_Error('aksrt_invalid_price', __('金额无效', 'aksrt'), ['status' => 400]);
}
$pay_decimal = $pay_amount->decimal();
$credit_decimal = $credit_amount->decimal();
$payment_method = sanitize_key($payment_method);
if ($payment_method === '' || $payment_method === 'balance') {
// Prefer first non-balance method.
$methods = $view['payment_methods'] ?? [];
$payment_method = !empty($methods[0]['id']) ? (string) $methods[0]['id'] : 'alipay';
}
$note = wp_json_encode([
'balance_recharge' => 1,
'package' => $package_key,
'credit_amount' => $credit_decimal,
'pay_amount' => $pay_decimal,
'title' => $title,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$repo = \AKSRT\Payment\OrderRepository::get_instance();
$existing = $repo->get_by_request_key($operation_key);
if (is_array($existing)) {
$existing_note = json_decode((string) ($existing['note'] ?? ''), true);
$existing_note = is_array($existing_note) ? $existing_note : [];
$existing_credit = isset($existing_note['credit_amount']) && is_string($existing_note['credit_amount'])
? $existing_note['credit_amount']
: '';
$existing_pay = isset($existing_note['pay_amount']) && is_string($existing_note['pay_amount'])
? $existing_note['pay_amount']
: '';
$same = (int) ($existing['user_id'] ?? 0) === $user_id
&& sanitize_key((string) ($existing['product_type'] ?? '')) === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE
&& sanitize_key((string) ($existing['payment_method'] ?? '')) === $payment_method
&& (string) ($existing_note['package'] ?? '') === $package_key
&& hash_equals($existing_credit, $credit_decimal)
&& hash_equals($existing_pay, $pay_decimal);
if (!$same) {
return new \WP_Error('aksrt_ledger_idempotency_conflict', __('相同操作标识已用于其他充值请求', 'aksrt'), ['status' => 409]);
}
$order_number = (string) $existing['order_number'];
} else {
$order_number = $repo->create([
'user_id' => $user_id,
'product_type' => \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE,
'product_id' => 0,
'amount' => $pay_decimal,
'currency' => 'CNY',
'status' => \AKSRT\Payment\Order::STATUS_PENDING,
'payment_method' => $payment_method,
'note' => is_string($note) ? $note : '',
'expires_at' => \AKSRT\Settings\PaymentRuntime::get_order_expiry_at(),
'request_key' => $operation_key,
]);
}
if ($order_number === '') {
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]);
}
$order = \AKSRT\Payment\OrderRepository::get_instance()->get($order_number);
if (!$order) {
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]);
}
if (is_array($existing) && (string) ($order['status'] ?? '') === \AKSRT\Payment\Order::STATUS_PAID) {
return [
'redirect' => false,
'order_number' => $order_number,
'amount' => $pay_decimal,
'credit_amount' => $credit_decimal,
'replayed' => true,
'wallet' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
'message' => __('充值成功', 'aksrt'),
'reload' => true,
];
}
$order['subject'] = $title;
$order['payment_method'] = $payment_method;
if (!class_exists('\AKSRT\Payment\Gateway')) {
return new \WP_Error('aksrt_pay_unavailable', __('支付网关不可用', 'aksrt'), ['status' => 500]);
}
$resolved = \AKSRT\Payment\PaymentOrchestrator::resolve_gateway($payment_method);
if (empty($resolved['gateway'])) {
return new \WP_Error(
(string) ($resolved['error_code'] ?? 'aksrt_gateway_unavailable'),
(string) ($resolved['message'] ?? __('当前支付方式不可用', 'aksrt')),
['status' => 400, 'order_number' => $order_number]
);
}
$payment_result = \AKSRT\Payment\PaymentOrchestrator::pay($resolved['gateway'], $order, [
'paid_note' => __('余额充值支付完成', 'aksrt'),
]);
if (empty($payment_result['success'])) {
return new \WP_Error(
(string) ($payment_result['code'] ?? 'aksrt_pay_failed'),
(string) ($payment_result['message'] ?? __('支付失败', 'aksrt')),
['status' => (int) ($payment_result['status'] ?? 400), 'order_number' => $order_number]
);
}
if (!empty($payment_result['redirect'])) {
return [
'redirect' => true,
'payment_url' => (string) ($payment_result['payment_url'] ?? ''),
'qr_content' => (string) ($payment_result['qr_content'] ?? ''),
'order_number' => $order_number,
'amount' => $pay_decimal,
'credit_amount' => $credit_decimal,
'message' => $payment_result['message'],
'reload' => false,
'poll' => true,
'transaction_id' => 0,
'replayed' => false,
'wallet' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
];
}
// The synchronous gateway has already committed the recharge and order state.
return [
'redirect' => false,
'order_number' => $order_number,
'amount' => $pay_decimal,
'credit_amount' => $credit_decimal,
'replayed' => false,
'wallet' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
'message' => __('充值成功', 'aksrt'),
'reload' => true,
];
}
}