Files
wordpress-theme-aksrt/src/AKSRT/Payment/UserOrders.php
T
kate522 815e83bac0 fix(user-center): complete certification and order flows
Fix modal scroll ownership and certification modal styling, store evidence as Media Library attachments, reconcile missed provider callbacks, validate currencies, and complete digital fulfillment only after durable entitlement delivery.
2026-07-29 10:29:59 +08:00

749 lines
30 KiB
PHP

<?php
/**
* Front-end "My Orders" view-model (all product_types).
*
* Shop logistics/review stay shop-only; content/VIP/recharge get type-aware cards.
*
* @package AKSRT
*/
namespace AKSRT\Payment;
defined('ABSPATH') || exit;
class UserOrders {
private static $instance = null;
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Paginated user order center payload.
*
* @param array{page?:int,per_page?:int,status?:string,product_type?:string} $args
* @return array{items:array,total:int,total_pages:int,page:int,stats:array,product_types:array}
*/
public function get_order_center_view(int $user_id, array $args = []): array {
if ($user_id <= 0 || !class_exists(OrderRepository::class)) {
return [
'items' => [],
'total' => 0,
'total_pages' => 1,
'page' => 1,
'stats' => $this->empty_stats(),
'product_types' => $this->product_type_filter_options(),
];
}
$page = max(1, (int) ($args['page'] ?? 1));
$per_page = max(1, min(50, (int) ($args['per_page'] ?? 20)));
$status = sanitize_key((string) ($args['status'] ?? ''));
$product_type = sanitize_key((string) ($args['product_type'] ?? ''));
$repo_args = [
'limit' => $per_page,
'offset' => ($page - 1) * $per_page,
];
if ($status !== '') {
$repo_args['status'] = $status;
}
if (in_array($status, [Order::STATUS_PAID, Order::STATUS_DELIVERED, Order::STATUS_PENDING_REVIEW], true)) {
$repo_args['product_type'] = \AKSRT\Core\DomainModel::ORDER_PRODUCT_SHOP;
} elseif ($product_type !== '' && $product_type !== 'all') {
$repo_args['product_type'] = $product_type;
}
$repo = OrderRepository::get_instance();
$orders = $repo->get_user_orders($user_id, $repo_args);
$total = $repo->count_user_orders($user_id, $repo_args);
$items = [];
foreach ($orders as $order) {
$items[] = $this->decorate_order_view($order, true);
}
return [
'items' => $items,
'total' => $total,
'total_pages' => max(1, (int) ceil(max(0, $total) / $per_page)),
'page' => $page,
'stats' => $this->get_user_order_stats($user_id),
'product_types' => $this->product_type_filter_options(),
];
}
public function get_order_detail_view(int $user_id, string $order_number): ?array {
if ($user_id <= 0 || $order_number === '' || !class_exists(OrderRepository::class)) {
return null;
}
$order = OrderRepository::get_instance()->get_user_order($user_id, $order_number);
if (!$order) {
return null;
}
if (class_exists(Gateway::class)) {
$order = Gateway::get_instance()->reconcile_order($order_number, $order) ?: $order;
}
return $this->decorate_order_view($order, true);
}
/**
* @return array{total:int,pending:int,paid:int}
*/
public function get_user_order_stats(int $user_id): array {
if ($user_id <= 0 || !class_exists(OrderRepository::class)) {
return $this->empty_stats();
}
$repo = OrderRepository::get_instance();
return [
'total' => $repo->count_user_orders($user_id, []),
'pending' => $repo->count_user_orders($user_id, ['status' => Order::STATUS_PENDING]),
'paid' => $repo->count_user_orders($user_id, ['status' => Order::STATUS_PAID]),
];
}
/**
* @return array<string, string>
*/
public function product_type_filter_options(): array {
$labels = \AKSRT\Core\DomainModel::order_product_type_labels();
return array_merge(['' => __('全部类型', 'aksrt')], $labels);
}
/**
* @param array<string, mixed> $order Normalized order row.
* @return array<string, mixed>
*/
public function decorate_order_view(array $order, bool $with_detail = false): array {
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
$order_number = (string) ($order['order_number'] ?? '');
$product_type = sanitize_key((string) ($order['product_type'] ?? ''));
$status = (string) ($order['status'] ?? Order::STATUS_PENDING);
$product_id = (int) ($order['product_id'] ?? 0);
$is_shop = $product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_SHOP
|| $product_type === 'shop';
$currency = sanitize_key((string) ($order['currency'] ?? 'CNY'));
$is_points = $currency === 'points'
|| sanitize_key((string) ($order['payment_method'] ?? '')) === 'points'
|| (!empty($meta['pay_modo']) && (string) $meta['pay_modo'] === 'points');
$type_labels = \AKSRT\Core\DomainModel::order_product_type_labels();
$type_label = $type_labels[$product_type] ?? ($product_type !== '' ? $product_type : __('订单', 'aksrt'));
$title = $this->resolve_order_title($order, $meta, $product_type, $product_id, $type_label);
$image = $this->resolve_order_image($order, $meta, $product_type, $product_id);
$permalink = $this->resolve_order_permalink($product_type, $product_id, $meta);
$order_items = [];
if ($is_shop && class_exists('\AKSRT\Shop\Shop')) {
$order_items = \AKSRT\Shop\Shop::get_instance()->get_order_items($order_number);
}
$has_structured_items = !empty($order_items);
$product = [];
if ($has_structured_items && class_exists('\AKSRT\Shop\Shop')) {
$product = \AKSRT\Shop\Shop::get_instance()->build_product_card_view((int) ($order_items[0]['product_id'] ?? 0));
} elseif ($is_shop && $product_id > 0 && class_exists('\AKSRT\Shop\Shop')) {
$product = \AKSRT\Shop\Shop::get_instance()->build_product_card_view($product_id);
}
if (!empty($product['title'])) {
$title = (string) $product['title'];
}
if (!empty($product['image'])) {
$image = (string) $product['image'];
}
if (!empty($product['permalink'])) {
$permalink = (string) $product['permalink'];
}
$shipping_records = [];
$after_sale_items = [];
if ($is_shop) {
if (class_exists(Shipping::class)) {
$shipping_records = Shipping::get_instance()->get_by_order($order_number);
}
if (class_exists(AfterSale::class)) {
$as = AfterSale::get_instance()->get_list([
'limit' => 20,
'offset' => 0,
'order_number' => $order_number,
]);
$after_sale_items = $as['items'] ?? [];
}
}
$subtotal = (float) ($meta['items'][0]['line_total'] ?? $order['amount'] ?? 0);
$shipping_fee = (float) ($meta['shipping_fee'] ?? 0);
$discount = (float) ($meta['discount'] ?? 0);
$expires_at = (string) ($order['expires_at'] ?? '');
$payment_value = sanitize_key((string) ($meta['payment_method']['value'] ?? $order['payment_method'] ?? 'balance'));
$can_pay = in_array($status, [Order::STATUS_PENDING, Order::STATUS_EXPIRED, Order::STATUS_CREATED], true)
&& $payment_value !== 'manual_review';
$can_cancel = in_array($status, [Order::STATUS_PENDING, Order::STATUS_CREATED, Order::STATUS_EXPIRED], true);
$can_after_sale = $is_shop && in_array($status, [Order::STATUS_PAID, Order::STATUS_DELIVERED, Order::STATUS_COMPLETED], true);
$can_receipt = $is_shop && $status === Order::STATUS_DELIVERED;
$can_review = $is_shop && $status === Order::STATUS_PENDING_REVIEW;
$can_rebuy = $is_shop && in_array($status, [Order::STATUS_COMPLETED, Order::STATUS_PENDING_REVIEW], true);
// Order detail modal is always available; product permalink is optional.
$can_view = true;
$can_view_product = $permalink !== '' && in_array($status, [
Order::STATUS_PAID,
Order::STATUS_DELIVERED,
Order::STATUS_COMPLETED,
Order::STATUS_PENDING_REVIEW,
], true);
$view = [
'id' => (int) ($order['id'] ?? 0),
'order_number' => $order_number,
'amount' => (float) ($order['amount'] ?? 0),
'currency' => $currency,
'currency_symbol' => $is_points
? (class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_currency_name() : __('积分', 'aksrt'))
: '¥',
'is_points' => $is_points,
'status' => $status,
'status_label' => Order::get_valid_statuses()[$status] ?? $status,
'product_type' => $product_type,
'product_type_label' => $type_label,
'product_id' => $product_id,
'product_name' => $title,
'product' => $product ?: [
'title' => $title,
'image' => $image,
'permalink' => $permalink,
],
'image' => $image,
'permalink' => $permalink,
'created_at' => (string) ($order['created_at'] ?? ''),
'paid_at' => (string) ($order['paid_at'] ?? ''),
'expires_at' => $expires_at,
'subtotal' => $subtotal,
'shipping_fee' => $shipping_fee,
'discount' => $discount,
'address' => $meta['address'] ?? [],
'payment_method' => $meta['payment_method'] ?? [
'value' => (string) ($order['payment_method'] ?? ''),
'label' => $this->payment_method_label((string) ($order['payment_method'] ?? '')),
],
'coupon' => $meta['coupon'] ?? [],
'items' => $has_structured_items ? $order_items : ($meta['items'] ?? []),
'shipping_method' => $meta['shipping_method'] ?? [],
'customer_note' => $meta['customer_note'] ?? ($order['note_text'] ?? ''),
'shipping_records' => $shipping_records,
'pay_url' => '#',
'actions' => [
'can_pay' => $can_pay,
'can_cancel' => $can_cancel,
'can_after_sale' => $can_after_sale,
'can_receipt' => $can_receipt,
'can_review' => $can_review,
'can_rebuy' => $can_rebuy,
'can_view' => $can_view,
'can_view_product' => $can_view_product,
],
'quantity' => $this->resolve_order_quantity($order_items, $meta),
'time_left' => $this->get_order_countdown($expires_at, $status),
'secondary_url' => $this->resolve_secondary_url($product_type),
'secondary_label' => $this->resolve_secondary_label($product_type),
];
if ($with_detail) {
$view['after_sale_records'] = $after_sale_items;
$view['timeline'] = $this->build_order_timeline($order, $shipping_records, $after_sale_items);
}
return $view;
}
/**
* Cancel pending order (any product_type).
*
* @return array{success:bool,message?:string,data?:array}
*/
public function cancel_order(int $user_id, string $order_number): array {
if ($user_id <= 0 || $order_number === '' || !class_exists(OrderRepository::class)) {
return ['success' => false, 'message' => __('订单不存在。', 'aksrt')];
}
$repo = OrderRepository::get_instance();
$order = $repo->get_user_order($user_id, $order_number);
if (!$order) {
return ['success' => false, 'message' => __('订单不存在。', 'aksrt')];
}
$status = (string) ($order['status'] ?? '');
if (!in_array($status, [Order::STATUS_PENDING, Order::STATUS_CREATED, Order::STATUS_EXPIRED], true)) {
return ['success' => false, 'message' => __('当前订单不可取消。', 'aksrt')];
}
$repo->update_status($order_number, Order::STATUS_CANCELLED, [
'force' => true,
'note' => __('用户在前台取消订单。', 'aksrt'),
]);
return [
'success' => true,
'data' => [
'order' => $this->get_order_detail_view($user_id, $order_number),
],
];
}
/**
* Resume payment via configured gateway (any product_type).
*
* @return array{success:bool,message?:string,data?:array}
*/
public function pay_order(int $user_id, string $order_number, string $payment_method = ''): array {
if ($user_id <= 0 || $order_number === '' || !class_exists(OrderRepository::class)) {
return ['success' => false, 'message' => __('订单不存在。', 'aksrt')];
}
// Prefer shop checkout when available (handles points + shop meta).
if (class_exists('\AKSRT\Shop\Shop')) {
return \AKSRT\Shop\Shop::get_instance()->pay_order($user_id, $order_number, $payment_method);
}
$repo = OrderRepository::get_instance();
$order = $repo->get_user_order($user_id, $order_number);
if (!$order) {
return ['success' => false, 'message' => __('订单不存在。', 'aksrt')];
}
$status = (string) ($order['status'] ?? '');
if (!in_array($status, [Order::STATUS_CREATED, Order::STATUS_PENDING], true)) {
return ['success' => false, 'message' => __('当前订单状态不可支付。', 'aksrt')];
}
$stored_method = sanitize_key((string) ($order['payment_method'] ?? ''));
$payment_method = sanitize_key($payment_method ?: ($stored_method ?: 'balance'));
if ($stored_method !== '' && $payment_method !== $stored_method) {
return ['success' => false, 'message' => __('支付方式与订单不一致,请重新下单。', 'aksrt')];
}
$gateway_manager = class_exists(Gateway::class) ? Gateway::get_instance() : null;
$gateway = $gateway_manager ? $gateway_manager->get_gateway($payment_method) : null;
if (!$gateway) {
return ['success' => false, 'message' => __('当前支付方式不可用。', 'aksrt')];
}
if (method_exists($gateway, 'is_configured') && !$gateway->is_configured()) {
return ['success' => false, 'message' => __('该支付方式尚未配置,请联系站长。', 'aksrt')];
}
$order['subject'] = $order['subject'] ?? sprintf(__('订单 %s', 'aksrt'), $order_number);
$order['payment_method'] = $payment_method;
$payment_result = PaymentOrchestrator::pay($gateway, $order, [
'paid_note' => __('用户在前台完成支付。', 'aksrt'),
]);
if (empty($payment_result['success'])) {
return ['success' => false, 'message' => $payment_result['message']];
}
if (!empty($payment_result['redirect'])) {
return [
'success' => true,
'data' => [
'redirect' => true,
'payment_url' => (string) ($payment_result['payment_url'] ?? ''),
'qr_content' => (string) ($payment_result['qr_content'] ?? ''),
'order_number' => $order_number,
'message' => $payment_result['message'],
],
];
}
return [
'success' => true,
'data' => [
'order' => $this->get_order_detail_view($user_id, $order_number),
'message' => $payment_result['message'],
],
];
}
private function empty_stats(): array {
return ['total' => 0, 'pending' => 0, 'paid' => 0];
}
/**
* @param array<string, mixed> $order
* @param array<string, mixed> $meta
*/
private function resolve_order_title(array $order, array $meta, string $product_type, int $product_id, string $type_label): string {
foreach (['product_title', 'title', 'package_title'] as $key) {
if (!empty($meta[$key]) && is_string($meta[$key])) {
return (string) $meta[$key];
}
}
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP) {
$pkg = (string) ($meta['package_title'] ?? '');
if ($pkg !== '') {
return $pkg;
}
return __('VIP 会员', 'aksrt');
}
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE) {
return (string) ($meta['title'] ?? __('余额充值', 'aksrt'));
}
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_POINTS_BUY) {
return (string) ($meta['title'] ?? __('购买积分', 'aksrt'));
}
if ($product_id > 0) {
$post_title = get_the_title($product_id);
if (is_string($post_title) && $post_title !== '') {
return $post_title;
}
}
return $type_label;
}
/**
* @param array<string, mixed> $order
* @param array<string, mixed> $meta
*/
private function resolve_order_image(array $order, array $meta, string $product_type, int $product_id): string {
if (!empty($meta['thumb']) && is_string($meta['thumb'])) {
return esc_url_raw((string) $meta['thumb']);
}
if ($product_id > 0 && has_post_thumbnail($product_id)) {
$url = get_the_post_thumbnail_url($product_id, 'thumbnail');
if (is_string($url) && $url !== '') {
return $url;
}
}
return '';
}
/**
* @param array<string, mixed> $meta
*/
private function resolve_order_permalink(string $product_type, int $product_id, array $meta): string {
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP) {
return function_exists('aksrt_get_user_center_tab_url')
? aksrt_get_user_center_tab_url('vip')
: '';
}
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE
|| $product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_POINTS_BUY) {
return function_exists('aksrt_get_user_center_tab_url')
? aksrt_get_user_center_tab_url('credits')
: '';
}
$post_id = $product_id > 0 ? $product_id : (int) ($meta['post_id'] ?? 0);
if ($post_id > 0) {
$link = get_permalink($post_id);
return is_string($link) ? $link : '';
}
return '';
}
private function resolve_secondary_url(string $product_type): string {
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP) {
return function_exists('aksrt_get_user_center_tab_url') ? aksrt_get_user_center_tab_url('vip') : '';
}
if (in_array($product_type, [
\AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE,
\AKSRT\Core\DomainModel::ORDER_PRODUCT_POINTS_BUY,
], true)) {
return function_exists('aksrt_get_user_center_tab_url') ? aksrt_get_user_center_tab_url('credits') : '';
}
return '';
}
private function resolve_secondary_label(string $product_type): string {
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP) {
return __('我的会员', 'aksrt');
}
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE) {
return __('我的余额', 'aksrt');
}
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_POINTS_BUY) {
return class_exists('\AKSRT\Settings\Settings')
? sprintf(__('我的%s', 'aksrt'), \AKSRT\Settings\Settings::get_currency_name())
: __('我的积分', 'aksrt');
}
return '';
}
private function payment_method_label(string $method): string {
$method = sanitize_key($method);
$map = [
'balance' => __('站内余额', 'aksrt'),
'points' => __('积分支付', 'aksrt'),
'alipay' => __('支付宝', 'aksrt'),
'wechatpay' => __('微信支付', 'aksrt'),
'wechat' => __('微信支付', 'aksrt'),
'paypal' => 'PayPal',
'stripe' => 'Stripe',
'epay_alipay' => __('易支付·支付宝', 'aksrt'),
'epay_wxpay' => __('易支付·微信', 'aksrt'),
];
return $map[$method] ?? ($method !== '' ? $method : __('—', 'aksrt'));
}
/**
* Build chronological order progress for the detail modal.
*
* Driven by current status + available timestamps / related records so the
* chain advances even when historical rows lack paid_at/delivered_at.
*
* @param array<string, mixed> $order
* @param array<int, array> $shipping_records
* @param array<int, array> $after_sale_records
* @return array<int, array{time:string,label:string,key?:string}>
*/
private function build_order_timeline(array $order, array $shipping_records, array $after_sale_records): array {
$status = sanitize_key((string) ($order['status'] ?? Order::STATUS_PENDING));
$created_at = $this->timeline_time((string) ($order['created_at'] ?? ''));
$paid_at = $this->timeline_time((string) ($order['paid_at'] ?? ''));
$delivered_at = $this->timeline_time((string) ($order['delivered_at'] ?? ''));
$completed_at = $this->timeline_time((string) ($order['completed_at'] ?? ''));
$cancelled_at = $this->timeline_time((string) ($order['cancelled_at'] ?? ''));
$refunded_at = $this->timeline_time((string) ($order['refunded_at'] ?? ''));
$shipping_created = '';
foreach ($shipping_records as $record) {
$t = $this->timeline_time((string) ($record['created_at'] ?? ''));
if ($t !== '' && ($shipping_created === '' || strcmp($t, $shipping_created) < 0)) {
$shipping_created = $t;
}
}
if ($delivered_at === '' && $shipping_created !== '') {
$delivered_at = $shipping_created;
}
$paid_like = [
Order::STATUS_PAID,
Order::STATUS_DELIVERED,
Order::STATUS_COMPLETED,
Order::STATUS_PENDING_REVIEW,
Order::STATUS_REFUNDING,
Order::STATUS_REFUNDED,
];
$ship_like = [
Order::STATUS_DELIVERED,
Order::STATUS_COMPLETED,
Order::STATUS_PENDING_REVIEW,
];
$done_like = [
Order::STATUS_COMPLETED,
Order::STATUS_PENDING_REVIEW,
];
$timeline = [];
$push = static function (array &$out, string $key, string $label, string $time): void {
if ($label === '') {
return;
}
$out[] = [
'key' => $key,
'label' => $label,
'time' => $time,
];
};
// 1) Always: order created.
$push(
$timeline,
'created',
__('订单创建', 'aksrt'),
$created_at !== '' ? $created_at : ''
);
// 2) Paid (status-driven; fall back to created_at when paid_at missing).
if ($paid_at !== '' || in_array($status, $paid_like, true)) {
$push(
$timeline,
'paid',
__('完成支付', 'aksrt'),
$paid_at !== '' ? $paid_at : $created_at
);
}
// 3) Shipped / delivered — only when logistics exists or status reached it.
$has_shipping = $shipping_records !== [];
if (
$delivered_at !== ''
|| $has_shipping
|| in_array($status, $ship_like, true)
) {
// Virtual / content flows often jump paid → completed without logistics;
// only show the ship step when we have evidence of shipping.
if ($delivered_at !== '' || $has_shipping || $status === Order::STATUS_DELIVERED) {
$push(
$timeline,
'delivered',
__('订单发货', 'aksrt'),
$delivered_at !== '' ? $delivered_at : ($paid_at !== '' ? $paid_at : $created_at)
);
}
}
// 4) Completed / pending review.
if ($completed_at !== '' || in_array($status, $done_like, true)) {
$done_label = $status === Order::STATUS_PENDING_REVIEW
? __('待评价', 'aksrt')
: __('交易完成', 'aksrt');
$push(
$timeline,
'completed',
$done_label,
$completed_at !== ''
? $completed_at
: ($delivered_at !== '' ? $delivered_at : ($paid_at !== '' ? $paid_at : $created_at))
);
}
// 5) Terminal cancel / expire / refund.
if ($status === Order::STATUS_CANCELLED || $cancelled_at !== '') {
$push(
$timeline,
'cancelled',
__('订单取消', 'aksrt'),
$cancelled_at !== '' ? $cancelled_at : $created_at
);
}
if ($status === Order::STATUS_EXPIRED) {
$push(
$timeline,
'expired',
__('订单过期', 'aksrt'),
$cancelled_at !== '' ? $cancelled_at : $created_at
);
}
if ($status === Order::STATUS_REFUNDING) {
$push(
$timeline,
'refunding',
__('退款处理中', 'aksrt'),
$refunded_at !== '' ? $refunded_at : ($paid_at !== '' ? $paid_at : $created_at)
);
}
if ($status === Order::STATUS_REFUNDED || $refunded_at !== '') {
$push(
$timeline,
'refunded',
__('完成退款', 'aksrt'),
$refunded_at !== '' ? $refunded_at : ($paid_at !== '' ? $paid_at : $created_at)
);
}
// Extra logistics / after-sale events (detail lines).
foreach ($shipping_records as $record) {
$tracking = trim((string) ($record['tracking_no'] ?? ''));
$carrier = trim((string) ($record['carrier_label'] ?? $record['carrier'] ?? ''));
$status_l = trim((string) ($record['status_label'] ?? $record['status'] ?? ''));
$parts = array_filter([$carrier, $tracking, $status_l], static fn($v) => $v !== '');
$label = $parts !== []
? sprintf(__('物流更新:%s', 'aksrt'), implode(' / ', $parts))
: __('物流更新', 'aksrt');
$push(
$timeline,
'shipping',
$label,
$this->timeline_time((string) ($record['created_at'] ?? $record['updated_at'] ?? ''))
);
}
foreach ($after_sale_records as $record) {
$reason = trim((string) ($record['reason'] ?? ''));
$type_l = trim((string) ($record['type_label'] ?? $record['type'] ?? ''));
$as_status = trim((string) ($record['status_label'] ?? $record['status'] ?? ''));
$parts = array_filter([$type_l, $as_status, $reason], static fn($v) => $v !== '');
$label = $parts !== []
? sprintf(__('售后:%s', 'aksrt'), implode(' — ', $parts))
: __('售后申请', 'aksrt');
$push(
$timeline,
'after_sale',
$label,
$this->timeline_time((string) ($record['created_at'] ?? $record['updated_at'] ?? ''))
);
}
usort($timeline, static function ($a, $b) {
$ta = (string) ($a['time'] ?? '');
$tb = (string) ($b['time'] ?? '');
if ($ta === $tb) {
// Keep canonical pipeline order when times collide / missing.
$rank = [
'created' => 10,
'paid' => 20,
'delivered' => 30,
'shipping' => 35,
'completed' => 40,
'refunding' => 50,
'refunded' => 60,
'cancelled' => 70,
'expired' => 80,
'after_sale'=> 90,
];
$ra = $rank[(string) ($a['key'] ?? '')] ?? 99;
$rb = $rank[(string) ($b['key'] ?? '')] ?? 99;
return $ra <=> $rb;
}
if ($ta === '') {
return 1;
}
if ($tb === '') {
return -1;
}
return strcmp($ta, $tb);
});
return $timeline;
}
/**
* Normalize a mysql datetime for timeline display (empty when invalid/zero).
*/
private function timeline_time(string $value): string {
$value = trim($value);
if ($value === '' || str_starts_with($value, '0000-00-00')) {
return '';
}
return $value;
}
private function get_order_countdown(string $expires_at, string $status): int {
if ($expires_at === '' || !in_array($status, [Order::STATUS_PENDING, Order::STATUS_EXPIRED, Order::STATUS_CREATED], true)) {
return 0;
}
$left = strtotime($expires_at) - time();
return max(0, (int) $left);
}
/**
* Sum line quantities for display (shop multi-item or single meta item).
*
* @param array<int, array<string, mixed>> $order_items
* @param array<string, mixed> $meta
*/
private function resolve_order_quantity(array $order_items, array $meta): int {
$qty = 0;
$source = !empty($order_items) ? $order_items : (is_array($meta['items'] ?? null) ? $meta['items'] : []);
foreach ($source as $row) {
if (!is_array($row)) {
continue;
}
$qty += max(1, (int) ($row['quantity'] ?? $row['qty'] ?? 1));
}
if ($qty <= 0) {
$qty = max(1, (int) ($meta['quantity'] ?? $meta['qty'] ?? 1));
}
return $qty;
}
}