finalizing subscription moduile, ready to test

This commit is contained in:
Dwindi Ramadhana
2026-01-29 11:54:42 +07:00
parent 6d2136d3b5
commit d80f34c8b9
34 changed files with 5619 additions and 468 deletions

View File

@@ -1,4 +1,5 @@
<?php
namespace WooNooW\Api;
use WP_Error;
@@ -8,40 +9,44 @@ use WC_Product;
use WC_Shipping_Zones;
use WC_Shipping_Rate;
if (!defined('ABSPATH')) { exit; }
if (!defined('ABSPATH')) {
exit;
}
class CheckoutController {
class CheckoutController
{
/**
* Register REST routes for checkout quote & submit
*/
public static function register() {
public static function register()
{
$namespace = 'woonoow/v1';
register_rest_route($namespace, '/checkout/quote', [
'methods' => 'POST',
'callback' => [ new self(), 'quote' ],
'permission_callback' => [ \WooNooW\Api\Permissions::class, 'anon_or_wp_nonce' ], // consider nonce check later
'callback' => [new self(), 'quote'],
'permission_callback' => [\WooNooW\Api\Permissions::class, 'anon_or_wp_nonce'], // consider nonce check later
]);
register_rest_route($namespace, '/checkout/submit', [
'methods' => 'POST',
'callback' => [ new self(), 'submit' ],
'permission_callback' => [ \WooNooW\Api\Permissions::class, 'anon_or_wp_nonce' ], // consider capability/nonce
'callback' => [new self(), 'submit'],
'permission_callback' => [\WooNooW\Api\Permissions::class, 'anon_or_wp_nonce'], // consider capability/nonce
]);
register_rest_route($namespace, '/checkout/fields', [
'methods' => 'POST',
'callback' => [ new self(), 'get_fields' ],
'permission_callback' => [ \WooNooW\Api\Permissions::class, 'anon_or_wp_nonce' ],
'callback' => [new self(), 'get_fields'],
'permission_callback' => [\WooNooW\Api\Permissions::class, 'anon_or_wp_nonce'],
]);
// Public countries endpoint for customer checkout form
register_rest_route($namespace, '/countries', [
'methods' => 'GET',
'callback' => [ new self(), 'get_countries' ],
'callback' => [new self(), 'get_countries'],
'permission_callback' => '__return_true', // Public - needed for checkout
]);
// Public order view endpoint for thank you page
register_rest_route($namespace, '/checkout/order/(?P<id>\d+)', [
'methods' => 'GET',
'callback' => [ new self(), 'get_order' ],
'callback' => [new self(), 'get_order'],
'permission_callback' => '__return_true', // Public, validated via order_key
'args' => [
'key' => [
@@ -53,8 +58,14 @@ class CheckoutController {
// Get available shipping rates for given address
register_rest_route($namespace, '/checkout/shipping-rates', [
'methods' => 'POST',
'callback' => [ new self(), 'get_shipping_rates' ],
'permission_callback' => [ \WooNooW\Api\Permissions::class, 'anon_or_wp_nonce' ],
'callback' => [new self(), 'get_shipping_rates'],
'permission_callback' => [\WooNooW\Api\Permissions::class, 'anon_or_wp_nonce'],
]);
// Process payment for an existing order (e.g. renewal)
register_rest_route($namespace, '/checkout/pay-order/(?P<id>\d+)', [
'methods' => 'POST',
'callback' => [new self(), 'pay_order'],
'permission_callback' => '__return_true', // Validated via order key/owner in method
]);
}
@@ -68,7 +79,8 @@ class CheckoutController {
* shipping_method: "flat_rate:1" | "free_shipping:3" | ...
* }
*/
public function quote(WP_REST_Request $r): array {
public function quote(WP_REST_Request $r): array
{
$__t0 = microtime(true);
$payload = $this->sanitize_payload($r);
@@ -162,7 +174,8 @@ class CheckoutController {
* Validates access via order_key (for guests) or logged-in customer ID
* GET /checkout/order/{id}?key=wc_order_xxx
*/
public function get_order(WP_REST_Request $r): array {
public function get_order(WP_REST_Request $r): array
{
$order_id = absint($r['id']);
$order_key = sanitize_text_field($r->get_param('key') ?? '');
@@ -175,9 +188,12 @@ class CheckoutController {
return ['error' => __('Order not found', 'woonoow')];
}
// Validate access: order_key must match OR user must be logged in and own the order
// Validate access: order_key must match OR user must be logged in and own the order (or be admin)
$valid_key = $order_key && hash_equals($order->get_order_key(), $order_key);
$valid_owner = is_user_logged_in() && get_current_user_id() === $order->get_customer_id();
$valid_owner = is_user_logged_in() && (
get_current_user_id() === $order->get_customer_id() ||
current_user_can('manage_woocommerce')
);
if (!$valid_key && !$valid_owner) {
return ['error' => __('Unauthorized access to order', 'woonoow')];
@@ -197,7 +213,7 @@ class CheckoutController {
'image' => $product ? wp_get_attachment_image_url($product->get_image_id(), 'thumbnail') : null,
];
}
// Build shipping lines
$shipping_lines = [];
foreach ($order->get_shipping_methods() as $shipping_item) {
@@ -208,16 +224,16 @@ class CheckoutController {
'total' => wc_price($shipping_item->get_total()),
];
}
// Get tracking info from order meta (various plugins use different keys)
$tracking_number = $order->get_meta('_tracking_number')
?: $order->get_meta('_wc_shipment_tracking_items')
$tracking_number = $order->get_meta('_tracking_number')
?: $order->get_meta('_wc_shipment_tracking_items')
?: $order->get_meta('_rajaongkir_awb_number')
?: '';
$tracking_url = $order->get_meta('_tracking_url')
$tracking_url = $order->get_meta('_tracking_url')
?: $order->get_meta('_rajaongkir_tracking_url')
?: '';
// Check for shipment tracking plugin format (array of tracking items)
if (is_array($tracking_number) && !empty($tracking_number)) {
$first_tracking = reset($tracking_number);
@@ -230,6 +246,7 @@ class CheckoutController {
'id' => $order->get_id(),
'number' => $order->get_order_number(),
'status' => $order->get_status(),
'created_via' => $order->get_created_via(),
'subtotal' => (float) $order->get_subtotal(),
'discount_total' => (float) $order->get_discount_total(),
'shipping_total' => (float) $order->get_shipping_total(),
@@ -249,6 +266,28 @@ class CheckoutController {
'phone' => $order->get_billing_phone(),
],
'items' => $items,
'subscription' => $this->get_subscription_for_response($order),
'available_gateways' => $this->get_available_gateways_for_order($order),
];
}
private function get_subscription_for_response($order)
{
if (!class_exists('\WooNooW\Modules\Subscription\SubscriptionManager')) {
return null;
}
$sub = \WooNooW\Modules\Subscription\SubscriptionManager::get_by_order_id($order->get_id());
if (!$sub) return null;
return [
'id' => (int) $sub->id,
'status' => $sub->status,
'billing_period' => $sub->billing_period,
'billing_interval' => (int) $sub->billing_interval,
'start_date' => $sub->start_date,
'next_payment_date' => $sub->next_payment_date,
'end_date' => $sub->end_date,
'recurring_amount' => (float) $sub->recurring_amount,
];
}
@@ -263,7 +302,8 @@ class CheckoutController {
* payment_method: "cod" | "bacs" | ...
* }
*/
public function submit(WP_REST_Request $r): array {
public function submit(WP_REST_Request $r): array
{
$__t0 = microtime(true);
$payload = $this->sanitize_payload($r);
@@ -281,11 +321,11 @@ class CheckoutController {
if (is_user_logged_in()) {
$user_id = get_current_user_id();
$order->set_customer_id($user_id);
// Update user's billing information from checkout data
if (!empty($payload['billing'])) {
$billing = $payload['billing'];
// Update first name and last name
if (!empty($billing['first_name'])) {
update_user_meta($user_id, 'first_name', sanitize_text_field($billing['first_name']));
@@ -295,12 +335,12 @@ class CheckoutController {
update_user_meta($user_id, 'last_name', sanitize_text_field($billing['last_name']));
update_user_meta($user_id, 'billing_last_name', sanitize_text_field($billing['last_name']));
}
// Update billing phone
if (!empty($billing['phone'])) {
update_user_meta($user_id, 'billing_phone', sanitize_text_field($billing['phone']));
}
// Update billing email
if (!empty($billing['email'])) {
update_user_meta($user_id, 'billing_email', sanitize_email($billing['email']));
@@ -310,20 +350,20 @@ class CheckoutController {
// Guest checkout - check if auto-register is enabled
$customer_settings = \WooNooW\Compat\CustomerSettingsProvider::get_settings();
$auto_register = $customer_settings['auto_register_members'] ?? false;
if ($auto_register && !empty($payload['billing']['email'])) {
$email = sanitize_email($payload['billing']['email']);
// Check if user already exists
$existing_user = get_user_by('email', $email);
if ($existing_user) {
// User exists - link order to them
$order->set_customer_id($existing_user->ID);
} else {
// Create new user account
$password = wp_generate_password(12, true, true);
$userdata = [
'user_login' => $email,
'user_email' => $email,
@@ -333,24 +373,24 @@ class CheckoutController {
'display_name' => trim((sanitize_text_field($payload['billing']['first_name'] ?? '') . ' ' . sanitize_text_field($payload['billing']['last_name'] ?? ''))) ?: $email,
'role' => 'customer', // WooCommerce customer role
];
$new_user_id = wp_insert_user($userdata);
if (!is_wp_error($new_user_id)) {
// Link order to new user
$order->set_customer_id($new_user_id);
// Store temp password in user meta for email template
// The real password is already set via wp_insert_user
update_user_meta($new_user_id, '_woonoow_temp_password', $password);
// AUTO-LOGIN: Set authentication cookie so user is logged in after page reload
wp_set_auth_cookie($new_user_id, true);
wp_set_current_user($new_user_id);
// Set WooCommerce customer billing data
$customer = new \WC_Customer($new_user_id);
if (!empty($payload['billing']['first_name'])) $customer->set_billing_first_name(sanitize_text_field($payload['billing']['first_name']));
if (!empty($payload['billing']['last_name'])) $customer->set_billing_last_name(sanitize_text_field($payload['billing']['last_name']));
if (!empty($payload['billing']['email'])) $customer->set_billing_email(sanitize_email($payload['billing']['email']));
@@ -360,9 +400,9 @@ class CheckoutController {
if (!empty($payload['billing']['state'])) $customer->set_billing_state(sanitize_text_field($payload['billing']['state']));
if (!empty($payload['billing']['postcode'])) $customer->set_billing_postcode(sanitize_text_field($payload['billing']['postcode']));
if (!empty($payload['billing']['country'])) $customer->set_billing_country(sanitize_text_field($payload['billing']['country']));
$customer->save();
// Send new account email (WooCommerce will handle this automatically via hook)
do_action('woocommerce_created_customer', $new_user_id, $userdata, $password);
}
@@ -430,12 +470,12 @@ class CheckoutController {
// Fallback: use shipping_cost directly from frontend
// This handles API-based shipping like Rajaongkir where WC zones don't apply
$item = new \WC_Order_Item_Shipping();
// Parse method ID from shipping_method (format: "method_id:instance_id" or "method_id:instance_id:variant")
$parts = explode(':', $payload['shipping_method']);
$method_id = $parts[0] ?? 'shipping';
$instance_id = isset($parts[1]) ? (int)$parts[1] : 0;
$item->set_props([
'method_title' => sanitize_text_field($payload['shipping_title'] ?? 'Shipping'),
'method_id' => sanitize_text_field($method_id),
@@ -479,12 +519,73 @@ class CheckoutController {
];
}
/**
* Process payment for an existing order
* POST /checkout/pay-order/{id}
*/
public function pay_order(WP_REST_Request $r): array
{
$order_id = absint($r['id']);
$order = wc_get_order($order_id);
if (!$order) {
return ['error' => __('Order not found', 'woonoow')];
}
// Validate access
$key = $r->get_param('key'); // optional if logged in
$valid_key = $key && hash_equals($order->get_order_key(), $key);
$valid_owner = is_user_logged_in() && (
get_current_user_id() === $order->get_customer_id() ||
current_user_can('manage_woocommerce')
);
if (!$valid_key && !$valid_owner) {
return ['error' => __('Unauthorized access', 'woonoow')];
}
if ($order->is_paid()) {
return ['error' => __('Order already paid', 'woonoow')];
}
$payment_method = wc_clean($r->get_param('payment_method'));
if (empty($payment_method)) {
return ['error' => __('Payment method required', 'woonoow')];
}
// Update payment method
$available = WC()->payment_gateways()->get_available_payment_gateways();
if (!isset($available[$payment_method])) {
return ['error' => __('Invalid payment method', 'woonoow')];
}
$gateway = $available[$payment_method];
$order->set_payment_method($gateway);
$order->save();
// Process payment
$result = $gateway->process_payment($order_id);
if (isset($result['result']) && $result['result'] === 'success') {
return [
'ok' => true,
'redirect' => $result['redirect'] ?? $order->get_checkout_order_received_url(),
];
}
return [
'error' => __('Payment failed', 'woonoow') . (isset($result['result']) ? ': ' . $result['result'] : ''),
'messages' => wc_get_notices('error'),
];
}
/**
* Get checkout fields with all filters applied
* Accepts: { items: [...], is_digital_only?: bool }
* Returns fields with required, hidden, etc. based on addons + cart context
*/
public function get_fields(WP_REST_Request $r): array {
public function get_fields(WP_REST_Request $r): array
{
$json = $r->get_json_params();
$items = isset($json['items']) && is_array($json['items']) ? $json['items'] : [];
$is_digital_only = isset($json['is_digital_only']) ? (bool) $json['is_digital_only'] : false;
@@ -504,7 +605,7 @@ class CheckoutController {
foreach ($fieldset as $key => $field) {
// Check if field should be hidden
$hidden = false;
// Hide shipping fields if digital only (your existing logic)
if ($is_digital_only && $fieldset_key === 'shipping') {
$hidden = true;
@@ -534,7 +635,7 @@ class CheckoutController {
'priority' => $field['priority'] ?? 10,
'options' => $field['options'] ?? null, // For select fields
'custom' => !in_array($key, $this->get_standard_field_keys()), // Flag custom fields
'autocomplete'=> $field['autocomplete'] ?? '',
'autocomplete' => $field['autocomplete'] ?? '',
'validate' => $field['validate'] ?? [],
// New fields for dynamic rendering
'input_class' => $field['input_class'] ?? [],
@@ -549,7 +650,7 @@ class CheckoutController {
}
// Sort by priority
usort($formatted, function($a, $b) {
usort($formatted, function ($a, $b) {
return $a['priority'] <=> $b['priority'];
});
@@ -564,7 +665,8 @@ class CheckoutController {
* Get list of standard WooCommerce field keys
* Plugins can extend this list via the 'woonoow_standard_checkout_field_keys' filter
*/
private function get_standard_field_keys(): array {
private function get_standard_field_keys(): array
{
$keys = [
'billing_first_name',
'billing_last_name',
@@ -588,7 +690,7 @@ class CheckoutController {
'shipping_postcode',
'order_comments',
];
/**
* Filter the list of standard checkout field keys.
* Plugins can add their own field keys to be recognized as "standard" (not custom).
@@ -600,14 +702,19 @@ class CheckoutController {
/** ----------------- Helpers ----------------- **/
private function accurate_quote_via_wc_cart(array $payload): array {
if (!WC()->customer) { WC()->customer = new \WC_Customer(get_current_user_id(), true); }
if (!WC()->cart) { WC()->cart = new \WC_Cart(); }
private function accurate_quote_via_wc_cart(array $payload): array
{
if (!WC()->customer) {
WC()->customer = new \WC_Customer(get_current_user_id(), true);
}
if (!WC()->cart) {
WC()->cart = new \WC_Cart();
}
// Address context for taxes/shipping rules - set temporarily without saving to user profile
$ship = !empty($payload['shipping']) ? $payload['shipping'] : $payload['billing'];
if (!empty($payload['billing'])) {
foreach (['country','state','postcode','city','address_1','address_2'] as $k) {
foreach (['country', 'state', 'postcode', 'city', 'address_1', 'address_2'] as $k) {
$setter = 'set_billing_' . $k;
if (method_exists(WC()->customer, $setter) && isset($payload['billing'][$k])) {
WC()->customer->{$setter}(wc_clean($payload['billing'][$k]));
@@ -615,7 +722,7 @@ class CheckoutController {
}
}
if (!empty($ship)) {
foreach (['country','state','postcode','city','address_1','address_2'] as $k) {
foreach (['country', 'state', 'postcode', 'city', 'address_1', 'address_2'] as $k) {
$setter = 'set_shipping_' . $k;
if (method_exists(WC()->customer, $setter) && isset($ship[$k])) {
WC()->customer->{$setter}(wc_clean($ship[$k]));
@@ -685,7 +792,8 @@ class CheckoutController {
];
}
private function sanitize_payload(WP_REST_Request $r): array {
private function sanitize_payload(WP_REST_Request $r): array
{
$json = $r->get_json_params();
$items = isset($json['items']) && is_array($json['items']) ? $json['items'] : [];
@@ -704,7 +812,7 @@ class CheckoutController {
];
}, $items),
'billing' => $billing,
'shipping'=> $shipping,
'shipping' => $shipping,
'coupons' => $coupons,
'shipping_method' => isset($json['shipping_method']) ? wc_clean($json['shipping_method']) : null,
'payment_method' => isset($json['payment_method']) ? wc_clean($json['payment_method']) : null,
@@ -716,7 +824,8 @@ class CheckoutController {
];
}
private function load_product(array $line) {
private function load_product(array $line)
{
$pid = (int)($line['variation_id'] ?? 0) ?: (int)($line['product_id'] ?? 0);
if (!$pid) {
return new WP_Error('bad_item', __('Invalid product id', 'woonoow'));
@@ -728,8 +837,9 @@ class CheckoutController {
return $product;
}
private function only_address_fields(array $src): array {
$keys = ['first_name','last_name','company','address_1','address_2','city','state','postcode','country','email','phone'];
private function only_address_fields(array $src): array
{
$keys = ['first_name', 'last_name', 'company', 'address_1', 'address_2', 'city', 'state', 'postcode', 'country', 'email', 'phone'];
$out = [];
foreach ($keys as $k) {
if (isset($src[$k])) $out[$k] = wc_clean(wp_unslash($src[$k]));
@@ -737,7 +847,8 @@ class CheckoutController {
return $out;
}
private function estimate_shipping(array $address, ?string $chosen_method): float {
private function estimate_shipping(array $address, ?string $chosen_method): float
{
$country = wc_clean($address['country'] ?? '');
$postcode = wc_clean($address['postcode'] ?? '');
$state = wc_clean($address['state'] ?? '');
@@ -745,12 +856,14 @@ class CheckoutController {
$cache_key = 'wnw_ship_' . md5(json_encode([$country, $state, $postcode, $city, (string) $chosen_method]));
$cached = wp_cache_get($cache_key, 'woonoow');
if ($cached !== false) { return (float) $cached; }
if ($cached !== false) {
return (float) $cached;
}
if (!$country) return 0.0;
$packages = [[
'destination' => compact('country','state','postcode','city'),
'destination' => compact('country', 'state', 'postcode', 'city'),
'contents_cost' => 0, // not exact in v0
'contents' => [],
'applied_coupons' => [],
@@ -778,7 +891,8 @@ class CheckoutController {
return $cost;
}
private function find_shipping_rate_for_order(WC_Order $order, string $chosen) {
private function find_shipping_rate_for_order(WC_Order $order, string $chosen)
{
$shipping = $order->get_address('shipping');
$packages = [[
'destination' => [
@@ -810,12 +924,13 @@ class CheckoutController {
* Get countries and states for checkout form
* Public endpoint - no authentication required
*/
public function get_countries(): array {
public function get_countries(): array
{
$wc_countries = WC()->countries;
// Get allowed selling countries
$allowed = $wc_countries->get_allowed_countries();
// Format for frontend
$countries = [];
foreach ($allowed as $code => $name) {
@@ -824,7 +939,7 @@ class CheckoutController {
'name' => $name,
];
}
// Get states for all allowed countries
$states = [];
foreach (array_keys($allowed) as $country_code) {
@@ -833,10 +948,10 @@ class CheckoutController {
$states[$country_code] = $country_states;
}
}
// Get default country
$default_country = $wc_countries->get_base_country();
return [
'countries' => $countries,
'states' => $states,
@@ -849,16 +964,17 @@ class CheckoutController {
* POST /checkout/shipping-rates
* Body: { shipping: { country, state, city, postcode, destination_id? }, items: [...] }
*/
public function get_shipping_rates(WP_REST_Request $r): array {
public function get_shipping_rates(WP_REST_Request $r): array
{
$payload = $r->get_json_params();
$shipping = $payload['shipping'] ?? [];
$items = $payload['items'] ?? [];
$country = wc_clean($shipping['country'] ?? '');
$state = wc_clean($shipping['state'] ?? '');
$city = wc_clean($shipping['city'] ?? '');
$postcode = wc_clean($shipping['postcode'] ?? '');
if (empty($country)) {
return [
'ok' => true,
@@ -866,10 +982,10 @@ class CheckoutController {
'message' => 'Country is required',
];
}
// Trigger hook for plugins to set session data (e.g., Rajaongkir destination_id)
do_action('woonoow/shipping/before_calculate', $shipping, $items);
// Set customer location for shipping calculation
if (WC()->customer) {
WC()->customer->set_shipping_country($country);
@@ -877,7 +993,7 @@ class CheckoutController {
WC()->customer->set_shipping_city($city);
WC()->customer->set_shipping_postcode($postcode);
}
// Build package for shipping calculation
$contents = [];
$contents_cost = 0;
@@ -893,7 +1009,7 @@ class CheckoutController {
];
$contents_cost += $price * $qty;
}
$package = [
'destination' => [
'country' => $country,
@@ -906,7 +1022,7 @@ class CheckoutController {
'applied_coupons' => [],
'user' => ['ID' => get_current_user_id()],
];
// Get matching shipping zone
$zone = WC_Shipping_Zones::get_zone_matching_package($package);
if (!$zone) {
@@ -916,11 +1032,11 @@ class CheckoutController {
'message' => 'No shipping zone matches your location',
];
}
// Get enabled shipping methods from zone
$methods = $zone->get_shipping_methods(true);
$rates = [];
foreach ($methods as $method) {
// Check if method has rates (some methods like live rate need to calculate)
if (method_exists($method, 'get_rates_for_package')) {
@@ -938,14 +1054,14 @@ class CheckoutController {
// Fallback for simple methods
$method_id = $method->id . ':' . $method->get_instance_id();
$cost = 0;
// Try to get cost from method
if (isset($method->cost)) {
$cost = (float) $method->cost;
} elseif (method_exists($method, 'get_option')) {
$cost = (float) $method->get_option('cost', 0);
}
$rates[] = [
'id' => $method_id,
'label' => $method->get_title(),
@@ -955,11 +1071,34 @@ class CheckoutController {
];
}
}
return [
'ok' => true,
'rates' => $rates,
'zone_name' => $zone->get_zone_name(),
];
}
}
private function get_available_gateways_for_order(WC_Order $order): array
{
// Mock cart for gateways that check cart total
if (!WC()->cart) {
WC()->initialize_cart();
}
// We can't easily bake the order into the cart, but many gateways just check 'needs_payment'
// or country.
$gateways = WC()->payment_gateways()->get_available_payment_gateways();
$results = [];
foreach ($gateways as $gateway) {
$results[] = [
'id' => $gateway->id,
'title' => $gateway->get_title() ?: $gateway->method_title ?: ucfirst($gateway->id), // Fallbacks
'description' => $gateway->get_description(),
'icon' => $gateway->get_icon(),
];
}
return $results;
}
}

View File

@@ -424,6 +424,23 @@ class ProductsController {
update_post_meta($product->get_id(), '_woonoow_license_expiry_days', self::sanitize_number($data['license_duration_days']));
}
// Subscription meta
if (isset($data['subscription_enabled'])) {
update_post_meta($product->get_id(), '_woonoow_subscription_enabled', $data['subscription_enabled'] ? 'yes' : 'no');
}
if (isset($data['subscription_period'])) {
update_post_meta($product->get_id(), '_woonoow_subscription_period', sanitize_key($data['subscription_period']));
}
if (isset($data['subscription_interval'])) {
update_post_meta($product->get_id(), '_woonoow_subscription_interval', absint($data['subscription_interval']));
}
if (isset($data['subscription_trial_days'])) {
update_post_meta($product->get_id(), '_woonoow_subscription_trial_days', absint($data['subscription_trial_days']));
}
if (isset($data['subscription_signup_fee'])) {
update_post_meta($product->get_id(), '_woonoow_subscription_signup_fee', self::sanitize_number($data['subscription_signup_fee']));
}
// Handle variations for variable products
if ($type === 'variable' && !empty($data['attributes']) && is_array($data['attributes'])) {
self::save_product_attributes($product, $data['attributes']);
@@ -568,6 +585,23 @@ class ProductsController {
update_post_meta($product->get_id(), '_woonoow_license_expiry_days', self::sanitize_number($data['license_duration_days']));
}
// Subscription meta
if (isset($data['subscription_enabled'])) {
update_post_meta($product->get_id(), '_woonoow_subscription_enabled', $data['subscription_enabled'] ? 'yes' : 'no');
}
if (isset($data['subscription_period'])) {
update_post_meta($product->get_id(), '_woonoow_subscription_period', sanitize_key($data['subscription_period']));
}
if (isset($data['subscription_interval'])) {
update_post_meta($product->get_id(), '_woonoow_subscription_interval', absint($data['subscription_interval']));
}
if (isset($data['subscription_trial_days'])) {
update_post_meta($product->get_id(), '_woonoow_subscription_trial_days', absint($data['subscription_trial_days']));
}
if (isset($data['subscription_signup_fee'])) {
update_post_meta($product->get_id(), '_woonoow_subscription_signup_fee', self::sanitize_number($data['subscription_signup_fee']));
}
// Allow plugins to perform additional updates (Level 1 compatibility)
do_action('woonoow/product_updated', $product, $data, $request);
@@ -767,6 +801,13 @@ class ProductsController {
$data['license_activation_limit'] = get_post_meta($product->get_id(), '_license_activation_limit', true) ?: '';
$data['license_duration_days'] = get_post_meta($product->get_id(), '_license_duration_days', true) ?: '';
// Subscription fields
$data['subscription_enabled'] = get_post_meta($product->get_id(), '_woonoow_subscription_enabled', true) === 'yes';
$data['subscription_period'] = get_post_meta($product->get_id(), '_woonoow_subscription_period', true) ?: 'month';
$data['subscription_interval'] = get_post_meta($product->get_id(), '_woonoow_subscription_interval', true) ?: '1';
$data['subscription_trial_days'] = get_post_meta($product->get_id(), '_woonoow_subscription_trial_days', true) ?: '';
$data['subscription_signup_fee'] = get_post_meta($product->get_id(), '_woonoow_subscription_signup_fee', true) ?: '';
// Images array (URLs) for frontend - featured + gallery
$images = [];
$featured_image_id = $product->get_image_id();

View File

@@ -26,6 +26,7 @@ use WooNooW\Api\ModuleSettingsController;
use WooNooW\Api\CampaignsController;
use WooNooW\Api\DocsController;
use WooNooW\Api\LicensesController;
use WooNooW\Api\SubscriptionsController;
use WooNooW\Frontend\ShopController;
use WooNooW\Frontend\CartController as FrontendCartController;
use WooNooW\Frontend\AccountController;
@@ -163,6 +164,9 @@ class Routes {
// Licenses controller (licensing module)
LicensesController::register_routes();
// Subscriptions controller (subscription module)
SubscriptionsController::register_routes();
// Modules controller
$modules_controller = new ModulesController();
$modules_controller->register_routes();

View File

@@ -0,0 +1,476 @@
<?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;
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');
},
]);
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();
},
]);
}
/**
* 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'),
'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']]);
// 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)
*/
public static function renew_subscription(WP_REST_Request $request)
{
$result = SubscriptionManager::renew($request->get_param('id'));
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']]);
}
/**
* 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]);
}
/**
* 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';
$enriched['can_pause'] = $subscription->status === 'active';
$enriched['can_resume'] = $subscription->status === 'on-hold';
$enriched['can_cancel'] = in_array($subscription->status, ['active', 'on-hold', 'pending']);
// 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);
return $enriched;
}
}