Subscription module: add gateway capability flow and UX fixes

This commit is contained in:
Dwindi Ramadhana
2026-06-02 00:38:42 +07:00
parent fec786daa6
commit df969b442d
15 changed files with 2375 additions and 138 deletions

View File

@@ -63,6 +63,7 @@ class SubscriptionManager
payment_meta LONGTEXT,
cancel_reason TEXT DEFAULT NULL,
pause_count INT UNSIGNED DEFAULT 0,
paused_at DATETIME DEFAULT NULL,
failed_payment_count INT UNSIGNED DEFAULT 0,
reminder_sent_at DATETIME DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
@@ -87,11 +88,50 @@ class SubscriptionManager
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta($sql_subscriptions);
dbDelta($sql_orders);
// Runtime migration: add paused_at to existing installations.
// dbDelta does not add new columns to existing tables.
if ($wpdb->get_var("SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '$table_subscriptions' AND COLUMN_NAME = 'paused_at'") == 0) {
$wpdb->query("ALTER TABLE $table_subscriptions ADD COLUMN paused_at DATETIME DEFAULT NULL AFTER pause_count");
}
}
/**
* Read a subscription meta key with variation-first, parent-fallback resolution.
*
* M1 — A variable product can have a "License 1-year" variation (period=year,
* length=1) and a "License 5-year" variation (period=year, length=5) living
* as siblings on the same parent product. The merchant authors those values
* per-variation. We must read the variation value first, then fall back to
* the parent if the variation didn't set it.
*
* An empty string and the literal `false` (post-meta "missing") are both
* treated as "not set". Only a real value returns.
*
* @param string $key Post meta key, e.g. '_woonoow_subscription_period'.
* @param int $variation_id Variation ID, or 0 if no variation.
* @param int $product_id Parent product ID.
* @param mixed $default Returned if neither variation nor parent has a value.
* @return mixed
*/
public static function get_subscription_meta($key, $variation_id, $product_id, $default = null)
{
if ($variation_id) {
$v = get_post_meta($variation_id, $key, true);
if ($v !== '' && $v !== false && $v !== null) {
return $v;
}
}
$p = get_post_meta($product_id, $key, true);
if ($p !== '' && $p !== false && $p !== null) {
return $p;
}
return $default;
}
/**
* Create subscription from order item
*
*
* @param \WC_Order $order
* @param \WC_Order_Item_Product $item
* @return int|false Subscription ID or false on failure
@@ -109,11 +149,13 @@ class SubscriptionManager
return false;
}
// Get subscription settings from product
$billing_period = get_post_meta($product_id, '_woonoow_subscription_period', true) ?: 'month';
$billing_interval = absint(get_post_meta($product_id, '_woonoow_subscription_interval', true)) ?: 1;
$trial_days = absint(get_post_meta($product_id, '_woonoow_subscription_trial_days', true));
$subscription_length = absint(get_post_meta($product_id, '_woonoow_subscription_length', true));
// M1 — Read subscription meta variation-first, then fall back to parent.
// Variation-level overrides let a merchant sell e.g. "License 1-year" and
// "License 5-year" as variations of one variable product.
$billing_period = self::get_subscription_meta('_woonoow_subscription_period', $variation_id, $product_id, 'month');
$billing_interval = absint(self::get_subscription_meta('_woonoow_subscription_interval', $variation_id, $product_id, 1));
$trial_days = absint(self::get_subscription_meta('_woonoow_subscription_trial_days', $variation_id, $product_id, 0));
$subscription_length = absint(self::get_subscription_meta('_woonoow_subscription_length', $variation_id, $product_id, 0));
// Calculate dates
$now = current_time('mysql');
@@ -285,26 +327,52 @@ class SubscriptionManager
$where = "WHERE 1=1";
$params = [];
$joins = "";
if ($args['status']) {
$where .= " AND status = %s";
$where .= " AND s.status = %s";
$params[] = $args['status'];
}
if ($args['product_id']) {
$where .= " AND product_id = %d";
$where .= " AND s.product_id = %d";
$params[] = $args['product_id'];
}
if ($args['user_id']) {
$where .= " AND user_id = %d";
$where .= " AND s.user_id = %d";
$params[] = $args['user_id'];
}
$order = "ORDER BY created_at DESC";
// M4 — Free-text search. The user types something; we match on:
// - numeric input → subscriptions.id exactly
// - any input → user_email / display_name / user_login LIKE
// The customer-facing word "search" maps to a JOIN on wp_users. We don't
// attempt to LIKE-match product name here because that would require a
// second JOIN on wp_posts and product name relevance is rarely what the
// admin types into this box — they want to find a specific customer's
// subscription.
$search = is_string($args['search']) ? trim($args['search']) : '';
if ($search !== '') {
$joins .= " LEFT JOIN {$wpdb->users} u ON u.ID = s.user_id";
if (ctype_digit($search)) {
$where .= " AND (s.id = %d OR u.user_email LIKE %s OR u.display_name LIKE %s)";
$params[] = (int) $search;
$params[] = '%' . $wpdb->esc_like($search) . '%';
$params[] = '%' . $wpdb->esc_like($search) . '%';
} else {
$where .= " AND (u.user_email LIKE %s OR u.display_name LIKE %s OR u.user_login LIKE %s)";
$like = '%' . $wpdb->esc_like($search) . '%';
$params[] = $like;
$params[] = $like;
$params[] = $like;
}
}
$order = "ORDER BY s.created_at DESC";
$limit = "LIMIT " . intval($args['limit']) . " OFFSET " . intval($args['offset']);
$sql = "SELECT * FROM " . self::$table_subscriptions . " $where $order $limit";
$sql = "SELECT s.* FROM " . self::$table_subscriptions . " s $joins $where $order $limit";
if (!empty($params)) {
$sql = $wpdb->prepare($sql, $params);
@@ -315,7 +383,10 @@ class SubscriptionManager
/**
* Count subscriptions
*
*
* M4 — supports the same `search` semantics as `get_all` so the pagination
* total matches the filtered result set.
*
* @param array $args
* @return int
*/
@@ -325,13 +396,31 @@ class SubscriptionManager
$where = "WHERE 1=1";
$params = [];
$joins = "";
if (!empty($args['status'])) {
$where .= " AND status = %s";
$where .= " AND s.status = %s";
$params[] = $args['status'];
}
$sql = "SELECT COUNT(*) FROM " . self::$table_subscriptions . " $where";
$search = is_string($args['search']) ? trim($args['search']) : '';
if ($search !== '') {
$joins .= " LEFT JOIN {$wpdb->users} u ON u.ID = s.user_id";
if (ctype_digit($search)) {
$where .= " AND (s.id = %d OR u.user_email LIKE %s OR u.display_name LIKE %s)";
$params[] = (int) $search;
$params[] = '%' . $wpdb->esc_like($search) . '%';
$params[] = '%' . $wpdb->esc_like($search) . '%';
} else {
$where .= " AND (u.user_email LIKE %s OR u.display_name LIKE %s OR u.user_login LIKE %s)";
$like = '%' . $wpdb->esc_like($search) . '%';
$params[] = $like;
$params[] = $like;
$params[] = $like;
}
}
$sql = "SELECT COUNT(*) FROM " . self::$table_subscriptions . " s $joins $where";
if (!empty($params)) {
$sql = $wpdb->prepare($sql, $params);
@@ -437,11 +526,12 @@ class SubscriptionManager
$updated = $wpdb->update(
self::$table_subscriptions,
[
'status' => 'on-hold',
'status' => 'on-hold',
'pause_count' => $subscription->pause_count + 1,
'paused_at' => current_time('mysql'),
],
['id' => $subscription_id],
['%s', '%d'],
['%s', '%d', '%s'],
['%d']
);
@@ -479,6 +569,8 @@ class SubscriptionManager
$subscription->billing_interval
);
$update_data['next_payment_date'] = $next_payment;
$update_data['paused_at'] = null;
$format[] = '%s';
$format[] = '%s';
}
@@ -505,11 +597,17 @@ class SubscriptionManager
*/
/**
* Process renewal for a subscription
*
* @param int $subscription_id
* @return bool
*
* M2 — `$charge_now = true` is the admin "charge immediately" flag. It
* bypasses the per-gateway capability gate so the auto-debit attempt is
* made even on normally-manual gateways. Use this for the ad-hoc admin
* "charge now" button, not for cron-driven renewals.
*
* @param int $subscription_id
* @param bool $charge_now M2: bypass capability gate, never fall through to manual.
* @return bool|array
*/
public static function renew($subscription_id)
public static function renew($subscription_id, $charge_now = false)
{
global $wpdb;
@@ -518,13 +616,15 @@ class SubscriptionManager
return false;
}
// Check for existing pending renewal order to prevent duplicates
// Check for existing pending/awaiting/failed renewal order to prevent duplicates.
// A previously failed renewal must also short-circuit so the customer retries the same
// order instead of accumulating duplicate renewal orders on the subscription.
$existing_pending = $wpdb->get_row($wpdb->prepare(
"SELECT so.order_id FROM " . self::$table_subscription_orders . " so
JOIN {$wpdb->posts} p ON so.order_id = p.ID
WHERE so.subscription_id = %d
AND so.order_type = 'renewal'
AND p.post_status IN ('wc-pending', 'pending', 'wc-on-hold', 'on-hold')",
AND p.post_status IN ('wc-pending', 'pending', 'wc-on-hold', 'on-hold', 'wc-failed', 'failed')",
$subscription_id
));
@@ -542,7 +642,7 @@ class SubscriptionManager
// Process payment
// Result can be: true (paid), false (failed), or 'manual' (waiting for payment)
$payment_result = self::process_renewal_payment($subscription, $renewal_order);
$payment_result = self::process_renewal_payment($subscription, $renewal_order, $charge_now);
if ($payment_result === true) {
self::handle_renewal_success($subscription_id, $renewal_order);
@@ -596,12 +696,20 @@ class SubscriptionManager
return false;
}
// Add product
// Add product. H4 — price-sync policy: by default we honor the customer's
// stored recurring_amount (grandfather them at the original price). The merchant
// can flip `price_sync_on_renewal` to 'use_current_product_price' to always bill
// the latest price on every renewal.
$product = wc_get_product($subscription->variation_id ?: $subscription->product_id);
if ($product) {
$settings = ModuleRegistry::get_settings('subscription');
$price_mode = $settings['price_sync_on_renewal'] ?? 'use_stored';
$line_total = ($price_mode === 'use_current_product_price' && $product->get_price() !== '')
? (float) $product->get_price()
: (float) $subscription->recurring_amount;
$renewal_order->add_product($product, 1, [
'total' => $subscription->recurring_amount,
'subtotal' => $subscription->recurring_amount,
'total' => $line_total,
'subtotal' => $line_total,
]);
}
@@ -630,19 +738,22 @@ class SubscriptionManager
/**
* Process payment for renewal order
*
* @param object $subscription
* @param \WC_Order $order
* @return bool
*/
/**
* Process payment for renewal order
*
* @param object $subscription
*
* M2 — `$force = true` is used by the admin "charge now" button. It
* bypasses the GatewayCapabilities gate: the admin has explicitly
* declared intent to charge, so we attempt the auto-debit path even on
* gateways that are normally manual-only. If the gateway does not
* implement `process_subscription_renewal_payment` and no external
* handler picks it up via filter, we fail loudly (return `false`) rather
* than silently creating a manual order — the admin expects an immediate
* charge, not a payment-link email.
*
* @param object $subscription
* @param \WC_Order $order
* @param bool $force M2: bypass capability gate; never fall through to manual.
* @return bool|string True if paid, false if failed, 'manual' if waiting
*/
private static function process_renewal_payment($subscription, $order)
private static function process_renewal_payment($subscription, $order, $force = false)
{
// Allow plugins to override payment processing completely
// Return true/false/'manual' to bypass default logic
@@ -663,14 +774,26 @@ class SubscriptionManager
$gateway = $gateways[$gateway_id];
// 1. Try Auto-Debit if supported
if (method_exists($gateway, 'process_subscription_renewal_payment')) {
$result = $gateway->process_subscription_renewal_payment($order, $subscription);
if (!is_wp_error($result) && $result) {
return true;
// 0. Per-gateway capability gate (§9 of the audit).
// If the gateway is not declared to support subscription auto-renew (or the kill
// switch is on), we skip the auto-debit attempt entirely and fall through to
// manual. The capability table is the merchant-visible source of truth — PHP
// introspection alone is no longer authoritative.
//
// M2 — `force` skips this gate. Admin has explicitly opted in to attempting the
// charge, so we ignore the capability declaration.
$capability_ok = $force || GatewayCapabilities::should_attempt_auto_renew($gateway_id);
if ($capability_ok) {
// 1. Try Auto-Debit if supported by the gateway implementation
if (method_exists($gateway, 'process_subscription_renewal_payment')) {
$result = $gateway->process_subscription_renewal_payment($order, $subscription);
if (!is_wp_error($result) && $result) {
return true;
}
// If explicit failure from auto-debit, return false (will trigger retry logic)
return false;
}
// If explicit failure from auto-debit, return false (will trigger retry logic)
return false;
}
// 2. Allow other plugins to handle auto-debit via filter (e.g. Stripe/PayPal adapters)
@@ -680,6 +803,15 @@ class SubscriptionManager
}
// 3. Fallback: Manual Payment
// M2 — In force mode, the admin said "charge now". If we got here, the gateway
// does not implement auto-debit and no external handler picked it up. Creating
// a manual order would silently contradict the admin's intent. Fail loudly so
// the admin sees the charge could not be processed.
if ($force) {
$order->update_status('failed', __('Admin "charge now" requested, but gateway does not support auto-debit', 'woonoow'));
return false;
}
// Set order to pending-payment
$order->update_status('pending', __('Awaiting manual renewal payment', 'woonoow'));