Subscription module: add gateway capability flow and UX fixes
This commit is contained in:
@@ -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'));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user