diff --git a/src/Content/PayOrders.php b/src/Content/PayOrders.php index 2a8a49a0..b36b4ea1 100644 --- a/src/Content/PayOrders.php +++ b/src/Content/PayOrders.php @@ -216,7 +216,7 @@ trait PayOrders { 'status' => \AKSRT\Payment\Order::STATUS_PENDING, 'payment_method' => 'points', 'note' => is_string($note) ? $note : '', - 'expires_at' => gmdate('Y-m-d H:i:s', time() + 30 * MINUTE_IN_SECONDS), + 'expires_at' => \AKSRT\Settings\PaymentRuntime::get_order_expiry_at(), ]); if ($order_number === '') { @@ -362,7 +362,7 @@ trait PayOrders { 'status' => \AKSRT\Payment\Order::STATUS_PENDING, 'payment_method' => 'free', 'note' => is_string($note) ? $note : '', - 'expires_at' => gmdate('Y-m-d H:i:s', time() + 30 * MINUTE_IN_SECONDS), + 'expires_at' => \AKSRT\Settings\PaymentRuntime::get_order_expiry_at(), ]); if ($order_number === '') { return new \WP_Error('aksrt_order_create_failed', __('创建订单失败。', 'aksrt'), ['status' => 500]); @@ -448,7 +448,7 @@ trait PayOrders { 'status' => \AKSRT\Payment\Order::STATUS_PENDING, 'payment_method' => $payment_method, 'note' => is_string($note) ? $note : '', - 'expires_at' => gmdate('Y-m-d H:i:s', time() + 30 * MINUTE_IN_SECONDS), + 'expires_at' => \AKSRT\Settings\PaymentRuntime::get_order_expiry_at(), ]); if ($order_number === '') { diff --git a/src/Content/VipServicePurchase.php b/src/Content/VipServicePurchase.php index 76e6650b..7b058923 100644 --- a/src/Content/VipServicePurchase.php +++ b/src/Content/VipServicePurchase.php @@ -369,7 +369,7 @@ trait VipServicePurchase { 'discount_tag' => $discount_tag, ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - $expires_at = gmdate('Y-m-d H:i:s', time() + 30 * MINUTE_IN_SECONDS); + $expires_at = \AKSRT\Settings\PaymentRuntime::get_order_expiry_at(); $order_number = \AKSRT\Payment\OrderRepository::get_instance()->create([ 'user_id' => $user_id, 'product_type' => \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP, @@ -750,7 +750,7 @@ trait VipServicePurchase { 'status' => \AKSRT\Payment\Order::STATUS_PENDING, 'payment_method' => 'points', 'note' => is_string($note) ? $note : '', - 'expires_at' => gmdate('Y-m-d H:i:s', time() + 30 * MINUTE_IN_SECONDS), + 'expires_at' => \AKSRT\Settings\PaymentRuntime::get_order_expiry_at(), ]); if ($order_number === '') { return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]); diff --git a/src/Credits/BalanceServiceCharge.php b/src/Credits/BalanceServiceCharge.php index 30c2e8ac..a27c048c 100644 --- a/src/Credits/BalanceServiceCharge.php +++ b/src/Credits/BalanceServiceCharge.php @@ -326,7 +326,7 @@ trait BalanceServiceCharge { 'status' => \AKSRT\Payment\Order::STATUS_PENDING, 'payment_method' => $payment_method, 'note' => is_string($note) ? $note : '', - 'expires_at' => gmdate('Y-m-d H:i:s', time() + 30 * MINUTE_IN_SECONDS), + 'expires_at' => \AKSRT\Settings\PaymentRuntime::get_order_expiry_at(), ]); if ($order_number === '') { return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]); diff --git a/src/Credits/BalanceServicePoints.php b/src/Credits/BalanceServicePoints.php index 35f70d1f..955b7a16 100644 --- a/src/Credits/BalanceServicePoints.php +++ b/src/Credits/BalanceServicePoints.php @@ -180,7 +180,7 @@ trait BalanceServicePoints { 'status' => \AKSRT\Payment\Order::STATUS_PENDING, 'payment_method' => $payment_method, 'note' => is_string($note) ? $note : '', - 'expires_at' => gmdate('Y-m-d H:i:s', time() + 30 * MINUTE_IN_SECONDS), + 'expires_at' => \AKSRT\Settings\PaymentRuntime::get_order_expiry_at(), ]); if ($order_number === '') { return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]); diff --git a/src/Payment/ChargeCard.php b/src/Payment/ChargeCard.php index 5833ad13..ebef70f0 100644 --- a/src/Payment/ChargeCard.php +++ b/src/Payment/ChargeCard.php @@ -554,7 +554,7 @@ class ChargeCard { 'status' => class_exists('AKSRT\\Payment\\Order') ? Order::STATUS_PENDING : 'pending', 'payment_method' => 'card_pass', 'note' => is_string($note) ? $note : '', - 'expires_at' => gmdate('Y-m-d H:i:s', time() + 30 * MINUTE_IN_SECONDS), + 'expires_at' => \AKSRT\Settings\PaymentRuntime::get_order_expiry_at(), ]); } diff --git a/src/Payment/OrderRepositoryAdmin.php b/src/Payment/OrderRepositoryAdmin.php index eda12da1..c919eab3 100644 --- a/src/Payment/OrderRepositoryAdmin.php +++ b/src/Payment/OrderRepositoryAdmin.php @@ -125,6 +125,7 @@ trait OrderRepositoryAdmin { public function get_orders_admin(array $args = [], string $search = ''): array { global $wpdb; + $this->expire_pending_orders(); [$where, $params] = $this->build_admin_order_filters($args, $search); $limit = max(1, min(100, (int) ($args['limit'] ?? 20))); @@ -147,6 +148,7 @@ trait OrderRepositoryAdmin { public function get_orders_count(array $args = [], string $search = ''): int { global $wpdb; + $this->expire_pending_orders(); [$where, $params] = $this->build_admin_order_filters($args, $search); $where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : ''; diff --git a/src/Payment/OrderRepositoryCore.php b/src/Payment/OrderRepositoryCore.php index ca9c4e5d..ebed44d9 100644 --- a/src/Payment/OrderRepositoryCore.php +++ b/src/Payment/OrderRepositoryCore.php @@ -57,10 +57,14 @@ trait OrderRepositoryCore { ]; $formats = ['%s', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s']; - // Honour configured expiry when callers pass expires_at (shop checkout etc.). + // Pending orders always receive an expiry. Explicit caller values remain + // supported for compatibility, while new flows use the shared runtime policy. if (array_key_exists('expires_at', $data) && $data['expires_at'] !== null && $data['expires_at'] !== '') { $row['expires_at'] = (string) $data['expires_at']; $formats[] = '%s'; + } elseif (in_array($row['status'], [Order::STATUS_CREATED, Order::STATUS_PENDING], true)) { + $row['expires_at'] = \AKSRT\Settings\PaymentRuntime::get_order_expiry_at(); + $formats[] = '%s'; } $wpdb->insert($this->table(), $row, $formats); @@ -86,7 +90,7 @@ trait OrderRepositoryCore { ), ARRAY_A); if ($row) { - return $this->normalize_row($row); + return $this->expire_order_if_due($this->normalize_row($row)); } return null; @@ -100,6 +104,7 @@ trait OrderRepositoryCore { public function get_user_orders(int $user_id, array $args = []): array { global $wpdb; + $this->expire_pending_orders($user_id); $parts = $this->get_user_order_query_parts($user_id, $args); $limit = max(1, min(100, (int) ($args['limit'] ?? 20))); $offset = max(0, (int) ($args['offset'] ?? 0)); @@ -143,6 +148,8 @@ trait OrderRepositoryCore { return 0; } + $this->expire_pending_orders($user_id); + $parts = $this->get_user_order_query_parts($user_id, $args); return (int) $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM {$this->table()} {$parts['where']}", @@ -169,6 +176,8 @@ trait OrderRepositoryCore { ]; } + $this->expire_pending_orders($user_id); + $parts = $this->get_user_order_query_parts($user_id, $args); $pending_statuses = [Order::STATUS_CREATED, Order::STATUS_PENDING]; $paid_status = Order::STATUS_PAID; @@ -199,6 +208,59 @@ trait OrderRepositoryCore { ]; } + /** + * Close every expired, unpaid order for one user (or all users). + * + * The status condition makes this idempotent and prevents an expiry sweep + * from overwriting a payment completed concurrently by a gateway callback. + */ + public function expire_pending_orders(?int $user_id = null): int { + global $wpdb; + + $where = "status IN ('created', 'pending') AND expires_at IS NOT NULL AND expires_at <> '' AND expires_at <= %s"; + $params = [gmdate('Y-m-d H:i:s')]; + if ($user_id !== null && $user_id > 0) { + $where .= ' AND user_id = %d'; + $params[] = $user_id; + } + + $sql = "UPDATE {$this->table()} SET status = 'expired', cancelled_at = %s WHERE {$where}"; + array_unshift($params, gmdate('Y-m-d H:i:s')); + $result = $wpdb->query($wpdb->prepare($sql, $params)); + + return is_numeric($result) ? (int) $result : 0; + } + + /** @param array $order @return array */ + private function expire_order_if_due(array $order): array { + $status = sanitize_key((string) ($order['status'] ?? '')); + $expires_at = trim((string) ($order['expires_at'] ?? '')); + if (!in_array($status, [Order::STATUS_CREATED, Order::STATUS_PENDING], true) || $expires_at === '') { + return $order; + } + + $expires = strtotime($expires_at . ' UTC'); + if ($expires === false || $expires > time()) { + return $order; + } + + global $wpdb; + $closed_at = gmdate('Y-m-d H:i:s'); + $updated = $wpdb->update( + $this->table(), + ['status' => Order::STATUS_EXPIRED, 'cancelled_at' => $closed_at], + ['order_number' => (string) $order['order_number'], 'status' => $status], + ['%s', '%s'], + ['%s', '%s'] + ); + if ($updated) { + $order['status'] = Order::STATUS_EXPIRED; + $order['cancelled_at'] = $closed_at; + } + + return $order; + } + public function update_status(string $order_number, string $new_status, array $extra = []): bool { @@ -394,6 +456,8 @@ trait OrderRepositoryCore { public function get_stats(int $user_id = 0): array { global $wpdb; + $this->expire_pending_orders($user_id > 0 ? $user_id : null); + $where = ''; $params = []; if ($user_id > 0) { @@ -703,7 +767,7 @@ trait OrderRepositoryCore { ARRAY_A ); if (is_array($row)) { - return $this->normalize_row($row); + return $this->expire_order_if_due($this->normalize_row($row)); } // Superseded attempts stay in the history array. @@ -719,7 +783,7 @@ trait OrderRepositoryCore { ARRAY_A ); if (is_array($row)) { - return $this->normalize_row($row); + return $this->expire_order_if_due($this->normalize_row($row)); } // Last resort: {order_number}P{n} prefix heuristic. diff --git a/src/Settings/PaymentRuntime.php b/src/Settings/PaymentRuntime.php index b6ab4943..11020d38 100644 --- a/src/Settings/PaymentRuntime.php +++ b/src/Settings/PaymentRuntime.php @@ -392,6 +392,20 @@ class PaymentRuntime { ]; } + /** + * Return the UTC expiry time for a newly created payable order. + * + * Keeping this in the payment runtime prevents individual checkout + * flows from drifting away from the admin "payment_order_expiry" setting. + */ + public static function get_order_expiry_at(?int $now = null): string { + $settings = self::get_order_settings(); + $timestamp = $now ?? time(); + $minutes = max(5, min(1440, (int) ($settings['expiry'] ?? 30))); + + return gmdate('Y-m-d H:i:s', $timestamp + $minutes * MINUTE_IN_SECONDS); + } + /** * @return array */