';
- echo '' . esc_html($order_number) . ' | ';
+ echo '' . esc_html($order_number) . ' ' . esc_html__('流水:', 'aksrt') . ($flow_number !== '' ? '' . esc_html($flow_number) . '' : '—') . ' | ';
echo '' . ($user ? esc_html($user->display_name . ' (#' . $user_id . ')') : esc_html((string) $user_id)) . ' | ';
- echo '' . esc_html((string) ($row['product_type'] ?? '')) . ' | ';
- echo '' . esc_html($this->cash_decimal((string) ($row['amount'] ?? '0'))) . ' ' . esc_html((string) ($row['currency'] ?? '')) . ' | ';
+ echo '' . esc_html($product_title !== '' ? $product_title : $product_type) . ' ' . esc_html($product_type) . ($product_id > 0 ? ' #' . esc_html((string) $product_id) : '') . ' | ';
+ echo '' . esc_html($this->order_amount($row)) . ' ' . esc_html((string) ($row['payment_method'] ?? '')) . ' | ';
echo '' . esc_html($status_label) . ' | ';
- echo '' . esc_html((string) ($row['created_at'] ?? '')) . ' | ';
+ echo '' . esc_html((string) ($row['created_at'] ?? '')) . ' ' . esc_html((string) ($row['paid_at'] ?? '') ?: '—') . ' | ';
echo '';
echo '' . esc_html__('详情', 'aksrt') . '';
$this->render_order_quick_status_buttons($order_number, $status_key);
@@ -262,6 +276,7 @@ class PayOrders {
? \AKSRT\Core\DomainModel::order_status_labels()
: [];
$context = $repo->get_admin_order_context($order_number);
+ $flow_number = \AKSRT\Finance\FinancialFlowQuery::get_instance()->order_payment_number($order_number);
$user_id = (int) ($order['user_id'] ?? 0);
$user = $user_id > 0 ? get_userdata($user_id) : null;
$status = (string) ($order['status'] ?? '');
@@ -277,13 +292,14 @@ class PayOrders {
echo '';
$rows = [
__('订单号', 'aksrt') => $order_number,
+ __('支付流水号', 'aksrt') => $flow_number !== '' ? $flow_number : '—',
__('用户', 'aksrt') => $user ? $user->display_name . ' (#' . $user_id . ')' : (string) $user_id,
__('商品类型', 'aksrt') => (string) ($order['product_type'] ?? ''),
__('商品 ID', 'aksrt') => (string) ($order['product_id'] ?? ''),
- __('金额', 'aksrt') => $this->cash_decimal((string) ($order['amount'] ?? '0')) . ' ' . (string) ($order['currency'] ?? ''),
+ __('金额', 'aksrt') => $this->order_amount($order),
__('状态', 'aksrt') => $labels[$status] ?? $status,
__('支付方式', 'aksrt') => (string) ($order['payment_method'] ?? ''),
- __('交易号', 'aksrt') => (string) ($order['payment_transaction_id'] ?? ''),
+ __('渠道交易号', 'aksrt') => (string) ($order['payment_transaction_id'] ?? ''),
__('创建时间', 'aksrt') => (string) ($order['created_at'] ?? ''),
__('支付时间', 'aksrt') => (string) ($order['paid_at'] ?? ''),
__('买家备注', 'aksrt') => $customer_note,
@@ -440,6 +456,16 @@ class PayOrders {
}
}
+ /** @param array $order */
+ private function order_amount(array $order): string {
+ $currency = sanitize_key((string) ($order['currency'] ?? ''));
+ $method = sanitize_key((string) ($order['payment_method'] ?? ''));
+ if ($currency === 'points' || $method === 'points') {
+ return max(0, (int) ($order['amount'] ?? 0)) . ' ' . __('积分', 'aksrt');
+ }
+ return $this->cash_decimal((string) ($order['amount'] ?? '0')) . ' ' . strtoupper((string) ($order['currency'] ?? 'CNY'));
+ }
+
private function render_header(string $title): void {
echo '';
echo ' ' . esc_html($title) . '';
diff --git a/src/Admin/Native/PayWithdrawals.php b/src/Admin/Native/PayWithdrawals.php
index e1d391df..4cd08eb2 100644
--- a/src/Admin/Native/PayWithdrawals.php
+++ b/src/Admin/Native/PayWithdrawals.php
@@ -39,6 +39,10 @@ class PayWithdrawals {
}
$status = isset($_GET['status']) ? sanitize_key((string) wp_unslash($_GET['status'])) : '';
+ $keyword = isset($_GET['s']) ? sanitize_text_field((string) wp_unslash($_GET['s'])) : '';
+ $user_filter = isset($_GET['user_id']) ? max(0, (int) $_GET['user_id']) : 0;
+ $date_from = isset($_GET['date_from']) ? $this->sanitize_date((string) wp_unslash($_GET['date_from'])) : '';
+ $date_to = isset($_GET['date_to']) ? $this->sanitize_date((string) wp_unslash($_GET['date_to'])) : '';
$page_num = max(1, (int) ($_GET['paged'] ?? 1));
$per_page = 20;
$svc = \AKSRT\Payment\Withdrawal::get_instance();
@@ -79,31 +83,47 @@ class PayWithdrawals {
);
}
echo ' ';
+ echo ' ';
+ echo ' ';
+ echo ' ';
+ echo ' ';
submit_button(__('筛选', 'aksrt'), 'secondary', '', false);
+ if ($status !== '' || $keyword !== '' || $user_filter > 0 || $date_from !== '' || $date_to !== '') {
+ echo ' ' . esc_html__('清除条件', 'aksrt') . '';
+ }
echo '';
$items = $svc->get_all([
'status' => $status,
+ 'keyword' => $keyword,
+ 'user_id' => $user_filter,
+ 'date_from' => $date_from,
+ 'date_to' => $date_to,
'limit' => $per_page,
'offset' => ($page_num - 1) * $per_page,
]);
- $total = $svc->get_count(['status' => $status]);
+ $total = $svc->get_count([
+ 'status' => $status,
+ 'keyword' => $keyword,
+ 'user_id' => $user_filter,
+ 'date_from' => $date_from,
+ 'date_to' => $date_to,
+ ]);
$pages = max(1, (int) ceil($total / $per_page));
echo ' ';
echo '';
- echo '| ID | ';
+ echo '' . esc_html__('提现单号', 'aksrt') . ' | ';
echo '' . esc_html__('用户', 'aksrt') . ' | ';
- echo '' . esc_html__('金额', 'aksrt') . ' | ';
- echo '' . esc_html__('方式', 'aksrt') . ' | ';
- echo '' . esc_html__('账户', 'aksrt') . ' | ';
+ echo '' . esc_html__('金额明细', 'aksrt') . ' | ';
+ echo '' . esc_html__('渠道 / 账户', 'aksrt') . ' | ';
echo '' . esc_html__('状态', 'aksrt') . ' | ';
- echo '' . esc_html__('申请时间', 'aksrt') . ' | ';
+ echo '' . esc_html__('申请 / 处理时间', 'aksrt') . ' | ';
echo '' . esc_html__('操作', 'aksrt') . ' | ';
echo ' ';
if (!$items) {
- echo '| ' . esc_html__('暂无提现记录', 'aksrt') . ' | ';
+ echo '| ' . esc_html__('暂无提现记录', 'aksrt') . ' | ';
}
foreach ($items as $row) {
@@ -115,25 +135,21 @@ class PayWithdrawals {
$net = (string) ($row['amount'] ?? '0.00');
$fee = (int) ($row['fee_rate'] ?? 0);
$acct = (string) ($row['display_account'] ?? $row['account_info'] ?? '');
- $amount_txt = $net;
- if ($gross !== $net) {
- $amount_txt = sprintf(
- /* translators: 1: gross 2: net 3: fee percent */
- __('申请 %1$s / 到账 %2$s(手续费 %3$d%%)', 'aksrt'),
- $gross,
- $net,
- $fee
- );
- }
+ $amount_txt = sprintf(
+ /* translators: 1: gross 2: net 3: fee percent */
+ __('申请 %1$s / 到账 %2$s(手续费 %3$d%%)', 'aksrt'),
+ $gross,
+ $net,
+ $fee
+ );
echo '';
- echo '| ' . esc_html((string) $id) . ' | ';
+ echo '' . esc_html((string) ($row['withdrawal_number'] ?? '')) . ' | ';
echo '' . ($user ? esc_html($user->display_name . ' (#' . $user_id . ')') : esc_html((string) $user_id)) . ' | ';
echo '' . esc_html($amount_txt) . ' | ';
- echo '' . esc_html((string) ($row['method'] ?? '')) . ' | ';
- echo '' . esc_html($acct) . ' | ';
+ echo '' . esc_html((string) ($row['method'] ?? '')) . ' ' . esc_html($acct) . ' | ';
echo '' . esc_html($status_options[$st] ?? $st) . ' | ';
- echo '' . esc_html((string) ($row['created_at'] ?? '')) . ' | ';
+ echo '' . esc_html((string) ($row['created_at'] ?? '')) . ' ' . esc_html((string) ($row['processed_at'] ?? '') ?: '—') . ' | ';
echo '';
$next_statuses = $st === 'pending' ? ['processing', 'rejected'] : ($st === 'processing' ? ['completed', 'rejected'] : []);
if ($next_statuses === []) {
@@ -258,6 +274,10 @@ class PayWithdrawals {
return add_query_arg(array_merge(['page' => $slug], $args), admin_url('admin.php'));
}
+ private function sanitize_date(string $date): string {
+ return preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) === 1 ? $date : '';
+ }
+
private function render_pagination(int $pages, int $page_num): void {
if ($pages <= 1) {
return;
diff --git a/src/Admin/Schema/SchemaPayment.php b/src/Admin/Schema/SchemaPayment.php
index 6bdc0b6a..807afe92 100644
--- a/src/Admin/Schema/SchemaPayment.php
+++ b/src/Admin/Schema/SchemaPayment.php
@@ -234,6 +234,8 @@ class SchemaPayment {
'groups' => [
['key' => 'basic', 'title' => '订单基础', 'fields' => [
['key' => 'payment_order_prefix', 'label' => '订单前缀', 'type' => 'input', 'default' => 'AKSRT'],
+ ['key' => 'payment_flow_prefix', 'label' => '流水号前缀', 'type' => 'input', 'sanitize_type' => 'business_prefix', 'default' => 'AK', 'maxlength' => 12, 'help' => '仅允许字母、数字、下划线和短横线,最多 12 位。'],
+ ['key' => 'payment_withdrawal_prefix', 'label' => '提现单号前缀', 'type' => 'input', 'sanitize_type' => 'business_prefix', 'default' => 'TX', 'maxlength' => 12, 'help' => '仅允许字母、数字、下划线和短横线,最多 12 位。'],
['key' => 'payment_order_expiry', 'label' => '订单有效期(分钟)', 'type' => 'number', 'default' => 30, 'min' => 5, 'max' => 1440],
['key' => 'pay_show_user', 'label' => '显示用户信息', 'type' => 'switch', 'default' => true],
['key' => 'pay_show_paycount', 'label' => '显示购买次数', 'type' => 'switch', 'default' => true],
diff --git a/src/Admin/SchemaProvider.php b/src/Admin/SchemaProvider.php
index 16eaf507..09586a7e 100644
--- a/src/Admin/SchemaProvider.php
+++ b/src/Admin/SchemaProvider.php
@@ -517,6 +517,9 @@ class SchemaProvider {
private function sanitize_field_value($value, array $field) {
$type = (string) ($field['type'] ?? 'text');
$sanitize_type = (string) ($field['sanitize_type'] ?? '');
+ if ($sanitize_type === 'business_prefix') {
+ return \AKSRT\Finance\BusinessNumber::normalize_prefix($value, (string) ($field['default'] ?? 'AK'));
+ }
if ($sanitize_type !== '') {
return $this->sanitize_value($value, $sanitize_type);
}
diff --git a/src/Content/PayOrders.php b/src/Content/PayOrders.php
index ccbf2430..f0d0e4f8 100644
--- a/src/Content/PayOrders.php
+++ b/src/Content/PayOrders.php
@@ -265,6 +265,7 @@ trait PayOrders {
'pay_modo' => 'points',
'balance' => $ledger->balance($user_id, \AKSRT\Ledger\AssetType::POINTS),
'transaction_id' => $deducted->transaction_id,
+ 'flow_number' => $deducted->flow_number,
'replayed' => $deducted->replayed,
'message' => __('支付成功', 'aksrt'),
'reload' => true,
@@ -503,6 +504,7 @@ trait PayOrders {
'amount' => $amount_decimal,
'pay_modo' => '0',
'transaction_id' => (int) ($history['items'][0]['id'] ?? 0),
+ 'flow_number' => \AKSRT\Finance\FinancialFlowQuery::get_instance()->order_payment_number($order_number),
'replayed' => true,
'balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
'message' => __('支付成功', 'aksrt'),
diff --git a/src/Content/VipServicePurchase.php b/src/Content/VipServicePurchase.php
index ec5ee5e1..6cac71d0 100644
--- a/src/Content/VipServicePurchase.php
+++ b/src/Content/VipServicePurchase.php
@@ -420,6 +420,7 @@ trait VipServicePurchase {
'days' => $days,
'action' => $action,
'transaction_id' => (int) ($history['items'][0]['id'] ?? 0),
+ 'flow_number' => \AKSRT\Finance\FinancialFlowQuery::get_instance()->order_payment_number($order_number),
'replayed' => true,
'balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
'message' => __('会员购买已完成', 'aksrt'),
@@ -878,6 +879,7 @@ trait VipServicePurchase {
'days' => $days,
'balance' => $ledger->balance($user_id, \AKSRT\Ledger\AssetType::POINTS),
'transaction_id' => $deducted->transaction_id,
+ 'flow_number' => $deducted->flow_number,
'replayed' => $deducted->replayed,
'message' => __('兑换成功', 'aksrt'),
'reload' => true,
diff --git a/src/Core/DBMigratorPart2.php b/src/Core/DBMigratorPart2.php
index 11fe5fd5..60911fbf 100644
--- a/src/Core/DBMigratorPart2.php
+++ b/src/Core/DBMigratorPart2.php
@@ -70,6 +70,52 @@ trait DBMigratorPart2 {
) ENGINE=InnoDB {$charset_collate};";
dbDelta($sql_asset_entry);
+ $sql_financial_flow = "CREATE TABLE {$prefix}aksrt_financial_flow (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ flow_number VARCHAR(32) NOT NULL,
+ operation_key VARCHAR(191) NOT NULL,
+ event_type VARCHAR(64) NOT NULL,
+ ref_type VARCHAR(32) DEFAULT NULL,
+ ref_key VARCHAR(191) DEFAULT NULL,
+ order_number VARCHAR(32) DEFAULT NULL,
+ withdrawal_number VARCHAR(32) DEFAULT NULL,
+ ledger_transaction_id BIGINT UNSIGNED DEFAULT NULL,
+ provider_transaction_id VARCHAR(128) DEFAULT NULL,
+ metadata LONGTEXT DEFAULT NULL,
+ created_at DATETIME(6) NOT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY uniq_flow_number (flow_number),
+ UNIQUE KEY uniq_flow_operation (operation_key),
+ UNIQUE KEY uniq_flow_ledger_transaction (ledger_transaction_id),
+ KEY idx_flow_event_time (event_type, created_at),
+ KEY idx_flow_order (order_number),
+ KEY idx_flow_withdrawal (withdrawal_number),
+ KEY idx_flow_reference (ref_type, ref_key),
+ KEY idx_flow_provider_transaction (provider_transaction_id)
+ ) ENGINE=InnoDB {$charset_collate};";
+ dbDelta($sql_financial_flow);
+
+ $sql_financial_flow_entry = "CREATE TABLE {$prefix}aksrt_financial_flow_entry (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ flow_id BIGINT UNSIGNED NOT NULL,
+ line_no SMALLINT UNSIGNED NOT NULL,
+ user_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
+ asset VARCHAR(16) NOT NULL,
+ direction VARCHAR(8) NOT NULL,
+ entry_type VARCHAR(32) NOT NULL,
+ delta_minor BIGINT NOT NULL,
+ balance_after_minor BIGINT UNSIGNED DEFAULT NULL,
+ counterparty_user_id BIGINT UNSIGNED DEFAULT NULL,
+ remark VARCHAR(255) DEFAULT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY uniq_flow_line (flow_id, line_no),
+ KEY idx_flow_entry_user (user_id, flow_id),
+ KEY idx_flow_entry_asset (asset, flow_id),
+ KEY idx_flow_entry_direction (direction, flow_id),
+ KEY idx_flow_entry_counterparty (counterparty_user_id, flow_id)
+ ) ENGINE=InnoDB {$charset_collate};";
+ dbDelta($sql_financial_flow_entry);
+
// ---------- 2. 签到 ----------
$sql_signin = "CREATE TABLE {$prefix}aksrt_signin_log (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
@@ -188,6 +234,7 @@ trait DBMigratorPart2 {
// ---------- 10. 提现台账 ----------
$sql_withdrawal = "CREATE TABLE {$prefix}aksrt_withdrawal (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ withdrawal_number VARCHAR(32) NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
amount DECIMAL(10,2) NOT NULL DEFAULT 0,
gross_amount DECIMAL(10,2) NOT NULL DEFAULT 0,
@@ -199,6 +246,7 @@ trait DBMigratorPart2 {
processed_at DATETIME DEFAULT NULL,
note TEXT DEFAULT NULL,
PRIMARY KEY (id),
+ UNIQUE KEY uniq_withdrawal_number (withdrawal_number),
UNIQUE KEY uniq_ledger_transaction (ledger_transaction_id),
KEY idx_user (user_id),
KEY idx_user_created (user_id, created_at),
diff --git a/src/Core/DBMigratorPart3.php b/src/Core/DBMigratorPart3.php
index 3ffbd9de..31738354 100644
--- a/src/Core/DBMigratorPart3.php
+++ b/src/Core/DBMigratorPart3.php
@@ -19,6 +19,8 @@ trait DBMigratorPart3 {
'asset_account' => 'aksrt_asset_account',
'asset_transaction' => 'aksrt_asset_transaction',
'asset_entry' => 'aksrt_asset_entry',
+ 'financial_flow' => 'aksrt_financial_flow',
+ 'financial_flow_entry' => 'aksrt_financial_flow_entry',
'signin_log' => 'aksrt_signin_log',
'dl_token' => 'aksrt_dl_token',
'notification' => 'aksrt_notification',
diff --git a/src/Finance/BusinessNumber.php b/src/Finance/BusinessNumber.php
new file mode 100644
index 00000000..5d6fda3a
--- /dev/null
+++ b/src/Finance/BusinessNumber.php
@@ -0,0 +1,36 @@
+format('YmdHis') . str_pad((string) $random, 6, '0', STR_PAD_LEFT);
+ }
+}
diff --git a/src/Finance/FinancialFlowQuery.php b/src/Finance/FinancialFlowQuery.php
new file mode 100644
index 00000000..b580f699
--- /dev/null
+++ b/src/Finance/FinancialFlowQuery.php
@@ -0,0 +1,163 @@
+get_var($wpdb->prepare(
+ "SELECT flow_number FROM {$this->flow_table()} WHERE order_number = %s AND event_type = 'order_payment' ORDER BY id ASC LIMIT 1",
+ $order_number
+ ));
+ }
+
+ /** @param array $order_numbers @return array */
+ public function order_payment_number_map(array $order_numbers): array {
+ global $wpdb;
+ $order_numbers = array_values(array_unique(array_filter(array_map(static function ($number): string {
+ return substr(sanitize_text_field((string) $number), 0, 32);
+ }, $order_numbers))));
+ if ($order_numbers === []) {
+ return [];
+ }
+ $placeholders = implode(',', array_fill(0, count($order_numbers), '%s'));
+ $rows = $wpdb->get_results($wpdb->prepare(
+ "SELECT order_number, flow_number FROM {$this->flow_table()} WHERE event_type = 'order_payment' AND order_number IN ({$placeholders}) ORDER BY id ASC",
+ $order_numbers
+ ), ARRAY_A);
+ $map = [];
+ foreach ((array) $rows as $row) {
+ $number = (string) ($row['order_number'] ?? '');
+ if ($number !== '' && !isset($map[$number])) {
+ $map[$number] = (string) ($row['flow_number'] ?? '');
+ }
+ }
+ return $map;
+ }
+
+ /**
+ * @param array $args
+ * @return array{items:array>,total:int,page:int,per_page:int}
+ */
+ public function search(array $args = []): array {
+ global $wpdb;
+ $page = max(1, (int) ($args['page'] ?? 1));
+ $per_page = min(100, max(1, (int) ($args['per_page'] ?? 20)));
+ $offset = ($page - 1) * $per_page;
+ $where = ['1=1'];
+ $params = [];
+ $keyword = trim(sanitize_text_field((string) ($args['keyword'] ?? '')));
+ if ($keyword !== '') {
+ $like = '%' . $wpdb->esc_like($keyword) . '%';
+ $where[] = '(f.flow_number LIKE %s OR f.operation_key LIKE %s OR f.order_number LIKE %s OR f.withdrawal_number LIKE %s OR f.provider_transaction_id LIKE %s OR f.ref_key LIKE %s)';
+ array_push($params, $like, $like, $like, $like, $like, $like);
+ }
+ $event_type = sanitize_key((string) ($args['event_type'] ?? ''));
+ if ($event_type !== '') {
+ $where[] = 'f.event_type = %s';
+ $params[] = $event_type;
+ }
+ $user_id = max(0, (int) ($args['user_id'] ?? 0));
+ if ($user_id > 0) {
+ $where[] = "EXISTS (SELECT 1 FROM {$this->entry_table()} eu WHERE eu.flow_id = f.id AND (eu.user_id = %d OR eu.counterparty_user_id = %d))";
+ array_push($params, $user_id, $user_id);
+ }
+ $asset = sanitize_key((string) ($args['asset'] ?? ''));
+ if (in_array($asset, ['cash', 'balance', 'points'], true)) {
+ $where[] = "EXISTS (SELECT 1 FROM {$this->entry_table()} ea WHERE ea.flow_id = f.id AND ea.asset = %s)";
+ $params[] = $asset;
+ }
+ $direction = sanitize_key((string) ($args['direction'] ?? ''));
+ if (in_array($direction, ['in', 'out'], true)) {
+ $where[] = "EXISTS (SELECT 1 FROM {$this->entry_table()} ed WHERE ed.flow_id = f.id AND ed.direction = %s)";
+ $params[] = $direction;
+ }
+ if (!empty($args['date_from'])) {
+ $where[] = 'f.created_at >= %s';
+ $params[] = (string) $args['date_from'];
+ }
+ if (!empty($args['date_to'])) {
+ $where[] = 'f.created_at < %s';
+ $params[] = (string) $args['date_to'];
+ }
+ $where_sql = implode(' AND ', $where);
+ $count_sql = "SELECT COUNT(*) FROM {$this->flow_table()} f WHERE {$where_sql}";
+ $total = (int) ($params ? $wpdb->get_var($wpdb->prepare($count_sql, $params)) : $wpdb->get_var($count_sql));
+ $item_sql = "SELECT f.* FROM {$this->flow_table()} f WHERE {$where_sql} ORDER BY f.created_at DESC, f.id DESC LIMIT %d OFFSET %d";
+ $rows = (array) $wpdb->get_results($wpdb->prepare($item_sql, array_merge($params, [$per_page, $offset])), ARRAY_A);
+ $ids = array_values(array_filter(array_map(static fn(array $row): int => (int) ($row['id'] ?? 0), $rows)));
+ $entry_map = $this->entry_map($ids);
+ foreach ($rows as &$row) {
+ $row['id'] = (int) ($row['id'] ?? 0);
+ $row['ledger_transaction_id'] = (int) ($row['ledger_transaction_id'] ?? 0);
+ $row['metadata'] = json_decode((string) ($row['metadata'] ?? ''), true) ?: [];
+ $row['entries'] = $entry_map[$row['id']] ?? [];
+ }
+ unset($row);
+ return ['items' => $rows, 'total' => $total, 'page' => $page, 'per_page' => $per_page];
+ }
+
+ /** @return array */
+ public function event_types(): array {
+ global $wpdb;
+ $values = (array) $wpdb->get_col("SELECT DISTINCT event_type FROM {$this->flow_table()} ORDER BY event_type ASC");
+ $out = [];
+ foreach ($values as $value) {
+ $key = sanitize_key((string) $value);
+ if ($key !== '') {
+ $out[$key] = $key;
+ }
+ }
+ return $out;
+ }
+
+ /** @param array $flow_ids @return array>> */
+ private function entry_map(array $flow_ids): array {
+ global $wpdb;
+ if ($flow_ids === []) {
+ return [];
+ }
+ $placeholders = implode(',', array_fill(0, count($flow_ids), '%d'));
+ $rows = (array) $wpdb->get_results($wpdb->prepare(
+ "SELECT * FROM {$this->entry_table()} WHERE flow_id IN ({$placeholders}) ORDER BY flow_id ASC, line_no ASC",
+ $flow_ids
+ ), ARRAY_A);
+ $map = [];
+ foreach ($rows as $row) {
+ foreach (['id', 'flow_id', 'line_no', 'user_id', 'delta_minor', 'balance_after_minor', 'counterparty_user_id'] as $field) {
+ $row[$field] = (int) ($row[$field] ?? 0);
+ }
+ $map[$row['flow_id']][] = $row;
+ }
+ return $map;
+ }
+
+ private function flow_table(): string {
+ return \AKSRT\Core\DBMigrator::table('financial_flow');
+ }
+
+ private function entry_table(): string {
+ return \AKSRT\Core\DBMigrator::table('financial_flow_entry');
+ }
+}
diff --git a/src/Finance/FinancialFlowService.php b/src/Finance/FinancialFlowService.php
new file mode 100644
index 00000000..8d919e75
--- /dev/null
+++ b/src/Finance/FinancialFlowService.php
@@ -0,0 +1,400 @@
+> $entries
+ * @return string|\WP_Error
+ */
+ public function record_ledger(LedgerCommand $command, int $transaction_id, array $entries, string $created_at) {
+ $order_number = $command->ref_type === 'order' ? $command->ref_key : (string) ($command->metadata['order_number'] ?? '');
+ $order = $order_number !== '' ? $this->resolve_order($order_number) : [];
+ $withdrawal_number = '';
+ if ($command->ref_type === 'withdraw') {
+ $withdrawal_number = $this->resolve_withdrawal_number($command, $transaction_id);
+ }
+ $provider_transaction_id = (string) ($order['payment_transaction_id'] ?? '');
+
+ $flow_entries = [];
+ foreach ($entries as $entry) {
+ $delta = (int) ($entry['delta_minor'] ?? 0);
+ $flow_entries[] = [
+ 'user_id' => max(0, (int) ($entry['user_id'] ?? 0)),
+ 'asset' => sanitize_key((string) ($entry['asset'] ?? '')),
+ 'direction' => $delta > 0 ? 'in' : 'out',
+ 'entry_type' => sanitize_key((string) ($entry['entry_type'] ?? 'adjustment')),
+ 'delta_minor' => $delta,
+ 'balance_after_minor' => max(0, (int) ($entry['balance_after_minor'] ?? 0)),
+ 'counterparty_user_id' => max(0, (int) ($entry['counterparty_user_id'] ?? 0)),
+ 'remark' => (string) ($entry['remark'] ?? ''),
+ ];
+ }
+ $event_type = $command->reason;
+ $metadata = $command->metadata;
+ if ($order !== [] && $this->is_successful_order_payment($order, $flow_entries)) {
+ $event_type = 'order_payment';
+ $metadata['ledger_reason'] = $command->reason;
+ if ($this->is_external_recharge($order, $flow_entries)) {
+ try {
+ $cash_minor = \AKSRT\Ledger\AssetAmount::balance((string) ($order['amount'] ?? ''))->minor();
+ } catch (\InvalidArgumentException $e) {
+ return new \WP_Error('aksrt_flow_amount', __('充值支付流水金额无效', 'aksrt'), ['status' => 500]);
+ }
+ $flow_entries[] = [
+ 'user_id' => max(0, (int) ($order['user_id'] ?? 0)),
+ 'asset' => 'cash',
+ 'direction' => 'out',
+ 'entry_type' => 'payment',
+ 'delta_minor' => -$cash_minor,
+ 'counterparty_user_id' => 0,
+ 'remark' => __('充值订单支付', 'aksrt'),
+ ];
+ }
+ }
+
+ return $this->record([
+ 'operation_key' => $command->operation_key,
+ 'event_type' => $event_type,
+ 'ref_type' => $command->ref_type,
+ 'ref_key' => $command->ref_key,
+ 'order_number' => $order_number,
+ 'withdrawal_number' => $withdrawal_number,
+ 'ledger_transaction_id' => $transaction_id,
+ 'provider_transaction_id' => $provider_transaction_id,
+ 'metadata' => $metadata,
+ 'created_at' => $created_at,
+ ], $flow_entries, true);
+ }
+
+ /** @return string|\WP_Error */
+ public function record_order_payment(array $order, bool $caller_transaction = false) {
+ return $this->record_order_event($order, 'order_payment', -1, $caller_transaction);
+ }
+
+ /** @return string|\WP_Error */
+ public function record_order_refund(array $order, bool $caller_transaction = false) {
+ return $this->record_order_event($order, 'order_refund', 1, $caller_transaction);
+ }
+
+ /** @return string|\WP_Error */
+ public function record_withdrawal_payout(array $withdrawal, string $operation_key, string $created_at, bool $caller_transaction = false) {
+ $user_id = max(0, (int) ($withdrawal['user_id'] ?? 0));
+ $number = sanitize_text_field((string) ($withdrawal['withdrawal_number'] ?? ''));
+ try {
+ $amount_minor = \AKSRT\Ledger\AssetAmount::balance((string) ($withdrawal['amount'] ?? ''))->minor();
+ } catch (\InvalidArgumentException $e) {
+ return new \WP_Error('aksrt_flow_amount', __('提现流水金额无效', 'aksrt'), ['status' => 500]);
+ }
+
+ return $this->record([
+ 'operation_key' => $operation_key,
+ 'event_type' => 'withdraw_payout',
+ 'ref_type' => 'withdraw',
+ 'ref_key' => $number,
+ 'withdrawal_number' => $number,
+ 'metadata' => ['method' => sanitize_key((string) ($withdrawal['method'] ?? ''))],
+ 'created_at' => $created_at,
+ ], [[
+ 'user_id' => $user_id,
+ 'asset' => 'cash',
+ 'direction' => 'in',
+ 'entry_type' => 'payout',
+ 'delta_minor' => $amount_minor,
+ 'remark' => __('提现打款完成', 'aksrt'),
+ ]], $caller_transaction);
+ }
+
+ /**
+ * @param array $flow
+ * @param array> $entries
+ * @return string|\WP_Error
+ */
+ private function record(array $flow, array $entries, bool $caller_transaction) {
+ global $wpdb;
+ $operation_key = substr(sanitize_text_field((string) ($flow['operation_key'] ?? '')), 0, 191);
+ $event_type = sanitize_key((string) ($flow['event_type'] ?? ''));
+ if ($operation_key === '' || $event_type === '' || $entries === []) {
+ return new \WP_Error('aksrt_flow_invalid', __('资金流水参数无效', 'aksrt'), ['status' => 500]);
+ }
+
+ $prepared_entries = $this->prepare_entries($entries);
+ if (is_wp_error($prepared_entries)) {
+ return $prepared_entries;
+ }
+
+ $existing = $this->number_for_operation($operation_key);
+ if ($existing !== '') {
+ return $existing;
+ }
+ $owns_transaction = !$caller_transaction;
+ if ($owns_transaction && $wpdb->query('START TRANSACTION') === false) {
+ return new \WP_Error('aksrt_flow_db', __('无法开启资金流水事务', 'aksrt'), ['status' => 500]);
+ }
+
+ $settings = \AKSRT\Settings\Settings::get_order_settings();
+ $prefix = BusinessNumber::normalize_prefix($settings['flow_prefix'] ?? 'AK', 'AK');
+ $flow_id = 0;
+ $flow_number = '';
+ for ($attempt = 1; $attempt <= self::MAX_NUMBER_ATTEMPTS; $attempt++) {
+ $flow_number = BusinessNumber::candidate($prefix);
+ $metadata = is_array($flow['metadata'] ?? null) ? $flow['metadata'] : [];
+ $inserted = $wpdb->insert($this->flow_table(), [
+ 'flow_number' => $flow_number,
+ 'operation_key' => $operation_key,
+ 'event_type' => $event_type,
+ 'ref_type' => ($flow['ref_type'] ?? '') !== '' ? sanitize_key((string) $flow['ref_type']) : null,
+ 'ref_key' => ($flow['ref_key'] ?? '') !== '' ? substr(sanitize_text_field((string) $flow['ref_key']), 0, 191) : null,
+ 'order_number' => ($flow['order_number'] ?? '') !== '' ? substr(sanitize_text_field((string) $flow['order_number']), 0, 32) : null,
+ 'withdrawal_number' => ($flow['withdrawal_number'] ?? '') !== '' ? substr(sanitize_text_field((string) $flow['withdrawal_number']), 0, 32) : null,
+ 'ledger_transaction_id' => max(0, (int) ($flow['ledger_transaction_id'] ?? 0)) ?: null,
+ 'provider_transaction_id' => ($flow['provider_transaction_id'] ?? '') !== '' ? substr(sanitize_text_field((string) $flow['provider_transaction_id']), 0, 128) : null,
+ 'metadata' => $metadata !== [] ? wp_json_encode($metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null,
+ 'created_at' => (string) ($flow['created_at'] ?? self::utc_now()),
+ ], ['%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%s', '%s']);
+ if ($inserted === 1) {
+ $flow_id = (int) $wpdb->insert_id;
+ break;
+ }
+ $existing = $this->number_for_operation($operation_key);
+ if ($existing !== '') {
+ if ($owns_transaction) {
+ $wpdb->query('ROLLBACK');
+ }
+ return $existing;
+ }
+ }
+ if ($flow_id <= 0) {
+ if ($owns_transaction) {
+ $wpdb->query('ROLLBACK');
+ }
+ return new \WP_Error('aksrt_flow_number', __('无法生成唯一流水号', 'aksrt'), ['status' => 500]);
+ }
+
+ foreach ($prepared_entries as $index => $entry) {
+ $inserted = $wpdb->insert($this->entry_table(), [
+ 'flow_id' => $flow_id,
+ 'line_no' => $index + 1,
+ 'user_id' => max(0, (int) ($entry['user_id'] ?? 0)),
+ 'asset' => $entry['asset'],
+ 'direction' => $entry['direction'],
+ 'entry_type' => substr(sanitize_key((string) ($entry['entry_type'] ?? 'adjustment')), 0, 32),
+ 'delta_minor' => $entry['delta_minor'],
+ 'balance_after_minor' => $entry['balance_after_minor'],
+ 'counterparty_user_id' => max(0, (int) ($entry['counterparty_user_id'] ?? 0)) ?: null,
+ 'remark' => ($entry['remark'] ?? '') !== '' ? substr(sanitize_text_field((string) $entry['remark']), 0, 255) : null,
+ ], ['%d', '%d', '%d', '%s', '%s', '%s', '%d', '%d', '%d', '%s']);
+ if ($inserted !== 1) {
+ if ($owns_transaction) {
+ $wpdb->query('ROLLBACK');
+ }
+ return new \WP_Error('aksrt_flow_entry', __('资金流水分录写入失败', 'aksrt'), ['status' => 500]);
+ }
+ }
+ if ($owns_transaction && $wpdb->query('COMMIT') === false) {
+ $wpdb->query('ROLLBACK');
+ return new \WP_Error('aksrt_flow_db', __('资金流水提交失败', 'aksrt'), ['status' => 500]);
+ }
+
+ return $flow_number;
+ }
+
+ /**
+ * Validate every entry before the flow head is inserted.
+ *
+ * @param array> $entries
+ * @return array>|\WP_Error
+ */
+ private function prepare_entries(array $entries) {
+ $prepared = [];
+ foreach (array_values($entries) as $entry) {
+ $delta = (int) ($entry['delta_minor'] ?? 0);
+ $asset = sanitize_key((string) ($entry['asset'] ?? ''));
+ $direction = sanitize_key((string) ($entry['direction'] ?? ($delta > 0 ? 'in' : 'out')));
+ if (!in_array($asset, ['cash', 'balance', 'points'], true) || $delta === 0) {
+ return new \WP_Error('aksrt_flow_entry', __('资金流水分录无效', 'aksrt'), ['status' => 500]);
+ }
+ if (!in_array($direction, ['in', 'out'], true) || ($delta > 0) !== ($direction === 'in')) {
+ return new \WP_Error('aksrt_flow_entry', __('资金流水方向无效', 'aksrt'), ['status' => 500]);
+ }
+ $prepared[] = [
+ 'user_id' => max(0, (int) ($entry['user_id'] ?? 0)),
+ 'asset' => $asset,
+ 'direction' => $direction,
+ 'entry_type' => substr(sanitize_key((string) ($entry['entry_type'] ?? 'adjustment')), 0, 32),
+ 'delta_minor' => $delta,
+ 'balance_after_minor' => array_key_exists('balance_after_minor', $entry) ? max(0, (int) $entry['balance_after_minor']) : null,
+ 'counterparty_user_id' => max(0, (int) ($entry['counterparty_user_id'] ?? 0)) ?: null,
+ 'remark' => ($entry['remark'] ?? '') !== '' ? substr(sanitize_text_field((string) $entry['remark']), 0, 255) : null,
+ ];
+ }
+ return $prepared;
+ }
+
+ public function number_for_operation(string $operation_key): string {
+ global $wpdb;
+ return (string) $wpdb->get_var($wpdb->prepare(
+ "SELECT flow_number FROM {$this->flow_table()} WHERE operation_key = %s LIMIT 1",
+ $operation_key
+ ));
+ }
+
+ public function number_for_ledger_transaction(int $transaction_id): string {
+ global $wpdb;
+ if ($transaction_id <= 0) {
+ return '';
+ }
+ return (string) $wpdb->get_var($wpdb->prepare(
+ "SELECT flow_number FROM {$this->flow_table()} WHERE ledger_transaction_id = %d LIMIT 1",
+ $transaction_id
+ ));
+ }
+
+ private function record_order_event(array $order, string $event_type, int $sign, bool $caller_transaction) {
+ $order_number = substr(sanitize_text_field((string) ($order['order_number'] ?? '')), 0, 32);
+ $user_id = max(0, (int) ($order['user_id'] ?? 0));
+ if ($order_number === '') {
+ return new \WP_Error('aksrt_flow_order', __('订单流水参数无效', 'aksrt'), ['status' => 500]);
+ }
+ $existing = $this->number_for_order_event($order_number, $event_type);
+ if ($existing !== '') {
+ return $existing;
+ }
+ $payment_method = sanitize_key((string) ($order['payment_method'] ?? ''));
+ $currency = sanitize_key((string) ($order['currency'] ?? 'CNY'));
+ if (in_array($payment_method, ['balance', 'points'], true) || $currency === 'points') {
+ return new \WP_Error('aksrt_flow_order_asset', __('余额或积分订单必须通过资产账本确认', 'aksrt'), ['status' => 500]);
+ }
+ try {
+ $amount_minor = \AKSRT\Ledger\AssetAmount::balance((string) ($order['amount'] ?? ''))->minor();
+ } catch (\InvalidArgumentException $e) {
+ return new \WP_Error('aksrt_flow_amount', __('订单流水金额无效', 'aksrt'), ['status' => 500]);
+ }
+ if ($amount_minor <= 0) {
+ return new \WP_Error('aksrt_flow_amount', __('订单流水金额无效', 'aksrt'), ['status' => 500]);
+ }
+ $operation_key = \AKSRT\Ledger\OperationKey::deterministic(
+ $event_type === 'order_payment' ? 'order-payment' : 'order-refund',
+ [$order_number]
+ );
+ return $this->record([
+ 'operation_key' => $operation_key,
+ 'event_type' => $event_type,
+ 'ref_type' => 'order',
+ 'ref_key' => $order_number,
+ 'order_number' => $order_number,
+ 'provider_transaction_id' => (string) ($order['payment_transaction_id'] ?? ''),
+ 'metadata' => [
+ 'payment_method' => $payment_method,
+ 'currency' => $currency,
+ ],
+ 'created_at' => self::utc_now(),
+ ], [[
+ 'user_id' => $user_id,
+ 'asset' => 'cash',
+ 'direction' => $sign > 0 ? 'in' : 'out',
+ 'entry_type' => $event_type === 'order_payment' ? 'payment' : 'refund',
+ 'delta_minor' => $sign * $amount_minor,
+ 'remark' => $event_type === 'order_payment' ? __('订单支付', 'aksrt') : __('订单退款', 'aksrt'),
+ ]], $caller_transaction);
+ }
+
+ private function number_for_order_event(string $order_number, string $event_type): string {
+ global $wpdb;
+ return (string) $wpdb->get_var($wpdb->prepare(
+ "SELECT flow_number FROM {$this->flow_table()} WHERE order_number = %s AND event_type = %s ORDER BY id ASC LIMIT 1",
+ $order_number,
+ $event_type
+ ));
+ }
+
+ private function resolve_withdrawal_number(LedgerCommand $command, int $transaction_id): string {
+ global $wpdb;
+ $table = \AKSRT\Core\DBMigrator::table('withdrawal');
+ $withdrawal_id = max(0, (int) ($command->metadata['withdrawal_id'] ?? 0));
+ if ($withdrawal_id <= 0 && ctype_digit($command->ref_key)) {
+ $withdrawal_id = (int) $command->ref_key;
+ }
+ if ($withdrawal_id > 0) {
+ return (string) $wpdb->get_var($wpdb->prepare("SELECT withdrawal_number FROM {$table} WHERE id = %d LIMIT 1", $withdrawal_id));
+ }
+ return (string) $wpdb->get_var($wpdb->prepare("SELECT withdrawal_number FROM {$table} WHERE ledger_transaction_id = %d LIMIT 1", $transaction_id));
+ }
+
+ /** @return array */
+ private function resolve_order(string $order_number): array {
+ global $wpdb;
+ $table = \AKSRT\Core\DBMigrator::table('order');
+ $row = $wpdb->get_row($wpdb->prepare(
+ "SELECT order_number, user_id, product_type, amount, currency, status, payment_method, payment_transaction_id FROM {$table} WHERE order_number = %s LIMIT 1",
+ $order_number
+ ), ARRAY_A);
+ return is_array($row) ? $row : [];
+ }
+
+ /** @param array $order @param array> $entries */
+ private function is_successful_order_payment(array $order, array $entries): bool {
+ if (sanitize_key((string) ($order['status'] ?? '')) !== \AKSRT\Payment\Order::STATUS_PAID) {
+ return false;
+ }
+ $buyer_id = max(0, (int) ($order['user_id'] ?? 0));
+ foreach ($entries as $entry) {
+ if ((int) ($entry['user_id'] ?? 0) === $buyer_id && (int) ($entry['delta_minor'] ?? 0) < 0) {
+ return true;
+ }
+ }
+ return $this->is_external_recharge($order, $entries);
+ }
+
+ /** @param array $order @param array> $entries */
+ private function is_external_recharge(array $order, array $entries): bool {
+ $method = sanitize_key((string) ($order['payment_method'] ?? ''));
+ if (sanitize_key((string) ($order['product_type'] ?? '')) !== \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE
+ || in_array($method, ['', 'balance', 'points'], true)
+ ) {
+ return false;
+ }
+ $buyer_id = max(0, (int) ($order['user_id'] ?? 0));
+ foreach ($entries as $entry) {
+ if ((int) ($entry['user_id'] ?? 0) === $buyer_id && (string) ($entry['asset'] ?? '') === 'balance' && (int) ($entry['delta_minor'] ?? 0) > 0) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private function flow_table(): string {
+ return \AKSRT\Core\DBMigrator::table('financial_flow');
+ }
+
+ private function entry_table(): string {
+ return \AKSRT\Core\DBMigrator::table('financial_flow_entry');
+ }
+
+ private static function utc_now(): string {
+ return (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s.u');
+ }
+
+}
diff --git a/src/Ledger/LedgerResult.php b/src/Ledger/LedgerResult.php
index 7c98908c..c5c4a35d 100644
--- a/src/Ledger/LedgerResult.php
+++ b/src/Ledger/LedgerResult.php
@@ -16,7 +16,8 @@ final class LedgerResult {
public readonly int $transaction_id,
public readonly bool $replayed,
public readonly array $entries,
- public readonly array $balances
+ public readonly array $balances,
+ public readonly string $flow_number = ''
) {}
public function balance(int $user_id, AssetType|string $asset): int {
@@ -31,6 +32,7 @@ final class LedgerResult {
'replayed' => $this->replayed,
'entries' => $this->entries,
'balances' => $this->balances,
+ 'flow_number' => $this->flow_number,
];
}
}
diff --git a/src/Ledger/LedgerService.php b/src/Ledger/LedgerService.php
index 3fc09793..7ef09b6b 100644
--- a/src/Ledger/LedgerService.php
+++ b/src/Ledger/LedgerService.php
@@ -409,6 +409,16 @@ final class LedgerService {
throw new \RuntimeException('ledger_business_mutation_failed');
}
}
+ $flow_number = \AKSRT\Finance\FinancialFlowService::get_instance()->record_ledger(
+ $command,
+ $transaction_id,
+ $written_entries,
+ $created_at
+ );
+ if (is_wp_error($flow_number)) {
+ throw new LedgerAbort($flow_number);
+ }
+ $result = new LedgerResult($transaction_id, false, $written_entries, $projected_balances, $flow_number);
if ($wpdb->query('COMMIT') === false) {
throw new \RuntimeException('ledger_commit_failed');
}
@@ -479,7 +489,8 @@ final class LedgerService {
$balances[$entry['user_id']][(string) $entry['asset']] = $entry['balance_after_minor'];
}
unset($entry);
- return new LedgerResult($transaction_id, $replayed, (array) $entries, $balances);
+ $flow_number = \AKSRT\Finance\FinancialFlowService::get_instance()->number_for_ledger_transaction($transaction_id);
+ return new LedgerResult($transaction_id, $replayed, (array) $entries, $balances, $flow_number);
}
private function db_error(int $errno, string $detail = ''): \WP_Error {
diff --git a/src/Payment/BalanceGateway.php b/src/Payment/BalanceGateway.php
index f7479f60..5cf87d4e 100644
--- a/src/Payment/BalanceGateway.php
+++ b/src/Payment/BalanceGateway.php
@@ -112,6 +112,7 @@ class BalanceGateway implements GatewayInterface {
'success' => true,
'transaction_id' => $transaction_code,
'ledger_transaction_id' => $result->transaction_id,
+ 'flow_number' => $result->flow_number,
'replayed' => $result->replayed,
'order_committed' => true,
'message' => __('余额支付成功', 'aksrt'),
@@ -267,6 +268,7 @@ class BalanceGateway implements GatewayInterface {
'refund_id' => 'BAL_REF_' . $order_number,
'transaction_id' => $result->transaction_id,
'ledger_transaction_id' => $result->transaction_id,
+ 'flow_number' => $result->flow_number,
'replayed' => $result->replayed,
'balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
'message' => __('退款成功', 'aksrt'),
diff --git a/src/Payment/OrderRepositoryAdmin.php b/src/Payment/OrderRepositoryAdmin.php
index 5b6f8d47..c4800ce0 100644
--- a/src/Payment/OrderRepositoryAdmin.php
+++ b/src/Payment/OrderRepositoryAdmin.php
@@ -376,20 +376,24 @@ trait OrderRepositoryAdmin {
}
if ($search !== '') {
- // Numeric search may mean user_id or product_id; also match order number / type.
+ $flow_table = \AKSRT\Core\DBMigrator::table('financial_flow');
+ $flow_match = "EXISTS (SELECT 1 FROM {$flow_table} ff WHERE ff.order_number = " . $this->table() . '.order_number AND ff.flow_number LIKE %s)';
+ // Numeric search may mean user_id or product_id; also match order or flow number / type.
if (ctype_digit($search)) {
- $where[] = '(order_number LIKE %s OR product_type LIKE %s OR user_id = %d OR product_id = %d)';
+ $where[] = "(order_number LIKE %s OR product_type LIKE %s OR user_id = %d OR product_id = %d OR {$flow_match})";
$like = '%' . $wpdb->esc_like($search) . '%';
$params[] = $like;
$params[] = $like;
$params[] = (int) $search;
$params[] = (int) $search;
+ $params[] = $like;
} else {
- $where[] = '(order_number LIKE %s OR product_type LIKE %s OR payment_method LIKE %s)';
+ $where[] = "(order_number LIKE %s OR product_type LIKE %s OR payment_method LIKE %s OR {$flow_match})";
$like = '%' . $wpdb->esc_like($search) . '%';
$params[] = $like;
$params[] = $like;
$params[] = $like;
+ $params[] = $like;
}
}
diff --git a/src/Payment/OrderRepositoryCore.php b/src/Payment/OrderRepositoryCore.php
index c217eca7..9cc08cd8 100644
--- a/src/Payment/OrderRepositoryCore.php
+++ b/src/Payment/OrderRepositoryCore.php
@@ -368,8 +368,10 @@ trait OrderRepositoryCore {
}
}
- private function update_status_in_transaction(string $order_number, string $new_status, array $extra = []): bool {
+ private function update_status_in_transaction(string $order_number, string $new_status, array $extra = []): bool {
global $wpdb;
+ $ledger_active = class_exists('\AKSRT\Ledger\LedgerService')
+ && \AKSRT\Ledger\LedgerService::get_instance()->transaction_active();
$valid = \AKSRT\Core\DomainModel::order_status_labels();
if (!isset($valid[$new_status])) return false;
@@ -499,6 +501,13 @@ trait OrderRepositoryCore {
}
}
+ if (!$ledger_active) {
+ $flow_number = \AKSRT\Finance\FinancialFlowService::get_instance()->record_order_payment($paid_order, true);
+ if (is_wp_error($flow_number)) {
+ return false;
+ }
+ }
+
if (class_exists('\AKSRT\Payment\CommissionLedger')) {
$commission = \AKSRT\Payment\CommissionLedger::get_instance()->record_order_transition(
$order_number,
@@ -535,6 +544,17 @@ trait OrderRepositoryCore {
'args' => [$order_number, $paid_order],
];
}
+
+ if ($new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED
+ && $current_status === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDING
+ && !$ledger_active
+ ) {
+ $refunded_order = array_merge($order, $update_data, ['status' => $new_status]);
+ $flow_number = \AKSRT\Finance\FinancialFlowService::get_instance()->record_order_refund($refunded_order, true);
+ if (is_wp_error($flow_number)) {
+ return false;
+ }
+ }
}
return $result !== false;
diff --git a/src/Payment/PaymentOrchestrator.php b/src/Payment/PaymentOrchestrator.php
index c0aec1f0..fdde7276 100644
--- a/src/Payment/PaymentOrchestrator.php
+++ b/src/Payment/PaymentOrchestrator.php
@@ -131,6 +131,7 @@ class PaymentOrchestrator {
'redirect' => false,
'transaction_id' => $transaction_id,
'ledger_transaction_id' => (int) ($result['ledger_transaction_id'] ?? 0),
+ 'flow_number' => (string) ($result['flow_number'] ?? ''),
'replayed' => !empty($result['replayed']),
'message' => (string) ($result['message'] ?? __('支付成功。', 'aksrt')),
];
diff --git a/src/Payment/UserOrders.php b/src/Payment/UserOrders.php
index 9debc361..b4211273 100644
--- a/src/Payment/UserOrders.php
+++ b/src/Payment/UserOrders.php
@@ -226,6 +226,9 @@ class UserOrders {
])),
];
if ($detail) {
+ $view['flow_number'] = $order_number !== '' && $group !== OrderCenterQuery::STATUS_PENDING
+ ? \AKSRT\Finance\FinancialFlowQuery::get_instance()->order_payment_number($order_number)
+ : '';
$view['paid_at'] = (string) ($order['paid_at'] ?? '');
$view['expires_at'] = (string) ($order['expires_at'] ?? '');
$view['reconciliation'] = $reconciliation ?: ['state' => 'not_needed', 'required' => false];
@@ -388,6 +391,7 @@ class UserOrders {
'order_number' => $order_number,
'order' => $this->get_order_detail_view($user_id, $order_number),
'transaction_id' => (int) $history['items'][0]['id'],
+ 'flow_number' => \AKSRT\Finance\FinancialFlowQuery::get_instance()->order_payment_number($order_number),
'replayed' => true,
'balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
'message' => __('支付成功。', 'aksrt'),
@@ -424,6 +428,7 @@ class UserOrders {
'order_number' => $order_number,
'order' => empty($result['redirect']) ? $this->get_order_detail_view($user_id, $order_number) : null,
'transaction_id' => (int) ($result['ledger_transaction_id'] ?? 0),
+ 'flow_number' => (string) ($result['flow_number'] ?? ''),
'replayed' => !empty($result['replayed']),
'balance' => $payment_method === 'balance' ? \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id) : null,
'message' => (string) ($result['message'] ?? ''),
diff --git a/src/Payment/Withdrawal.php b/src/Payment/Withdrawal.php
index 03211d09..5181b1be 100644
--- a/src/Payment/Withdrawal.php
+++ b/src/Payment/Withdrawal.php
@@ -99,7 +99,7 @@ class Withdrawal {
/**
* Atomically validates quotas, freezes wallet funds, writes the ledger and creates an order.
- * @return array{withdrawal_id:int,net_amount:string,wallet_balance:string,transaction_id:int,replayed:bool,usage:array}|\WP_Error
+ * @return array{withdrawal_id:int,withdrawal_number:string,net_amount:string,wallet_balance:string,transaction_id:int,flow_number:string,replayed:bool,usage:array}|\WP_Error
*/
public function create_balance_request(int $user_id, int $gross_minor, string $method, string $account, array $settings, string $operation_key) {
global $wpdb;
@@ -115,6 +115,7 @@ class Withdrawal {
$window = $this->daily_window((string) ($settings['reset_time'] ?? '00:00'));
$usage = null;
$withdrawal_id = 0;
+ $withdrawal_number = '';
$guard = function () use ($user_id, $gross_minor, $settings, $window, &$usage) {
global $wpdb;
$wpdb->get_results($wpdb->prepare(
@@ -137,7 +138,7 @@ class Withdrawal {
}
return true;
};
- $mutation = function (int $transaction_id) use ($user_id, $gross_minor, $net_minor, $fee_rate, $method, $account, $operation_key, &$withdrawal_id) {
+ $mutation = function (int $transaction_id) use ($user_id, $gross_minor, $net_minor, $fee_rate, $method, $account, $operation_key, &$withdrawal_id, &$withdrawal_number) {
global $wpdb;
$payload = wp_json_encode([
'account' => $account,
@@ -146,16 +147,26 @@ class Withdrawal {
'source' => 'balance',
'operation_key' => $operation_key,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: $account;
- $ok = $wpdb->insert($this->table(), [
- 'user_id' => $user_id,
- 'amount' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $net_minor)->decimal(),
- 'gross_amount' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $gross_minor)->decimal(),
- 'method' => sanitize_key($method),
- 'account_info' => $payload,
- 'status' => self::STATUS_PENDING,
- 'ledger_transaction_id' => $transaction_id,
- 'created_at' => current_time('mysql'),
- ], ['%d', '%s', '%s', '%s', '%s', '%s', '%d', '%s']);
+ $order_settings = \AKSRT\Settings\Settings::get_order_settings();
+ $prefix = \AKSRT\Finance\BusinessNumber::normalize_prefix($order_settings['withdrawal_prefix'] ?? 'TX', 'TX');
+ $ok = false;
+ for ($attempt = 1; $attempt <= 5; $attempt++) {
+ $withdrawal_number = \AKSRT\Finance\BusinessNumber::candidate($prefix);
+ $ok = $wpdb->insert($this->table(), [
+ 'withdrawal_number' => $withdrawal_number,
+ 'user_id' => $user_id,
+ 'amount' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $net_minor)->decimal(),
+ 'gross_amount' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $gross_minor)->decimal(),
+ 'method' => sanitize_key($method),
+ 'account_info' => $payload,
+ 'status' => self::STATUS_PENDING,
+ 'ledger_transaction_id' => $transaction_id,
+ 'created_at' => current_time('mysql'),
+ ], ['%s', '%d', '%s', '%s', '%s', '%s', '%s', '%d', '%s']);
+ if ($ok === 1) {
+ break;
+ }
+ }
if ($ok !== 1) {
return new \WP_Error('aksrt_withdraw_failed', __('提现申请失败', 'aksrt'), ['status' => 500]);
}
@@ -179,10 +190,12 @@ class Withdrawal {
return $result;
}
if ($withdrawal_id <= 0) {
- $withdrawal_id = (int) $wpdb->get_var($wpdb->prepare(
- "SELECT id FROM {$this->table()} WHERE ledger_transaction_id = %d",
+ $replayed_row = $wpdb->get_row($wpdb->prepare(
+ "SELECT id, withdrawal_number FROM {$this->table()} WHERE ledger_transaction_id = %d",
$result->transaction_id
- ));
+ ), ARRAY_A);
+ $withdrawal_id = (int) ($replayed_row['id'] ?? 0);
+ $withdrawal_number = (string) ($replayed_row['withdrawal_number'] ?? '');
}
if ($withdrawal_id <= 0) {
return new \WP_Error('aksrt_withdraw_missing', __('提现记录不存在', 'aksrt'), ['status' => 500]);
@@ -197,9 +210,11 @@ class Withdrawal {
}
return [
'withdrawal_id' => $withdrawal_id,
+ 'withdrawal_number' => $withdrawal_number,
'net_amount' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $net_minor)->decimal(),
'wallet_balance' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $result->balance($user_id, \AKSRT\Ledger\AssetType::BALANCE))->decimal(),
'transaction_id' => $result->transaction_id,
+ 'flow_number' => $result->flow_number,
'replayed' => $result->replayed,
'usage' => [
'count' => $usage['count'],
@@ -253,7 +268,7 @@ class Withdrawal {
* Status transition with balance side-effects.
* Reject of balance-source holds refunds gross_amount once (idempotent).
*
- * @return array{transaction_id:int,replayed:bool,wallet_balance:string}|\WP_Error
+ * @return array{transaction_id:int,flow_number:string,withdrawal_number:string,replayed:bool,wallet_balance:string}|\WP_Error
*/
public function process_status_change(int $withdrawal_id, string $new_status, string $note = '', string $operation_key = '') {
$new_status = sanitize_key($new_status);
@@ -287,6 +302,8 @@ class Withdrawal {
}
if (is_array($preflight_replay)) {
$preflight_replay['wallet_balance'] = \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal((int) ($row['user_id'] ?? 0));
+ $preflight_replay['withdrawal_number'] = (string) ($row['withdrawal_number'] ?? '');
+ $preflight_replay['flow_number'] = \AKSRT\Finance\FinancialFlowService::get_instance()->number_for_operation($operation_key);
return $preflight_replay;
}
$transitions = [
@@ -302,6 +319,7 @@ class Withdrawal {
$user_id = (int) ($row['user_id'] ?? 0);
$ledger_result = null;
+ $completed_flow_number = '';
$locked_row = null;
$locked_meta = [];
$operation_replay = null;
@@ -400,6 +418,19 @@ class Withdrawal {
}
$locked = $lock_and_validate();
$updated = is_wp_error($locked) ? $locked : $update_locked(0);
+ if (!is_wp_error($updated) && $new_status === self::STATUS_COMPLETED && !is_array($operation_replay)) {
+ $flow = \AKSRT\Finance\FinancialFlowService::get_instance()->record_withdrawal_payout(
+ array_merge((array) $locked_row, ['status' => $new_status]),
+ $operation_key,
+ (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s.u'),
+ true
+ );
+ if (is_wp_error($flow)) {
+ $updated = $flow;
+ } else {
+ $completed_flow_number = $flow;
+ }
+ }
if (is_wp_error($updated) || $wpdb->query('COMMIT') === false) {
$wpdb->query('ROLLBACK');
return is_wp_error($updated) ? $updated : new \WP_Error('aksrt_withdraw_update', __('提现状态更新失败', 'aksrt'), ['status' => 500]);
@@ -429,6 +460,8 @@ class Withdrawal {
return [
'transaction_id' => $ledger_result instanceof \AKSRT\Ledger\LedgerResult ? $ledger_result->transaction_id : 0,
+ 'flow_number' => $ledger_result instanceof \AKSRT\Ledger\LedgerResult ? $ledger_result->flow_number : $completed_flow_number,
+ 'withdrawal_number' => (string) ($row['withdrawal_number'] ?? ''),
'replayed' => $replayed,
'wallet_balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
];
@@ -528,15 +561,7 @@ class Withdrawal {
*/
public function get_all(array $args = []): array {
global $wpdb;
-
- $where = [];
- $params = [];
-
- $status = sanitize_key((string) ($args['status'] ?? ''));
- if ($status !== '') {
- $where[] = 'status = %s';
- $params[] = $status;
- }
+ [$where, $params] = $this->build_admin_filters($args);
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
$offset = max(0, (int) ($args['offset'] ?? 0));
@@ -556,15 +581,55 @@ class Withdrawal {
public function get_count(array $args = []): int {
global $wpdb;
+ [$where, $params] = $this->build_admin_filters($args);
+ $where_sql = $where ? ' WHERE ' . implode(' AND ', $where) : '';
+ $sql = "SELECT COUNT(*) FROM {$this->table()}{$where_sql}";
+ return (int) ($params ? $wpdb->get_var($wpdb->prepare($sql, $params)) : $wpdb->get_var($sql));
+ }
+ /** @return array{0:array,1:array} */
+ private function build_admin_filters(array $args): array {
+ global $wpdb;
+ $where = [];
+ $params = [];
$status = sanitize_key((string) ($args['status'] ?? ''));
- if ($status !== '') {
- return (int) $wpdb->get_var($wpdb->prepare(
- "SELECT COUNT(*) FROM {$this->table()} WHERE status = %s",
- $status
- ));
+ if (in_array($status, [self::STATUS_PENDING, self::STATUS_PROCESSING, self::STATUS_COMPLETED, self::STATUS_REJECTED], true)) {
+ $where[] = 'status = %s';
+ $params[] = $status;
}
- return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$this->table()}");
+ $user_id = max(0, (int) ($args['user_id'] ?? 0));
+ if ($user_id > 0) {
+ $where[] = 'user_id = %d';
+ $params[] = $user_id;
+ }
+ $keyword = trim(sanitize_text_field((string) ($args['keyword'] ?? '')));
+ if ($keyword !== '') {
+ $like = '%' . $wpdb->esc_like($keyword) . '%';
+ if (ctype_digit($keyword)) {
+ $where[] = '(withdrawal_number LIKE %s OR user_id = %d)';
+ $params[] = $like;
+ $params[] = (int) $keyword;
+ } else {
+ $where[] = 'withdrawal_number LIKE %s';
+ $params[] = $like;
+ }
+ }
+ if (!empty($args['date_from'])) {
+ $where[] = 'created_at >= %s';
+ $params[] = (string) $args['date_from'] . ' 00:00:00';
+ }
+ if (!empty($args['date_to'])) {
+ try {
+ $end = (new \DateTimeImmutable((string) $args['date_to'] . ' 00:00:00', wp_timezone()))->modify('+1 day')->format('Y-m-d H:i:s');
+ } catch (\Exception $e) {
+ $end = '';
+ }
+ if ($end !== '') {
+ $where[] = 'created_at < %s';
+ $params[] = $end;
+ }
+ }
+ return [$where, $params];
}
public function get_stats(): array {
diff --git a/src/Settings/CreditsPaymentGroups.php b/src/Settings/CreditsPaymentGroups.php
index b704789b..2e193edb 100644
--- a/src/Settings/CreditsPaymentGroups.php
+++ b/src/Settings/CreditsPaymentGroups.php
@@ -169,7 +169,7 @@ trait CreditsPaymentGroups {
/**
* 获取订单设置
*
- * @return array{prefix:string,expiry:int,show_user:bool,show_paycount:bool,no_logged_in:bool,cookie_day:int,no_logged_remind:bool,wait_float:bool,order_max_minutes:int,mark:string,box_position:string,show_ip:bool,show_device:bool,show_order_no:bool,show_create_time:bool,show_pay_time:bool,status_texts:array,service:array}
+ * @return array{prefix:string,flow_prefix:string,withdrawal_prefix:string,expiry:int,show_user:bool,show_paycount:bool,no_logged_in:bool,cookie_day:int,no_logged_remind:bool,wait_float:bool,order_max_minutes:int,mark:string,box_position:string,show_ip:bool,show_device:bool,show_order_no:bool,show_create_time:bool,show_pay_time:bool,status_texts:array,service:array}
*/
public static function get_order_settings(): array {
return PaymentRuntime::get_order_settings();
diff --git a/src/Settings/PaymentRuntime.php b/src/Settings/PaymentRuntime.php
index 0e0f1e1d..1961dc81 100644
--- a/src/Settings/PaymentRuntime.php
+++ b/src/Settings/PaymentRuntime.php
@@ -369,6 +369,8 @@ class PaymentRuntime {
return [
'prefix' => sanitize_text_field((string) ($s['payment_order_prefix'] ?? 'AKSRT')),
+ 'flow_prefix' => \AKSRT\Finance\BusinessNumber::normalize_prefix($s['payment_flow_prefix'] ?? 'AK', 'AK'),
+ 'withdrawal_prefix' => \AKSRT\Finance\BusinessNumber::normalize_prefix($s['payment_withdrawal_prefix'] ?? 'TX', 'TX'),
'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']),
diff --git a/tests/asset-ledger/BusinessNumberTest.php b/tests/asset-ledger/BusinessNumberTest.php
new file mode 100644
index 00000000..f67ada85
--- /dev/null
+++ b/tests/asset-ledger/BusinessNumberTest.php
@@ -0,0 +1,19 @@
+ ['points' => 25, 'balance' => 1234],
- ]);
+ ], 'AK20260825123456000001');
self::assertSame(25, $result->balance(4, AssetType::POINTS));
self::assertSame(1234, $result->balance(4, AssetType::BALANCE));
self::assertSame(0, $result->balance(99, AssetType::BALANCE));
self::assertSame(17, $result->to_array()['transaction_id']);
self::assertTrue($result->to_array()['replayed']);
+ self::assertSame('AK20260825123456000001', $result->flow_number);
+ self::assertSame('AK20260825123456000001', $result->to_array()['flow_number']);
}
}
diff --git a/tests/asset-ledger/bootstrap.php b/tests/asset-ledger/bootstrap.php
index 9b2db01f..3d8a6668 100644
--- a/tests/asset-ledger/bootstrap.php
+++ b/tests/asset-ledger/bootstrap.php
@@ -12,6 +12,16 @@ if (!function_exists('sanitize_key')) {
return strtolower((string) preg_replace('/[^a-z0-9_\-]/', '', $key));
}
}
+if (!function_exists('sanitize_text_field')) {
+ function sanitize_text_field($value): string {
+ return trim(strip_tags((string) $value));
+ }
+}
+if (!function_exists('wp_timezone')) {
+ function wp_timezone(): DateTimeZone {
+ return new DateTimeZone('UTC');
+ }
+}
if (!function_exists('wp_json_encode')) {
function wp_json_encode($value, int $flags = 0) {
return json_encode($value, $flags);
@@ -59,3 +69,4 @@ require_once dirname(__DIR__, 2) . '/src/Ledger/AssetAmount.php';
require_once dirname(__DIR__, 2) . '/src/Ledger/LedgerCommand.php';
require_once dirname(__DIR__, 2) . '/src/Ledger/LedgerResult.php';
require_once dirname(__DIR__, 2) . '/src/Ledger/OperationKey.php';
+require_once dirname(__DIR__, 2) . '/src/Finance/BusinessNumber.php';
diff --git a/tests/widget-system/financial-flow-contract.test.cjs b/tests/widget-system/financial-flow-contract.test.cjs
new file mode 100644
index 00000000..d2166683
--- /dev/null
+++ b/tests/widget-system/financial-flow-contract.test.cjs
@@ -0,0 +1,43 @@
+const test = require('node:test')
+const assert = require('node:assert/strict')
+const fs = require('node:fs')
+const path = require('node:path')
+
+const root = path.resolve(__dirname, '../..')
+const read = file => fs.readFileSync(path.join(root, file), 'utf8')
+
+test('financial flow and withdrawal numbers use the new-only schema', () => {
+ const schema = read('src/Core/DBMigratorPart2.php')
+ assert.match(schema, /CREATE TABLE \{\$prefix\}aksrt_financial_flow \([\s\S]*?UNIQUE KEY uniq_flow_number \(flow_number\)/)
+ assert.match(schema, /UNIQUE KEY uniq_flow_operation \(operation_key\)/)
+ assert.match(schema, /CREATE TABLE \{\$prefix\}aksrt_financial_flow_entry \([\s\S]*?UNIQUE KEY uniq_flow_line \(flow_id, line_no\)/)
+ assert.match(schema, /CREATE TABLE \{\$prefix\}aksrt_withdrawal \([\s\S]*?withdrawal_number VARCHAR\(32\) NOT NULL[\s\S]*?UNIQUE KEY uniq_withdrawal_number \(withdrawal_number\)/)
+})
+
+test('order settings expose strict flow and withdrawal prefixes', () => {
+ const schema = read('assets/admin/src/schema/sections/payment/order.js')
+ const provider = read('src/Admin/SchemaProvider.php')
+ assert.match(schema, /key:\s*'payment_flow_prefix'[^{\n]*sanitize_type:\s*'business_prefix'[^{\n]*default:\s*'AK'/)
+ assert.match(schema, /key:\s*'payment_withdrawal_prefix'[^{\n]*sanitize_type:\s*'business_prefix'[^{\n]*default:\s*'TX'/)
+ assert.match(provider, /business_prefix[\s\S]*?BusinessNumber::normalize_prefix/)
+})
+
+test('ledger and business state write their flow before commit', () => {
+ const ledger = read('src/Ledger/LedgerService.php')
+ const order = read('src/Payment/OrderRepositoryCore.php')
+ const withdrawal = read('src/Payment/Withdrawal.php')
+ assert.ok(ledger.indexOf('record_ledger(') < ledger.indexOf("query('COMMIT')"))
+ assert.match(order, /record_order_payment\(\$paid_order, true\)/)
+ assert.match(order, /record_order_refund\(\$refunded_order, true\)/)
+ assert.match(withdrawal, /record_withdrawal_payout\([\s\S]*?true[\s\S]*?query\('COMMIT'\)/)
+})
+
+test('native and front-end surfaces expose immutable business numbers', () => {
+ assert.match(read('src/Admin/Native/PayMenus.php'), /PayFlows::PAGE_SLUG/)
+ assert.match(read('src/Admin/Native/PayFlows.php'), /流水明细/)
+ assert.match(read('src/Admin/Native/PayOrders.php'), /支付流水号/)
+ assert.match(read('src/Admin/Native/PayWithdrawals.php'), /提现单号/)
+ assert.match(read('src/Payment/UserOrders.php'), /\$view\['flow_number'\]/)
+ assert.match(read('assets/js/order-center.js'), /\["流水号", order\.flow_number \|\| "—"\]/)
+ assert.match(read('inc/helpers/user-center-renderers/credits-wallet.php'), /withdrawal_number/)
+})
diff --git a/uninstall.php b/uninstall.php
index 8abf6a69..5a2660db 100644
--- a/uninstall.php
+++ b/uninstall.php
@@ -24,6 +24,8 @@ global $wpdb;
// ── Drop custom tables ──
$tables = [
+ 'aksrt_financial_flow_entry',
+ 'aksrt_financial_flow',
'aksrt_asset_entry',
'aksrt_asset_transaction',
'aksrt_asset_account',
| |