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

@@ -17,6 +17,7 @@ use WP_REST_Response;
use WP_Error;
use WooNooW\Core\ModuleRegistry;
use WooNooW\Modules\Subscription\SubscriptionManager;
use WooNooW\Modules\Subscription\GatewayCapabilities;
class SubscriptionsController
{
@@ -40,6 +41,17 @@ class SubscriptionsController
},
]);
// M3 — Bulk operations. Body shape: { action: 'cancel' | 'export_csv', ids: number[] }.
// For 'cancel' we return { ok: int, failed: [{id, error}] }. For 'export_csv' the
// response is a text/csv body with Content-Disposition.
register_rest_route('woonoow/v1', '/subscriptions/bulk', [
'methods' => 'POST',
'callback' => [__CLASS__, 'bulk_action'],
'permission_callback' => function () {
return current_user_can('manage_woocommerce');
},
]);
register_rest_route('woonoow/v1', '/subscriptions/(?P<id>\d+)', [
'methods' => 'GET',
'callback' => [__CLASS__, 'get_subscription'],
@@ -136,6 +148,23 @@ class SubscriptionsController
return is_user_logged_in();
},
]);
// §9 — Gateway capability matrix (admin)
register_rest_route('woonoow/v1', '/subscriptions/gateway-capabilities', [
'methods' => 'GET',
'callback' => [__CLASS__, 'get_gateway_capabilities'],
'permission_callback' => function () {
return current_user_can('manage_woocommerce');
},
]);
register_rest_route('woonoow/v1', '/subscriptions/gateway-capabilities', [
'methods' => 'POST',
'callback' => [__CLASS__, 'update_gateway_capabilities'],
'permission_callback' => function () {
return current_user_can('manage_woocommerce');
},
]);
}
/**
@@ -147,12 +176,16 @@ class SubscriptionsController
'status' => $request->get_param('status'),
'product_id' => $request->get_param('product_id'),
'user_id' => $request->get_param('user_id'),
'search' => $request->get_param('search'),
'limit' => $request->get_param('per_page') ?: 20,
'offset' => (($request->get_param('page') ?: 1) - 1) * ($request->get_param('per_page') ?: 20),
];
$subscriptions = SubscriptionManager::get_all($args);
$total = SubscriptionManager::count(['status' => $args['status']]);
$total = SubscriptionManager::count([
'status' => $args['status'],
'search' => $args['search'],
]);
// Enrich with product and user info
$enriched = [];
@@ -244,16 +277,27 @@ class SubscriptionsController
/**
* Renew subscription (admin - force immediate renewal)
*
* M2 — supports `?charge_now=true` to bypass the per-gateway capability
* gate. With the flag, the auto-debit path is attempted even on gateways
* that are normally manual-only; on failure the order is marked failed
* (no manual fallback) so the admin can see the charge couldn't go
* through.
*/
public static function renew_subscription(WP_REST_Request $request)
{
$result = SubscriptionManager::renew($request->get_param('id'));
$charge_now = filter_var($request->get_param('charge_now'), FILTER_VALIDATE_BOOLEAN);
$result = SubscriptionManager::renew($request->get_param('id'), $charge_now);
if (!$result) {
return new WP_Error('renew_failed', __('Failed to process renewal', 'woonoow'), ['status' => 500]);
}
return new WP_REST_Response(['success' => true, 'order_id' => $result['order_id']]);
return new WP_REST_Response([
'success' => true,
'order_id' => $result['order_id'],
'status' => $result['status'] ?? 'complete',
]);
}
/**
@@ -284,6 +328,98 @@ class SubscriptionsController
return new WP_REST_Response(['success' => true]);
}
/**
* M3 — Bulk action endpoint.
*
* Body: { action: 'cancel' | 'export_csv', ids: number[] }
*
* - 'cancel' returns JSON `{ ok: int, failed: [{id, error}] }`. Per-subscription
* errors do not abort the batch — the admin sees the per-row outcome.
* - 'export_csv' streams a CSV download. We don't use WP_REST_Response's
* download flag because we want to set a custom filename.
*
* Hard cap of 500 ids per call to avoid runaway batches. A real implementation
* would dispatch this via Action Scheduler; for now we run inline because
* 500 cancels is <1s of DB writes.
*/
public static function bulk_action(WP_REST_Request $request)
{
$action = (string) $request->get_param('action');
$ids = $request->get_param('ids');
if (!is_array($ids) || empty($ids)) {
return new WP_Error('bad_request', __('ids must be a non-empty array', 'woonoow'), ['status' => 400]);
}
// Coerce to ints, drop non-numeric junk, dedupe.
$ids = array_values(array_unique(array_filter(array_map('intval', $ids), function ($i) { return $i > 0; })));
if (empty($ids)) {
return new WP_Error('bad_request', __('ids must contain at least one positive integer', 'woonoow'), ['status' => 400]);
}
if (count($ids) > 500) {
return new WP_Error('batch_too_large', __('Maximum 500 ids per bulk request', 'woonoow'), ['status' => 400]);
}
if ($action === 'cancel') {
$ok = 0;
$failed = [];
foreach ($ids as $id) {
$result = SubscriptionManager::cancel($id);
if ($result === false || $result === null) {
$failed[] = ['id' => $id, 'error' => __('Cancel returned false', 'woonoow')];
} else {
$ok++;
}
}
return new WP_REST_Response(['ok' => $ok, 'failed' => $failed]);
}
if ($action === 'export_csv') {
$rows = [];
foreach ($ids as $id) {
$sub = SubscriptionManager::get($id);
if (!$sub) {
$rows[] = [
'id' => $id, 'status' => 'missing', 'user_name' => '', 'user_email' => '',
'product_name' => '', 'billing_period' => '', 'billing_interval' => '',
'recurring_amount' => '', 'next_payment_date' => '', 'start_date' => '',
'end_date' => '', 'payment_method' => '',
];
continue;
}
$rows[] = [
'id' => (int) $sub->id,
'status' => (string) $sub->status,
'user_name' => (string) ($sub->user_name ?? ''),
'user_email' => (string) ($sub->user_email ?? ''),
'product_name' => (string) ($sub->product_name ?? ''),
'billing_period' => (string) $sub->billing_period,
'billing_interval' => (int) $sub->billing_interval,
'recurring_amount' => (string) $sub->recurring_amount,
'next_payment_date' => (string) ($sub->next_payment_date ?? ''),
'start_date' => (string) $sub->start_date,
'end_date' => (string) ($sub->end_date ?? ''),
'payment_method' => (string) ($sub->payment_method ?? ''),
];
}
$filename = 'woonoow-subscriptions-' . gmdate('Ymd-His') . '.csv';
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $filename . '"');
$out = fopen('php://output', 'w');
if (!empty($rows)) {
fputcsv($out, array_keys($rows[0]));
foreach ($rows as $r) {
fputcsv($out, $r);
}
}
fclose($out);
exit;
}
return new WP_Error('unknown_action', __('Unknown bulk action', 'woonoow'), ['status' => 400]);
}
/**
* Get customer's subscriptions
*/
@@ -453,10 +589,31 @@ class SubscriptionsController
// Add computed fields
$enriched['is_active'] = $subscription->status === 'active';
$enriched['can_pause'] = $subscription->status === 'active';
$enriched['can_resume'] = $subscription->status === 'on-hold';
// Surface pause-limit context to the client (H2). The server-side pause handler in
// SubscriptionManager::pause() already enforces the limit; this just tells the UI how
// many pauses remain so the button can be disabled with a tooltip before the customer
// hits the wall and gets a generic 500.
$settings = \WooNooW\Core\ModuleRegistry::get_settings('subscription');
$max_pause_count = isset($settings['max_pause_count']) ? (int) $settings['max_pause_count'] : 3;
$enriched['max_pause_count'] = $max_pause_count;
$enriched['pauses_remaining'] = $max_pause_count > 0
? max(0, $max_pause_count - (int) $subscription->pause_count)
: null; // null = unlimited
// Whether this customer is actually allowed to pause, incorporating:
// - feature toggle (allow_customer_pause setting)
// - subscription status
// - lifetime pause limit
$allow_pause_feature = !empty($settings['allow_customer_pause']);
$pause_limit_ok = ($max_pause_count <= 0) || ($subscription->pause_count < $max_pause_count);
$enriched['can_pause'] = $subscription->status === 'active' && $allow_pause_feature && $pause_limit_ok;
$enriched['can_resume'] = in_array($subscription->status, ['on-hold', 'pending-cancel']);
$enriched['can_cancel'] = in_array($subscription->status, ['active', 'on-hold', 'pending']);
// Expose paused_at so the UI can show when the subscription was paused
$enriched['paused_at'] = $subscription->paused_at ?? null;
// Format billing info
$period_labels = [
'day' => __('day', 'woonoow'),
@@ -496,6 +653,110 @@ class SubscriptionsController
$enriched['payment_method_title'] = $payment_title;
// §9 — Tell the client whether the stored gateway is declared to support
// subscription auto-renew. The renewal flow uses this for messaging and the
// admin uses it for at-a-glance status.
$enriched['gateway_supports_auto_renew'] = !empty($subscription->payment_method)
? GatewayCapabilities::should_attempt_auto_renew($subscription->payment_method)
: false;
$enriched['gateway_force_manual'] = GatewayCapabilities::force_manual();
return $enriched;
}
/**
* §9 — List the merged gateway capability matrix for the admin UI.
*
* Returns a row per available WC payment gateway with:
* id, title, description, enabled (site-enabled),
* auto_renew (effective — capability table + kill switch),
* override (the merchant-set override, or null if using default),
* default (the built-in default for this gateway ID)
*/
public static function get_gateway_capabilities(WP_REST_Request $request)
{
if (!function_exists('WC')) {
return new WP_Error('wc_missing', __('WooCommerce is not active.', 'woonoow'), ['status' => 500]);
}
$gateways = WC()->payment_gateways()->payment_gateways();
$stored = get_option(GatewayCapabilities::OPTION_KEY, []);
if (!is_array($stored)) {
$stored = [];
}
$defaults = GatewayCapabilities::default_capabilities();
$kill_switch = GatewayCapabilities::force_manual();
$rows = [];
foreach ($gateways as $id => $gateway) {
$default = isset($defaults[$id]) ? (bool) $defaults[$id]['subscription_auto_renew'] : false;
$override = array_key_exists($id, $stored) ? (bool) $stored[$id]['subscription_auto_renew'] : null;
$effective = GatewayCapabilities::should_attempt_auto_renew($id);
$rows[] = [
'id' => $id,
'title' => $gateway->get_title() ?: $gateway->method_title ?: $id,
'description' => $gateway->get_description(),
'enabled' => $gateway->enabled === 'yes',
'default' => $default,
'override' => $override,
'auto_renew' => $effective,
'forced_manual' => $kill_switch,
];
}
return new WP_REST_Response([
'gateways' => array_values($rows),
'kill_switch' => $kill_switch,
]);
}
/**
* §9 — Persist merchant overrides for the per-gateway capability table.
*
* Body shape: { overrides: { '<gateway_id>': bool | null, ... } }
* - bool => explicit override (true = auto-renew, false = manual)
* - null => clear override, fall back to default
*
* The kill switch is NOT set here — it lives in the standard module
* settings under `force_manual_renewal` (use the generic settings endpoint).
*/
public static function update_gateway_capabilities(WP_REST_Request $request)
{
$body = $request->get_json_params();
if (!is_array($body) || !isset($body['overrides']) || !is_array($body['overrides'])) {
return new WP_Error('bad_request', __('overrides map is required.', 'woonoow'), ['status' => 400]);
}
$stored = get_option(GatewayCapabilities::OPTION_KEY, []);
if (!is_array($stored)) {
$stored = [];
}
$defaults = GatewayCapabilities::default_capabilities();
$valid_ids = $defaults;
if (function_exists('WC')) {
foreach (WC()->payment_gateways()->payment_gateways() as $id => $gw) {
$valid_ids[$id] = ['subscription_auto_renew' => false];
}
}
foreach ($body['overrides'] as $id => $value) {
$id = sanitize_key((string) $id);
if ($id === '' || !array_key_exists($id, $valid_ids)) {
continue; // unknown gateway — ignore
}
if ($value === null) {
unset($stored[$id]);
} else {
$stored[$id] = ['subscription_auto_renew' => (bool) $value];
}
}
update_option(GatewayCapabilities::OPTION_KEY, $stored);
return new WP_REST_Response([
'success' => true,
'overrides' => $stored,
]);
}
}