'GET', 'callback' => [__CLASS__, 'get_subscriptions'], 'permission_callback' => function () { return current_user_can('manage_woocommerce'); }, ]); // 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\d+)', [ 'methods' => 'GET', 'callback' => [__CLASS__, 'get_subscription'], 'permission_callback' => function () { return current_user_can('manage_woocommerce'); }, ]); register_rest_route('woonoow/v1', '/subscriptions/(?P\d+)', [ 'methods' => 'PUT', 'callback' => [__CLASS__, 'update_subscription'], 'permission_callback' => function () { return current_user_can('manage_woocommerce'); }, ]); register_rest_route('woonoow/v1', '/subscriptions/(?P\d+)/cancel', [ 'methods' => 'POST', 'callback' => [__CLASS__, 'cancel_subscription'], 'permission_callback' => function () { return current_user_can('manage_woocommerce'); }, ]); register_rest_route('woonoow/v1', '/subscriptions/(?P\d+)/renew', [ 'methods' => 'POST', 'callback' => [__CLASS__, 'renew_subscription'], 'permission_callback' => function () { return current_user_can('manage_woocommerce'); }, ]); register_rest_route('woonoow/v1', '/subscriptions/(?P\d+)/pause', [ 'methods' => 'POST', 'callback' => [__CLASS__, 'pause_subscription'], 'permission_callback' => function () { return current_user_can('manage_woocommerce'); }, ]); register_rest_route('woonoow/v1', '/subscriptions/(?P\d+)/resume', [ 'methods' => 'POST', 'callback' => [__CLASS__, 'resume_subscription'], 'permission_callback' => function () { return current_user_can('manage_woocommerce'); }, ]); // Customer routes register_rest_route('woonoow/v1', '/account/subscriptions', [ 'methods' => 'GET', 'callback' => [__CLASS__, 'get_customer_subscriptions'], 'permission_callback' => function () { return is_user_logged_in(); }, ]); register_rest_route('woonoow/v1', '/account/subscriptions/(?P\d+)', [ 'methods' => 'GET', 'callback' => [__CLASS__, 'get_customer_subscription'], 'permission_callback' => function () { return is_user_logged_in(); }, ]); register_rest_route('woonoow/v1', '/account/subscriptions/(?P\d+)/cancel', [ 'methods' => 'POST', 'callback' => [__CLASS__, 'customer_cancel'], 'permission_callback' => function () { return is_user_logged_in(); }, ]); register_rest_route('woonoow/v1', '/account/subscriptions/(?P\d+)/pause', [ 'methods' => 'POST', 'callback' => [__CLASS__, 'customer_pause'], 'permission_callback' => function () { return is_user_logged_in(); }, ]); register_rest_route('woonoow/v1', '/account/subscriptions/(?P\d+)/resume', [ 'methods' => 'POST', 'callback' => [__CLASS__, 'customer_resume'], 'permission_callback' => function () { return is_user_logged_in(); }, ]); register_rest_route('woonoow/v1', '/account/subscriptions/(?P\d+)/renew', [ 'methods' => 'POST', 'callback' => [__CLASS__, 'customer_renew'], 'permission_callback' => function () { 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'); }, ]); } /** * Get all subscriptions (admin) */ public static function get_subscriptions(WP_REST_Request $request) { $args = [ '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'], 'search' => $args['search'], ]); // Enrich with product and user info $enriched = []; foreach ($subscriptions as $subscription) { $enriched[] = self::enrich_subscription($subscription); } return new WP_REST_Response([ 'subscriptions' => $enriched, 'total' => $total, 'page' => $request->get_param('page') ?: 1, 'per_page' => $args['limit'], ]); } /** * Get single subscription (admin) */ public static function get_subscription(WP_REST_Request $request) { $subscription = SubscriptionManager::get($request->get_param('id')); if (!$subscription) { return new WP_Error('not_found', __('Subscription not found', 'woonoow'), ['status' => 404]); } $enriched = self::enrich_subscription($subscription); $enriched['orders'] = SubscriptionManager::get_orders($subscription->id); return new WP_REST_Response($enriched); } /** * Update subscription (admin) */ public static function update_subscription(WP_REST_Request $request) { global $wpdb; $subscription = SubscriptionManager::get($request->get_param('id')); if (!$subscription) { return new WP_Error('not_found', __('Subscription not found', 'woonoow'), ['status' => 404]); } $data = $request->get_json_params(); $allowed_fields = ['status', 'next_payment_date', 'end_date', 'billing_period', 'billing_interval']; $update_data = []; $format = []; foreach ($allowed_fields as $field) { if (isset($data[$field])) { $update_data[$field] = $data[$field]; $format[] = is_numeric($data[$field]) ? '%d' : '%s'; } } if (empty($update_data)) { return new WP_Error('no_data', __('No valid fields to update', 'woonoow'), ['status' => 400]); } $table = $wpdb->prefix . 'woonoow_subscriptions'; $updated = $wpdb->update($table, $update_data, ['id' => $subscription->id], $format, ['%d']); if ($updated === false) { return new WP_Error('update_failed', __('Failed to update subscription', 'woonoow'), ['status' => 500]); } return new WP_REST_Response(['success' => true]); } /** * Cancel subscription (admin) */ public static function cancel_subscription(WP_REST_Request $request) { $data = $request->get_json_params(); $reason = $data['reason'] ?? 'Cancelled by admin'; $result = SubscriptionManager::cancel($request->get_param('id'), $reason); if (!$result) { return new WP_Error('cancel_failed', __('Failed to cancel subscription', 'woonoow'), ['status' => 500]); } return new WP_REST_Response(['success' => true]); } /** * 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) { $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'], 'status' => $result['status'] ?? 'complete', ]); } /** * Pause subscription (admin) */ public static function pause_subscription(WP_REST_Request $request) { $result = SubscriptionManager::pause($request->get_param('id')); if (!$result) { return new WP_Error('pause_failed', __('Failed to pause subscription', 'woonoow'), ['status' => 500]); } return new WP_REST_Response(['success' => true]); } /** * Resume subscription (admin) */ public static function resume_subscription(WP_REST_Request $request) { $result = SubscriptionManager::resume($request->get_param('id')); if (!$result) { return new WP_Error('resume_failed', __('Failed to resume subscription', 'woonoow'), ['status' => 500]); } 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 */ public static function get_customer_subscriptions(WP_REST_Request $request) { $user_id = get_current_user_id(); $subscriptions = SubscriptionManager::get_by_user($user_id, [ 'status' => $request->get_param('status'), 'limit' => $request->get_param('per_page') ?: 20, 'offset' => (($request->get_param('page') ?: 1) - 1) * ($request->get_param('per_page') ?: 20), ]); // Enrich each subscription $enriched = []; foreach ($subscriptions as $subscription) { $enriched[] = self::enrich_subscription($subscription); } return new WP_REST_Response($enriched); } /** * Get customer's subscription detail */ public static function get_customer_subscription(WP_REST_Request $request) { $user_id = get_current_user_id(); $subscription = SubscriptionManager::get($request->get_param('id')); if (!$subscription || $subscription->user_id != $user_id) { return new WP_Error('not_found', __('Subscription not found', 'woonoow'), ['status' => 404]); } $enriched = self::enrich_subscription($subscription); $enriched['orders'] = SubscriptionManager::get_orders($subscription->id); return new WP_REST_Response($enriched); } /** * Customer cancel their own subscription */ public static function customer_cancel(WP_REST_Request $request) { // Check if customer cancellation is allowed $settings = ModuleRegistry::get_settings('subscription'); if (empty($settings['allow_customer_cancel'])) { return new WP_Error('not_allowed', __('Customer cancellation is not allowed', 'woonoow'), ['status' => 403]); } $user_id = get_current_user_id(); $subscription = SubscriptionManager::get($request->get_param('id')); if (!$subscription || $subscription->user_id != $user_id) { return new WP_Error('not_found', __('Subscription not found', 'woonoow'), ['status' => 404]); } $data = $request->get_json_params(); $reason = $data['reason'] ?? 'Cancelled by customer'; $result = SubscriptionManager::cancel($subscription->id, $reason); if (!$result) { return new WP_Error('cancel_failed', __('Failed to cancel subscription', 'woonoow'), ['status' => 500]); } return new WP_REST_Response(['success' => true]); } /** * Customer pause their own subscription */ public static function customer_pause(WP_REST_Request $request) { // Check if customer pause is allowed $settings = ModuleRegistry::get_settings('subscription'); if (empty($settings['allow_customer_pause'])) { return new WP_Error('not_allowed', __('Customer pause is not allowed', 'woonoow'), ['status' => 403]); } $user_id = get_current_user_id(); $subscription = SubscriptionManager::get($request->get_param('id')); if (!$subscription || $subscription->user_id != $user_id) { return new WP_Error('not_found', __('Subscription not found', 'woonoow'), ['status' => 404]); } $result = SubscriptionManager::pause($subscription->id); if (!$result) { return new WP_Error('pause_failed', __('Failed to pause subscription. Maximum pauses may have been reached.', 'woonoow'), ['status' => 500]); } return new WP_REST_Response(['success' => true]); } /** * Customer resume their own subscription */ public static function customer_resume(WP_REST_Request $request) { $user_id = get_current_user_id(); $subscription = SubscriptionManager::get($request->get_param('id')); if (!$subscription || $subscription->user_id != $user_id) { return new WP_Error('not_found', __('Subscription not found', 'woonoow'), ['status' => 404]); } $result = SubscriptionManager::resume($subscription->id); if (!$result) { return new WP_Error('resume_failed', __('Failed to resume subscription', 'woonoow'), ['status' => 500]); } return new WP_REST_Response(['success' => true]); } /** * Customer renew their own subscription (Early Renewal) */ public static function customer_renew(WP_REST_Request $request) { $user_id = get_current_user_id(); $subscription = SubscriptionManager::get($request->get_param('id')); if (!$subscription || $subscription->user_id != $user_id) { return new WP_Error('not_found', __('Subscription not found', 'woonoow'), ['status' => 404]); } // Check if subscription is active (for early renewal) or on-hold with no pending payment if ($subscription->status !== 'active' && $subscription->status !== 'on-hold') { return new WP_Error('not_allowed', __('Only active subscriptions can be renewed early', 'woonoow'), ['status' => 403]); } // Trigger renewal $result = SubscriptionManager::renew($subscription->id); // SubscriptionManager::renew returns array (success) or false (failed) if (!$result) { return new WP_Error('renew_failed', __('Failed to create renewal order', 'woonoow'), ['status' => 500]); } return new WP_REST_Response([ 'success' => true, 'order_id' => $result['order_id'], 'status' => $result['status'] ]); } /** * Enrich subscription with product and user info */ private static function enrich_subscription($subscription) { $enriched = (array) $subscription; // Add product info $product_id = $subscription->variation_id ?: $subscription->product_id; $product = wc_get_product($product_id); $enriched['product_name'] = $product ? $product->get_name() : __('Unknown Product', 'woonoow'); $enriched['product_image'] = $product ? wp_get_attachment_url($product->get_image_id()) : ''; // Add user info $user = get_userdata($subscription->user_id); $enriched['user_email'] = $user ? $user->user_email : ''; $enriched['user_name'] = $user ? $user->display_name : __('Unknown User', 'woonoow'); // Add computed fields $enriched['is_active'] = $subscription->status === 'active'; // 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'), 'week' => __('week', 'woonoow'), 'month' => __('month', 'woonoow'), 'year' => __('year', 'woonoow'), ]; $interval = $subscription->billing_interval > 1 ? $subscription->billing_interval . ' ' : ''; $period = $period_labels[$subscription->billing_period] ?? $subscription->billing_period; if ($subscription->billing_interval > 1) { $period .= 's'; // Pluralize } $enriched['billing_schedule'] = sprintf(__('Every %s%s', 'woonoow'), $interval, $period); // Add payment method title $payment_title = $subscription->payment_method; // Default to ID // 1. Try from payment_meta (stored snapshot) if (!empty($subscription->payment_meta)) { $meta = json_decode($subscription->payment_meta, true); if (isset($meta['method_title']) && !empty($meta['method_title'])) { $payment_title = $meta['method_title']; } } // 2. If it looks like an ID (no spaces, lowercase), try to get fresh title from gateway if ($payment_title === $subscription->payment_method && function_exists('WC')) { $gateways_handler = WC()->payment_gateways(); if ($gateways_handler) { $gateways = $gateways_handler->payment_gateways(); if (isset($gateways[$subscription->payment_method])) { $gw = $gateways[$subscription->payment_method]; $payment_title = $gw->get_title() ?: $gw->method_title; } } } $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: { '': 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, ]); } }