feat(payment): rebuild order details and asset records

This commit is contained in:
kate522 committed 2026-10-05 16:42:25 +08:00
1 parent 476e2f961d
commit 7b55cfb190
57 files changed
+2080 -1294

No files matched your search

-195
View File
@@ -1,195 +0,0 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Admin\Native;
defined('ABSPATH') || exit;
final class PayFlows {
public const CAPABILITY = 'manage_options';
public const PAGE_SLUG = 'aksrt_flows';
private static ?self $instance = null;
private function __construct() {}
public static function get_instance(): self {
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
public function render_page(): void {
if (!current_user_can(self::CAPABILITY)) {
wp_die(esc_html__('您没有权限访问此页面。', 'aksrt'));
}
$keyword = isset($_GET['s']) ? sanitize_text_field((string) wp_unslash($_GET['s'])) : '';
$event_type = isset($_GET['event_type']) ? sanitize_key((string) wp_unslash($_GET['event_type'])) : '';
$asset = isset($_GET['asset']) ? sanitize_key((string) wp_unslash($_GET['asset'])) : '';
$direction = isset($_GET['direction']) ? sanitize_key((string) wp_unslash($_GET['direction'])) : '';
$user_id = 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 = max(1, (int) ($_GET['paged'] ?? 1));
$query = \AKSRT\Finance\FinancialFlowQuery::get_instance();
$result = $query->search([
'keyword' => $keyword,
'event_type' => $event_type,
'asset' => $asset,
'direction' => $direction,
'user_id' => $user_id,
'date_from' => $this->date_bound_utc($date_from, false),
'date_to' => $this->date_bound_utc($date_to, true),
'page' => $page,
'per_page' => 20,
]);
$pages = max(1, (int) ceil($result['total'] / $result['per_page']));
echo '<div class="wrap"><h1>' . esc_html__('流水明细', 'aksrt') . '</h1>';
echo '<p class="description">' . esc_html__('仅展示已经确认发生的现金、余额和积分变动;流水不可编辑。', 'aksrt') . '</p>';
echo '<form method="get" style="margin:12px 0;display:flex;gap:6px;align-items:center;flex-wrap:wrap;">';
echo '<input type="hidden" name="page" value="' . esc_attr(self::PAGE_SLUG) . '">';
echo '<input type="search" name="s" value="' . esc_attr($keyword) . '" placeholder="' . esc_attr__('账本流水号 / 流水号 / 渠道交易号', 'aksrt') . '" style="min-width:280px;">';
echo '<input type="number" min="1" name="user_id" value="' . esc_attr($user_id > 0 ? (string) $user_id : '') . '" placeholder="' . esc_attr__('用户 ID', 'aksrt') . '" class="small-text">';
echo '<select name="event_type"><option value="">' . esc_html__('全部事件', 'aksrt') . '</option>';
foreach ($query->event_types() as $key => $label) {
echo '<option value="' . esc_attr($key) . '"' . selected($event_type, $key, false) . '>' . esc_html($this->event_label($label)) . '</option>';
}
echo '</select>';
$this->render_select('asset', $asset, __('全部资产', 'aksrt'), ['cash' => __('现金', 'aksrt'), 'balance' => __('余额', 'aksrt'), 'points' => __('积分', 'aksrt')]);
$this->render_select('direction', $direction, __('全部方向', 'aksrt'), ['in' => __('收入', 'aksrt'), 'out' => __('支出', 'aksrt')]);
echo '<label>' . esc_html__('从', 'aksrt') . ' <input type="date" name="date_from" value="' . esc_attr($date_from) . '"></label>';
echo '<label>' . esc_html__('至', 'aksrt') . ' <input type="date" name="date_to" value="' . esc_attr($date_to) . '"></label>';
submit_button(__('筛选', 'aksrt'), 'secondary', '', false);
if ($keyword !== '' || $event_type !== '' || $asset !== '' || $direction !== '' || $user_id > 0 || $date_from !== '' || $date_to !== '') {
echo '<a class="button" href="' . esc_url($this->page_url()) . '">' . esc_html__('清除条件', 'aksrt') . '</a>';
}
echo '</form>';
echo '<p>' . esc_html(sprintf(__('共 %d 条流水', 'aksrt'), (int) $result['total'])) . '</p>';
echo '<table class="wp-list-table widefat fixed striped"><thead><tr>';
echo '<th style="width:180px">' . esc_html__('账本流水号 / 时间', 'aksrt') . '</th>';
echo '<th style="width:130px">' . esc_html__('事件', 'aksrt') . '</th>';
echo '<th>' . esc_html__('用户与分录', 'aksrt') . '</th>';
echo '<th>' . esc_html__('关联业务', 'aksrt') . '</th>';
echo '<th>' . esc_html__('渠道交易号', 'aksrt') . '</th>';
echo '</tr></thead><tbody>';
if ($result['items'] === []) {
echo '<tr><td colspan="5">' . esc_html__('暂无匹配流水', 'aksrt') . '</td></tr>';
}
foreach ($result['items'] as $flow) {
echo '<tr>';
echo '<td><code>' . esc_html((string) $flow['flow_number']) . '</code><br><span class="description">' . esc_html($this->local_time((string) $flow['created_at'])) . '</span></td>';
echo '<td><strong>' . esc_html($this->event_label((string) $flow['event_type'])) . '</strong><br><code>' . esc_html((string) $flow['event_type']) . '</code></td>';
echo '<td>' . $this->entries_html((array) $flow['entries']) . '</td>';
echo '<td>' . $this->reference_html($flow) . '</td>';
echo '<td>' . ((string) ($flow['provider_transaction_id'] ?? '') !== '' ? '<code>' . esc_html((string) $flow['provider_transaction_id']) . '</code>' : '&mdash;') . '</td>';
echo '</tr>';
}
echo '</tbody></table>';
if ($pages > 1) {
echo '<div class="tablenav"><div class="tablenav-pages">' . wp_kses_post((string) paginate_links([
'base' => add_query_arg('paged', '%#%'),
'format' => '',
'prev_text' => '&laquo;',
'next_text' => '&raquo;',
'total' => $pages,
'current' => $page,
])) . '</div></div>';
}
echo '</div>';
}
/** @param array<string,string> $options */
private function render_select(string $name, string $value, string $empty_label, array $options): void {
echo '<select name="' . esc_attr($name) . '"><option value="">' . esc_html($empty_label) . '</option>';
foreach ($options as $key => $label) {
echo '<option value="' . esc_attr($key) . '"' . selected($value, $key, false) . '>' . esc_html($label) . '</option>';
}
echo '</select>';
}
/** @param array<int,array<string,mixed>> $entries */
private function entries_html(array $entries): string {
if ($entries === []) {
return '&mdash;';
}
$lines = [];
foreach ($entries as $entry) {
$user_id = (int) ($entry['user_id'] ?? 0);
$user = $user_id > 0 ? get_userdata($user_id) : null;
$user_label = $user ? $user->display_name . ' (#' . $user_id . ')' : '#' . $user_id;
$asset = (string) ($entry['asset'] ?? '');
$delta = (int) ($entry['delta_minor'] ?? 0);
$amount = $asset === 'points'
? (string) abs($delta)
: \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, abs($delta))->decimal();
$asset_label = ['cash' => __('现金', 'aksrt'), 'balance' => __('余额', 'aksrt'), 'points' => __('积分', 'aksrt')][$asset] ?? $asset;
$lines[] = esc_html($user_label) . ' · ' . esc_html($asset_label) . ' <strong>' . ($delta > 0 ? '+' : '-') . esc_html($amount) . '</strong>';
}
return implode('<br>', $lines);
}
/** @param array<string,mixed> $flow */
private function reference_html(array $flow): string {
$lines = [];
if (!empty($flow['order_number'])) {
$url = add_query_arg(['page' => PayOrders::PAGE_SLUG, 'order' => (string) $flow['order_number']], admin_url('admin.php'));
$lines[] = esc_html__('订单', 'aksrt') . ' <a href="' . esc_url($url) . '"><code>' . esc_html((string) $flow['order_number']) . '</code></a>';
}
if (!empty($flow['withdrawal_number'])) {
$lines[] = esc_html__('提现', 'aksrt') . ' <code>' . esc_html((string) $flow['withdrawal_number']) . '</code>';
}
if ($lines === [] && !empty($flow['ref_key'])) {
$lines[] = esc_html((string) ($flow['ref_type'] ?? '')) . ' · ' . esc_html((string) $flow['ref_key']);
}
return $lines !== [] ? implode('<br>', $lines) : '&mdash;';
}
private function event_label(string $event_type): string {
$labels = [
'order_payment' => __('订单支付', 'aksrt'),
'order_refund' => __('订单退款', 'aksrt'),
'withdraw_hold' => __('提现冻结', 'aksrt'),
'withdraw_refund' => __('提现退回', 'aksrt'),
'withdraw_payout' => __('提现打款', 'aksrt'),
'creator_income' => __('创作收益', 'aksrt'),
'admin_adjustment' => __('后台调账', 'aksrt'),
'points_exchange' => __('积分兑换', 'aksrt'),
'transfer' => __('用户转账', 'aksrt'),
];
return $labels[$event_type] ?? $event_type;
}
private function sanitize_date(string $date): string {
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) === 1 ? $date : '';
}
private function date_bound_utc(string $date, bool $exclusive_end): string {
if ($date === '') {
return '';
}
$local = new \DateTimeImmutable($date . ' 00:00:00', wp_timezone());
if ($exclusive_end) {
$local = $local->modify('+1 day');
}
return $local->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d H:i:s');
}
private function local_time(string $utc): string {
if ($utc === '') {
return '';
}
try {
return (new \DateTimeImmutable($utc, new \DateTimeZone('UTC')))->setTimezone(wp_timezone())->format('Y-m-d H:i:s');
} catch (\Exception $e) {
return $utc;
}
}
private function page_url(): string {
return add_query_arg(['page' => self::PAGE_SLUG], admin_url('admin.php'));
}
}
+1 -8
View File
@@ -43,7 +43,7 @@ class PayMenus {
],
'orders' => [
'slug' => 'aksrt_orders',
'title' => __('订单', 'aksrt'),
'title' => __('订单明细', 'aksrt'),
'callback' => static function (): void {
if (class_exists('AKSRT\Admin\Native\PayOrders')) {
PayOrders::get_instance()->render_orders_page();
@@ -52,13 +52,6 @@ class PayMenus {
wp_die(esc_html__('订单模块未初始化。', 'aksrt'));
},
],
'flows' => [
'slug' => PayFlows::PAGE_SLUG,
'title' => __('流水明细', 'aksrt'),
'callback' => static function (): void {
PayFlows::get_instance()->render_page();
},
],
'products' => [
'slug' => 'aksrt_pay_products',
'title' => __('付费文章', 'aksrt'),
+158 -29
View File
@@ -29,11 +29,12 @@ class PayOrders {
add_action('admin_post_aksrt_pay_update_order_note', [$this, 'handle_update_order_note']);
add_action('admin_post_aksrt_pay_clear_closed_orders', [$this, 'handle_clear_closed_orders']);
add_action('admin_post_aksrt_pay_delete_order', [$this, 'handle_delete_order']);
add_action('admin_post_aksrt_after_sale_review', [$this, 'handle_after_sale_review']);
}
public function render_orders_page(): void {
$this->ensure_cap();
$this->render_header(__('订单', 'aksrt'));
$this->render_header(__('订单明细', 'aksrt'));
if (!class_exists('\AKSRT\Payment\OrderRepository')) {
echo '<div class="notice notice-error"><p>' . esc_html__('订单服务不可用。', 'aksrt') . '</p></div>';
@@ -41,7 +42,21 @@ class PayOrders {
return;
}
$detail_order = isset($_GET['order']) ? sanitize_text_field((string) wp_unslash($_GET['order'])) : '';
$tab = isset($_GET['tab']) ? sanitize_key((string) wp_unslash($_GET['tab'])) : 'orders';
if (!in_array($tab, ['stats', 'orders', 'after-sales'], true)) $tab = 'orders';
$this->render_workbench_tabs($tab);
if ($tab === 'stats') {
$this->render_statistics_page();
$this->render_footer();
return;
}
if ($tab === 'after-sales') {
$this->render_after_sales_page();
$this->render_footer();
return;
}
$detail_order = isset($_GET['order']) ? sanitize_text_field((string) wp_unslash($_GET['order'])) : '';
if ($detail_order !== '') {
$this->render_order_detail_page($detail_order);
$this->render_footer();
@@ -50,10 +65,13 @@ class PayOrders {
$type_tabs = [
'' => __('全部类型', 'aksrt'),
\AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE => __('内容付费', 'aksrt'),
\AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP => __('VIP', 'aksrt'),
\AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE => __('充值', 'aksrt'),
\AKSRT\Core\DomainModel::ORDER_PRODUCT_POINTS => __('购买积分', 'aksrt'),
\AKSRT\Core\DomainModel::ORDER_PRODUCT_READING => __('付费阅读', 'aksrt'),
\AKSRT\Core\DomainModel::ORDER_PRODUCT_DOWNLOAD => __('付费下载', 'aksrt'),
\AKSRT\Core\DomainModel::ORDER_PRODUCT_IMAGE => __('付费图片', 'aksrt'),
\AKSRT\Core\DomainModel::ORDER_PRODUCT_VIDEO => __('付费视频', 'aksrt'),
\AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP => __('购买会员', 'aksrt'),
\AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE => __('余额充值', 'aksrt'),
\AKSRT\Core\DomainModel::ORDER_PRODUCT_POINTS => __('购买积分', 'aksrt'),
];
$method_tabs = [
'' => __('支付方式', 'aksrt'),
@@ -68,6 +86,8 @@ class PayOrders {
$product_id = isset($_GET['product_id']) ? max(0, (int) $_GET['product_id']) : 0;
$user_id = isset($_GET['user_id']) ? max(0, (int) $_GET['user_id']) : 0;
$pay_method = isset($_GET['payment_method']) ? sanitize_key((string) wp_unslash($_GET['payment_method'])) : '';
$date_from = isset($_GET['date_from']) ? sanitize_text_field((string) wp_unslash($_GET['date_from'])) : '';
$date_to = isset($_GET['date_to']) ? sanitize_text_field((string) wp_unslash($_GET['date_to'])) : '';
$page_num = max(1, (int) ($_GET['paged'] ?? 1));
$per_page = 20;
@@ -82,6 +102,8 @@ class PayOrders {
$args = [
'limit' => $per_page,
'offset' => ($page_num - 1) * $per_page,
'date_from' => $date_from,
'date_to' => $date_to,
];
if ($status !== '' && $status !== 'all') {
$args['status'] = $status;
@@ -118,6 +140,8 @@ class PayOrders {
'user_id' => $user_id > 0 ? $user_id : null,
'payment_method' => $pay_method !== '' ? $pay_method : null,
'status' => ($status !== '' && $status !== 'all') ? $status : null,
'date_from' => $date_from !== '' ? $date_from : null,
'date_to' => $date_to !== '' ? $date_to : null,
's' => $search !== '' ? $search : null,
]));
$cls = ($product_type === $tkey) ? 'current' : '';
@@ -136,6 +160,8 @@ class PayOrders {
'user_id' => $user_id > 0 ? $user_id : null,
'payment_method' => $mkey !== '' ? $mkey : null,
'status' => ($status !== '' && $status !== 'all') ? $status : null,
'date_from' => $date_from !== '' ? $date_from : null,
'date_to' => $date_to !== '' ? $date_to : null,
's' => $search !== '' ? $search : null,
]));
$cls = ($pay_method === $mkey) ? 'current' : '';
@@ -155,6 +181,8 @@ class PayOrders {
if ($pay_method !== '') {
echo '<input type="hidden" name="payment_method" value="' . esc_attr($pay_method) . '" />';
}
echo '<label>' . esc_html__('开始日期', 'aksrt') . ' <input type="date" name="date_from" value="' . esc_attr($date_from) . '" /></label> ';
echo '<label>' . esc_html__('结束日期', 'aksrt') . ' <input type="date" name="date_to" value="' . esc_attr($date_to) . '" /></label> ';
echo '<select name="status">';
echo '<option value="">' . esc_html__('全部状态', 'aksrt') . '</option>';
foreach ($labels as $key => $label) {
@@ -167,9 +195,9 @@ class PayOrders {
}
echo '</select> ';
echo '<input type="number" min="0" name="user_id" value="' . esc_attr($user_id > 0 ? (string) $user_id : '') . '" placeholder="' . esc_attr__('用户 ID', 'aksrt') . '" class="small-text" /> ';
echo '<input type="search" name="s" value="' . esc_attr($search) . '" placeholder="' . esc_attr__('流水号 / 账本流水号 / 类型 / 用户', 'aksrt') . '" /> ';
echo '<input type="search" name="s" value="' . esc_attr($search) . '" placeholder="' . esc_attr__('订单号 / 用户 / 商品', 'aksrt') . '" /> ';
submit_button(__('筛选', 'aksrt'), 'secondary', '', false);
if ($product_id > 0 || $product_type !== '' || $user_id > 0 || $pay_method !== '') {
if ($product_id > 0 || $product_type !== '' || $user_id > 0 || $pay_method !== '' || $date_from !== '' || $date_to !== '') {
$filter_bits = [];
if ($product_type !== '') {
$filter_bits[] = sprintf(__('类型=%s', 'aksrt'), $product_type);
@@ -183,6 +211,8 @@ class PayOrders {
if ($pay_method !== '') {
$filter_bits[] = sprintf(__('支付方式=%s', 'aksrt'), $method_tabs[$pay_method]);
}
if ($date_from !== '') $filter_bits[] = sprintf(__('开始日期=%s', 'aksrt'), $date_from);
if ($date_to !== '') $filter_bits[] = sprintf(__('结束日期=%s', 'aksrt'), $date_to);
echo ' <span class="description">' . esc_html(implode(' · ', $filter_bits)) . '</span>';
echo ' <a class="button button-small" href="' . esc_url($this->page_url('aksrt_orders')) . '">' . esc_html__('清除过滤', 'aksrt') . '</a>';
}
@@ -200,7 +230,7 @@ class PayOrders {
echo '<table class="wp-list-table widefat fixed striped">';
echo '<thead><tr>';
echo '<th>' . esc_html__('流水号', 'aksrt') . '</th>';
echo '<th>' . esc_html__('订单号', 'aksrt') . '</th>';
echo '<th>' . esc_html__('用户', 'aksrt') . '</th>';
echo '<th>' . esc_html__('商品信息', 'aksrt') . '</th>';
echo '<th>' . esc_html__('金额 / 支付方式', 'aksrt') . '</th>';
@@ -221,6 +251,7 @@ class PayOrders {
$status_label = $labels[$status_key] ?? $status_key;
$product_id = (int) ($row['product_id'] ?? 0);
$product_type = sanitize_key((string) ($row['product_type'] ?? ''));
$type_labels = \AKSRT\Core\DomainModel::order_product_type_labels();
$meta = is_array($row['meta_payload'] ?? null) ? $row['meta_payload'] : [];
$product_title = (string) ($meta['product_title'] ?? $meta['title'] ?? $meta['package_title'] ?? '');
if ($product_title === '' && $product_id > 0 && $product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE) {
@@ -230,7 +261,7 @@ class PayOrders {
echo '<tr>';
echo '<td><code>' . esc_html($order_number) . '</code></td>';
echo '<td>' . ($user ? esc_html($user->display_name . ' (#' . $user_id . ')') : esc_html((string) $user_id)) . '</td>';
echo '<td><strong>' . esc_html($product_title !== '' ? $product_title : $product_type) . '</strong><br><span class="description">' . esc_html($product_type) . ($product_id > 0 ? ' #' . esc_html((string) $product_id) : '') . '</span></td>';
echo '<td><strong>' . esc_html($product_title !== '' ? $product_title : ($type_labels[$product_type] ?? $product_type)) . '</strong><br><span class="description">' . esc_html($type_labels[$product_type] ?? $product_type) . ($product_id > 0 ? ' #' . esc_html((string) $product_id) : '') . '</span></td>';
echo '<td><strong>' . esc_html($this->order_amount($row)) . '</strong><br><span class="description">' . esc_html((string) ($row['payment_method'] ?? '')) . '</span></td>';
echo '<td>' . esc_html($status_label) . '</td>';
echo '<td>' . esc_html((string) ($row['created_at'] ?? '')) . '<br><span class="description">' . esc_html((string) ($row['paid_at'] ?? '') ?: '—') . '</span></td>';
@@ -280,7 +311,6 @@ 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'] ?? '');
@@ -295,10 +325,9 @@ class PayOrders {
echo '<table class="widefat striped" style="max-width:960px;"><tbody>';
$rows = [
__('流水号', 'aksrt') => $order_number,
__('账本流水号', 'aksrt') => $flow_number !== '' ? $flow_number : '—',
__('订单号', 'aksrt') => $order_number,
__('用户', 'aksrt') => $user ? $user->display_name . ' (#' . $user_id . ')' : (string) $user_id,
__('商品类型', 'aksrt') => (string) ($order['product_type'] ?? ''),
__('商品类型', 'aksrt') => \AKSRT\Core\DomainModel::order_product_type_labels()[(string) ($order['product_type'] ?? '')] ?? (string) ($order['product_type'] ?? ''),
__('商品 ID', 'aksrt') => (string) ($order['product_id'] ?? ''),
__('金额', 'aksrt') => $this->order_amount($order),
__('状态', 'aksrt') => $labels[$status] ?? $status,
@@ -311,6 +340,18 @@ class PayOrders {
foreach ($rows as $label => $value) {
echo '<tr><th style="width:160px;">' . esc_html($label) . '</th><td>' . esc_html((string) $value) . '</td></tr>';
}
$after_sales = \AKSRT\Payment\AfterSaleService::get_instance()->list_for_order($order_number);
if ($after_sales) {
echo '<tr><th>' . esc_html__('售后记录', 'aksrt') . '</th><td>';
$status_labels = ['pending' => '待审核', 'processing' => '处理中', 'approved' => '已通过', 'rejected' => '已拒绝', 'cancelled' => '已取消', 'completed' => '已完成'];
foreach ($after_sales as $application) {
echo '<p><strong>' . esc_html($status_labels[(string) ($application['status'] ?? '')] ?? (string) ($application['status'] ?? '')) . '</strong> · ' . esc_html((string) ($application['refund_amount'] ?? '')) . '<br>' . esc_html((string) ($application['reason'] ?? ''));
if (!empty($application['admin_note'])) echo '<br>' . esc_html__('处理备注:', 'aksrt') . esc_html((string) $application['admin_note']);
if (!empty($application['gateway_result'])) echo '<br><code>' . esc_html((string) $application['gateway_result']) . '</code>';
echo '</p>';
}
echo '</td></tr>';
}
if (!empty($context)) {
echo '<tr><th>' . esc_html__('附加上下文', 'aksrt') . '</th><td><pre style="white-space:pre-wrap;margin:0;">' . esc_html(wp_json_encode($context, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)) . '</pre></td></tr>';
}
@@ -331,6 +372,92 @@ class PayOrders {
echo '</div>';
}
private function render_workbench_tabs(string $active): void {
echo '<nav class="nav-tab-wrapper" style="margin:12px 0 18px;">';
foreach (['stats' => __('统计', 'aksrt'), 'orders' => __('订单', 'aksrt'), 'after-sales' => __('售后', 'aksrt')] as $key => $label) {
$url = $this->page_url(self::PAGE_SLUG, ['tab' => $key]);
echo '<a class="nav-tab ' . ($active === $key ? 'nav-tab-active' : '') . '" href="' . esc_url($url) . '">' . esc_html($label) . '</a>';
}
echo '</nav>';
}
private function render_statistics_page(): void {
$stats = \AKSRT\Payment\OrderRepository::get_instance()->get_admin_order_stats([
'date_from' => isset($_GET['date_from']) ? sanitize_text_field((string) wp_unslash($_GET['date_from'])) : '',
'date_to' => isset($_GET['date_to']) ? sanitize_text_field((string) wp_unslash($_GET['date_to'])) : '',
]);
echo '<form method="get" style="margin-bottom:16px;"><input type="hidden" name="page" value="' . esc_attr(self::PAGE_SLUG) . '" /><input type="hidden" name="tab" value="stats" />';
echo '<label>' . esc_html__('开始日期', 'aksrt') . ' <input type="date" name="date_from" value="' . esc_attr((string) ($_GET['date_from'] ?? '')) . '" /></label> ';
echo '<label>' . esc_html__('结束日期', 'aksrt') . ' <input type="date" name="date_to" value="' . esc_attr((string) ($_GET['date_to'] ?? '')) . '" /></label> ';
submit_button(__('筛选', 'aksrt'), 'secondary', '', false);
echo '</form><div style="display:flex;flex-wrap:wrap;gap:12px;">';
foreach (['total' => __('订单总数', 'aksrt'), 'pending' => __('待支付', 'aksrt'), 'paid' => __('已支付', 'aksrt'), 'refunded' => __('已退款', 'aksrt'), 'closed' => __('已关闭', 'aksrt'), 'revenue' => __('实收金额', 'aksrt'), 'after_sale_pending' => __('售后待审核', 'aksrt')] as $key => $label) {
$this->stat_card($label, (string) ($stats[$key] ?? 0), $this->page_url(self::PAGE_SLUG, ['tab' => 'orders']));
}
echo '</div><h2>' . esc_html__('按商品类型统计', 'aksrt') . '</h2><table class="widefat striped" style="max-width:640px;"><tbody>';
foreach ((array) ($stats['by_type'] ?? []) as $type => $count) echo '<tr><th>' . esc_html(\AKSRT\Core\DomainModel::order_product_type_labels()[$type] ?? $type) . '</th><td>' . esc_html((string) $count) . '</td></tr>';
echo '</tbody></table>';
}
private function render_after_sales_page(): void {
$service = \AKSRT\Payment\AfterSaleService::get_instance();
$status = isset($_GET['after_sale_status']) ? sanitize_key((string) wp_unslash($_GET['after_sale_status'])) : '';
$date_from = isset($_GET['date_from']) ? sanitize_text_field((string) wp_unslash($_GET['date_from'])) : '';
$date_to = isset($_GET['date_to']) ? sanitize_text_field((string) wp_unslash($_GET['date_to'])) : '';
$date_from = preg_match('/^\d{4}-\d{2}-\d{2}$/', $date_from) ? $date_from : '';
$date_to = preg_match('/^\d{4}-\d{2}-\d{2}$/', $date_to) ? $date_to : '';
$filter_args = ['status' => $status, 'date_from' => $date_from, 'date_to' => $date_to];
$items = $service->admin_list($filter_args + ['limit' => 100]);
echo '<form method="get"><input type="hidden" name="page" value="' . esc_attr(self::PAGE_SLUG) . '" /><input type="hidden" name="tab" value="after-sales" />';
echo '<select name="after_sale_status"><option value="">' . esc_html__('全部售后状态', 'aksrt') . '</option>';
foreach ([\AKSRT\Payment\AfterSaleService::STATUS_PENDING => '待审核', \AKSRT\Payment\AfterSaleService::STATUS_PROCESSING => '处理中', \AKSRT\Payment\AfterSaleService::STATUS_APPROVED => '已通过', \AKSRT\Payment\AfterSaleService::STATUS_REJECTED => '已拒绝', \AKSRT\Payment\AfterSaleService::STATUS_CANCELLED => '已取消', \AKSRT\Payment\AfterSaleService::STATUS_COMPLETED => '已完成'] as $key => $label) echo '<option value="' . esc_attr($key) . '"' . selected($status, $key, false) . '>' . esc_html($label) . '</option>';
echo '</select> <label>' . esc_html__('开始日期', 'aksrt') . ' <input type="date" name="date_from" value="' . esc_attr($date_from) . '" /></label> <label>' . esc_html__('结束日期', 'aksrt') . ' <input type="date" name="date_to" value="' . esc_attr($date_to) . '" /></label> ';
submit_button(__('筛选', 'aksrt'), 'secondary', '', false);
echo '</form><table class="widefat striped"><thead><tr><th>ID</th><th>订单号</th><th>用户</th><th>原因</th><th>退款金额</th><th>状态</th><th>操作 / 处理记录</th></tr></thead><tbody>';
if (!$items) echo '<tr><td colspan="7">' . esc_html__('暂无售后申请', 'aksrt') . '</td></tr>';
foreach ($items as $item) {
$id = (int) $item['id']; $number = (string) $item['order_number']; $uid = (int) $item['user_id'];
$user = $uid > 0 ? get_userdata($uid) : null;
$order = \AKSRT\Payment\OrderRepository::get_instance()->get($number);
$type = $order ? \AKSRT\Core\DomainModel::resolve_order_product_type((string) ($order['product_type'] ?? ''), (array) ($order['meta_payload'] ?? [])) : '';
$type_label = \AKSRT\Core\DomainModel::order_product_type_labels()[$type] ?? $type;
$status_labels = [\AKSRT\Payment\AfterSaleService::STATUS_PENDING => '待审核', \AKSRT\Payment\AfterSaleService::STATUS_PROCESSING => '处理中', \AKSRT\Payment\AfterSaleService::STATUS_APPROVED => '已通过', \AKSRT\Payment\AfterSaleService::STATUS_REJECTED => '已拒绝', \AKSRT\Payment\AfterSaleService::STATUS_CANCELLED => '已取消', \AKSRT\Payment\AfterSaleService::STATUS_COMPLETED => '已完成'];
$refund_amount = (string) ($item['refund_amount'] ?? '0');
if ($order && (string) ($order['currency'] ?? '') === 'points') $refund_amount .= ' ' . __('积分', 'aksrt'); else $refund_amount = '¥' . $refund_amount;
echo '<tr><td>' . esc_html((string) $id) . '<br><small>' . esc_html((string) ($item['created_at'] ?? '')) . '</small></td><td><a href="' . esc_url($this->page_url(self::PAGE_SLUG, ['tab' => 'orders', 'order' => $number])) . '"><code>' . esc_html($number) . '</code></a><br><span class="description">' . esc_html($type_label) . ($order ? ' · ' . esc_html(\AKSRT\Payment\UserOrders::get_instance()->decorate_order_view($order, false)['product_name'] ?? '') : '') . '</span></td><td>' . esc_html($user ? $user->display_name . ' (#' . $uid . ')' : '#' . $uid) . '</td><td>' . esc_html((string) $item['reason']) . '</td><td>' . esc_html($refund_amount) . '</td><td>' . esc_html($status_labels[(string) $item['status']] ?? (string) $item['status']) . '</td><td>';
if ((string) $item['status'] === 'pending') foreach (['approve' => '通过', 'reject' => '拒绝'] as $decision => $label) {
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="display:inline-block;vertical-align:top;margin-right:4px;"><input type="hidden" name="action" value="aksrt_after_sale_review" /><input type="hidden" name="after_sale_id" value="' . esc_attr((string) $id) . '" /><input type="hidden" name="decision" value="' . esc_attr($decision) . '" />'; wp_nonce_field('aksrt_after_sale_review_' . $id); echo '<textarea name="admin_note" rows="2" placeholder="' . esc_attr__('管理员备注', 'aksrt') . '"></textarea><br><button class="button button-small" type="submit">' . esc_html($label) . '</button></form>';
}
if ((string) $item['status'] === 'processing') {
$gateway_result = json_decode((string) ($item['gateway_result'] ?? ''), true);
$manual_refund = is_array($gateway_result) && sanitize_key((string) ($gateway_result['status'] ?? '')) === 'manual';
foreach (($manual_refund ? ['complete' => '确认线下退款完成', 'reject' => '确认未退款并拒绝'] : ['approve' => '同步退款状态']) as $decision => $label) {
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="display:inline-block;margin-right:4px;"><input type="hidden" name="action" value="aksrt_after_sale_review" /><input type="hidden" name="after_sale_id" value="' . esc_attr((string) $id) . '" /><input type="hidden" name="decision" value="' . esc_attr($decision) . '" />'; wp_nonce_field('aksrt_after_sale_review_' . $id); echo '<button class="button button-small" type="submit">' . esc_html__($label, 'aksrt') . '</button></form>';
}
}
if (!empty($item['admin_note'])) echo '<small>' . esc_html__('备注:', 'aksrt') . esc_html((string) $item['admin_note']) . '</small><br>';
if (!empty($item['gateway_result'])) {
$result_data = json_decode((string) $item['gateway_result'], true);
$result_text = is_array($result_data) ? implode(' · ', array_filter([(string) ($result_data['status'] ?? ''), (string) ($result_data['message'] ?? ''), !empty($result_data['refund_id']) ? '退款单号 ' . (string) $result_data['refund_id'] : ''])) : (string) $item['gateway_result'];
echo '<small>' . esc_html__('渠道结果:', 'aksrt') . ' ' . esc_html($result_text) . '</small>';
}
echo '</td></tr>';
}
echo '</tbody></table>';
}
public function handle_after_sale_review(): void {
if (!current_user_can(self::CAPABILITY)) wp_die(esc_html__('权限不足', 'aksrt'));
$id = isset($_POST['after_sale_id']) ? absint($_POST['after_sale_id']) : 0;
check_admin_referer('aksrt_after_sale_review_' . $id);
$decision = isset($_POST['decision']) ? sanitize_key((string) wp_unslash($_POST['decision'])) : '';
$note = isset($_POST['admin_note']) ? sanitize_textarea_field((string) wp_unslash($_POST['admin_note'])) : '';
$result = \AKSRT\Payment\AfterSaleService::get_instance()->review($id, $decision, $note);
$args = ['tab' => 'after-sales', 'aksrt_pay_msg' => is_wp_error($result) ? 'after_sale_review_failed' : 'after_sale_reviewed'];
if (is_wp_error($result)) $args['after_sale_error'] = substr($result->get_error_message(), 0, 240);
wp_safe_redirect($this->page_url(self::PAGE_SLUG, $args)); exit;
}
public function handle_update_order_status(): void {
if (!current_user_can(self::CAPABILITY)) {
wp_die(esc_html__('权限不足', 'aksrt'));
@@ -417,17 +544,14 @@ class PayOrders {
}
private function render_order_quick_status_buttons(string $order_number, string $current_status, bool $redirect_detail = false): void {
$labels = [
'paid' => __('确认收款', 'aksrt'),
'refunding' => __('发起退款', 'aksrt'),
'refunded' => __('确认退款', 'aksrt'),
'closed' => __('关闭订单', 'aksrt'),
];
$labels = [
'paid' => __('确认收款', 'aksrt'),
'closed' => __('关闭订单', 'aksrt'),
];
$transitions = \AKSRT\Core\DomainModel::order_transitions();
foreach ((array) ($transitions[$current_status] ?? []) as $status) {
$label = $status === 'paid' && $current_status === 'refunding'
? __('取消退款', 'aksrt')
: ($labels[$status] ?? $status);
if ($status === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED) continue;
$label = $labels[$status] ?? $status;
$confirm = sprintf(__('确认将订单状态改为“%s”?', 'aksrt'), $label);
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="display:inline;">';
echo '<input type="hidden" name="action" value="aksrt_pay_update_order_status" />';
@@ -513,13 +637,18 @@ class PayOrders {
'shipping_created' => __('发货记录已创建。', 'aksrt'),
'shipping_create_failed' => __('发货记录创建失败。', 'aksrt'),
'after_sale_created' => __('售后工单已创建。', 'aksrt'),
'after_sale_create_failed' => __('售后工单创建失败。', 'aksrt'),
'after_sale_create_failed' => __('售后工单创建失败。', 'aksrt'),
'after_sale_reviewed' => __('售后审核已处理。', 'aksrt'),
'after_sale_review_failed' => __('售后审核失败,请查看申请状态和错误原因。', 'aksrt'),
];
if (!isset($map[$code])) {
return;
}
$class = str_contains($code, 'failed') ? 'notice-error' : 'notice-success';
echo '<div class="notice ' . esc_attr($class) . ' is-dismissible"><p>' . esc_html($map[$code]) . '</p></div>';
if (!isset($map[$code])) {
return;
}
$class = str_contains($code, 'failed') ? 'notice-error' : 'notice-success';
echo '<div class="notice ' . esc_attr($class) . ' is-dismissible"><p>' . esc_html($map[$code]) . '</p></div>';
if ($code === 'after_sale_review_failed' && isset($_GET['after_sale_error'])) {
echo '<div class="notice notice-error"><p>' . esc_html(sanitize_text_field((string) wp_unslash($_GET['after_sale_error']))) . '</p></div>';
}
}
private function page_url(string $slug, array $args = []): string {
-1
View File
@@ -68,7 +68,6 @@ class SchemaPayment {
['key' => 'status', 'title' => '状态设置', 'fields' => [
['key' => 'payment_order_status_pending', 'label' => '待支付状态文案', 'type' => 'input', 'default' => '待支付'],
['key' => 'payment_order_status_paid', 'label' => '已支付状态文案', 'type' => 'input', 'default' => '已支付'],
['key' => 'payment_order_status_refunding', 'label' => '退款中状态文案', 'type' => 'input', 'default' => '退款中'],
['key' => 'payment_order_status_refunded', 'label' => '已退款状态文案', 'type' => 'input', 'default' => '已退款'],
['key' => 'payment_order_status_closed', 'label' => '已关闭状态文案', 'type' => 'input', 'default' => '已关闭'],
]],
+76 -74
View File
@@ -140,16 +140,10 @@ class UserController extends ControllerBase {
'callback' => [$this, 'rest_get_credits_log'],
'permission_callback' => [$this, 'check_permission'],
'args' => [
'transaction_id' => ['type' => 'integer', 'minimum' => 1],
'operation_key' => ['type' => 'string', 'maxLength' => 191, 'sanitize_callback' => 'sanitize_text_field'],
'user_id' => ['type' => 'integer', 'minimum' => 1],
'actor_user_id' => ['type' => 'integer', 'minimum' => 1],
'asset' => ['type' => 'string', 'enum' => ['points', 'balance']],
'reason' => ['type' => 'string', 'maxLength' => 64, 'sanitize_callback' => 'sanitize_key'],
'ref_type' => ['type' => 'string', 'maxLength' => 32, 'sanitize_callback' => 'sanitize_key'],
'ref_key' => ['type' => 'string', 'maxLength' => 191, 'sanitize_callback' => 'sanitize_text_field'],
'user_id' => ['type' => 'integer', 'minimum' => 1, 'required' => true],
'asset' => ['type' => 'string', 'enum' => ['points', 'balance'], 'required' => true],
'page' => ['type' => 'integer', 'minimum' => 1, 'default' => 1],
'per_page' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100, 'default' => 20],
'per_page' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 50, 'default' => 10],
'date_from' => ['type' => 'string', 'sanitize_callback' => 'sanitize_text_field'],
'date_to' => ['type' => 'string', 'sanitize_callback' => 'sanitize_text_field'],
],
@@ -589,22 +583,36 @@ class UserController extends ControllerBase {
}
public function rest_list_credit_users(\WP_REST_Request $request): \WP_REST_Response {
$args = [
'search' => (string) $request->get_param('search'),
'page' => (int) $request->get_param('page'),
'per_page' => (int) $request->get_param('per_page'),
];
$asset = sanitize_key((string) $request->get_param('asset'));
$asset = in_array($asset, ['points', 'balance'], true) ? $asset : 'points';
$data = \AKSRT\Ledger\LedgerQuery::get_instance()->list_accounts($asset, $args);
foreach ($data['items'] as &$item) {
$item['asset'] = $asset;
$item['balance'] = $asset === 'balance'
? \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, (int) $item['balance_minor'])->decimal()
: (int) $item['balance_minor'];
unset($item['balance_minor']);
global $wpdb;
$page = max(1, (int) $request->get_param('page'));
$per_page = min(100, max(1, (int) $request->get_param('per_page')));
$search = trim(sanitize_text_field((string) $request->get_param('search')));
$where = [];
$params = [];
if ($search !== '') {
$like = '%' . $wpdb->esc_like($search) . '%';
$where[] = '(u.user_login LIKE %s OR u.user_email LIKE %s OR u.display_name LIKE %s' . (ctype_digit($search) ? ' OR u.ID = %d' : '') . ')';
array_push($params, $like, $like, $like);
if (ctype_digit($search)) $params[] = (int) $search;
}
unset($item);
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$total_sql = "SELECT COUNT(*) FROM {$wpdb->users} u {$where_sql}";
$total = (int) ($params ? $wpdb->get_var($wpdb->prepare($total_sql, $params)) : $wpdb->get_var($total_sql));
$sql = "SELECT u.ID AS id, u.user_login AS login, u.user_email AS email, u.display_name, u.user_registered AS registered,
(SELECT m.meta_value FROM {$wpdb->usermeta} m WHERE m.user_id = u.ID AND m.meta_key = 'balance' ORDER BY m.umeta_id ASC LIMIT 1) AS balance_value,
(SELECT m.meta_value FROM {$wpdb->usermeta} m WHERE m.user_id = u.ID AND m.meta_key = 'points' ORDER BY m.umeta_id ASC LIMIT 1) AS points_value
FROM {$wpdb->users} u {$where_sql} ORDER BY u.user_registered DESC, u.ID DESC LIMIT %d OFFSET %d";
$rows = $wpdb->get_results($wpdb->prepare($sql, array_merge($params, [$per_page, ($page - 1) * $per_page])), ARRAY_A);
$items = [];
foreach ((array) $rows as $row) {
try { $balance = \AKSRT\Ledger\AssetAmount::balance((string) ($row['balance_value'] ?? '0'))->decimal(); } catch (\InvalidArgumentException $e) { $balance = '0.00'; }
$items[] = [
'id' => (int) $row['id'], 'login' => (string) $row['login'], 'email' => (string) $row['email'],
'display_name' => (string) $row['display_name'], 'registered' => (string) $row['registered'],
'balance' => $balance, 'points' => max(0, (int) ($row['points_value'] ?? 0)),
];
}
$data = ['items' => $items, 'total' => $total, 'page' => $page, 'per_page' => $per_page];
return $this->success(['data' => $data]);
}
@@ -653,64 +661,58 @@ class UserController extends ControllerBase {
'balance' => $asset === 'balance'
? \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $result->balance($user_id, $asset))->decimal()
: $result->balance($user_id, $asset),
'transaction_id' => $result->transaction_id,
'replayed' => $result->replayed,
], 'message' => __('资产调整已保存', 'aksrt')]);
}
public function rest_get_credits_log(\WP_REST_Request $request): \WP_REST_Response {
$args = [
'transaction_id' => (int) $request->get_param('transaction_id'),
'operation_key' => (string) $request->get_param('operation_key'),
'user_id' => (int) $request->get_param('user_id'),
'actor_user_id' => (int) $request->get_param('actor_user_id'),
'reason' => (string) $request->get_param('reason'),
'ref_type' => (string) $request->get_param('ref_type'),
'ref_key' => (string) $request->get_param('ref_key'),
'page' => (int) $request->get_param('page'),
'per_page' => (int) $request->get_param('per_page'),
'date_from' => (string) $request->get_param('date_from'),
'date_to' => (string) $request->get_param('date_to'),
];
$args['asset'] = sanitize_key((string) $request->get_param('asset'));
$data = \AKSRT\Ledger\LedgerQuery::get_instance()->history($args);
foreach ($data['items'] as &$item) {
$assets = array_values(array_unique(array_map('strval', (array) ($item['assets'] ?? []))));
$single_asset = count($assets) === 1 ? $assets[0] : '';
if ($single_asset !== '') {
$item['delta'] = $this->asset_minor_for_response($single_asset, (int) ($item['delta_minor'] ?? 0), true);
$item['fee'] = $this->asset_minor_for_response($single_asset, (int) ($item['fee_minor'] ?? 0));
$item['balance_after'] = $this->asset_minor_for_response($single_asset, (int) ($item['balance_after_minor'] ?? 0));
} else {
$item['delta'] = null;
$item['fee'] = null;
$item['balance_after'] = null;
}
unset($item['delta_minor'], $item['fee_minor'], $item['balance_after_minor']);
foreach ($item['entries'] as &$entry) {
$entry_asset = (string) ($entry['asset'] ?? '');
$entry['delta'] = $this->asset_minor_for_response($entry_asset, (int) ($entry['delta_minor'] ?? 0), true);
$entry['balance_after'] = $this->asset_minor_for_response($entry_asset, (int) ($entry['balance_after_minor'] ?? 0));
unset($entry['delta_minor'], $entry['balance_after_minor']);
}
unset($entry);
$user_id = max(0, (int) $request->get_param('user_id'));
$asset = sanitize_key((string) $request->get_param('asset'));
if ($user_id <= 0 || !get_userdata($user_id) || !in_array($asset, ['points', 'balance'], true)) {
return $this->error(__('用户或资产类型无效', 'aksrt'), 400);
}
unset($item);
$meta_key = $asset === 'points' ? 'points_record' : 'balance_record';
$records = get_user_meta($user_id, $meta_key, true);
$records = is_array($records) ? array_slice($records, 0, 50) : [];
$date_from = sanitize_text_field((string) $request->get_param('date_from'));
$date_to = sanitize_text_field((string) $request->get_param('date_to'));
$records = array_values(array_filter($records, static function ($record) use ($date_from, $date_to): bool {
if (!is_array($record)) return false;
$day = substr((string) ($record['time'] ?? ''), 0, 10);
return ($date_from === '' || $day >= $date_from) && ($date_to === '' || $day <= $date_to);
}));
$page = max(1, (int) $request->get_param('page'));
$per_page = min(50, max(1, (int) $request->get_param('per_page')));
$items = [];
foreach (array_slice($records, ($page - 1) * $per_page, $per_page) as $index => $record) {
$value = $asset === 'points' ? (int) ($record['value'] ?? 0) : $this->asset_value_to_minor((string) ($record['value'] ?? '0'));
$after = $asset === 'points' ? (int) ($record['points'] ?? 0) : $this->asset_value_to_minor((string) ($record['balance'] ?? '0'));
$items[] = [
'id' => (($page - 1) * $per_page) + $index + 1,
'operation_key' => (string) ($record['operation_key'] ?? ''),
'reason' => (string) ($record['type'] ?? ''),
'ref_type' => !empty($record['order_num']) ? 'order' : '',
'ref_key' => (string) ($record['order_num'] ?? ''),
'actor_user_id' => 0,
'assets' => [$asset],
'created_at' => (string) ($record['time'] ?? ''),
'remark' => (string) ($record['desc'] ?? ''),
'entries' => [[
'user_id' => $user_id, 'asset' => $asset, 'entry_type' => $value < 0 ? 'debit' : 'credit',
'delta' => $value, 'balance_after' => $after, 'remark' => (string) ($record['desc'] ?? ''),
]],
];
}
$data = ['items' => $items, 'total' => count($records), 'page' => $page, 'per_page' => $per_page];
return $this->success(['data' => $data]);
}
private function asset_minor_for_response(string $asset, int $minor, bool $signed = false) {
if ($asset === \AKSRT\Ledger\AssetType::POINTS->value) {
return $minor;
}
if ($asset !== \AKSRT\Ledger\AssetType::BALANCE->value) {
return null;
}
$negative = $minor < 0;
$value = \AKSRT\Ledger\AssetAmount::decimal_from_minor(abs($minor));
if ($negative) {
return '-' . $value;
}
return $signed && $minor > 0 ? '+' . $value : $value;
private function asset_value_to_minor(string $value): int {
$value = trim($value);
$negative = str_starts_with($value, '-');
if ($negative) $value = substr($value, 1);
try { $minor = \AKSRT\Ledger\AssetAmount::balance($value)->minor(); } catch (\InvalidArgumentException $e) { return 0; }
return $negative ? -$minor : $minor;
}
}