fix(pay): refund wallet on withdraw reject and show pending history

Close the freeze/refund loop when admin rejects a balance withdrawal,
block concurrent open applications, and surface processing state plus
server-rendered withdraw history on the wallet card.
This commit is contained in:
kate522 committed 2026-07-16 16:28:56 +08:00
1 parent d57fc6f934
commit 4da003663d
6 files changed
+514 -136

No files matched your search

+38
View File
@@ -928,3 +928,41 @@ body.uc-modal-open {
} }
.uc-card-pass__tip.is-error { color: #b91c1c; } .uc-card-pass__tip.is-error { color: #b91c1c; }
/* Withdraw history + pending foot */
.uc-asset-card__foot {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed var(--muted-border-color, rgba(0,0,0,.08));
}
.uc-asset-card__foot button {
background: none;
border: 0;
padding: 0;
cursor: pointer;
}
.uc-asset-card__head-action {
background: none;
border: 0;
cursor: pointer;
}
.uc-withdraw-history__item:last-child {
border-bottom: 0 !important;
}
/* Points tasks tab */
.uc-points-tasks .border-bottom,
.uc-points-date .border-bottom,
.uc-withdraw-history__item.border-bottom {
border-bottom: 1px solid var(--muted-border-color, rgba(0,0,0,.08));
}
.uc-points-tasks button,
.uc-points-date button {
background: none;
border: 0;
cursor: pointer;
padding: 0;
}
.focus-color { color: var(--focus-color, #f59e0b); }
+24 -2
View File
@@ -908,10 +908,17 @@
link.addEventListener('click', function (e) { link.addEventListener('click', function (e) {
e.preventDefault(); e.preventDefault();
var key = link.getAttribute('data-aksrt-asset-tab'); var key = link.getAttribute('data-aksrt-asset-tab');
document.querySelectorAll('[data-aksrt-asset-tab]').forEach(function (a) { document.querySelectorAll('.uc-asset-tabs__nav [data-aksrt-asset-tab]').forEach(function (a) {
var li = a.closest('li'); var li = a.closest('li');
if (li) li.classList.toggle('active', a === link); if (!li) return;
var aKey = a.getAttribute('data-aksrt-asset-tab');
var on = aKey === key || (key === 'points_date' && aKey === 'points_free');
li.classList.toggle('active', on);
}); });
var tabs = document.querySelector('.uc-asset-tabs');
if (tabs && typeof tabs.scrollIntoView === 'function') {
tabs.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
document.querySelectorAll('[data-aksrt-asset-pane]').forEach(function (pane) { document.querySelectorAll('[data-aksrt-asset-pane]').forEach(function (pane) {
var on = pane.getAttribute('data-aksrt-asset-pane') === key; var on = pane.getAttribute('data-aksrt-asset-pane') === key;
pane.classList.toggle('is-active', on); pane.classList.toggle('is-active', on);
@@ -921,6 +928,20 @@
}); });
} }
function initWithdrawHistory() {
var modal = document.querySelector('[data-aksrt-withdraw-history-modal]');
if (!modal) return;
function open() { modal.hidden = false; document.body.classList.add('uc-modal-open'); }
function close() { modal.hidden = true; document.body.classList.remove('uc-modal-open'); }
document.querySelectorAll('[data-aksrt-withdraw-history-open]').forEach(function (btn) {
btn.addEventListener('click', function (e) { e.preventDefault(); open(); });
});
modal.querySelectorAll('[data-aksrt-withdraw-history-close]').forEach(function (btn) {
btn.addEventListener('click', function (e) { e.preventDefault(); close(); });
});
}
function bootAssets() { function bootAssets() {
initSignin(); initSignin();
initInvite(); initInvite();
@@ -932,6 +953,7 @@
initExchange(); initExchange();
initPointsBuy(); initPointsBuy();
initWithdraw(); initWithdraw();
initWithdrawHistory();
initAssetTabs(); initAssetTabs();
} }
@@ -107,13 +107,27 @@ class AKSRT_Admin_Pay_Withdrawals {
$user_id = (int) ($row['user_id'] ?? 0); $user_id = (int) ($row['user_id'] ?? 0);
$user = $user_id > 0 ? get_userdata($user_id) : null; $user = $user_id > 0 ? get_userdata($user_id) : null;
$st = (string) ($row['status'] ?? ''); $st = (string) ($row['status'] ?? '');
$gross = (float) ($row['gross_amount'] ?? $row['amount'] ?? 0);
$net = (float) ($row['amount'] ?? 0);
$fee = (int) ($row['fee_rate'] ?? 0);
$acct = (string) ($row['display_account'] ?? $row['account_info'] ?? '');
$amount_txt = number_format($net, 2);
if ($gross > 0 && abs($gross - $net) > 0.001) {
$amount_txt = sprintf(
/* translators: 1: gross 2: net 3: fee percent */
__('申请 %1$s / 到账 %2$s(手续费 %3$d%%)', 'aksrt'),
number_format($gross, 2),
number_format($net, 2),
$fee
);
}
echo '<tr>'; echo '<tr>';
echo '<td>' . esc_html((string) $id) . '</td>'; echo '<td>' . esc_html((string) $id) . '</td>';
echo '<td>' . ($user ? esc_html($user->display_name . ' (#' . $user_id . ')') : esc_html((string) $user_id)) . '</td>'; echo '<td>' . ($user ? esc_html($user->display_name . ' (#' . $user_id . ')') : esc_html((string) $user_id)) . '</td>';
echo '<td>' . esc_html(number_format((float) ($row['amount'] ?? 0), 2)) . '</td>'; echo '<td>' . esc_html($amount_txt) . '</td>';
echo '<td>' . esc_html((string) ($row['method'] ?? '')) . '</td>'; echo '<td>' . esc_html((string) ($row['method'] ?? '')) . '</td>';
echo '<td>' . esc_html((string) ($row['account_info'] ?? '')) . '</td>'; echo '<td>' . esc_html($acct) . '</td>';
echo '<td>' . esc_html($status_options[$st] ?? $st) . '</td>'; echo '<td>' . esc_html($status_options[$st] ?? $st) . '</td>';
echo '<td>' . esc_html((string) ($row['created_at'] ?? '')) . '</td>'; echo '<td>' . esc_html((string) ($row['created_at'] ?? '')) . '</td>';
echo '<td>'; echo '<td>';
@@ -169,13 +183,18 @@ class AKSRT_Admin_Pay_Withdrawals {
$note = isset($_POST['note']) ? sanitize_textarea_field((string) wp_unslash($_POST['note'])) : ''; $note = isset($_POST['note']) ? sanitize_textarea_field((string) wp_unslash($_POST['note'])) : '';
check_admin_referer('aksrt_pay_update_withdrawal_' . $id); check_admin_referer('aksrt_pay_update_withdrawal_' . $id);
$ok = false; $msg = 'withdraw_failed';
if ($id > 0 && $new_status !== '' && class_exists('AKSRT_Withdrawal')) { if ($id > 0 && $new_status !== '' && class_exists('AKSRT_Withdrawal')) {
$ok = AKSRT_Withdrawal::get_instance()->update_status($id, $new_status, $note); $result = AKSRT_Withdrawal::get_instance()->process_status_change($id, $new_status, $note);
if (!is_wp_error($result) && $result) {
$msg = 'withdraw_updated';
} elseif (is_wp_error($result) && $result->get_error_code() === 'aksrt_balance') {
$msg = 'withdraw_refund_failed';
}
} }
wp_safe_redirect($this->page_url('aksrt_withdraw', [ wp_safe_redirect($this->page_url('aksrt_withdraw', [
'aksrt_pay_msg' => $ok ? 'withdraw_updated' : 'withdraw_failed', 'aksrt_pay_msg' => $msg,
])); ]));
exit; exit;
} }
@@ -208,8 +227,9 @@ class AKSRT_Admin_Pay_Withdrawals {
} }
$code = sanitize_key((string) wp_unslash($_GET['aksrt_pay_msg'])); $code = sanitize_key((string) wp_unslash($_GET['aksrt_pay_msg']));
$map = [ $map = [
'withdraw_updated' => __('提现状态已更新。', 'aksrt'), 'withdraw_updated' => __('提现状态已更新。', 'aksrt'),
'withdraw_failed' => __('提现状态更新失败。', 'aksrt'), 'withdraw_failed' => __('提现状态更新失败。', 'aksrt'),
'withdraw_refund_failed' => __('状态未更新:余额退回失败,请检查钱包后重试。', 'aksrt'),
]; ];
if (!isset($map[$code])) { if (!isset($map[$code])) {
return; return;
@@ -734,6 +734,10 @@ class AKSRT_Balance_Service {
if ($account === '') { if ($account === '') {
return new WP_Error('aksrt_withdraw_account', __('请填写收款账号信息', 'aksrt'), ['status' => 400]); return new WP_Error('aksrt_withdraw_account', __('请填写收款账号信息', 'aksrt'), ['status' => 400]);
} }
// One open balance withdrawal at a time (Zibll parity).
if (AKSRT_Withdrawal::get_instance()->has_open_withdrawal($user_id)) {
return new WP_Error('aksrt_withdraw_pending', __('您有处理中的提现申请,请等待完成后再申请', 'aksrt'), ['status' => 409]);
}
$wallet = AKSRT_Balance::balance($user_id); $wallet = AKSRT_Balance::balance($user_id);
if ($wallet < $amount) { if ($wallet < $amount) {
return new WP_Error('aksrt_balance_insufficient', __('余额不足', 'aksrt'), ['status' => 402]); return new WP_Error('aksrt_balance_insufficient', __('余额不足', 'aksrt'), ['status' => 402]);
+349 -126
View File
@@ -1,8 +1,6 @@
<?php <?php
/** /**
* AKSRT Withdrawal — 提现台账 * AKSRT Withdrawal — 提现台账 + 状态副作用(冻结退回)
*
* 管理用户提现申请、审核和处理。
* *
* @package AKSRT * @package AKSRT
*/ */
@@ -11,156 +9,381 @@ defined('ABSPATH') || exit;
class AKSRT_Withdrawal { class AKSRT_Withdrawal {
const STATUS_PENDING = 'pending'; public const STATUS_PENDING = 'pending';
const STATUS_APPROVED = 'approved'; public const STATUS_APPROVED = 'approved';
const STATUS_PROCESSING = 'processing'; public const STATUS_PROCESSING = 'processing';
const STATUS_COMPLETED = 'completed'; public const STATUS_COMPLETED = 'completed';
const STATUS_REJECTED = 'rejected'; public const STATUS_REJECTED = 'rejected';
private static $instance = null; private static $instance = null;
public static function get_instance(): self { public static function get_instance(): self {
if (null === self::$instance) self::$instance = new self(); if (null === self::$instance) {
return self::$instance; self::$instance = new self();
} }
return self::$instance;
}
private function table(): string { private function table(): string {
return AKSRT_DB_Migrator::table('withdrawal'); return AKSRT_DB_Migrator::table('withdrawal');
} }
/** /**
* 创建提现申请 * 创建提现申请
*/ */
public function create(int $user_id, float $amount, string $method = '', string $account_info = ''): int { public function create(int $user_id, float $amount, string $method = '', string $account_info = ''): int {
global $wpdb; global $wpdb;
if ($user_id <= 0 || $amount <= 0) return 0; if ($user_id <= 0 || $amount <= 0) {
return 0;
}
$wpdb->insert($this->table(), [ $wpdb->insert($this->table(), [
'user_id' => $user_id, 'user_id' => $user_id,
'amount' => $amount, 'amount' => $amount,
'method' => sanitize_key($method), 'method' => sanitize_key($method),
'account_info' => sanitize_textarea_field($account_info), 'account_info' => sanitize_textarea_field($account_info),
'status' => self::STATUS_PENDING, 'status' => self::STATUS_PENDING,
'created_at' => current_time('mysql'), 'created_at' => current_time('mysql'),
], ['%d', '%f', '%s', '%s', '%s', '%s']); ], ['%d', '%f', '%s', '%s', '%s', '%s']);
return (int) $wpdb->insert_id; return (int) $wpdb->insert_id;
} }
/** /**
* 更新提现状态 * Get single row.
*/ *
public function update_status(int $withdrawal_id, string $new_status, string $note = ''): bool { * @return array<string,mixed>|null
global $wpdb; */
public function get(int $withdrawal_id): ?array {
global $wpdb;
if ($withdrawal_id <= 0) {
return null;
}
$row = $wpdb->get_row(
$wpdb->prepare("SELECT * FROM {$this->table()} WHERE id = %d LIMIT 1", $withdrawal_id),
ARRAY_A
);
return is_array($row) ? $row : null;
}
$update = ['status' => sanitize_key($new_status)]; /**
$format = ['%s']; * 更新提现状态(无余额副作用)。优先使用 process_status_change()。
*/
public function update_status(int $withdrawal_id, string $new_status, string $note = ''): bool {
global $wpdb;
if ($new_status === self::STATUS_COMPLETED || $new_status === self::STATUS_REJECTED) { $update = ['status' => sanitize_key($new_status)];
$update['processed_at'] = current_time('mysql'); $format = ['%s'];
$format[] = '%s';
}
if ($note !== '') { if ($new_status === self::STATUS_COMPLETED || $new_status === self::STATUS_REJECTED) {
$update['note'] = sanitize_textarea_field($note); $update['processed_at'] = current_time('mysql');
$format[] = '%s'; $format[] = '%s';
} }
return $wpdb->update($this->table(), $update, ['id' => $withdrawal_id], $format, ['%d']) !== false; if ($note !== '') {
} $update['note'] = sanitize_textarea_field($note);
$format[] = '%s';
}
/** return $wpdb->update($this->table(), $update, ['id' => $withdrawal_id], $format, ['%d']) !== false;
* 获取用户提现列表 }
*/
public function get_user_withdrawals(int $user_id, array $args = []): array {
global $wpdb;
$where = ['user_id = %d']; /**
$params = [$user_id]; * Status transition with balance side-effects.
* Reject of balance-source holds refunds gross_amount once (idempotent).
*
* @return true|WP_Error
*/
public function process_status_change(int $withdrawal_id, string $new_status, string $note = '') {
$new_status = sanitize_key($new_status);
$allowed = [
self::STATUS_PENDING,
self::STATUS_APPROVED,
self::STATUS_PROCESSING,
self::STATUS_COMPLETED,
self::STATUS_REJECTED,
];
if ($withdrawal_id <= 0 || !in_array($new_status, $allowed, true)) {
return new WP_Error('aksrt_withdraw_status', __('提现状态无效', 'aksrt'));
}
$status = sanitize_key((string) ($args['status'] ?? '')); $row = $this->get($withdrawal_id);
if ($status !== '') { if (!$row) {
$where[] = 'status = %s'; return new WP_Error('aksrt_withdraw_missing', __('提现记录不存在', 'aksrt'));
$params[] = $status; }
}
$limit = max(1, min(100, (int) ($args['limit'] ?? 20))); $old_status = sanitize_key((string) ($row['status'] ?? ''));
$offset = max(0, (int) ($args['offset'] ?? 0)); if ($old_status === $new_status && $note === '') {
return true;
}
$sql = "SELECT * FROM {$this->table()} WHERE " . implode(' AND ', $where) . " ORDER BY created_at DESC LIMIT %d OFFSET %d"; $meta = $this->parse_account_meta((string) ($row['account_info'] ?? ''));
$params[] = $limit; $user_id = (int) ($row['user_id'] ?? 0);
$params[] = $offset;
return (array) $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A); // Refund frozen balance when moving into rejected (once).
} if (
$new_status === self::STATUS_REJECTED
&& $old_status !== self::STATUS_REJECTED
&& empty($meta['balance_refunded'])
&& $this->is_balance_source($meta)
&& class_exists('AKSRT_Balance')
&& $user_id > 0
) {
$gross = $this->resolve_gross_amount($row, $meta);
if ($gross > 0) {
$refund = AKSRT_Balance::add(
$user_id,
$gross,
'withdraw_refund',
['type' => 'withdraw', 'id' => $withdrawal_id],
sprintf(__('提现拒绝退回 #%d', 'aksrt'), $withdrawal_id)
);
if (is_wp_error($refund)) {
return $refund;
}
$meta['balance_refunded'] = true;
$meta['refunded_at'] = current_time('mysql');
}
}
/** $account_info = $this->encode_account_meta($meta, (string) ($row['account_info'] ?? ''));
* 获取所有提现列表(管理后台) $merged_note = $this->merge_note((string) ($row['note'] ?? ''), $note);
*/
public function get_all(array $args = []): array {
global $wpdb;
$where = []; global $wpdb;
$params = []; $update = [
'status' => $new_status,
'account_info' => $account_info,
];
$format = ['%s', '%s'];
$status = sanitize_key((string) ($args['status'] ?? '')); if ($new_status === self::STATUS_COMPLETED || $new_status === self::STATUS_REJECTED) {
if ($status !== '') { $update['processed_at'] = current_time('mysql');
$where[] = 'status = %s'; $format[] = '%s';
$params[] = $status; }
} if ($merged_note !== '') {
$update['note'] = $merged_note;
$format[] = '%s';
}
$limit = max(1, min(100, (int) ($args['limit'] ?? 20))); $ok = $wpdb->update($this->table(), $update, ['id' => $withdrawal_id], $format, ['%d']) !== false;
$offset = max(0, (int) ($args['offset'] ?? 0)); return $ok ? true : new WP_Error('aksrt_withdraw_update', __('提现状态更新失败', 'aksrt'));
}
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : ''; /**
$sql = "SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d"; * Whether user has open (pending/processing/approved) withdrawals.
$params[] = $limit; */
$params[] = $offset; public function has_open_withdrawal(int $user_id): bool {
return $this->count_open($user_id) > 0;
}
if (!empty($params)) { /**
return (array) $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A); * Count open withdrawals.
} */
return (array) $wpdb->get_results($sql, ARRAY_A); public function count_open(int $user_id): int {
} global $wpdb;
if ($user_id <= 0) {
return 0;
}
return (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM {$this->table()}
WHERE user_id = %d AND status IN ('pending','approved','processing')",
$user_id
));
}
/** /**
* 获取提现总数(支持状态过滤) * Sum of frozen gross amounts still open (balance source).
*/ */
public function get_count(array $args = []): int { public function sum_pending_hold(int $user_id): float {
global $wpdb; global $wpdb;
if ($user_id <= 0) {
return 0.0;
}
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT amount, account_info FROM {$this->table()}
WHERE user_id = %d AND status IN ('pending','approved','processing')",
$user_id
), ARRAY_A);
$total = 0.0;
foreach ((array) $rows as $row) {
$meta = $this->parse_account_meta((string) ($row['account_info'] ?? ''));
if (!$this->is_balance_source($meta) || !empty($meta['balance_refunded'])) {
continue;
}
$total += $this->resolve_gross_amount($row, $meta);
}
return round($total, 2);
}
$status = sanitize_key((string) ($args['status'] ?? '')); /**
if ($status !== '') { * 获取用户提现列表
return (int) $wpdb->get_var($wpdb->prepare( *
"SELECT COUNT(*) FROM {$this->table()} WHERE status = %s", * @param array{status?:string,limit?:int,offset?:int,open_only?:bool} $args
$status * @return list<array<string,mixed>>
)); */
} public function get_user_withdrawals(int $user_id, array $args = []): array {
return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$this->table()}"); global $wpdb;
}
/** $where = ['user_id = %d'];
* 获取提现统计 $params = [$user_id];
*/
public function get_stats(): array {
global $wpdb;
$row = $wpdb->get_row( if (!empty($args['open_only'])) {
"SELECT $where[] = "status IN ('pending','approved','processing')";
COUNT(*) as total, } else {
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending, $status = sanitize_key((string) ($args['status'] ?? ''));
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed, if ($status !== '') {
COALESCE(SUM(CASE WHEN status = 'completed' THEN amount ELSE 0 END), 0) as total_paid $where[] = 'status = %s';
FROM {$this->table()}", $params[] = $status;
ARRAY_A }
); }
return [ $limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
'total' => (int) ($row['total'] ?? 0), $offset = max(0, (int) ($args['offset'] ?? 0));
'pending' => (int) ($row['pending'] ?? 0),
'completed' => (int) ($row['completed'] ?? 0), $sql = "SELECT * FROM {$this->table()} WHERE " . implode(' AND ', $where) . ' ORDER BY created_at DESC LIMIT %d OFFSET %d';
'total_paid' => (float) ($row['total_paid'] ?? 0), $params[] = $limit;
]; $params[] = $offset;
}
$rows = (array) $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
return array_map([$this, 'normalize_row'], $rows);
}
/**
* 获取所有提现列表(管理后台)
*
* @return list<array<string,mixed>>
*/
public function get_all(array $args = []): array {
global $wpdb;
$where = [];
$params = [];
$status = sanitize_key((string) ($args['status'] ?? ''));
if ($status !== '') {
$where[] = 'status = %s';
$params[] = $status;
}
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
$offset = max(0, (int) ($args['offset'] ?? 0));
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$sql = "SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d";
$params[] = $limit;
$params[] = $offset;
if (!empty($params)) {
$rows = (array) $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
} else {
$rows = (array) $wpdb->get_results($sql, ARRAY_A);
}
return array_map([$this, 'normalize_row'], $rows);
}
public function get_count(array $args = []): int {
global $wpdb;
$status = sanitize_key((string) ($args['status'] ?? ''));
if ($status !== '') {
return (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM {$this->table()} WHERE status = %s",
$status
));
}
return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$this->table()}");
}
public function get_stats(): array {
global $wpdb;
$row = $wpdb->get_row(
"SELECT
COUNT(*) as total,
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
COALESCE(SUM(CASE WHEN status = 'completed' THEN amount ELSE 0 END), 0) as total_paid
FROM {$this->table()}",
ARRAY_A
);
return [
'total' => (int) ($row['total'] ?? 0),
'pending' => (int) ($row['pending'] ?? 0),
'completed' => (int) ($row['completed'] ?? 0),
'total_paid' => (float) ($row['total_paid'] ?? 0),
];
}
/**
* @param array<string,mixed> $row
* @return array<string,mixed>
*/
private function normalize_row(array $row): array {
$meta = $this->parse_account_meta((string) ($row['account_info'] ?? ''));
$gross = $this->resolve_gross_amount($row, $meta);
$row['meta'] = $meta;
$row['gross_amount'] = $gross;
$row['fee_rate'] = (int) ($meta['fee_rate'] ?? 0);
$row['display_account'] = (string) ($meta['account'] ?? $row['account_info'] ?? '');
$row['source'] = (string) ($meta['source'] ?? 'balance');
return $row;
}
/**
* @return array<string,mixed>
*/
private function parse_account_meta(string $raw): array {
$raw = trim($raw);
if ($raw === '') {
return [];
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : ['account' => $raw];
}
/**
* @param array<string,mixed> $meta
*/
private function encode_account_meta(array $meta, string $fallback_raw): string {
if ($meta === []) {
return $fallback_raw;
}
$json = wp_json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return is_string($json) ? $json : $fallback_raw;
}
/**
* @param array<string,mixed> $meta
*/
private function is_balance_source(array $meta): bool {
$source = sanitize_key((string) ($meta['source'] ?? 'balance'));
// Empty / balance = cash wallet freeze path.
return $source === '' || $source === 'balance';
}
/**
* @param array<string,mixed> $row
* @param array<string,mixed> $meta
*/
private function resolve_gross_amount(array $row, array $meta): float {
if (isset($meta['gross_amount'])) {
return max(0, round((float) $meta['gross_amount'], 2));
}
// Legacy rows: amount was net or gross without meta.
return max(0, round((float) ($row['amount'] ?? 0), 2));
}
private function merge_note(string $old, string $new): string {
$new = sanitize_textarea_field($new);
if ($new === '') {
return sanitize_textarea_field($old);
}
$old = sanitize_textarea_field($old);
if ($old === '') {
return $new;
}
return $old . "\n" . $new;
}
} }
@@ -26,6 +26,14 @@ $exchange_on = !empty($recharge['exchange']['enabled']);
$all_settings = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_all() : []; $all_settings = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_all() : [];
$withdraw_on = !empty($all_settings['pay_balance_withdraw_s']); $withdraw_on = !empty($all_settings['pay_balance_withdraw_s']);
$withdraw_min = max(1, (float) ($all_settings['pay_balance_withdraw_lowest_money'] ?? $all_settings['pay_rebate_withdraw_lowest_money'] ?? 50)); $withdraw_min = max(1, (float) ($all_settings['pay_balance_withdraw_lowest_money'] ?? $all_settings['pay_rebate_withdraw_lowest_money'] ?? 50));
$withdraw_pending_hold = 0.0;
$withdraw_history = [];
if ($withdraw_on && class_exists('AKSRT_Withdrawal')) {
$wd_svc = AKSRT_Withdrawal::get_instance();
$withdraw_pending_hold = $wd_svc->sum_pending_hold((int) ($user_id ?? 0));
$withdraw_history = $wd_svc->get_user_withdrawals((int) ($user_id ?? 0), ['limit' => 30]);
}
$withdraw_processing = $withdraw_pending_hold > 0;
$card_pass_balance = ['enabled' => false, 'desc' => '', 'only_password' => true]; $card_pass_balance = ['enabled' => false, 'desc' => '', 'only_password' => true];
if (class_exists('AKSRT_Charge_Card')) { if (class_exists('AKSRT_Charge_Card')) {
$opts = AKSRT_Charge_Card::get_instance()->get_public_options(); $opts = AKSRT_Charge_Card::get_instance()->get_public_options();
@@ -49,6 +57,12 @@ $mark = (string) ($charge_view['currency_mark'] ?? '元');
<div class="uc-asset-card__title"> <div class="uc-asset-card__title">
<i class="fas fa-wallet em12 mr6" aria-hidden="true"></i><?php esc_html_e('余额', 'aksrt'); ?> <i class="fas fa-wallet em12 mr6" aria-hidden="true"></i><?php esc_html_e('余额', 'aksrt'); ?>
</div> </div>
<?php if ($withdraw_on) : ?>
<button type="button" class="uc-asset-card__head-action muted-color px12" data-aksrt-withdraw-history-open>
<?php echo $withdraw_processing ? esc_html__('提现处理中', 'aksrt') : esc_html__('提现记录', 'aksrt'); ?>
<i class="fa fa-angle-right em12 ml3" aria-hidden="true"></i>
</button>
<?php endif; ?>
</div> </div>
<div class="uc-asset-card__body flex jsb ac"> <div class="uc-asset-card__body flex jsb ac">
<div class="uc-asset-card__amount c-blue-2"> <div class="uc-asset-card__amount c-blue-2">
@@ -60,7 +74,11 @@ $mark = (string) ($charge_view['currency_mark'] ?? '元');
<button type="button" class="but c-blue-2 mr6 hollow" data-aksrt-transfer-open="balance"><?php esc_html_e('转账', 'aksrt'); ?></button> <button type="button" class="but c-blue-2 mr6 hollow" data-aksrt-transfer-open="balance"><?php esc_html_e('转账', 'aksrt'); ?></button>
<?php endif; ?> <?php endif; ?>
<?php if ($withdraw_on) : ?> <?php if ($withdraw_on) : ?>
<button type="button" class="but c-blue-2 mr6 hollow" data-aksrt-withdraw-open><?php esc_html_e('提现', 'aksrt'); ?></button> <?php if ($withdraw_processing) : ?>
<button type="button" class="but c-blue-2 mr6 hollow" data-aksrt-withdraw-history-open><?php esc_html_e('提现处理中', 'aksrt'); ?></button>
<?php else : ?>
<button type="button" class="but c-blue-2 mr6 hollow" data-aksrt-withdraw-open><?php esc_html_e('提现', 'aksrt'); ?></button>
<?php endif; ?>
<?php endif; ?> <?php endif; ?>
<button type="button" class="but b-blue-2" data-aksrt-charge-open><?php esc_html_e('充值', 'aksrt'); ?></button> <button type="button" class="but b-blue-2" data-aksrt-charge-open><?php esc_html_e('充值', 'aksrt'); ?></button>
</div> </div>
@@ -74,6 +92,12 @@ $mark = (string) ($charge_view['currency_mark'] ?? '元');
<i class="fa fa-angle-right em12 ml3" aria-hidden="true"></i> <i class="fa fa-angle-right em12 ml3" aria-hidden="true"></i>
</button> </button>
<?php endif; ?> <?php endif; ?>
<?php if ($withdraw_on) : ?>
<div class="uc-asset-card__foot muted-2-color em09 flex jsb ac mt6">
<span><?php printf(esc_html__('处理中冻结 %s', 'aksrt'), esc_html(class_exists('AKSRT_Balance') ? AKSRT_Balance::format((float) $withdraw_pending_hold) : number_format_i18n((float) $withdraw_pending_hold, 2))); ?></span>
<button type="button" class="muted-color" data-aksrt-withdraw-history-open><?php esc_html_e('提现记录', 'aksrt'); ?></button>
</div>
<?php endif; ?>
</div> </div>
<?php if (!empty($card_pass_balance['enabled'])) : ?> <?php if (!empty($card_pass_balance['enabled'])) : ?>
@@ -231,6 +255,53 @@ $mark = (string) ($charge_view['currency_mark'] ?? '元');
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php if ($withdraw_on) :
$status_labels = [
'pending' => __('待处理', 'aksrt'),
'approved' => __('已通过', 'aksrt'),
'processing' => __('处理中', 'aksrt'),
'completed' => __('已完成', 'aksrt'),
'rejected' => __('已拒绝', 'aksrt'),
];
?>
<div class="uc-modal" id="aksrt-withdraw-history-modal" hidden data-aksrt-withdraw-history-modal>
<div class="uc-modal__overlay" data-aksrt-withdraw-history-close></div>
<div class="uc-modal__dialog" role="dialog" aria-modal="true">
<button type="button" class="uc-modal__close" data-aksrt-withdraw-history-close aria-label="<?php esc_attr_e('关闭', 'aksrt'); ?>"><i class="fas fa-times" aria-hidden="true"></i></button>
<h3 class="uc-modal__title"><i class="fas fa-file-invoice-dollar mr6" aria-hidden="true"></i><?php esc_html_e('提现记录', 'aksrt'); ?></h3>
<div class="uc-withdraw-history">
<?php if (!empty($withdraw_history)) : ?>
<?php foreach ($withdraw_history as $wd) :
$st = sanitize_key((string) ($wd['status'] ?? ''));
$gross = (float) ($wd['gross_amount'] ?? $wd['amount'] ?? 0);
$net = (float) ($wd['amount'] ?? 0);
$st_class = $st === 'rejected' ? 'c-red' : ($st === 'completed' ? 'c-green' : 'c-blue');
?>
<div class="uc-withdraw-history__item border-bottom padding-h10">
<div class="flex jsb ac">
<span class="badg badg-sm <?php echo esc_attr($st_class); ?>"><?php echo esc_html($status_labels[$st] ?? $st); ?></span>
<b class="em12"><?php echo esc_html(class_exists('AKSRT_Balance') ? AKSRT_Balance::format($gross) : number_format_i18n($gross, 2)); ?></b>
</div>
<div class="muted-2-color em09 mt6">
<?php echo esc_html((string) ($wd['created_at'] ?? '')); ?>
<?php if (abs($gross - $net) > 0.001) : ?>
· <?php printf(esc_html__('到账 %s', 'aksrt'), esc_html(class_exists('AKSRT_Balance') ? AKSRT_Balance::format($net) : number_format_i18n($net, 2))); ?>
<?php endif; ?>
</div>
<?php if (!empty($wd['note'])) : ?>
<div class="muted-2-color em09 mt3"><?php echo esc_html((string) $wd['note']); ?></div>
<?php endif; ?>
</div>
<?php endforeach; ?>
<?php else : ?>
<div class="uc-transactions__empty"><p><?php esc_html_e('暂无提现记录', 'aksrt'); ?></p></div>
<?php endif; ?>
</div>
</div>
</div>
<?php endif; ?>
<?php if (empty($skip_wallet_ledger)) : ?> <?php if (empty($skip_wallet_ledger)) : ?>
<div class="uc-transactions uc-transactions--wallet"> <div class="uc-transactions uc-transactions--wallet">
<div class="uc-transactions__header"> <div class="uc-transactions__header">