Files
WooNooW/includes/Api/SubscriptionsController.php

763 lines
29 KiB
PHP

<?php
/**
* Subscriptions API Controller
*
* REST API endpoints for subscription management.
*
* @package WooNooW\Api
*/
namespace WooNooW\Api;
if (!defined('ABSPATH')) exit;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
use WooNooW\Core\ModuleRegistry;
use WooNooW\Modules\Subscription\SubscriptionManager;
use WooNooW\Modules\Subscription\GatewayCapabilities;
class SubscriptionsController
{
/**
* Register REST routes
*/
public static function register_routes()
{
// Check if module is enabled
if (!ModuleRegistry::is_enabled('subscription')) {
return;
}
// Admin routes
register_rest_route('woonoow/v1', '/subscriptions', [
'methods' => '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<id>\d+)', [
'methods' => 'GET',
'callback' => [__CLASS__, 'get_subscription'],
'permission_callback' => function () {
return current_user_can('manage_woocommerce');
},
]);
register_rest_route('woonoow/v1', '/subscriptions/(?P<id>\d+)', [
'methods' => 'PUT',
'callback' => [__CLASS__, 'update_subscription'],
'permission_callback' => function () {
return current_user_can('manage_woocommerce');
},
]);
register_rest_route('woonoow/v1', '/subscriptions/(?P<id>\d+)/cancel', [
'methods' => 'POST',
'callback' => [__CLASS__, 'cancel_subscription'],
'permission_callback' => function () {
return current_user_can('manage_woocommerce');
},
]);
register_rest_route('woonoow/v1', '/subscriptions/(?P<id>\d+)/renew', [
'methods' => 'POST',
'callback' => [__CLASS__, 'renew_subscription'],
'permission_callback' => function () {
return current_user_can('manage_woocommerce');
},
]);
register_rest_route('woonoow/v1', '/subscriptions/(?P<id>\d+)/pause', [
'methods' => 'POST',
'callback' => [__CLASS__, 'pause_subscription'],
'permission_callback' => function () {
return current_user_can('manage_woocommerce');
},
]);
register_rest_route('woonoow/v1', '/subscriptions/(?P<id>\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<id>\d+)', [
'methods' => 'GET',
'callback' => [__CLASS__, 'get_customer_subscription'],
'permission_callback' => function () {
return is_user_logged_in();
},
]);
register_rest_route('woonoow/v1', '/account/subscriptions/(?P<id>\d+)/cancel', [
'methods' => 'POST',
'callback' => [__CLASS__, 'customer_cancel'],
'permission_callback' => function () {
return is_user_logged_in();
},
]);
register_rest_route('woonoow/v1', '/account/subscriptions/(?P<id>\d+)/pause', [
'methods' => 'POST',
'callback' => [__CLASS__, 'customer_pause'],
'permission_callback' => function () {
return is_user_logged_in();
},
]);
register_rest_route('woonoow/v1', '/account/subscriptions/(?P<id>\d+)/resume', [
'methods' => 'POST',
'callback' => [__CLASS__, 'customer_resume'],
'permission_callback' => function () {
return is_user_logged_in();
},
]);
register_rest_route('woonoow/v1', '/account/subscriptions/(?P<id>\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: { '<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,
]);
}
}