feat(payment): rebuild order details and asset records

This commit is contained in:
2026-10-05 16:42:25 +08:00
parent 476e2f961d
commit 7b55cfb190
57 changed files with 2080 additions and 1294 deletions
+139 -53
View File
@@ -243,7 +243,7 @@ trait OrderRepositoryCore {
* their existing user-wide semantics, so changing a filter does not change the
* summary cards. The subquery avoids four independent COUNT statements.
*
* @return array{total:int,stats:array{total:int,pending:int,paid:int}}
* @return array{total:int,stats:array<string,int>}
*/
public function get_user_order_center_summary(int $user_id, array $args = []): array {
global $wpdb;
@@ -251,7 +251,7 @@ trait OrderRepositoryCore {
if ($user_id <= 0) {
return [
'total' => 0,
'stats' => ['total' => 0, 'pending' => 0, 'paid' => 0],
'stats' => ['total' => 0, 'pending' => 0, 'paid' => 0, 'refunded' => 0, 'closed' => 0, 'after_sale_pending' => 0],
];
}
@@ -264,12 +264,15 @@ trait OrderRepositoryCore {
(SELECT COUNT(*) FROM {$this->table()} {$parts['where']}) AS filtered_total,
COUNT(*) AS known_total,
SUM(CASE WHEN status = %s THEN 1 ELSE 0 END) AS pending,
SUM(CASE WHEN status = %s THEN 1 ELSE 0 END) AS paid
SUM(CASE WHEN status = %s THEN 1 ELSE 0 END) AS paid,
SUM(CASE WHEN status = 'refunding' THEN 1 ELSE 0 END) AS paid_legacy,
SUM(CASE WHEN status = %s THEN 1 ELSE 0 END) AS refunded,
SUM(CASE WHEN status = %s THEN 1 ELSE 0 END) AS closed
FROM {$this->table()}
WHERE user_id = %d AND status IN ({$known_placeholders})";
$params = array_merge(
$parts['params'],
[Order::STATUS_PENDING, Order::STATUS_PAID],
[Order::STATUS_PENDING, Order::STATUS_PAID, Order::STATUS_REFUNDED, Order::STATUS_CLOSED],
[$user_id],
$known_statuses
);
@@ -280,7 +283,10 @@ trait OrderRepositoryCore {
'stats' => [
'total' => (int) ($row['known_total'] ?? 0),
'pending' => (int) ($row['pending'] ?? 0),
'paid' => (int) ($row['paid'] ?? 0),
'paid' => (int) ($row['paid'] ?? 0) + (int) ($row['paid_legacy'] ?? 0),
'refunded' => (int) ($row['refunded'] ?? 0),
'closed' => (int) ($row['closed'] ?? 0),
'after_sale_pending' => class_exists(AfterSaleService::class) ? AfterSaleService::get_instance()->count_user_open($user_id) : 0,
],
];
}
@@ -340,8 +346,16 @@ trait OrderRepositoryCore {
public function update_status(string $order_number, string $new_status, array $extra = []): bool {
public function update_status(string $order_number, string $new_status, array $extra = []): bool {
global $wpdb;
if ($new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED && empty($extra['allow_refund'])) {
return false;
}
if ($new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED
&& (!class_exists(AfterSaleService::class) || !AfterSaleService::get_instance()->can_finalize_order_refund((int) ($extra['after_sale_id'] ?? 0), $order_number))
) {
return false;
}
$preflight = $this->get($order_number);
if (!$preflight) {
@@ -349,7 +363,7 @@ trait OrderRepositoryCore {
}
if (
$new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_PAID
&& (string) ($preflight['status'] ?? '') !== \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDING
&& (string) ($preflight['status'] ?? '') !== 'refunding'
&& sanitize_key((string) ($preflight['product_type'] ?? '')) === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE
&& class_exists('\AKSRT\Credits\BalanceService')
) {
@@ -367,7 +381,8 @@ trait OrderRepositoryCore {
if ($root_call) {
$this->status_after_commit_events = [];
}
$owns_transaction = $root_call && !$ledger_active;
$transaction_managed = !empty($extra['transaction_managed']);
$owns_transaction = $root_call && !$ledger_active && !$transaction_managed;
if ($owns_transaction && $wpdb->query('START TRANSACTION') === false) {
return false;
}
@@ -398,6 +413,8 @@ trait OrderRepositoryCore {
\AKSRT\Ledger\LedgerService::get_instance()->after_commit(function () use ($events): void {
$this->dispatch_status_events($events);
});
} elseif ($transaction_managed) {
$this->status_after_commit_events = $events;
}
}
return true;
@@ -425,14 +442,18 @@ trait OrderRepositoryCore {
$valid = \AKSRT\Core\DomainModel::order_status_labels();
if (!isset($valid[$new_status])) return false;
$order_row = $wpdb->get_row($wpdb->prepare(
$order_row = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$this->table()} WHERE order_number = %s FOR UPDATE",
$order_number
), ARRAY_A);
$database_status = is_array($order_row) ? sanitize_key((string) ($order_row['status'] ?? '')) : '';
$order = is_array($order_row) ? $this->normalize_row($order_row) : null;
if (!$order) return false;
$current_status = (string) ($order['status'] ?? '');
$current_status = (string) ($order['status'] ?? '');
if ($database_status === 'refunding' && $new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED) {
$current_status = \AKSRT\Core\DomainModel::ORDER_STATUS_PAID;
}
if (!\AKSRT\Core\DomainModel::can_transition_order_status($current_status, $new_status)) {
return false;
@@ -457,12 +478,7 @@ trait OrderRepositoryCore {
}
}
if (
in_array($new_status, [
\AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDING,
\AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED,
], true)
) {
if ($new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED) {
$existing_paid = is_string($order['paid_at'] ?? null) ? trim((string) $order['paid_at']) : '';
if ($existing_paid === '' || str_starts_with($existing_paid, '0000-00-00')) {
if (!isset($update_data['paid_at'])) {
@@ -502,7 +518,7 @@ trait OrderRepositoryCore {
$result = $wpdb->update(
$this->table(),
$update_data,
['order_number' => $order_number, 'status' => $current_status],
['order_number' => $order_number, 'status' => $database_status],
$format,
['%s', '%s']
);
@@ -550,26 +566,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,
$new_status,
$paid_order
);
if (is_wp_error($commission)) {
return false;
}
}
$this->status_after_commit_events[] = [
'hook' => 'aksrt_order_commission',
'args' => [$order_number, $new_status, $paid_order],
];
$product_type = sanitize_key((string) ($paid_order['product_type'] ?? ''));
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE && class_exists('\AKSRT\Content\Pay')) {
if (\AKSRT\Core\DomainModel::is_resource_product_type($product_type) && class_exists('\AKSRT\Content\Pay')) {
$fulfilled = \AKSRT\Content\Pay::get_instance()->fulfill_paid_order($order_number, $paid_order);
if (is_wp_error($fulfilled)) {
return false;
@@ -594,16 +597,6 @@ trait OrderRepositoryCore {
];
}
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;
@@ -681,11 +674,10 @@ trait OrderRepositoryCore {
$sql = "SELECT
COUNT(*) as total,
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) as paid,
SUM(CASE WHEN status = 'refunding' THEN 1 ELSE 0 END) as refunding,
SUM(CASE WHEN status IN ('paid','refunding') THEN 1 ELSE 0 END) as paid,
SUM(CASE WHEN status = 'refunded' THEN 1 ELSE 0 END) as refunded,
SUM(CASE WHEN status = 'closed' THEN 1 ELSE 0 END) as closed,
SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) as revenue
SUM(CASE WHEN status IN ('paid','refunding') THEN amount ELSE 0 END) as revenue
FROM {$this->table()} {$where}";
if (!empty($params)) {
@@ -698,13 +690,66 @@ trait OrderRepositoryCore {
'total' => (int) ($row['total'] ?? 0),
'pending' => (int) ($row['pending'] ?? 0),
'paid' => (int) ($row['paid'] ?? 0),
'refunding' => (int) ($row['refunding'] ?? 0),
'refunded' => (int) ($row['refunded'] ?? 0),
'closed' => (int) ($row['closed'] ?? 0),
'revenue' => $this->cash_decimal((string) ($row['revenue'] ?? '0')),
];
}
/** @return array<string,mixed> */
public function get_admin_order_stats(array $args = []): array {
global $wpdb;
$this->expire_pending_orders();
$where = [];
$params = [];
foreach (['date_from' => '>=', 'date_to' => '<='] as $key => $operator) {
$date = sanitize_text_field((string) ($args[$key] ?? ''));
if ($date !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
$where[] = 'created_at ' . $operator . ' %s';
$params[] = $operator === '>=' ? $date . ' 00:00:00' : $date . ' 23:59:59';
}
}
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$sql = "SELECT COUNT(*) AS total,
SUM(status = 'pending') AS pending,
SUM(status IN ('paid','refunding')) AS paid,
SUM(status = 'refunded') AS refunded,
SUM(status = 'closed') AS closed,
SUM(CASE WHEN status IN ('paid','refunding') AND currency <> 'points' THEN amount ELSE 0 END) AS revenue
FROM {$this->table()} {$where_sql}";
$row = $params ? $wpdb->get_row($wpdb->prepare($sql, $params), ARRAY_A) : $wpdb->get_row($sql, ARRAY_A);
$type_sql = "SELECT product_type, note, COUNT(*) AS total FROM {$this->table()} {$where_sql} GROUP BY product_type, note";
$type_rows = $params ? $wpdb->get_results($wpdb->prepare($type_sql, $params), ARRAY_A) : $wpdb->get_results($type_sql, ARRAY_A);
$by_type = array_fill_keys(array_keys(\AKSRT\Core\DomainModel::order_product_type_labels()), 0);
foreach ((array) $type_rows as $type_row) {
$meta = json_decode((string) ($type_row['note'] ?? ''), true);
$type = \AKSRT\Core\DomainModel::resolve_order_product_type((string) ($type_row['product_type'] ?? ''), is_array($meta) ? $meta : []);
if (isset($by_type[$type])) {
$by_type[$type] += (int) ($type_row['total'] ?? 0);
}
}
return [
'total' => (int) ($row['total'] ?? 0),
'pending' => (int) ($row['pending'] ?? 0),
'paid' => (int) ($row['paid'] ?? 0),
'refunded' => (int) ($row['refunded'] ?? 0),
'closed' => (int) ($row['closed'] ?? 0),
'revenue' => $this->cash_decimal((string) ($row['revenue'] ?? '0')),
'by_type' => $by_type,
'after_sale_pending' => class_exists(AfterSaleService::class) ? AfterSaleService::get_instance()->count([
'status' => AfterSaleService::STATUS_PENDING,
'date_from' => $args['date_from'] ?? '',
'date_to' => $args['date_to'] ?? '',
]) : 0,
];
}
public function dispatch_managed_status_events(): void {
$events = $this->status_after_commit_events;
$this->status_after_commit_events = [];
$this->dispatch_status_events($events);
}
public function has_paid_access(int $user_id, string $product_type, int $product_id): bool {
@@ -712,7 +757,10 @@ trait OrderRepositoryCore {
return false;
}
return null !== $this->find_latest_success_order($user_id, $product_type, $product_id);
if (\AKSRT\Core\DomainModel::is_resource_product_type($product_type)) {
return null !== $this->find_latest_success_content_order($user_id, $product_id);
}
return null !== $this->find_latest_success_order($user_id, $product_type, $product_id);
}
@@ -756,8 +804,25 @@ trait OrderRepositoryCore {
$product_type = sanitize_key((string) ($args['product_type'] ?? ''));
if ($product_type !== '') {
$where[] = 'product_type = %s';
$params[] = $product_type;
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE) {
$types = \AKSRT\Core\DomainModel::resource_order_product_types();
$where[] = 'product_type IN (' . implode(', ', array_fill(0, count($types), '%s')) . ')';
array_push($params, ...$types);
} else {
$legacy_pay_types = [
\AKSRT\Core\DomainModel::ORDER_PRODUCT_READING => \AKSRT\Core\DomainModel::PAY_TYPE_READING,
\AKSRT\Core\DomainModel::ORDER_PRODUCT_DOWNLOAD => \AKSRT\Core\DomainModel::PAY_TYPE_DOWNLOAD,
\AKSRT\Core\DomainModel::ORDER_PRODUCT_IMAGE => \AKSRT\Core\DomainModel::PAY_TYPE_GALLERY,
\AKSRT\Core\DomainModel::ORDER_PRODUCT_VIDEO => \AKSRT\Core\DomainModel::PAY_TYPE_VIDEO,
];
if (isset($legacy_pay_types[$product_type])) {
$where[] = "(product_type = %s OR (product_type = %s AND note LIKE %s))";
array_push($params, $product_type, \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE, '%"pay_type":"' . $wpdb->esc_like($legacy_pay_types[$product_type]) . '"%');
} else {
$where[] = 'product_type = %s';
$params[] = $product_type;
}
}
}
$order_number_prefix = strtoupper(preg_replace('/[^A-Za-z0-9_-]/', '', (string) ($args['order_number_prefix'] ?? '')) ?? '');
@@ -780,7 +845,10 @@ trait OrderRepositoryCore {
if ($row['amount'] === null) {
$row['amount'] = (string) ($row['currency'] ?? '') === 'points' ? 0 : '0.00';
}
$row['status'] = sanitize_key((string) ($row['status'] ?? ''));
$row['status'] = sanitize_key((string) ($row['status'] ?? ''));
if ($row['status'] === 'refunding') {
$row['status'] = Order::STATUS_PAID;
}
$row['meta_payload'] = [];
if (!empty($row['note']) && is_string($row['note'])) {
@@ -794,6 +862,10 @@ trait OrderRepositoryCore {
$row['note_text'] = $row['note'];
}
}
$row['product_type'] = \AKSRT\Core\DomainModel::resolve_order_product_type(
sanitize_key((string) ($row['product_type'] ?? '')),
$row['meta_payload']
);
return $row;
}
@@ -860,6 +932,20 @@ trait OrderRepositoryCore {
return is_array($row) ? $this->normalize_row($row) : null;
}
private function find_latest_success_content_order(int $user_id, int $product_id): ?array {
global $wpdb;
$types = \AKSRT\Core\DomainModel::resource_order_product_types();
$placeholders = implode(', ', array_fill(0, count($types), '%s'));
$statuses = \AKSRT\Core\DomainModel::paid_order_statuses();
$status_placeholders = implode(', ', array_fill(0, count($statuses), '%s'));
$params = array_merge([$user_id, $product_id], $types, $statuses);
$row = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$this->table()} WHERE user_id = %d AND product_id = %d AND product_type IN ({$placeholders}) AND status IN ({$status_placeholders}) ORDER BY COALESCE(paid_at, created_at) DESC, id DESC LIMIT 1",
$params
), ARRAY_A);
return is_array($row) ? $this->normalize_row($row) : null;
}
/**