feat(payment): rebuild order details and asset records
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user