fix(orders): enforce configured expiry
Apply the configured order lifetime to all checkout flows and close unpaid orders once their expiry is reached.
This commit is contained in:
@@ -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<string,mixed> $order @return array<string,mixed> */
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user