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;
}
}

View File

@@ -13,7 +13,7 @@ if ( ! defined('ABSPATH') ) exit;
*/
class NavigationRegistry {
const NAV_OPTION = 'wnw_nav_tree';
const NAV_VERSION = '1.2.0'; // Added Menus (Menu Editor)
const NAV_VERSION = '1.3.0'; // Added Subscriptions section
/**
* Initialize hooks
@@ -132,6 +132,8 @@ class NavigationRegistry {
// Future: Drafts, Recurring, etc.
],
],
// Subscriptions - only if module enabled
...self::get_subscriptions_section(),
[
'key' => 'products',
'label' => __('Products', 'woonoow'),
@@ -242,6 +244,30 @@ class NavigationRegistry {
return $children;
}
/**
* Get subscriptions navigation section
* Returns empty array if module is not enabled
*
* @return array Subscriptions section or empty array
*/
private static function get_subscriptions_section(): array {
if (!\WooNooW\Core\ModuleRegistry::is_enabled('subscription')) {
return [];
}
return [
[
'key' => 'subscriptions',
'label' => __('Subscriptions', 'woonoow'),
'path' => '/subscriptions',
'icon' => 'repeat',
'children' => [
['label' => __('All Subscriptions', 'woonoow'), 'mode' => 'spa', 'path' => '/subscriptions', 'exact' => true],
],
],
];
}
/**
* Get the complete navigation tree
*

View File

@@ -1,4 +1,5 @@
<?php
/**
* Module Registry
*
@@ -10,14 +11,16 @@
namespace WooNooW\Core;
class ModuleRegistry {
class ModuleRegistry
{
/**
* Get built-in modules
*
* @return array
*/
private static function get_builtin_modules() {
private static function get_builtin_modules()
{
$modules = [
'newsletter' => [
'id' => 'newsletter',
@@ -68,6 +71,7 @@ class ModuleRegistry {
'category' => 'products',
'icon' => 'refresh-cw',
'default_enabled' => false,
'has_settings' => true,
'features' => [
__('Recurring billing', 'woonoow'),
__('Subscription management', 'woonoow'),
@@ -91,19 +95,20 @@ class ModuleRegistry {
],
],
];
return $modules;
}
/**
* Get addon modules from AddonRegistry
*
* @return array
*/
private static function get_addon_modules() {
private static function get_addon_modules()
{
$addons = apply_filters('woonoow/addon_registry', []);
$modules = [];
foreach ($addons as $addon_id => $addon) {
$modules[$addon_id] = [
'id' => $addon_id,
@@ -120,31 +125,33 @@ class ModuleRegistry {
'settings_component' => $addon['settings_component'] ?? null,
];
}
return $modules;
}
/**
* Get all modules (built-in + addons)
*
* @return array
*/
public static function get_all_modules() {
public static function get_all_modules()
{
$builtin = self::get_builtin_modules();
$addons = self::get_addon_modules();
return array_merge($builtin, $addons);
}
/**
* Get categories dynamically from registered modules
*
* @return array Associative array of category_id => label
*/
public static function get_categories() {
public static function get_categories()
{
$all_modules = self::get_all_modules();
$categories = [];
// Extract unique categories from modules
foreach ($all_modules as $module) {
$cat = $module['category'] ?? 'other';
@@ -152,27 +159,28 @@ class ModuleRegistry {
$categories[$cat] = self::get_category_label($cat);
}
}
// Sort by predefined order
$order = ['marketing', 'customers', 'products', 'shipping', 'payments', 'analytics', 'other'];
uksort($categories, function($a, $b) use ($order) {
uksort($categories, function ($a, $b) use ($order) {
$pos_a = array_search($a, $order);
$pos_b = array_search($b, $order);
if ($pos_a === false) $pos_a = 999;
if ($pos_b === false) $pos_b = 999;
return $pos_a - $pos_b;
});
return $categories;
}
/**
* Get human-readable label for category
*
* @param string $category Category ID
* @return string
*/
private static function get_category_label($category) {
private static function get_category_label($category)
{
$labels = [
'marketing' => __('Marketing & Sales', 'woonoow'),
'customers' => __('Customer Experience', 'woonoow'),
@@ -182,19 +190,20 @@ class ModuleRegistry {
'analytics' => __('Analytics & Reports', 'woonoow'),
'other' => __('Other Extensions', 'woonoow'),
];
return $labels[$category] ?? ucfirst($category);
}
/**
* Group modules by category
*
* @return array
*/
public static function get_grouped_modules() {
public static function get_grouped_modules()
{
$all_modules = self::get_all_modules();
$grouped = [];
foreach ($all_modules as $module) {
$cat = $module['category'] ?? 'other';
if (!isset($grouped[$cat])) {
@@ -202,18 +211,19 @@ class ModuleRegistry {
}
$grouped[$cat][] = $module;
}
return $grouped;
}
/**
* Get enabled modules
*
* @return array
*/
public static function get_enabled_modules() {
public static function get_enabled_modules()
{
$enabled = get_option('woonoow_enabled_modules', null);
// First time - use defaults
if ($enabled === null) {
$modules = self::get_all_modules();
@@ -225,89 +235,93 @@ class ModuleRegistry {
}
update_option('woonoow_enabled_modules', $enabled);
}
return $enabled;
}
/**
* Check if a module is enabled
*
* @param string $module_id
* @return bool
*/
public static function is_enabled($module_id) {
public static function is_enabled($module_id)
{
$enabled = self::get_enabled_modules();
return in_array($module_id, $enabled);
}
/**
* Enable a module
*
* @param string $module_id
* @return bool
*/
public static function enable($module_id) {
public static function enable($module_id)
{
$modules = self::get_all_modules();
if (!isset($modules[$module_id])) {
return false;
}
$enabled = self::get_enabled_modules();
if (!in_array($module_id, $enabled)) {
$enabled[] = $module_id;
update_option('woonoow_enabled_modules', $enabled);
// Clear navigation cache when module is toggled
if (class_exists('\WooNooW\Compat\NavigationRegistry')) {
\WooNooW\Compat\NavigationRegistry::flush();
}
do_action('woonoow/module/enabled', $module_id);
return true;
}
return false;
}
/**
* Disable a module
*
* @param string $module_id
* @return bool
*/
public static function disable($module_id) {
public static function disable($module_id)
{
$enabled = self::get_enabled_modules();
if (in_array($module_id, $enabled)) {
$enabled = array_diff($enabled, [$module_id]);
update_option('woonoow_enabled_modules', array_values($enabled));
// Clear navigation cache when module is toggled
if (class_exists('\WooNooW\Compat\NavigationRegistry')) {
\WooNooW\Compat\NavigationRegistry::flush();
}
do_action('woonoow/module/disabled', $module_id);
return true;
}
return false;
}
/**
* Get modules by category
*
* @param string $category
* @return array
*/
public static function get_by_category($category) {
public static function get_by_category($category)
{
$modules = self::get_all_modules();
$enabled = self::get_enabled_modules();
$result = [];
foreach ($modules as $module) {
if ($module['category'] === $category) {
@@ -315,23 +329,63 @@ class ModuleRegistry {
$result[] = $module;
}
}
return $result;
}
/**
* Get all modules with enabled status
*
* @return array
*/
public static function get_all_with_status() {
public static function get_all_with_status()
{
$modules = self::get_all_modules();
$enabled = self::get_enabled_modules();
foreach ($modules as $id => $module) {
$modules[$id]['enabled'] = in_array($id, $enabled);
foreach ($modules as $id => &$module) {
$module['enabled'] = in_array($id, $enabled);
}
return $modules;
}
/**
* Get module settings
*
* @param string $module_id
* @return array
*/
public static function get_settings($module_id)
{
$settings = get_option("woonoow_module_{$module_id}_settings", []);
// Apply defaults from schema if available
$schema = apply_filters('woonoow/module_settings_schema', []);
if (isset($schema[$module_id])) {
$defaults = self::get_schema_defaults($schema[$module_id]);
$settings = wp_parse_args($settings, $defaults);
}
return $settings;
}
/**
* Get default values from schema
*
* @param array $schema
* @return array
*/
private static function get_schema_defaults($schema)
{
$defaults = [];
foreach ($schema as $key => $field) {
if (isset($field['default'])) {
$defaults[$key] = $field['default'];
}
}
return $defaults;
}
}

View File

@@ -1,4 +1,5 @@
<?php
/**
* Email Renderer
*
@@ -9,70 +10,75 @@
namespace WooNooW\Core\Notifications;
class EmailRenderer {
class EmailRenderer
{
/**
* Instance
*/
private static $instance = null;
/**
* Get instance
*/
public static function instance() {
public static function instance()
{
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Render email
*
* @param string $event_id Event ID (order_placed, order_processing, etc.)
* @param string $recipient_type Recipient type (staff, customer)
* @param mixed $data Order, Product, or Customer object
* @param WC_Order|WC_Product|WC_Customer|mixed $data Order, Product, or Customer object
* @param array $extra_data Additional data
* @return array|null ['to', 'subject', 'body']
*/
public function render($event_id, $recipient_type, $data, $extra_data = []) {
public function render($event_id, $recipient_type, $data, $extra_data = [])
{
// Get template settings
$template_settings = $this->get_template_settings($event_id, $recipient_type);
if (!$template_settings) {
return null;
}
// Get recipient email
$to = $this->get_recipient_email($recipient_type, $data);
if (!$to) {
return null;
}
// Get variables
$variables = $this->get_variables($event_id, $data, $extra_data);
// Replace variables in subject and content
$subject = $this->replace_variables($template_settings['subject'], $variables);
$content = $this->replace_variables($template_settings['body'], $variables);
// Parse cards in content
$content = $this->parse_cards($content);
// Get HTML template
$template_path = $this->get_design_template();
// Render final HTML
$html = $this->render_html($template_path, $content, $subject, $variables);
return [
'to' => $to,
'subject' => $subject,
'body' => $html,
];
}
/**
* Get template settings
*
@@ -80,30 +86,31 @@ class EmailRenderer {
* @param string $recipient_type
* @return array|null
*/
private function get_template_settings($event_id, $recipient_type) {
private function get_template_settings($event_id, $recipient_type)
{
// Get saved template (with recipient_type for proper default template lookup)
$template = TemplateProvider::get_template($event_id, 'email', $recipient_type);
if (!$template) {
if (defined('WP_DEBUG') && WP_DEBUG) {
}
return null;
}
if (defined('WP_DEBUG') && WP_DEBUG) {
}
// Get design template preference
$settings = get_option('woonoow_notification_settings', []);
$design = $settings['email_design_template'] ?? 'modern';
return [
'subject' => $template['subject'] ?? '',
'body' => $template['body'] ?? '',
'design' => $design,
];
}
/**
* Get recipient email
*
@@ -111,23 +118,24 @@ class EmailRenderer {
* @param mixed $data
* @return string|null
*/
private function get_recipient_email($recipient_type, $data) {
private function get_recipient_email($recipient_type, $data)
{
if ($recipient_type === 'staff') {
return get_option('admin_email');
}
// Customer
if ($data instanceof \WC_Order) {
if ($data instanceof WC_Order) {
return $data->get_billing_email();
}
if ($data instanceof \WC_Customer) {
if ($data instanceof WC_Customer) {
return $data->get_email();
}
return null;
}
/**
* Get variables for template
*
@@ -136,7 +144,8 @@ class EmailRenderer {
* @param array $extra_data
* @return array
*/
private function get_variables($event_id, $data, $extra_data = []) {
private function get_variables($event_id, $data, $extra_data = [])
{
$variables = [
'site_name' => get_bloginfo('name'),
'site_title' => get_bloginfo('name'),
@@ -147,12 +156,12 @@ class EmailRenderer {
'support_email' => get_option('admin_email'),
'current_year' => date('Y'),
];
// Order variables
if ($data instanceof \WC_Order) {
if ($data instanceof WC_Order) {
// Calculate estimated delivery (3-5 business days from now)
$estimated_delivery = date('F j', strtotime('+3 days')) . '-' . date('j', strtotime('+5 days'));
// Completion date (for completed orders)
$completion_date = '';
if ($data->get_date_completed()) {
@@ -160,13 +169,13 @@ class EmailRenderer {
} else {
$completion_date = date('F j, Y'); // Fallback to today
}
// Payment date
$payment_date = '';
if ($data->get_date_paid()) {
$payment_date = $data->get_date_paid()->date('F j, Y');
}
$variables = array_merge($variables, [
'order_number' => $data->get_order_number(),
'order_id' => $data->get_id(),
@@ -202,7 +211,7 @@ class EmailRenderer {
'tracking_url' => $data->get_meta('_tracking_url') ?: '#',
'shipping_carrier' => $data->get_meta('_shipping_carrier') ?: 'Standard Shipping',
]);
// Order items table
$items_html = '<table class="order-details" style="width: 100%; border-collapse: collapse;">';
$items_html .= '<thead><tr>';
@@ -210,7 +219,7 @@ class EmailRenderer {
$items_html .= '<th style="text-align: center; padding: 12px 0; border-bottom: 1px solid #e5e5e5;">Qty</th>';
$items_html .= '<th style="text-align: right; padding: 12px 0; border-bottom: 1px solid #e5e5e5;">Price</th>';
$items_html .= '</tr></thead><tbody>';
foreach ($data->get_items() as $item) {
$product = $item->get_product();
$items_html .= '<tr>';
@@ -228,16 +237,16 @@ class EmailRenderer {
);
$items_html .= '</tr>';
}
$items_html .= '</tbody></table>';
// Both naming conventions for compatibility
$variables['order_items'] = $items_html;
$variables['order_items_table'] = $items_html;
}
// Product variables
if ($data instanceof \WC_Product) {
if ($data instanceof WC_Product) {
$variables = array_merge($variables, [
'product_id' => $data->get_id(),
'product_name' => $data->get_name(),
@@ -248,27 +257,27 @@ class EmailRenderer {
'stock_status' => $data->get_stock_status(),
]);
}
// Customer variables
if ($data instanceof \WC_Customer) {
if ($data instanceof WC_Customer) {
// Get temp password from user meta (stored during auto-registration)
$user_temp_password = get_user_meta($data->get_id(), '_woonoow_temp_password', true);
// Generate login URL (pointing to SPA login instead of wp-login)
$appearance_settings = get_option('woonoow_appearance_settings', []);
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
$use_browser_router = $appearance_settings['general']['use_browser_router'] ?? true;
if ($spa_page_id) {
$spa_url = get_permalink($spa_page_id);
// Use path format for BrowserRouter, hash format for HashRouter
$login_url = $use_browser_router
$login_url = $use_browser_router
? trailingslashit($spa_url) . 'login'
: $spa_url . '#/login';
} else {
$login_url = wp_login_url();
}
$variables = array_merge($variables, [
'customer_id' => $data->get_id(),
'customer_name' => $data->get_display_name(),
@@ -282,31 +291,32 @@ class EmailRenderer {
'shop_url' => get_permalink(wc_get_page_id('shop')),
]);
}
// Merge extra data
$variables = array_merge($variables, $extra_data);
return apply_filters('woonoow_email_variables', $variables, $event_id, $data);
}
/**
* Parse [card] tags and convert to HTML
*
* @param string $content
* @return string
*/
private function parse_cards($content) {
private function parse_cards($content)
{
// Use a single unified regex to match BOTH syntaxes in document order
// This ensures cards are rendered in the order they appear
$combined_pattern = '/\[card(?::(\w+)|([^\]]*)?)\](.*?)\[\/card\]/s';
preg_match_all($combined_pattern, $content, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
if (empty($matches)) {
// No cards found, wrap entire content in a single card
return $this->render_card($content, []);
}
$html = '';
foreach ($matches as $match) {
// Determine which syntax was matched
@@ -314,7 +324,7 @@ class EmailRenderer {
$new_syntax_type = !empty($match[1][0]) ? $match[1][0] : null; // [card:type] format
$old_syntax_attrs = $match[2][0] ?? ''; // [card type="..."] format
$card_content = $match[3][0];
if ($new_syntax_type) {
// NEW syntax [card:type]
$attributes = ['type' => $new_syntax_type];
@@ -322,42 +332,43 @@ class EmailRenderer {
// OLD syntax [card type="..."] or [card]
$attributes = $this->parse_card_attributes($old_syntax_attrs);
}
$html .= $this->render_card($card_content, $attributes);
$html .= $this->render_card_spacing();
}
// Remove last spacing
$html = preg_replace('/<table[^>]*class="card-spacing"[^>]*>.*?<\/table>\s*$/s', '', $html);
return $html;
}
/**
* Parse card attributes from [card ...] tag
*
* @param string $attr_string
* @return array
*/
private function parse_card_attributes($attr_string) {
private function parse_card_attributes($attr_string)
{
$attributes = [
'type' => 'default',
'bg' => null,
];
// Parse type="highlight"
if (preg_match('/type=["\']([^"\']+)["\']/', $attr_string, $match)) {
$attributes['type'] = $match[1];
}
// Parse bg="url"
if (preg_match('/bg=["\']([^"\']+)["\']/', $attr_string, $match)) {
$attributes['bg'] = $match[1];
}
return $attributes;
}
/**
* Render a single card
*
@@ -365,13 +376,14 @@ class EmailRenderer {
* @param array $attributes
* @return string
*/
private function render_card($content, $attributes) {
private function render_card($content, $attributes)
{
$type = $attributes['type'] ?? 'default';
$bg = $attributes['bg'] ?? null;
// Parse markdown in content
$content = MarkdownParser::parse($content);
// Get email customization settings for colors
// Use unified colors from Appearance > General > Colors
$appearance = get_option('woonoow_appearance_settings', []);
@@ -382,10 +394,10 @@ class EmailRenderer {
$hero_gradient_start = $colors['gradientStart'] ?? '#667eea';
$hero_gradient_end = $colors['gradientEnd'] ?? '#764ba2';
$hero_text_color = '#ffffff'; // Always white on gradient
// Parse button shortcodes with FULL INLINE STYLES for Gmail compatibility
// Helper function to generate button HTML
$generateButtonHtml = function($url, $style, $text) use ($primary_color, $secondary_color, $button_text_color) {
$generateButtonHtml = function ($url, $style, $text) use ($primary_color, $secondary_color, $button_text_color) {
if ($style === 'outline') {
// Outline button - transparent background with border
$button_style = sprintf(
@@ -401,7 +413,7 @@ class EmailRenderer {
esc_attr($button_text_color)
);
}
// Use table-based button for better email client compatibility
return sprintf(
'<table role="presentation" border="0" cellpadding="0" cellspacing="0" style="margin: 16px auto;"><tr><td align="center"><a href="%s" style="%s">%s</a></td></tr></table>',
@@ -410,11 +422,11 @@ class EmailRenderer {
esc_html($text)
);
};
// NEW FORMAT: [button:style](url)Text[/button]
$content = preg_replace_callback(
'/\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/',
function($matches) use ($generateButtonHtml) {
function ($matches) use ($generateButtonHtml) {
$style = $matches[1]; // solid or outline
$url = $matches[2];
$text = trim($matches[3]);
@@ -422,11 +434,11 @@ class EmailRenderer {
},
$content
);
// OLD FORMAT: [button url="..." style="solid|outline"]Text[/button]
$content = preg_replace_callback(
'/\[button\s+url=["\']([^"\']+)["\'](?:\s+style=["\'](solid|outline)["\'])?\]([^\[]+)\[\/button\]/',
function($matches) use ($generateButtonHtml) {
function ($matches) use ($generateButtonHtml) {
$url = $matches[1];
$style = $matches[2] ?? 'solid';
$text = trim($matches[3]);
@@ -434,15 +446,15 @@ class EmailRenderer {
},
$content
);
$class = 'card';
$style = 'width: 100%; background-color: #ffffff; border-radius: 8px;';
$content_style = 'padding: 32px 40px;';
// Add type class and styling
if ($type !== 'default') {
$class .= ' card-' . esc_attr($type);
// Apply gradient and text color for hero cards
if ($type === 'hero') {
$style .= sprintf(
@@ -451,7 +463,7 @@ class EmailRenderer {
esc_attr($hero_gradient_end)
);
$content_style .= sprintf(' color: %s;', esc_attr($hero_text_color));
// Add inline color to all headings and paragraphs for email client compatibility
$content = preg_replace(
'/<(h[1-6]|p)([^>]*)>/',
@@ -472,13 +484,13 @@ class EmailRenderer {
$style .= ' background-color: #fff8e1;';
}
}
// Add background image
if ($bg) {
$class .= ' card-bg';
$style .= ' background-image: url(' . esc_url($bg) . ');';
}
return sprintf(
'<table role="presentation" class="%s" border="0" cellpadding="0" cellspacing="0" style="%s">
<tr>
@@ -493,18 +505,19 @@ class EmailRenderer {
$content
);
}
/**
* Render card spacing
*
* @return string
*/
private function render_card_spacing() {
private function render_card_spacing()
{
return '<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
<tr><td class="card-spacing" style="height: 24px; font-size: 24px; line-height: 24px;">&nbsp;</td></tr>
</table>';
}
/**
* Replace variables in text
*
@@ -512,34 +525,36 @@ class EmailRenderer {
* @param array $variables
* @return string
*/
private function replace_variables($text, $variables) {
private function replace_variables($text, $variables)
{
foreach ($variables as $key => $value) {
$text = str_replace('{' . $key . '}', $value, $text);
}
return $text;
}
/**
* Get design template path
*
* @return string
*/
private function get_design_template() {
private function get_design_template()
{
// Use single base template (theme-agnostic)
$template_path = WOONOOW_PATH . 'templates/emails/base.html';
// Allow filtering template path
$template_path = apply_filters('woonoow_email_template', $template_path);
// Fallback to base if custom template doesn't exist
if (!file_exists($template_path)) {
$template_path = WOONOOW_PATH . 'templates/emails/base.html';
}
return $template_path;
}
/**
* Render HTML email
*
@@ -549,29 +564,30 @@ class EmailRenderer {
* @param array $variables All variables
* @return string
*/
private function render_html($template_path, $content, $subject, $variables) {
private function render_html($template_path, $content, $subject, $variables)
{
if (!file_exists($template_path)) {
// Fallback to plain HTML
return $content;
}
// Load template
$html = file_get_contents($template_path);
// Get email customization settings
$email_settings = get_option('woonoow_email_settings', []);
// Email body background
$body_bg = '#f8f8f8';
// Email header (logo or text)
$logo_url = $email_settings['logo_url'] ?? '';
// Fallback to site icon if no logo set
if (empty($logo_url) && has_site_icon()) {
$logo_url = get_site_icon_url(200);
}
if (!empty($logo_url)) {
$header = sprintf(
'<a href="%s"><img src="%s" alt="%s" style="max-width: 200px; max-height: 60px;"></a>',
@@ -588,17 +604,17 @@ class EmailRenderer {
esc_html($header_text)
);
}
// Email footer with {current_year} variable support
$footer_text = !empty($email_settings['footer_text']) ? $email_settings['footer_text'] : sprintf(
'© %s %s. All rights reserved.',
date('Y'),
$variables['store_name']
);
// Replace {current_year} with actual year
$footer_text = str_replace('{current_year}', date('Y'), $footer_text);
// Social icons with PNG images
$social_html = '';
if (!empty($email_settings['social_links']) && is_array($email_settings['social_links'])) {
@@ -617,13 +633,13 @@ class EmailRenderer {
}
$social_html .= '</div>';
}
$footer = sprintf(
'<p style="font-family: \'Inter\', Arial, sans-serif; font-size: 13px; line-height: 1.5; color: #888888; margin: 0 0 8px 0; text-align: center;">%s</p>%s',
nl2br(esc_html($footer_text)),
$social_html
);
// Replace placeholders
$html = str_replace('{{email_subject}}', esc_html($subject), $html);
$html = str_replace('{{email_body_bg}}', esc_attr($body_bg), $html);
@@ -633,15 +649,15 @@ class EmailRenderer {
$html = str_replace('{{store_name}}', esc_html($variables['store_name']), $html);
$html = str_replace('{{store_url}}', esc_url($variables['store_url']), $html);
$html = str_replace('{{current_year}}', date('Y'), $html);
// Replace all other variables
foreach ($variables as $key => $value) {
$html = str_replace('{{' . $key . '}}', $value, $html);
}
return $html;
}
/**
* Get social icon URL
*
@@ -649,7 +665,8 @@ class EmailRenderer {
* @param string $color 'white' or 'black'
* @return string
*/
private function get_social_icon_url($platform, $color = 'white') {
private function get_social_icon_url($platform, $color = 'white')
{
// Use plugin URL constant if available, otherwise calculate from file path
if (defined('WOONOOW_URL')) {
$plugin_url = WOONOOW_URL;

View File

@@ -0,0 +1,375 @@
<?php
/**
* Notification Template Provider
*
* Manages notification templates for all channels.
*
* @package WooNooW\Core\Notifications
*/
namespace WooNooW\Core\Notifications;
use WooNooW\Email\DefaultTemplates as EmailDefaultTemplates;
class TemplateProvider {
/**
* Option key for storing templates
*/
const OPTION_KEY = 'woonoow_notification_templates';
/**
* Get all templates
*
* @return array
*/
public static function get_templates() {
$templates = get_option(self::OPTION_KEY, []);
// Merge with defaults
$defaults = self::get_default_templates();
return array_merge($defaults, $templates);
}
/**
* Get template for specific event and channel
*
* @param string $event_id Event ID
* @param string $channel_id Channel ID
* @param string $recipient_type Recipient type ('customer' or 'staff')
* @return array|null
*/
public static function get_template($event_id, $channel_id, $recipient_type = 'customer') {
$templates = self::get_templates();
$key = "{$recipient_type}_{$event_id}_{$channel_id}";
if (isset($templates[$key])) {
return $templates[$key];
}
// Return default if exists
$defaults = self::get_default_templates();
if (isset($defaults[$key])) {
return $defaults[$key];
}
return null;
}
/**
* Save template
*
* @param string $event_id Event ID
* @param string $channel_id Channel ID
* @param array $template Template data
* @param string $recipient_type Recipient type ('customer' or 'staff')
* @return bool
*/
public static function save_template($event_id, $channel_id, $template, $recipient_type = 'customer') {
$templates = get_option(self::OPTION_KEY, []);
$key = "{$recipient_type}_{$event_id}_{$channel_id}";
$templates[$key] = [
'event_id' => $event_id,
'channel_id' => $channel_id,
'recipient_type' => $recipient_type,
'subject' => $template['subject'] ?? '',
'body' => $template['body'] ?? '',
'variables' => $template['variables'] ?? [],
'updated_at' => current_time('mysql'),
];
return update_option(self::OPTION_KEY, $templates);
}
/**
* Delete template (revert to default)
*
* @param string $event_id Event ID
* @param string $channel_id Channel ID
* @param string $recipient_type Recipient type ('customer' or 'staff')
* @return bool
*/
public static function delete_template($event_id, $channel_id, $recipient_type = 'customer') {
$templates = get_option(self::OPTION_KEY, []);
$key = "{$recipient_type}_{$event_id}_{$channel_id}";
if (isset($templates[$key])) {
unset($templates[$key]);
return update_option(self::OPTION_KEY, $templates);
}
return false;
}
/**
* Get WooCommerce email template content
*
* @param string $email_id WooCommerce email ID
* @return array|null
*/
private static function get_wc_email_template($email_id) {
if (!function_exists('WC')) {
return null;
}
$mailer = \WC()->mailer();
$emails = $mailer->get_emails();
if (isset($emails[$email_id])) {
$email = $emails[$email_id];
return [
'subject' => $email->get_subject(),
'heading' => $email->get_heading(),
'enabled' => $email->is_enabled(),
];
}
return null;
}
/**
* Get default templates
*
* @return array
*/
public static function get_default_templates() {
$templates = [];
// Get all events from EventRegistry (single source of truth)
$all_events = EventRegistry::get_all_events();
// Get email templates from DefaultTemplates
$allEmailTemplates = EmailDefaultTemplates::get_all_templates();
foreach ($all_events as $event) {
$event_id = $event['id'];
$recipient_type = $event['recipient_type'];
// Get template body from the new clean markdown source
$body = $allEmailTemplates[$recipient_type][$event_id] ?? '';
$subject = EmailDefaultTemplates::get_default_subject($recipient_type, $event_id);
// If template doesn't exist, create a simple fallback
if (empty($body)) {
$body = "[card]\n\n## Notification\n\nYou have a new notification about {$event_id}.\n\n[/card]";
$subject = __('Notification from {store_name}', 'woonoow');
}
$templates["{$recipient_type}_{$event_id}_email"] = [
'event_id' => $event_id,
'channel_id' => 'email',
'recipient_type' => $recipient_type,
'subject' => $subject,
'body' => $body,
'variables' => self::get_variables_for_event($event_id),
];
}
// Add push notification templates
$templates['staff_order_placed_push'] = [
'event_id' => 'order_placed',
'channel_id' => 'push',
'recipient_type' => 'staff',
'subject' => __('New Order #{order_number}', 'woonoow'),
'body' => __('New order from {customer_name} - {order_total}', 'woonoow'),
'variables' => self::get_order_variables(),
];
$templates['customer_order_processing_push'] = [
'event_id' => 'order_processing',
'channel_id' => 'push',
'recipient_type' => 'customer',
'subject' => __('Order Processing', 'woonoow'),
'body' => __('Your order #{order_number} is being processed', 'woonoow'),
'variables' => self::get_order_variables(),
];
$templates['customer_order_completed_push'] = [
'event_id' => 'order_completed',
'channel_id' => 'push',
'recipient_type' => 'customer',
'subject' => __('Order Completed', 'woonoow'),
'body' => __('Your order #{order_number} has been completed!', 'woonoow'),
'variables' => self::get_order_variables(),
];
$templates['staff_order_cancelled_push'] = [
'event_id' => 'order_cancelled',
'channel_id' => 'push',
'recipient_type' => 'staff',
'subject' => __('Order Cancelled', 'woonoow'),
'body' => __('Order #{order_number} has been cancelled', 'woonoow'),
'variables' => self::get_order_variables(),
];
$templates['customer_order_refunded_push'] = [
'event_id' => 'order_refunded',
'channel_id' => 'push',
'recipient_type' => 'customer',
'subject' => __('Order Refunded', 'woonoow'),
'body' => __('Your order #{order_number} has been refunded', 'woonoow'),
'variables' => self::get_order_variables(),
];
$templates['staff_low_stock_push'] = [
'event_id' => 'low_stock',
'channel_id' => 'push',
'recipient_type' => 'staff',
'subject' => __('Low Stock Alert', 'woonoow'),
'body' => __('{product_name} is running low on stock', 'woonoow'),
'variables' => self::get_product_variables(),
];
$templates['staff_out_of_stock_push'] = [
'event_id' => 'out_of_stock',
'channel_id' => 'push',
'recipient_type' => 'staff',
'subject' => __('Out of Stock Alert', 'woonoow'),
'body' => __('{product_name} is now out of stock', 'woonoow'),
'variables' => self::get_product_variables(),
];
$templates['customer_new_customer_push'] = [
'event_id' => 'new_customer',
'channel_id' => 'push',
'recipient_type' => 'customer',
'subject' => __('Welcome!', 'woonoow'),
'body' => __('Welcome to {store_name}, {customer_name}!', 'woonoow'),
'variables' => self::get_customer_variables(),
];
$templates['customer_customer_note_push'] = [
'event_id' => 'customer_note',
'channel_id' => 'push',
'recipient_type' => 'customer',
'subject' => __('Order Note Added', 'woonoow'),
'body' => __('A note has been added to order #{order_number}', 'woonoow'),
'variables' => self::get_order_variables(),
];
return $templates;
}
/**
* Get variables for a specific event
*
* @param string $event_id Event ID
* @return array
*/
private static function get_variables_for_event($event_id) {
// Product events
if (in_array($event_id, ['low_stock', 'out_of_stock'])) {
return self::get_product_variables();
}
// Customer events (but not order-related)
if ($event_id === 'new_customer') {
return self::get_customer_variables();
}
// Subscription events
if (strpos($event_id, 'subscription_') === 0) {
return self::get_subscription_variables();
}
// All other events are order-related
return self::get_order_variables();
}
/**
* Get available order variables
*
* @return array
*/
public static function get_order_variables() {
return [
'order_number' => __('Order Number', 'woonoow'),
'order_total' => __('Order Total', 'woonoow'),
'order_status' => __('Order Status', 'woonoow'),
'order_date' => __('Order Date', 'woonoow'),
'order_url' => __('Order URL', 'woonoow'),
'order_items_list' => __('Order Items (formatted list)', 'woonoow'),
'order_items_table' => __('Order Items (formatted table)', 'woonoow'),
'payment_method' => __('Payment Method', 'woonoow'),
'payment_url' => __('Payment URL (for pending payments)', 'woonoow'),
'shipping_method' => __('Shipping Method', 'woonoow'),
'tracking_number' => __('Tracking Number', 'woonoow'),
'refund_amount' => __('Refund Amount', 'woonoow'),
'customer_name' => __('Customer Name', 'woonoow'),
'customer_email' => __('Customer Email', 'woonoow'),
'customer_phone' => __('Customer Phone', 'woonoow'),
'billing_address' => __('Billing Address', 'woonoow'),
'shipping_address' => __('Shipping Address', 'woonoow'),
'store_name' => __('Store Name', 'woonoow'),
'store_url' => __('Store URL', 'woonoow'),
'store_email' => __('Store Email', 'woonoow'),
];
}
/**
* Get available product variables
*
* @return array
*/
public static function get_product_variables() {
return [
'product_name' => __('Product Name', 'woonoow'),
'product_sku' => __('Product SKU', 'woonoow'),
'product_url' => __('Product URL', 'woonoow'),
'stock_quantity' => __('Stock Quantity', 'woonoow'),
'store_name' => __('Store Name', 'woonoow'),
'store_url' => __('Store URL', 'woonoow'),
];
}
/**
* Get available customer variables
*
* @return array
*/
public static function get_customer_variables() {
return [
'customer_name' => __('Customer Name', 'woonoow'),
'customer_email' => __('Customer Email', 'woonoow'),
'customer_phone' => __('Customer Phone', 'woonoow'),
'store_name' => __('Store Name', 'woonoow'),
'store_url' => __('Store URL', 'woonoow'),
'store_email' => __('Store Email', 'woonoow'),
];
}
/**
* Get available subscription variables
*
* @return array
*/
public static function get_subscription_variables() {
return [
'subscription_id' => __('Subscription ID', 'woonoow'),
'subscription_status' => __('Subscription Status', 'woonoow'),
'product_name' => __('Product Name', 'woonoow'),
'billing_period' => __('Billing Period (e.g., Monthly)', 'woonoow'),
'recurring_amount' => __('Recurring Amount', 'woonoow'),
'next_payment_date' => __('Next Payment Date', 'woonoow'),
'end_date' => __('Subscription End Date', 'woonoow'),
'cancel_reason' => __('Cancellation Reason', 'woonoow'),
'customer_name' => __('Customer Name', 'woonoow'),
'customer_email' => __('Customer Email', 'woonoow'),
'store_name' => __('Store Name', 'woonoow'),
'store_url' => __('Store URL', 'woonoow'),
'my_account_url' => __('My Account URL', 'woonoow'),
];
}
/**
* Replace variables in template
*
* @param string $content Content with variables
* @param array $data Data to replace variables
* @return string
*/
public static function replace_variables($content, $data) {
foreach ($data as $key => $value) {
$content = str_replace('{' . $key . '}', $value, $content);
}
return $content;
}
}

View File

@@ -107,32 +107,6 @@ class TemplateProvider {
return false;
}
/**
* Get WooCommerce email template content
*
* @param string $email_id WooCommerce email ID
* @return array|null
*/
private static function get_wc_email_template($email_id) {
if (!function_exists('WC')) {
return null;
}
$mailer = WC()->mailer();
$emails = $mailer->get_emails();
if (isset($emails[$email_id])) {
$email = $emails[$email_id];
return [
'subject' => $email->get_subject(),
'heading' => $email->get_heading(),
'enabled' => $email->is_enabled(),
];
}
return null;
}
/**
* Get default templates
*
@@ -264,6 +238,11 @@ class TemplateProvider {
return self::get_customer_variables();
}
// Subscription events
if (strpos($event_id, 'subscription_') === 0) {
return self::get_subscription_variables();
}
// All other events are order-related
return self::get_order_variables();
}
@@ -330,6 +309,29 @@ class TemplateProvider {
];
}
/**
* Get available subscription variables
*
* @return array
*/
public static function get_subscription_variables() {
return [
'subscription_id' => __('Subscription ID', 'woonoow'),
'subscription_status' => __('Subscription Status', 'woonoow'),
'product_name' => __('Product Name', 'woonoow'),
'billing_period' => __('Billing Period (e.g., Monthly)', 'woonoow'),
'recurring_amount' => __('Recurring Amount', 'woonoow'),
'next_payment_date' => __('Next Payment Date', 'woonoow'),
'end_date' => __('Subscription End Date', 'woonoow'),
'cancel_reason' => __('Cancellation Reason', 'woonoow'),
'customer_name' => __('Customer Name', 'woonoow'),
'customer_email' => __('Customer Email', 'woonoow'),
'store_name' => __('Store Name', 'woonoow'),
'store_url' => __('Store URL', 'woonoow'),
'my_account_url' => __('My Account URL', 'woonoow'),
];
}
/**
* Replace variables in template
*

View File

@@ -1,4 +1,5 @@
<?php
namespace WooNooW\Frontend;
use WooNooW\Frontend\PageSSR;
@@ -18,32 +19,32 @@ class TemplateOverride
{
// Register rewrite rules for BrowserRouter SEO (must be on 'init')
add_action('init', [__CLASS__, 'register_spa_rewrite_rules']);
// Flush rewrite rules when relevant settings change
add_action('update_option_woonoow_appearance_settings', function($old_value, $new_value) {
add_action('update_option_woonoow_appearance_settings', function ($old_value, $new_value) {
$old_general = $old_value['general'] ?? [];
$new_general = $new_value['general'] ?? [];
// Only flush if spa_mode, spa_page, or use_browser_router changed
$needs_flush =
$needs_flush =
($old_general['spa_mode'] ?? '') !== ($new_general['spa_mode'] ?? '') ||
($old_general['spa_page'] ?? '') !== ($new_general['spa_page'] ?? '') ||
($old_general['use_browser_router'] ?? true) !== ($new_general['use_browser_router'] ?? true);
if ($needs_flush) {
flush_rewrite_rules();
}
}, 10, 2);
// Redirect WooCommerce pages to SPA routes early (before template loads)
add_action('template_redirect', [__CLASS__, 'redirect_wc_pages_to_spa'], 5);
// Serve SPA directly for frontpage routes (priority 1 = very early, before WC)
add_action('template_redirect', [__CLASS__, 'serve_spa_for_frontpage_routes'], 1);
// Serve SSR for bots on pages/CPT with WooNooW structure (priority 2 = after frontpage check)
add_action('template_redirect', [__CLASS__, 'maybe_serve_ssr_for_bots'], 2);
// Hook to wp_loaded with priority 10 (BEFORE WooCommerce's priority 20)
// This ensures we process add-to-cart before WooCommerce does
add_action('wp_loaded', [__CLASS__, 'intercept_add_to_cart'], 10);
@@ -74,7 +75,7 @@ class TemplateOverride
add_action('get_header', [__CLASS__, 'remove_theme_header']);
add_action('get_footer', [__CLASS__, 'remove_theme_footer']);
}
/**
* Register rewrite rules for BrowserRouter SEO
* Catches all SPA routes and serves the SPA page
@@ -83,25 +84,25 @@ class TemplateOverride
{
$appearance_settings = get_option('woonoow_appearance_settings', []);
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
// Check if BrowserRouter is enabled (default: true for new installs)
$use_browser_router = $appearance_settings['general']['use_browser_router'] ?? true;
if (!$spa_page_id || !$use_browser_router) {
return;
}
$spa_page = get_post($spa_page_id);
if (!$spa_page) {
return;
}
$spa_slug = $spa_page->post_name;
// Check if SPA page is set as WordPress frontpage
$frontpage_id = (int) get_option('page_on_front');
$is_spa_frontpage = $frontpage_id && $frontpage_id === (int) $spa_page_id;
if ($is_spa_frontpage) {
// When SPA is frontpage, add root-level routes
// /shop, /shop/* → SPA page
@@ -115,28 +116,33 @@ class TemplateOverride
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=shop/$matches[1]',
'top'
);
// /product/* → SPA page
add_rewrite_rule(
'^product/(.*)$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=product/$matches[1]',
'top'
);
// /cart → SPA page
add_rewrite_rule(
'^cart/?$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=cart',
'top'
);
// /checkout → SPA page
// /checkout, /checkout/* → SPA page
add_rewrite_rule(
'^checkout/?$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=checkout',
'top'
);
add_rewrite_rule(
'^checkout/(.*)$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=checkout/$matches[1]',
'top'
);
// /my-account, /my-account/* → SPA page
add_rewrite_rule(
'^my-account/?$',
@@ -148,7 +154,7 @@ class TemplateOverride
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=my-account/$matches[1]',
'top'
);
// /login, /register, /reset-password → SPA page
add_rewrite_rule(
'^login/?$',
@@ -165,6 +171,24 @@ class TemplateOverride
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=reset-password',
'top'
);
// /order-pay/* → SPA page
add_rewrite_rule(
'^order-pay/(.*)$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=order-pay/$matches[1]',
'top'
);
// /order-pay/* → SPA page
add_rewrite_rule(
'^order-pay/(.*)$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=order-pay/$matches[1]',
'top'
);
// /order-pay/* → SPA page (moved to checkout/pay/ in new structure)
// Removed direct order-pay rule to favor checkout subpath
} else {
// Rewrite /slug to serve the SPA page (base URL)
add_rewrite_rule(
@@ -172,7 +196,7 @@ class TemplateOverride
'index.php?page_id=' . $spa_page_id,
'top'
);
// Rewrite /slug/anything to serve the SPA page with path
// React Router handles the path after that
add_rewrite_rule(
@@ -181,9 +205,9 @@ class TemplateOverride
'top'
);
}
// Register query var for the SPA path
add_filter('query_vars', function($vars) {
add_filter('query_vars', function ($vars) {
$vars[] = 'woonoow_spa_path';
return $vars;
});
@@ -249,32 +273,32 @@ class TemplateOverride
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
$spa_mode = $appearance_settings['general']['spa_mode'] ?? 'full';
$use_browser_router = $appearance_settings['general']['use_browser_router'] ?? true;
// Only redirect when SPA mode is 'full'
if ($spa_mode !== 'full') {
return;
}
if (!$spa_page_id) {
return; // No SPA page configured
}
// Skip if SPA is set as frontpage (serve_spa_for_frontpage_routes handles it)
$frontpage_id = (int) get_option('page_on_front');
if ($frontpage_id && $frontpage_id === (int) $spa_page_id) {
return;
}
// Already on SPA page, don't redirect
global $post;
if ($post && $post->ID == $spa_page_id) {
return;
}
$spa_url = trailingslashit(get_permalink($spa_page_id));
// Helper function to build route URL based on router type
$build_route = function($path) use ($spa_url, $use_browser_router) {
$build_route = function ($path) use ($spa_url, $use_browser_router) {
if ($use_browser_router) {
// Path format: /store/cart
return $spa_url . ltrim($path, '/');
@@ -282,13 +306,13 @@ class TemplateOverride
// Hash format: /store/#/cart
return rtrim($spa_url, '/') . '#/' . ltrim($path, '/');
};
// Check which WC page we're on and redirect
if (is_shop()) {
wp_redirect($build_route('shop'), 302);
exit;
}
if (is_product()) {
// Use get_queried_object() which returns the WP_Post, then get slug
$product_post = get_queried_object();
@@ -298,29 +322,37 @@ class TemplateOverride
exit;
}
}
if (is_cart()) {
wp_redirect($build_route('cart'), 302);
exit;
}
if (is_checkout() && !is_order_received_page()) {
// Check for order-pay endpoint
if (is_wc_endpoint_url('order-pay')) {
global $wp;
$order_id = $wp->query_vars['order-pay'];
wp_redirect($build_route('order-pay/' . $order_id), 302);
exit;
}
wp_redirect($build_route('checkout'), 302);
exit;
}
if (is_account_page()) {
wp_redirect($build_route('my-account'), 302);
exit;
}
// Redirect structural pages with WooNooW sections to SPA
if (is_singular('page') && $post) {
// Skip the SPA page itself and frontpage
if ($post->ID == $spa_page_id || $post->ID == $frontpage_id) {
return;
}
// Check if page has WooNooW structure
$structure = get_post_meta($post->ID, '_wn_page_structure', true);
if (!empty($structure) && !empty($structure['sections'])) {
@@ -348,22 +380,22 @@ class TemplateOverride
$appearance_settings = get_option('woonoow_appearance_settings', []);
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
$spa_mode = $appearance_settings['general']['spa_mode'] ?? 'full';
// Only run in full SPA mode
if ($spa_mode !== 'full' || !$spa_page_id) {
return;
}
// Check if SPA page is set as WordPress frontpage
$frontpage_id = (int) get_option('page_on_front');
if (!$frontpage_id || $frontpage_id !== (int) $spa_page_id) {
return; // SPA is not frontpage, let normal routing handle it
}
// Get the current request path relative to site root
$request_uri = $_SERVER['REQUEST_URI'] ?? '/';
$home_path = parse_url(home_url(), PHP_URL_PATH);
// Normalize request URI for subdirectory installs
if ($home_path && $home_path !== '/') {
$home_path = rtrim($home_path, '/');
@@ -375,7 +407,7 @@ class TemplateOverride
$path = parse_url($request_uri, PHP_URL_PATH);
$path = '/' . trim($path, '/');
// Define SPA routes that should be intercepted when SPA is frontpage
$spa_routes = [
'/', // Frontpage itself
@@ -385,37 +417,39 @@ class TemplateOverride
'/my-account', // Account page
'/login', // Login page
'/register', // Register page
'/register', // Register page
'/reset-password', // Password reset
'/order-pay', // Order pay page
];
// Check for exact matches or path prefixes
$should_serve_spa = false;
// Check exact matches
if (in_array($path, $spa_routes)) {
$should_serve_spa = true;
}
// Check path prefixes (for sub-routes)
$prefix_routes = ['/shop/', '/my-account/', '/product/'];
$prefix_routes = ['/shop/', '/my-account/', '/product/', '/checkout/'];
foreach ($prefix_routes as $prefix) {
if (strpos($path, $prefix) === 0) {
$should_serve_spa = true;
break;
}
}
// Check for structural pages with WooNooW sections
if (!$should_serve_spa && !empty($path) && $path !== '/') {
// Try to find a page by slug matching the path
$slug = trim($path, '/');
// Handle nested slugs (get the last part as the page slug)
if (strpos($slug, '/') !== false) {
$slug_parts = explode('/', $slug);
$slug = end($slug_parts);
}
$page = get_page_by_path($slug);
if ($page) {
// Check if this page has WooNooW structure
@@ -425,26 +459,26 @@ class TemplateOverride
}
}
}
// Not a SPA route
if (!$should_serve_spa) {
return;
}
// Prevent caching for dynamic SPA content
nocache_headers();
// Load the SPA template directly and exit
$spa_template = plugin_dir_path(dirname(dirname(__FILE__))) . 'templates/spa-full-page.php';
if (file_exists($spa_template)) {
// Set up minimal WordPress environment for the template
status_header(200);
// Define constant to tell Assets to load unconditionally
if (!defined('WOONOOW_SERVE_SPA')) {
define('WOONOOW_SERVE_SPA', true);
}
// Include the SPA template
include $spa_template;
exit;
@@ -487,12 +521,12 @@ class TemplateOverride
// Check spa_mode from appearance settings FIRST
$appearance_settings = get_option('woonoow_appearance_settings', []);
$spa_mode = $appearance_settings['general']['spa_mode'] ?? 'full';
// If SPA is disabled, return original template immediately
if ($spa_mode === 'disabled') {
return $template;
}
// Check if current page is a designated SPA page
if (self::is_spa_page()) {
$spa_template = plugin_dir_path(dirname(dirname(__FILE__))) . 'templates/spa-full-page.php';
@@ -511,7 +545,7 @@ class TemplateOverride
}
}
}
// For spa_mode = 'checkout_only'
if ($spa_mode === 'checkout_only') {
if (is_checkout() || is_order_received_page() || is_account_page() || is_cart()) {
@@ -634,7 +668,7 @@ class TemplateOverride
private static function is_spa_page()
{
global $post;
// Get SPA settings from appearance
$appearance_settings = get_option('woonoow_appearance_settings', []);
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
@@ -716,7 +750,7 @@ class TemplateOverride
return $template;
}
/**
* Detect if current request is from a bot/crawler
* Used to serve SSR content for SEO instead of SPA redirect
@@ -730,10 +764,10 @@ class TemplateOverride
if (empty($user_agent)) {
return false;
}
// Convert to lowercase for case-insensitive matching
$user_agent = strtolower($user_agent);
// Known bot patterns
$bot_patterns = [
// Search engine crawlers
@@ -745,13 +779,13 @@ class TemplateOverride
'yandexbot',
'sogou',
'exabot',
// Generic patterns
'crawler',
'spider',
'robot',
'scraper',
// Social media bots (for link previews)
'facebookexternalhit',
'twitterbot',
@@ -760,7 +794,7 @@ class TemplateOverride
'slackbot',
'telegrambot',
'discordbot',
// Other known bots
'applebot',
'semrushbot',
@@ -769,22 +803,22 @@ class TemplateOverride
'dotbot',
'petalbot',
'bytespider',
// Prerender services
'prerender',
'headlesschrome',
];
// Check if User-Agent contains any bot pattern
foreach ($bot_patterns as $pattern) {
if (strpos($user_agent, $pattern) !== false) {
return true;
}
}
return false;
}
/**
* Serve SSR content for bots
* Renders page structure as static HTML for search engine indexing
@@ -798,17 +832,17 @@ class TemplateOverride
// Generate cache key
$cache_id = $post_obj ? $post_obj->ID : $page_id;
$cache_key = "wn_ssr_{$type}_{$cache_id}";
// Check cache TTL (default 1 hour, filterable)
$cache_ttl = apply_filters('woonoow_ssr_cache_ttl', HOUR_IN_SECONDS);
// Try to get cached content
$cached = get_transient($cache_key);
if ($cached !== false) {
echo $cached;
exit;
}
// Get page structure
if ($type === 'page') {
$structure = get_post_meta($page_id, '_wn_page_structure', true);
@@ -816,103 +850,155 @@ class TemplateOverride
// CPT template - type is the post_type like 'post', 'portfolio', etc.
$structure = get_option("wn_template_{$type}", null);
}
if (empty($structure) || empty($structure['sections'])) {
return false; // No structure, let normal WP handle it
}
// Render using PageSSR
$post_data = null;
if ($post_obj && $type !== 'page') {
$placeholder_renderer = new PlaceholderRenderer();
$post_data = $placeholder_renderer->build_post_data($post_obj);
}
$ssr = new PageSSR();
$html = $ssr->render($structure['sections'], $post_data);
if (empty($html)) {
return false;
}
// Get page title
$title = $type === 'page' ? get_the_title($page_id) : '';
if ($post_obj) {
$title = get_the_title($post_obj);
}
// SEO data
$seo_title = $title . ' - ' . get_bloginfo('name');
$seo_description = '';
// Try to get Yoast/Rank Math SEO data
if ($type === 'page') {
$seo_title = get_post_meta($page_id, '_yoast_wpseo_title', true) ?:
get_post_meta($page_id, 'rank_math_title', true) ?: $seo_title;
$seo_description = get_post_meta($page_id, '_yoast_wpseo_metadesc', true) ?:
get_post_meta($page_id, 'rank_math_description', true) ?: '';
$seo_title = get_post_meta($page_id, '_yoast_wpseo_title', true) ?:
get_post_meta($page_id, 'rank_math_title', true) ?: $seo_title;
$seo_description = get_post_meta($page_id, '_yoast_wpseo_metadesc', true) ?:
get_post_meta($page_id, 'rank_math_description', true) ?: '';
} elseif ($post_obj) {
$seo_title = get_post_meta($post_obj->ID, '_yoast_wpseo_title', true) ?:
get_post_meta($post_obj->ID, 'rank_math_title', true) ?: $seo_title;
$seo_description = get_post_meta($post_obj->ID, '_yoast_wpseo_metadesc', true) ?:
get_post_meta($post_obj->ID, 'rank_math_description', true) ?:
wp_trim_words(wp_strip_all_tags($post_obj->post_content), 30);
$seo_title = get_post_meta($post_obj->ID, '_yoast_wpseo_title', true) ?:
get_post_meta($post_obj->ID, 'rank_math_title', true) ?: $seo_title;
$seo_description = get_post_meta($post_obj->ID, '_yoast_wpseo_metadesc', true) ?:
get_post_meta($post_obj->ID, 'rank_math_description', true) ?:
wp_trim_words(wp_strip_all_tags($post_obj->post_content), 30);
}
// Output SSR HTML - start output buffering for caching
ob_start();
?>
<!DOCTYPE html>
<html <?php language_attributes(); ?>>
<head>
<meta charset="<?php bloginfo('charset'); ?>">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?php echo esc_html($seo_title); ?></title>
<?php if ($seo_description): ?>
<meta name="description" content="<?php echo esc_attr($seo_description); ?>">
<?php endif; ?>
<link rel="canonical" href="<?php echo esc_url(get_permalink($post_obj ?: $page_id)); ?>">
<meta property="og:title" content="<?php echo esc_attr($seo_title); ?>">
<meta property="og:type" content="website">
<meta property="og:url" content="<?php echo esc_url(get_permalink($post_obj ?: $page_id)); ?>">
<?php if ($seo_description): ?>
<meta property="og:description" content="<?php echo esc_attr($seo_description); ?>">
<?php endif; ?>
<style>
/* Minimal SSR styles for bots */
body { font-family: system-ui, -apple-system, sans-serif; line-height: 1.6; margin: 0; padding: 0; }
.wn-ssr { max-width: 1200px; margin: 0 auto; padding: 20px; }
.wn-section { padding: 40px 0; }
.wn-section h1, .wn-section h2 { margin-bottom: 16px; }
.wn-section p { margin-bottom: 12px; }
.wn-section img { max-width: 100%; height: auto; }
.wn-hero { background: #f5f5f5; padding: 60px 20px; text-align: center; }
.wn-cta-banner { background: #4f46e5; color: white; padding: 40px 20px; text-align: center; }
.wn-cta-banner a { color: white; text-decoration: underline; }
.wn-feature-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 24px; }
.wn-feature-item { padding: 20px; border: 1px solid #e5e5e5; border-radius: 8px; }
</style>
<?php wp_head(); ?>
</head>
<body <?php body_class('wn-ssr-page'); ?>>
<div class="wn-ssr">
<?php echo $html; ?>
</div>
<?php wp_footer(); ?>
</body>
</html>
<?php
?>
<!DOCTYPE html>
<html <?php language_attributes(); ?>>
<head>
<meta charset="<?php bloginfo('charset'); ?>">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?php echo esc_html($seo_title); ?></title>
<?php if ($seo_description): ?>
<meta name="description" content="<?php echo esc_attr($seo_description); ?>">
<?php endif; ?>
<link rel="canonical" href="<?php echo esc_url(get_permalink($post_obj ?: $page_id)); ?>">
<meta property="og:title" content="<?php echo esc_attr($seo_title); ?>">
<meta property="og:type" content="website">
<meta property="og:url" content="<?php echo esc_url(get_permalink($post_obj ?: $page_id)); ?>">
<?php if ($seo_description): ?>
<meta property="og:description" content="<?php echo esc_attr($seo_description); ?>">
<?php endif; ?>
<style>
/* Minimal SSR styles for bots */
body {
font-family: system-ui, -apple-system, sans-serif;
line-height: 1.6;
margin: 0;
padding: 0;
}
.wn-ssr {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.wn-section {
padding: 40px 0;
}
.wn-section h1,
.wn-section h2 {
margin-bottom: 16px;
}
.wn-section p {
margin-bottom: 12px;
}
.wn-section img {
max-width: 100%;
height: auto;
}
.wn-hero {
background: #f5f5f5;
padding: 60px 20px;
text-align: center;
}
.wn-cta-banner {
background: #4f46e5;
color: white;
padding: 40px 20px;
text-align: center;
}
.wn-cta-banner a {
color: white;
text-decoration: underline;
}
.wn-feature-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 24px;
}
.wn-feature-item {
padding: 20px;
border: 1px solid #e5e5e5;
border-radius: 8px;
}
</style>
<?php wp_head(); ?>
</head>
<body <?php body_class('wn-ssr-page'); ?>>
<div class="wn-ssr">
<?php echo $html; ?>
</div>
<?php wp_footer(); ?>
</body>
</html>
<?php
// Get buffered output
$output = ob_get_clean();
// Cache the output for bots (uses cache TTL from filter)
set_transient($cache_key, $output, $cache_ttl);
// Output and exit
echo $output;
exit;
}
/**
* Handle SSR for structural pages and CPT items when bot detected
* Should be called from template_redirect hook
@@ -923,22 +1009,22 @@ class TemplateOverride
if (!self::is_bot()) {
return;
}
// Check if this is a page with WooNooW structure
if (is_singular('page')) {
$page_id = get_queried_object_id();
$structure = get_post_meta($page_id, '_wn_page_structure', true);
if (!empty($structure) && !empty($structure['sections'])) {
self::serve_ssr_content($page_id, 'page');
}
}
// Check for CPT items with templates
$post_type = get_post_type();
if ($post_type && is_singular() && $post_type !== 'page') {
$template = get_option("wn_template_{$post_type}", null);
if (!empty($template) && !empty($template['sections'])) {
$post_obj = get_queried_object();
self::serve_ssr_content($post_obj->ID, $post_type, $post_obj);
@@ -946,4 +1032,3 @@ class TemplateOverride
}
}
}

View File

@@ -323,6 +323,12 @@ class LicenseManager {
}
}
// Check subscription status if linked
$subscription_status = self::get_order_subscription_status($license['order_id']);
if ($subscription_status !== null && !in_array($subscription_status, ['active', 'pending-cancel'])) {
return new \WP_Error('subscription_inactive', __('Subscription is not active', 'woonoow'));
}
// Check activation limit
if ($license['activation_limit'] > 0 && $license['activation_count'] >= $license['activation_limit']) {
return new \WP_Error('activation_limit_reached', __('Activation limit reached', 'woonoow'));
@@ -433,8 +439,12 @@ class LicenseManager {
$is_expired = $license['expires_at'] && strtotime($license['expires_at']) < time();
// Check subscription status if linked
$subscription_status = self::get_order_subscription_status($license['order_id']);
$is_subscription_valid = $subscription_status === null || in_array($subscription_status, ['active', 'pending-cancel']);
return [
'valid' => $license['status'] === 'active' && !$is_expired,
'valid' => $license['status'] === 'active' && !$is_expired && $is_subscription_valid,
'license_key' => $license['license_key'],
'status' => $license['status'],
'activation_limit' => (int) $license['activation_limit'],
@@ -444,9 +454,52 @@ class LicenseManager {
: -1,
'expires_at' => $license['expires_at'],
'is_expired' => $is_expired,
'subscription_status' => $subscription_status,
'subscription_active' => $is_subscription_valid,
];
}
/**
* Check if an order has a linked subscription and return its status
*
* @param int $order_id
* @return string|null Subscription status or null if no subscription
*/
public static function get_order_subscription_status($order_id) {
// Check if subscription module is enabled
if (!ModuleRegistry::is_enabled('subscription')) {
return null;
}
global $wpdb;
$table = $wpdb->prefix . 'woonoow_subscription_orders';
// Check if table exists
$table_exists = $wpdb->get_var("SHOW TABLES LIKE '$table'");
if (!$table_exists) {
return null;
}
// Find subscription linked to this order
$subscription_id = $wpdb->get_var($wpdb->prepare(
"SELECT subscription_id FROM $table WHERE order_id = %d LIMIT 1",
$order_id
));
if (!$subscription_id) {
return null;
}
// Get subscription status
$subscriptions_table = $wpdb->prefix . 'woonoow_subscriptions';
$status = $wpdb->get_var($wpdb->prepare(
"SELECT status FROM $subscriptions_table WHERE id = %d",
$subscription_id
));
return $status;
}
/**
* Revoke license
*/

View File

@@ -0,0 +1,893 @@
<?php
/**
* Subscription Manager
*
* Core business logic for subscription management
*
* @package WooNooW\Modules\Subscription
*/
namespace WooNooW\Modules\Subscription;
if (!defined('ABSPATH')) exit;
use WooNooW\Core\ModuleRegistry;
class SubscriptionManager
{
/** @var string Subscriptions table name */
private static $table_subscriptions;
/** @var string Subscription orders table name */
private static $table_subscription_orders;
/**
* Initialize the manager
*/
public static function init()
{
global $wpdb;
self::$table_subscriptions = $wpdb->prefix . 'woonoow_subscriptions';
self::$table_subscription_orders = $wpdb->prefix . 'woonoow_subscription_orders';
}
/**
* Create database tables
*/
public static function create_tables()
{
global $wpdb;
$charset_collate = $wpdb->get_charset_collate();
$table_subscriptions = $wpdb->prefix . 'woonoow_subscriptions';
$table_subscription_orders = $wpdb->prefix . 'woonoow_subscription_orders';
$sql_subscriptions = "CREATE TABLE $table_subscriptions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
order_id BIGINT UNSIGNED NOT NULL,
product_id BIGINT UNSIGNED NOT NULL,
variation_id BIGINT UNSIGNED DEFAULT NULL,
status ENUM('pending', 'active', 'on-hold', 'cancelled', 'expired', 'pending-cancel') DEFAULT 'pending',
billing_period ENUM('day', 'week', 'month', 'year') NOT NULL,
billing_interval INT UNSIGNED DEFAULT 1,
recurring_amount DECIMAL(12,4) DEFAULT 0,
start_date DATETIME NOT NULL,
trial_end_date DATETIME DEFAULT NULL,
next_payment_date DATETIME DEFAULT NULL,
end_date DATETIME DEFAULT NULL,
last_payment_date DATETIME DEFAULT NULL,
payment_method VARCHAR(100) DEFAULT NULL,
payment_meta LONGTEXT,
cancel_reason TEXT DEFAULT NULL,
pause_count INT UNSIGNED DEFAULT 0,
failed_payment_count INT UNSIGNED DEFAULT 0,
reminder_sent_at DATETIME DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user_id (user_id),
INDEX idx_order_id (order_id),
INDEX idx_product_id (product_id),
INDEX idx_status (status),
INDEX idx_next_payment (next_payment_date)
) $charset_collate;";
$sql_orders = "CREATE TABLE $table_subscription_orders (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
subscription_id BIGINT UNSIGNED NOT NULL,
order_id BIGINT UNSIGNED NOT NULL,
order_type ENUM('parent', 'renewal', 'switch', 'resubscribe') DEFAULT 'renewal',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_subscription (subscription_id),
INDEX idx_order (order_id)
) $charset_collate;";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta($sql_subscriptions);
dbDelta($sql_orders);
}
/**
* Create subscription from order item
*
* @param \WC_Order $order
* @param \WC_Order_Item_Product $item
* @return int|false Subscription ID or false on failure
*/
public static function create_from_order($order, $item)
{
global $wpdb;
$product_id = $item->get_product_id();
$variation_id = $item->get_variation_id();
$user_id = $order->get_user_id();
if (!$user_id) {
// Guest orders not supported for subscriptions
return false;
}
// Get subscription settings from product
$billing_period = get_post_meta($product_id, '_woonoow_subscription_period', true) ?: 'month';
$billing_interval = absint(get_post_meta($product_id, '_woonoow_subscription_interval', true)) ?: 1;
$trial_days = absint(get_post_meta($product_id, '_woonoow_subscription_trial_days', true));
$subscription_length = absint(get_post_meta($product_id, '_woonoow_subscription_length', true));
// Calculate dates
$now = current_time('mysql');
$start_date = $now;
$trial_end_date = null;
if ($trial_days > 0) {
$trial_end_date = date('Y-m-d H:i:s', strtotime($now . " + $trial_days days"));
$next_payment_date = $trial_end_date;
} else {
$next_payment_date = self::calculate_next_payment_date($now, $billing_period, $billing_interval);
}
// Calculate end date if subscription has fixed length
$end_date = null;
if ($subscription_length > 0) {
$end_date = self::calculate_end_date($start_date, $billing_period, $billing_interval, $subscription_length);
}
// Get recurring amount (product price)
$product = $item->get_product();
$recurring_amount = $product ? $product->get_price() : $item->get_total();
// Get payment method
$payment_method = $order->get_payment_method();
$payment_meta = json_encode([
'method_title' => $order->get_payment_method_title(),
'customer_id' => $order->get_customer_id(),
]);
// Insert subscription
$inserted = $wpdb->insert(
self::$table_subscriptions,
[
'user_id' => $user_id,
'order_id' => $order->get_id(),
'product_id' => $product_id,
'variation_id' => $variation_id ?: null,
'status' => 'active',
'billing_period' => $billing_period,
'billing_interval' => $billing_interval,
'recurring_amount' => $recurring_amount,
'start_date' => $start_date,
'trial_end_date' => $trial_end_date,
'next_payment_date' => $next_payment_date,
'end_date' => $end_date,
'last_payment_date' => $now,
'payment_method' => $payment_method,
'payment_meta' => $payment_meta,
],
['%d', '%d', '%d', '%d', '%s', '%s', '%d', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s']
);
if (!$inserted) {
return false;
}
$subscription_id = $wpdb->insert_id;
// Link parent order to subscription
$wpdb->insert(
self::$table_subscription_orders,
[
'subscription_id' => $subscription_id,
'order_id' => $order->get_id(),
'order_type' => 'parent',
],
['%d', '%d', '%s']
);
// Trigger action
do_action('woonoow/subscription/created', $subscription_id, $order, $item);
return $subscription_id;
}
/**
* Get subscription by ID
*
* @param int $subscription_id
* @return object|null
*/
public static function get($subscription_id)
{
global $wpdb;
return $wpdb->get_row($wpdb->prepare(
"SELECT * FROM " . self::$table_subscriptions . " WHERE id = %d",
$subscription_id
));
}
/**
* Get subscription by related order ID (parent or renewal)
*
* @param int $order_id
* @return object|null
*/
public static function get_by_order_id($order_id)
{
global $wpdb;
$table_subscriptions = $wpdb->prefix . 'woonoow_subscriptions';
$table_subscription_orders = $wpdb->prefix . 'woonoow_subscription_orders';
// Join subscriptions table to get full subscription data
return $wpdb->get_row($wpdb->prepare(
"SELECT s.*
FROM $table_subscriptions s
JOIN $table_subscription_orders so ON s.id = so.subscription_id
WHERE so.order_id = %d",
$order_id
));
}
/**
* Get subscriptions by user
*
* @param int $user_id
* @param array $args
* @return array
*/
public static function get_by_user($user_id, $args = [])
{
global $wpdb;
$defaults = [
'status' => null,
'limit' => 20,
'offset' => 0,
];
$args = wp_parse_args($args, $defaults);
$where = "WHERE user_id = %d";
$params = [$user_id];
if ($args['status']) {
$where .= " AND status = %s";
$params[] = $args['status'];
}
$sql = $wpdb->prepare(
"SELECT * FROM " . self::$table_subscriptions . " $where ORDER BY created_at DESC LIMIT %d OFFSET %d",
array_merge($params, [$args['limit'], $args['offset']])
);
return $wpdb->get_results($sql);
}
/**
* Get all subscriptions (admin)
*
* @param array $args
* @return array
*/
public static function get_all($args = [])
{
global $wpdb;
$defaults = [
'status' => null,
'product_id' => null,
'user_id' => null,
'limit' => 20,
'offset' => 0,
'search' => null,
];
$args = wp_parse_args($args, $defaults);
$where = "WHERE 1=1";
$params = [];
if ($args['status']) {
$where .= " AND status = %s";
$params[] = $args['status'];
}
if ($args['product_id']) {
$where .= " AND product_id = %d";
$params[] = $args['product_id'];
}
if ($args['user_id']) {
$where .= " AND user_id = %d";
$params[] = $args['user_id'];
}
$order = "ORDER BY created_at DESC";
$limit = "LIMIT " . intval($args['limit']) . " OFFSET " . intval($args['offset']);
$sql = "SELECT * FROM " . self::$table_subscriptions . " $where $order $limit";
if (!empty($params)) {
$sql = $wpdb->prepare($sql, $params);
}
return $wpdb->get_results($sql);
}
/**
* Count subscriptions
*
* @param array $args
* @return int
*/
public static function count($args = [])
{
global $wpdb;
$where = "WHERE 1=1";
$params = [];
if (!empty($args['status'])) {
$where .= " AND status = %s";
$params[] = $args['status'];
}
$sql = "SELECT COUNT(*) FROM " . self::$table_subscriptions . " $where";
if (!empty($params)) {
$sql = $wpdb->prepare($sql, $params);
}
return (int) $wpdb->get_var($sql);
}
/**
* Update subscription status
*
* @param int $subscription_id
* @param string $status
* @param string|null $reason
* @return bool
*/
public static function update_status($subscription_id, $status, $reason = null)
{
global $wpdb;
$data = ['status' => $status];
$format = ['%s'];
if ($reason !== null) {
$data['cancel_reason'] = $reason;
$format[] = '%s';
}
$updated = $wpdb->update(
self::$table_subscriptions,
$data,
['id' => $subscription_id],
$format,
['%d']
);
if ($updated !== false) {
do_action('woonoow/subscription/status_changed', $subscription_id, $status, $reason);
}
return $updated !== false;
}
/**
* Cancel subscription
*
* @param int $subscription_id
* @param string $reason
* @param bool $immediate Force immediate cancellation
* @return bool
*/
public static function cancel($subscription_id, $reason = '', $immediate = false)
{
$subscription = self::get($subscription_id);
if (!$subscription || in_array($subscription->status, ['cancelled', 'expired'])) {
return false;
}
// Default to pending-cancel if there's time left
$new_status = 'cancelled';
$now = current_time('mysql');
if (!$immediate && $subscription->next_payment_date && $subscription->next_payment_date > $now) {
$new_status = 'pending-cancel';
}
$success = self::update_status($subscription_id, $new_status, $reason);
if ($success) {
if ($new_status === 'pending-cancel') {
do_action('woonoow/subscription/pending_cancel', $subscription_id, $reason);
} else {
do_action('woonoow/subscription/cancelled', $subscription_id, $reason);
}
}
return $success;
}
/**
* Pause subscription
*
* @param int $subscription_id
* @return bool
*/
public static function pause($subscription_id)
{
global $wpdb;
$subscription = self::get($subscription_id);
if (!$subscription || $subscription->status !== 'active') {
return false;
}
// Check max pause count
$settings = ModuleRegistry::get_settings('subscription');
$max_pause = $settings['max_pause_count'] ?? 3;
if ($max_pause > 0 && $subscription->pause_count >= $max_pause) {
return false;
}
$updated = $wpdb->update(
self::$table_subscriptions,
[
'status' => 'on-hold',
'pause_count' => $subscription->pause_count + 1,
],
['id' => $subscription_id],
['%s', '%d'],
['%d']
);
if ($updated !== false) {
do_action('woonoow/subscription/paused', $subscription_id);
}
return $updated !== false;
}
/**
* Resume subscription
*
* @param int $subscription_id
* @return bool
*/
public static function resume($subscription_id)
{
global $wpdb;
$subscription = self::get($subscription_id);
if (!$subscription || !in_array($subscription->status, ['on-hold', 'pending-cancel'])) {
return false;
}
$update_data = ['status' => 'active'];
$format = ['%s'];
// Only recalculate payment date if resuming from on-hold
if ($subscription->status === 'on-hold') {
// Recalculate next payment date from now
$next_payment = self::calculate_next_payment_date(
current_time('mysql'),
$subscription->billing_period,
$subscription->billing_interval
);
$update_data['next_payment_date'] = $next_payment;
$format[] = '%s';
}
$updated = $wpdb->update(
self::$table_subscriptions,
$update_data,
['id' => $subscription_id],
$format,
['%d']
);
if ($updated !== false) {
do_action('woonoow/subscription/resumed', $subscription_id);
}
return $updated !== false;
}
/**
* Process renewal for a subscription
*
* @param int $subscription_id
* @return bool
*/
/**
* Process renewal for a subscription
*
* @param int $subscription_id
* @return bool
*/
public static function renew($subscription_id)
{
global $wpdb;
$subscription = self::get($subscription_id);
if (!$subscription || !in_array($subscription->status, ['active', 'on-hold'])) {
return false;
}
// Check for existing pending renewal order to prevent duplicates
$existing_pending = $wpdb->get_row($wpdb->prepare(
"SELECT so.order_id FROM " . self::$table_subscription_orders . " so
JOIN {$wpdb->posts} p ON so.order_id = p.ID
WHERE so.subscription_id = %d
AND so.order_type = 'renewal'
AND p.post_status IN ('wc-pending', 'pending', 'wc-on-hold', 'on-hold')",
$subscription_id
));
if ($existing_pending) {
return ['success' => true, 'order_id' => (int) $existing_pending->order_id, 'status' => 'existing'];
}
// Create renewal order
$renewal_order = self::create_renewal_order($subscription);
if (!$renewal_order) {
// Failed to create order
self::handle_renewal_failure($subscription_id);
return false;
}
// Process payment
// Result can be: true (paid), false (failed), or 'manual' (waiting for payment)
$payment_result = self::process_renewal_payment($subscription, $renewal_order);
if ($payment_result === true) {
self::handle_renewal_success($subscription_id, $renewal_order);
return ['success' => true, 'order_id' => $renewal_order->get_id(), 'status' => 'complete'];
} elseif ($payment_result === 'manual') {
// Manual payment required
// CHECK: Is this an early renewal? (Next payment date is in future)
$now = current_time('mysql');
$is_early_renewal = $subscription->next_payment_date && $subscription->next_payment_date > $now;
if ($is_early_renewal) {
// Early renewal: Keep active, just waiting for payment
return ['success' => true, 'order_id' => $renewal_order->get_id(), 'status' => 'manual'];
}
// Normal/Overdue renewal: Set to on-hold
self::update_status($subscription_id, 'on-hold', 'awaiting_payment');
return ['success' => true, 'order_id' => $renewal_order->get_id(), 'status' => 'manual'];
} else {
// Auto-debit failed
self::handle_renewal_failure($subscription_id);
return false;
}
}
/**
* Create a renewal order
*
* @param object $subscription
* @return \WC_Order|false
*/
private static function create_renewal_order($subscription)
{
global $wpdb;
// Get original order
$parent_order = wc_get_order($subscription->order_id);
if (!$parent_order) {
return false;
}
// Create new order
$renewal_order = wc_create_order([
'customer_id' => $subscription->user_id,
'status' => 'pending',
'parent' => $subscription->order_id,
]);
if (is_wp_error($renewal_order)) {
return false;
}
// Add product
$product = wc_get_product($subscription->variation_id ?: $subscription->product_id);
if ($product) {
$renewal_order->add_product($product, 1, [
'total' => $subscription->recurring_amount,
'subtotal' => $subscription->recurring_amount,
]);
}
// Copy billing/shipping from parent
$renewal_order->set_address($parent_order->get_address('billing'), 'billing');
$renewal_order->set_address($parent_order->get_address('shipping'), 'shipping');
$renewal_order->set_payment_method($subscription->payment_method);
// Calculate totals
$renewal_order->calculate_totals();
$renewal_order->save();
// Link to subscription
$wpdb->insert(
self::$table_subscription_orders,
[
'subscription_id' => $subscription->id,
'order_id' => $renewal_order->get_id(),
'order_type' => 'renewal',
],
['%d', '%d', '%s']
);
return $renewal_order;
}
/**
* Process payment for renewal order
*
* @param object $subscription
* @param \WC_Order $order
* @return bool
*/
/**
* Process payment for renewal order
*
* @param object $subscription
* @param \WC_Order $order
* @return bool|string True if paid, false if failed, 'manual' if waiting
*/
private static function process_renewal_payment($subscription, $order)
{
// Allow plugins to override payment processing completely
// Return true/false/'manual' to bypass default logic
$pre = apply_filters('woonoow_pre_process_subscription_payment', null, $subscription, $order);
if ($pre !== null) {
return $pre;
}
// Get payment gateway
$gateways = WC()->payment_gateways()->get_available_payment_gateways();
$gateway_id = $subscription->payment_method;
if (!isset($gateways[$gateway_id])) {
// Payment method not available - treat as failure so user can fix
$order->update_status('failed', __('Payment method not available for renewal', 'woonoow'));
return false;
}
$gateway = $gateways[$gateway_id];
// 1. Try Auto-Debit if supported
if (method_exists($gateway, 'process_subscription_renewal_payment')) {
$result = $gateway->process_subscription_renewal_payment($order, $subscription);
if (!is_wp_error($result) && $result) {
return true;
}
// If explicit failure from auto-debit, return false (will trigger retry logic)
return false;
}
// 2. Allow other plugins to handle auto-debit via filter (e.g. Stripe/PayPal adapters)
$external_result = apply_filters('woonoow_process_subscription_payment', null, $gateway, $order, $subscription);
if ($external_result !== null) {
return $external_result ? true : false;
}
// 3. Fallback: Manual Payment
// Set order to pending-payment
$order->update_status('pending', __('Awaiting manual renewal payment', 'woonoow'));
// Send renewal payment email to customer
do_action('woonoow/subscription/renewal_payment_due', $subscription->id, $order);
return 'manual'; // Return special status
}
/**
* Handle successful renewal
*
* @param int $subscription_id
* @param \WC_Order $order
*/
public static function handle_renewal_success($subscription_id, $order)
{
global $wpdb;
$subscription = self::get($subscription_id);
// Calculate next payment date
// For early renewal, start from the current next_payment_date if it's in the future
// Otherwise start from now (for expired/overdue subscriptions)
$now = current_time('mysql');
$base_date = $now;
if ($subscription->next_payment_date && $subscription->next_payment_date > $now) {
$base_date = $subscription->next_payment_date;
}
$next_payment = self::calculate_next_payment_date(
$base_date,
$subscription->billing_period,
$subscription->billing_interval
);
// Check if subscription should end
if ($subscription->end_date && strtotime($next_payment) > strtotime($subscription->end_date)) {
$next_payment = null;
}
$wpdb->update(
self::$table_subscriptions,
[
'status' => 'active',
'next_payment_date' => $next_payment,
'last_payment_date' => current_time('mysql'),
'failed_payment_count' => 0,
],
['id' => $subscription_id],
['%s', '%s', '%s', '%d'],
['%d']
);
// Complete the order
$order->payment_complete();
do_action('woonoow/subscription/renewed', $subscription_id, $order);
}
/**
* Handle failed renewal
*
* @param int $subscription_id
*/
private static function handle_renewal_failure($subscription_id)
{
global $wpdb;
$subscription = self::get($subscription_id);
$new_failed_count = $subscription->failed_payment_count + 1;
// Get settings
$settings = ModuleRegistry::get_settings('subscription');
$max_attempts = $settings['expire_after_failed_attempts'] ?? 3;
if ($new_failed_count >= $max_attempts) {
// Mark as expired
$wpdb->update(
self::$table_subscriptions,
[
'status' => 'expired',
'failed_payment_count' => $new_failed_count,
],
['id' => $subscription_id],
['%s', '%d'],
['%d']
);
do_action('woonoow/subscription/expired', $subscription_id, 'payment_failed');
} else {
// Just increment failed count
$wpdb->update(
self::$table_subscriptions,
['failed_payment_count' => $new_failed_count],
['id' => $subscription_id],
['%d'],
['%d']
);
do_action('woonoow/subscription/renewal_failed', $subscription_id, $new_failed_count);
}
}
/**
* Calculate next payment date
*
* @param string $from_date
* @param string $period
* @param int $interval
* @return string
*/
public static function calculate_next_payment_date($from_date, $period, $interval = 1)
{
$interval = max(1, $interval);
switch ($period) {
case 'day':
$modifier = "+ $interval days";
break;
case 'week':
$modifier = "+ $interval weeks";
break;
case 'month':
$modifier = "+ $interval months";
break;
case 'year':
$modifier = "+ $interval years";
break;
default:
$modifier = "+ 1 month";
}
return date('Y-m-d H:i:s', strtotime($from_date . ' ' . $modifier));
}
/**
* Calculate subscription end date
*
* @param string $start_date
* @param string $period
* @param int $interval
* @param int $length
* @return string
*/
private static function calculate_end_date($start_date, $period, $interval, $length)
{
$total_periods = $interval * $length;
switch ($period) {
case 'day':
$modifier = "+ $total_periods days";
break;
case 'week':
$modifier = "+ $total_periods weeks";
break;
case 'month':
$modifier = "+ $total_periods months";
break;
case 'year':
$modifier = "+ $total_periods years";
break;
default:
$modifier = "+ $total_periods months";
}
return date('Y-m-d H:i:s', strtotime($start_date . ' ' . $modifier));
}
/**
* Get subscriptions due for renewal
*
* @return array
*/
public static function get_due_renewals()
{
global $wpdb;
$now = current_time('mysql');
return $wpdb->get_results($wpdb->prepare(
"SELECT * FROM " . self::$table_subscriptions . "
WHERE status = 'active'
AND next_payment_date IS NOT NULL
AND next_payment_date <= %s
ORDER BY next_payment_date ASC",
$now
));
}
/**
* Get subscription orders
*
* @param int $subscription_id
* @return array
*/
public static function get_orders($subscription_id)
{
global $wpdb;
return $wpdb->get_results($wpdb->prepare(
"SELECT so.*, p.post_status as order_status
FROM " . self::$table_subscription_orders . " so
LEFT JOIN {$wpdb->posts} p ON so.order_id = p.ID
WHERE so.subscription_id = %d
ORDER BY so.created_at DESC",
$subscription_id
));
}
}

View File

@@ -0,0 +1,563 @@
<?php
/**
* Subscription Module Bootstrap
*
* @package WooNooW\Modules\Subscription
*/
namespace WooNooW\Modules\Subscription;
if (!defined('ABSPATH')) exit;
use WooNooW\Core\ModuleRegistry;
use WooNooW\Modules\SubscriptionSettings;
class SubscriptionModule
{
/**
* Initialize the subscription module
*/
public static function init()
{
// Register settings schema
SubscriptionSettings::init();
// Initialize manager immediately since we're already in plugins_loaded
self::maybe_init_manager();
// Install tables on module enable
add_action('woonoow/module/enabled', [__CLASS__, 'on_module_enabled']);
// Add product meta fields
add_action('woocommerce_product_options_general_product_data', [__CLASS__, 'add_product_subscription_fields']);
add_action('woocommerce_process_product_meta', [__CLASS__, 'save_product_subscription_fields']);
// Hook into order completion to create subscriptions
add_action('woocommerce_order_status_completed', [__CLASS__, 'maybe_create_subscription'], 10, 1);
add_action('woocommerce_order_status_processing', [__CLASS__, 'maybe_create_subscription'], 10, 1);
// Hook into order status change to handle manual renewal payments
add_action('woocommerce_order_status_changed', [__CLASS__, 'on_order_status_changed'], 10, 3);
// Modify add to cart button text for subscription products
add_filter('woocommerce_product_single_add_to_cart_text', [__CLASS__, 'subscription_add_to_cart_text'], 10, 2);
add_filter('woocommerce_product_add_to_cart_text', [__CLASS__, 'subscription_add_to_cart_text'], 10, 2);
// Register subscription notification events
add_filter('woonoow_notification_events_registry', [__CLASS__, 'register_notification_events']);
// Hook subscription lifecycle events to send notifications
add_action('woonoow/subscription/pending_cancel', [__CLASS__, 'on_pending_cancel'], 10, 2);
add_action('woonoow/subscription/cancelled', [__CLASS__, 'on_cancelled'], 10, 2);
add_action('woonoow/subscription/expired', [__CLASS__, 'on_expired'], 10, 2);
add_action('woonoow/subscription/paused', [__CLASS__, 'on_paused'], 10, 1);
add_action('woonoow/subscription/resumed', [__CLASS__, 'on_resumed'], 10, 1);
add_action('woonoow/subscription/renewal_failed', [__CLASS__, 'on_renewal_failed'], 10, 2);
add_action('woonoow/subscription/renewal_payment_due', [__CLASS__, 'on_renewal_payment_due'], 10, 2);
add_action('woonoow/subscription/renewal_reminder', [__CLASS__, 'on_renewal_reminder'], 10, 1);
}
/**
* Initialize manager if module is enabled
*/
public static function maybe_init_manager()
{
if (ModuleRegistry::is_enabled('subscription')) {
// Ensure tables exist
self::ensure_tables();
SubscriptionManager::init();
SubscriptionScheduler::init();
}
}
/**
* Ensure database tables exist
*/
private static function ensure_tables()
{
global $wpdb;
$table = $wpdb->prefix . 'woonoow_subscriptions';
// Check if table exists
if ($wpdb->get_var("SHOW TABLES LIKE '$table'") !== $table) {
SubscriptionManager::create_tables();
}
}
/**
* Handle module enable
*/
public static function on_module_enabled($module_id)
{
if ($module_id === 'subscription') {
SubscriptionManager::create_tables();
}
}
/**
* Add subscription fields to product edit page
*/
public static function add_product_subscription_fields()
{
global $post;
if (!ModuleRegistry::is_enabled('subscription')) {
return;
}
echo '<div class="options_group show_if_simple show_if_variable">';
woocommerce_wp_checkbox([
'id' => '_woonoow_subscription_enabled',
'label' => __('Enable Subscription', 'woonoow'),
'description' => __('Enable recurring subscription billing for this product', 'woonoow'),
]);
echo '<div class="woonoow-subscription-options" style="display:none;">';
woocommerce_wp_select([
'id' => '_woonoow_subscription_period',
'label' => __('Billing Period', 'woonoow'),
'description' => __('How often to bill the customer', 'woonoow'),
'options' => [
'day' => __('Daily', 'woonoow'),
'week' => __('Weekly', 'woonoow'),
'month' => __('Monthly', 'woonoow'),
'year' => __('Yearly', 'woonoow'),
],
'value' => get_post_meta($post->ID, '_woonoow_subscription_period', true) ?: 'month',
]);
woocommerce_wp_text_input([
'id' => '_woonoow_subscription_interval',
'label' => __('Billing Interval', 'woonoow'),
'description' => __('Bill every X periods (e.g., 2 = every 2 months)', 'woonoow'),
'type' => 'number',
'value' => get_post_meta($post->ID, '_woonoow_subscription_interval', true) ?: 1,
'custom_attributes' => [
'min' => '1',
'max' => '365',
'step' => '1',
],
]);
woocommerce_wp_text_input([
'id' => '_woonoow_subscription_trial_days',
'label' => __('Free Trial Days', 'woonoow'),
'description' => __('Number of free trial days before first billing (0 = no trial)', 'woonoow'),
'type' => 'number',
'value' => get_post_meta($post->ID, '_woonoow_subscription_trial_days', true) ?: 0,
'custom_attributes' => [
'min' => '0',
'step' => '1',
],
]);
woocommerce_wp_text_input([
'id' => '_woonoow_subscription_signup_fee',
'label' => __('Sign-up Fee', 'woonoow') . ' (' . get_woocommerce_currency_symbol() . ')',
'description' => __('One-time fee charged on first subscription order', 'woonoow'),
'type' => 'text',
'value' => get_post_meta($post->ID, '_woonoow_subscription_signup_fee', true) ?: '',
'data_type' => 'price',
]);
woocommerce_wp_text_input([
'id' => '_woonoow_subscription_length',
'label' => __('Subscription Length', 'woonoow'),
'description' => __('Number of billing periods (0 = unlimited/until cancelled)', 'woonoow'),
'type' => 'number',
'value' => get_post_meta($post->ID, '_woonoow_subscription_length', true) ?: 0,
'custom_attributes' => [
'min' => '0',
'step' => '1',
],
]);
echo '</div>'; // .woonoow-subscription-options
// Add inline script to show/hide options based on checkbox
?>
<script type="text/javascript">
jQuery(function($) {
function toggleSubscriptionOptions() {
if ($('#_woonoow_subscription_enabled').is(':checked')) {
$('.woonoow-subscription-options').show();
} else {
$('.woonoow-subscription-options').hide();
}
}
toggleSubscriptionOptions();
$('#_woonoow_subscription_enabled').on('change', toggleSubscriptionOptions);
});
</script>
<?php
echo '</div>'; // .options_group
}
/**
* Save subscription fields
*/
public static function save_product_subscription_fields($post_id)
{
$subscription_enabled = isset($_POST['_woonoow_subscription_enabled']) ? 'yes' : 'no';
update_post_meta($post_id, '_woonoow_subscription_enabled', $subscription_enabled);
if (isset($_POST['_woonoow_subscription_period'])) {
update_post_meta($post_id, '_woonoow_subscription_period', sanitize_text_field($_POST['_woonoow_subscription_period']));
}
if (isset($_POST['_woonoow_subscription_interval'])) {
update_post_meta($post_id, '_woonoow_subscription_interval', absint($_POST['_woonoow_subscription_interval']));
}
if (isset($_POST['_woonoow_subscription_trial_days'])) {
update_post_meta($post_id, '_woonoow_subscription_trial_days', absint($_POST['_woonoow_subscription_trial_days']));
}
if (isset($_POST['_woonoow_subscription_signup_fee'])) {
update_post_meta($post_id, '_woonoow_subscription_signup_fee', wc_format_decimal($_POST['_woonoow_subscription_signup_fee']));
}
if (isset($_POST['_woonoow_subscription_length'])) {
update_post_meta($post_id, '_woonoow_subscription_length', absint($_POST['_woonoow_subscription_length']));
}
}
/**
* Maybe create subscription from completed order
*/
public static function maybe_create_subscription($order_id)
{
if (!ModuleRegistry::is_enabled('subscription')) {
return;
}
$order = wc_get_order($order_id);
if (!$order) {
return;
}
// Check if subscription already created for this order
if ($order->get_meta('_woonoow_subscription_created')) {
return;
}
foreach ($order->get_items() as $item) {
$product_id = $item->get_product_id();
$variation_id = $item->get_variation_id();
// Check if product has subscription enabled
if (get_post_meta($product_id, '_woonoow_subscription_enabled', true) !== 'yes') {
continue;
}
// Create subscription for this product
SubscriptionManager::create_from_order($order, $item);
}
// Mark order as processed
$order->update_meta_data('_woonoow_subscription_created', 'yes');
$order->save();
}
/**
* Modify add to cart button text for subscription products
*/
public static function subscription_add_to_cart_text($text, $product)
{
if (!ModuleRegistry::is_enabled('subscription')) {
return $text;
}
$product_id = $product->get_id();
if (get_post_meta($product_id, '_woonoow_subscription_enabled', true) === 'yes') {
$settings = ModuleRegistry::get_settings('subscription');
return $settings['button_text_subscribe'] ?? __('Subscribe Now', 'woonoow');
}
return $text;
}
/**
* Register subscription notification events
*
* @param array $events Existing events
* @return array Updated events
*/
public static function register_notification_events($events)
{
// Customer notifications
$events['subscription_pending_cancel'] = [
'id' => 'subscription_pending_cancel',
'label' => __('Subscription Pending Cancellation', 'woonoow'),
'description' => __('When a subscription is scheduled for cancellation at period end', 'woonoow'),
'category' => 'subscriptions',
'recipient_type' => 'customer',
'wc_email' => '',
'enabled' => true,
'variables' => self::get_subscription_variables(),
];
$events['subscription_cancelled'] = [
'id' => 'subscription_cancelled',
'label' => __('Subscription Cancelled', 'woonoow'),
'description' => __('When a subscription is cancelled and access ends', 'woonoow'),
'category' => 'subscriptions',
'recipient_type' => 'customer',
'wc_email' => '',
'enabled' => true,
'variables' => self::get_subscription_variables(),
];
$events['subscription_expired'] = [
'id' => 'subscription_expired',
'label' => __('Subscription Expired', 'woonoow'),
'description' => __('When a subscription expires due to end date or failed payments', 'woonoow'),
'category' => 'subscriptions',
'recipient_type' => 'customer',
'wc_email' => '',
'enabled' => true,
'variables' => self::get_subscription_variables(),
];
$events['subscription_paused'] = [
'id' => 'subscription_paused',
'label' => __('Subscription Paused', 'woonoow'),
'description' => __('When a subscription is put on hold', 'woonoow'),
'category' => 'subscriptions',
'recipient_type' => 'customer',
'wc_email' => '',
'enabled' => true,
'variables' => self::get_subscription_variables(),
];
$events['subscription_resumed'] = [
'id' => 'subscription_resumed',
'label' => __('Subscription Resumed', 'woonoow'),
'description' => __('When a subscription is resumed from pause', 'woonoow'),
'category' => 'subscriptions',
'recipient_type' => 'customer',
'wc_email' => '',
'enabled' => true,
'variables' => self::get_subscription_variables(),
];
$events['subscription_renewal_failed'] = [
'id' => 'subscription_renewal_failed',
'label' => __('Subscription Renewal Failed', 'woonoow'),
'description' => __('When a renewal payment fails', 'woonoow'),
'category' => 'subscriptions',
'recipient_type' => 'customer',
'wc_email' => '',
'enabled' => true,
'variables' => self::get_subscription_variables(),
];
$events['subscription_renewal_payment_due'] = [
'id' => 'subscription_renewal_payment_due',
'label' => __('Subscription Renewal Payment Due', 'woonoow'),
'description' => __('When a manual payment is required for subscription renewal', 'woonoow'),
'category' => 'subscriptions',
'recipient_type' => 'customer',
'wc_email' => '',
'enabled' => true,
'variables' => array_merge(self::get_subscription_variables(), [
'{payment_link}' => __('Link to payment page', 'woonoow'),
]),
];
$events['subscription_renewal_reminder'] = [
'id' => 'subscription_renewal_reminder',
'label' => __('Subscription Renewal Reminder', 'woonoow'),
'description' => __('Reminder before subscription renewal', 'woonoow'),
'category' => 'subscriptions',
'recipient_type' => 'customer',
'wc_email' => '',
'enabled' => true,
'variables' => self::get_subscription_variables(),
];
// Staff notifications
$events['subscription_cancelled_admin'] = [
'id' => 'subscription_cancelled',
'label' => __('Subscription Cancelled', 'woonoow'),
'description' => __('When a customer cancels their subscription', 'woonoow'),
'category' => 'subscriptions',
'recipient_type' => 'staff',
'wc_email' => '',
'enabled' => true,
'variables' => self::get_subscription_variables(),
];
$events['subscription_renewal_failed_admin'] = [
'id' => 'subscription_renewal_failed',
'label' => __('Subscription Renewal Failed', 'woonoow'),
'description' => __('When a subscription renewal payment fails', 'woonoow'),
'category' => 'subscriptions',
'recipient_type' => 'staff',
'wc_email' => '',
'enabled' => true,
'variables' => self::get_subscription_variables(),
];
return $events;
}
/**
* Get subscription-specific template variables
*
* @return array
*/
private static function get_subscription_variables()
{
return [
'{subscription_id}' => __('Subscription ID', 'woonoow'),
'{subscription_status}' => __('Subscription status', 'woonoow'),
'{product_name}' => __('Product name', 'woonoow'),
'{billing_period}' => __('Billing period (e.g., monthly)', 'woonoow'),
'{recurring_amount}' => __('Recurring payment amount', 'woonoow'),
'{next_payment_date}' => __('Next payment date', 'woonoow'),
'{end_date}' => __('Subscription end date', 'woonoow'),
'{cancel_reason}' => __('Cancellation reason', 'woonoow'),
];
}
/**
* Handle pending cancellation notification
*/
public static function on_pending_cancel($subscription_id, $reason = '')
{
self::send_subscription_notification('subscription_pending_cancel', $subscription_id, $reason);
}
/**
* Handle cancellation notification
*/
public static function on_cancelled($subscription_id, $reason = '')
{
self::send_subscription_notification('subscription_cancelled', $subscription_id, $reason);
}
/**
* Handle expiration notification
*/
public static function on_expired($subscription_id, $reason = '')
{
self::send_subscription_notification('subscription_expired', $subscription_id, $reason);
}
/**
* Handle pause notification
*/
public static function on_paused($subscription_id)
{
self::send_subscription_notification('subscription_paused', $subscription_id);
}
/**
* Handle resume notification
*/
public static function on_resumed($subscription_id)
{
self::send_subscription_notification('subscription_resumed', $subscription_id);
}
/**
* Handle renewal failed notification
*/
public static function on_renewal_failed($subscription_id, $failed_count)
{
self::send_subscription_notification('subscription_renewal_failed', $subscription_id, '', $failed_count);
}
/**
* Handle renewal payment due notification
*/
public static function on_renewal_payment_due($subscription_id, $order = null)
{
$payment_link = '';
if ($order && is_a($order, 'WC_Order')) {
$payment_link = $order->get_checkout_payment_url();
}
self::send_subscription_notification('subscription_renewal_payment_due', $subscription_id, '', 0, ['payment_link' => $payment_link]);
}
/**
* Handle renewal reminder notification
*/
public static function on_renewal_reminder($subscription)
{
if (!$subscription || !isset($subscription->id)) {
return;
}
self::send_subscription_notification('subscription_renewal_reminder', $subscription->id);
}
/**
* Send subscription notification
*
* @param string $event_id Event ID
* @param int $subscription_id Subscription ID
* @param string $reason Optional reason
* @param int $failed_count Optional failed payment count
* @param array $extra_data Optional extra data variables
*/
private static function send_subscription_notification($event_id, $subscription_id, $reason = '', $failed_count = 0, $extra_data = [])
{
$subscription = SubscriptionManager::get($subscription_id);
if (!$subscription) {
return;
}
$user = get_user_by('id', $subscription->user_id);
$product = wc_get_product($subscription->product_id);
$data = [
'subscription' => $subscription,
'customer' => $user,
'product' => $product,
'reason' => $reason,
'failed_count' => $failed_count,
'payment_link' => $extra_data['payment_link'] ?? '',
];
// Send via NotificationManager
if (class_exists('\\WooNooW\\Core\\Notifications\\NotificationManager')) {
\WooNooW\Core\Notifications\NotificationManager::send($event_id, 'email', $data);
}
}
/**
* Handle manual renewal payment completion
*/
public static function on_order_status_changed($order_id, $old_status, $new_status)
{
if (!ModuleRegistry::is_enabled('subscription')) {
return;
}
if (!in_array($new_status, ['processing', 'completed'])) {
return;
}
// Check if this is a subscription renewal order
global $wpdb;
$table_orders = $wpdb->prefix . 'woonoow_subscription_orders';
$link = $wpdb->get_row($wpdb->prepare(
"SELECT subscription_id, order_type FROM $table_orders WHERE order_id = %d",
$order_id
));
if ($link && $link->order_type === 'renewal') {
$order = wc_get_order($order_id);
if ($order) {
SubscriptionManager::handle_renewal_success($link->subscription_id, $order);
}
}
}
}

View File

@@ -0,0 +1,263 @@
<?php
/**
* Subscription Scheduler
*
* Handles cron jobs for subscription renewals and expirations
*
* @package WooNooW\Modules\Subscription
*/
namespace WooNooW\Modules\Subscription;
if (!defined('ABSPATH')) exit;
use WooNooW\Core\ModuleRegistry;
class SubscriptionScheduler
{
/**
* Cron hook for processing renewals
*/
const RENEWAL_HOOK = 'woonoow_process_subscription_renewals';
/**
* Cron hook for checking expired subscriptions
*/
const EXPIRY_HOOK = 'woonoow_check_expired_subscriptions';
/**
* Cron hook for sending renewal reminders
*/
const REMINDER_HOOK = 'woonoow_send_renewal_reminders';
/**
* Initialize the scheduler
*/
public static function init()
{
// Register cron handlers
add_action(self::RENEWAL_HOOK, [__CLASS__, 'process_renewals']);
add_action(self::EXPIRY_HOOK, [__CLASS__, 'check_expirations']);
add_action(self::REMINDER_HOOK, [__CLASS__, 'send_reminders']);
// Schedule cron events if not already scheduled
self::schedule_events();
// Cleanup on plugin deactivation
register_deactivation_hook(WOONOOW_PLUGIN_FILE, [__CLASS__, 'unschedule_events']);
}
/**
* Schedule cron events
*/
public static function schedule_events()
{
if (!wp_next_scheduled(self::RENEWAL_HOOK)) {
wp_schedule_event(time(), 'hourly', self::RENEWAL_HOOK);
}
if (!wp_next_scheduled(self::EXPIRY_HOOK)) {
wp_schedule_event(time(), 'daily', self::EXPIRY_HOOK);
}
if (!wp_next_scheduled(self::REMINDER_HOOK)) {
wp_schedule_event(time(), 'daily', self::REMINDER_HOOK);
}
}
/**
* Unschedule cron events
*/
public static function unschedule_events()
{
wp_clear_scheduled_hook(self::RENEWAL_HOOK);
wp_clear_scheduled_hook(self::EXPIRY_HOOK);
wp_clear_scheduled_hook(self::REMINDER_HOOK);
}
/**
* Process due subscription renewals
*/
public static function process_renewals()
{
if (!ModuleRegistry::is_enabled('subscription')) {
return;
}
$due_subscriptions = SubscriptionManager::get_due_renewals();
foreach ($due_subscriptions as $subscription) {
// Log renewal attempt
do_action('woonoow/subscription/renewal_processing', $subscription->id);
try {
$success = SubscriptionManager::renew($subscription->id);
if ($success) {
do_action('woonoow/subscription/renewal_completed', $subscription->id);
} else {
// Auto-debit failed (returns false), so schedule retry
// Note: 'manual' falls into a separate bucket in SubscriptionManager and returns true (handled)
self::schedule_retry($subscription->id);
}
} catch (\Exception $e) {
// Log error
error_log('[WooNooW Subscription] Renewal failed for subscription #' . $subscription->id . ': ' . $e->getMessage());
do_action('woonoow/subscription/renewal_error', $subscription->id, $e);
// Also schedule retry on exception
self::schedule_retry($subscription->id);
}
}
}
/**
* Check for expired subscriptions
*/
public static function check_expirations()
{
global $wpdb;
if (!ModuleRegistry::is_enabled('subscription')) {
return;
}
$table = $wpdb->prefix . 'woonoow_subscriptions';
$now = current_time('mysql');
// Find subscriptions that have passed their end date
$expired = $wpdb->get_results($wpdb->prepare(
"SELECT id FROM $table
WHERE status = 'active'
AND end_date IS NOT NULL
AND end_date <= %s",
$now
));
foreach ($expired as $subscription) {
SubscriptionManager::update_status($subscription->id, 'expired');
do_action('woonoow/subscription/expired', $subscription->id, 'end_date_reached');
}
// Also check pending-cancel subscriptions that need to be finalized
$pending_cancel = $wpdb->get_results($wpdb->prepare(
"SELECT id FROM $table
WHERE status = 'pending-cancel'
AND next_payment_date IS NOT NULL
AND next_payment_date <= %s",
$now
));
foreach ($pending_cancel as $subscription) {
SubscriptionManager::update_status($subscription->id, 'cancelled');
do_action('woonoow/subscription/cancelled', $subscription->id, 'pending_cancel_completed');
}
}
/**
* Send renewal reminder emails
*/
public static function send_reminders()
{
global $wpdb;
if (!ModuleRegistry::is_enabled('subscription')) {
return;
}
// Check if reminders are enabled
$settings = ModuleRegistry::get_settings('subscription');
if (empty($settings['send_renewal_reminder'])) {
return;
}
$days_before = $settings['reminder_days_before'] ?? 3;
$reminder_date = date('Y-m-d H:i:s', strtotime("+$days_before days"));
$tomorrow = date('Y-m-d H:i:s', strtotime('+' . ($days_before + 1) . ' days'));
$table = $wpdb->prefix . 'woonoow_subscriptions';
// Find subscriptions due for reminder (that haven't had reminder sent for this billing cycle)
$due_reminders = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM $table
WHERE status = 'active'
AND next_payment_date IS NOT NULL
AND next_payment_date >= %s
AND next_payment_date < %s
AND (reminder_sent_at IS NULL OR reminder_sent_at < last_payment_date OR (last_payment_date IS NULL AND reminder_sent_at < start_date))",
$reminder_date,
$tomorrow
));
foreach ($due_reminders as $subscription) {
// Trigger reminder email
do_action('woonoow/subscription/renewal_reminder', $subscription);
// Mark reminder as sent in database
$wpdb->update(
$table,
['reminder_sent_at' => current_time('mysql')],
['id' => $subscription->id],
['%s'],
['%d']
);
}
}
/**
* Get retry schedule for failed payments
*
* @param int $subscription_id
* @return string|null Next retry datetime or null if no more retries
*/
public static function get_next_retry_date($subscription_id)
{
$subscription = SubscriptionManager::get($subscription_id);
if (!$subscription) {
return null;
}
$settings = ModuleRegistry::get_settings('subscription');
if (empty($settings['renewal_retry_enabled'])) {
return null;
}
$retry_days_str = $settings['renewal_retry_days'] ?? '1,3,5';
$retry_days = array_map('intval', array_filter(explode(',', $retry_days_str)));
$failed_count = $subscription->failed_payment_count;
if ($failed_count >= count($retry_days)) {
return null; // No more retries
}
$days_to_add = $retry_days[$failed_count] ?? 1;
return date('Y-m-d H:i:s', strtotime("+$days_to_add days"));
}
/**
* Schedule a retry for failed payment
*
* @param int $subscription_id
*/
public static function schedule_retry($subscription_id)
{
global $wpdb;
$next_retry = self::get_next_retry_date($subscription_id);
if ($next_retry) {
$table = $wpdb->prefix . 'woonoow_subscriptions';
$wpdb->update(
$table,
['next_payment_date' => $next_retry],
['id' => $subscription_id],
['%s'],
['%d']
);
}
}
}

View File

@@ -0,0 +1,109 @@
<?php
/**
* Subscription Module Settings
*
* @package WooNooW\Modules
*/
namespace WooNooW\Modules;
if (!defined('ABSPATH')) exit;
class SubscriptionSettings {
/**
* Initialize the settings
*/
public static function init() {
add_filter('woonoow/module_settings_schema', [__CLASS__, 'register_schema']);
}
/**
* Register subscription settings schema
*/
public static function register_schema($schemas) {
$schemas['subscription'] = [
'default_status' => [
'type' => 'select',
'label' => __('Default Subscription Status', 'woonoow'),
'description' => __('Status for new subscriptions after successful payment', 'woonoow'),
'options' => [
'active' => __('Active', 'woonoow'),
'pending' => __('Pending', 'woonoow'),
],
'default' => 'active',
],
'button_text_subscribe' => [
'type' => 'text',
'label' => __('Subscribe Button Text', 'woonoow'),
'description' => __('Text for the subscribe button on subscription products', 'woonoow'),
'placeholder' => 'Subscribe Now',
'default' => 'Subscribe Now',
],
'button_text_renew' => [
'type' => 'text',
'label' => __('Renew Button Text', 'woonoow'),
'description' => __('Text for the renewal button', 'woonoow'),
'placeholder' => 'Renew Subscription',
'default' => 'Renew Subscription',
],
'allow_customer_cancel' => [
'type' => 'toggle',
'label' => __('Allow Customer Cancellation', 'woonoow'),
'description' => __('Allow customers to cancel their own subscriptions', 'woonoow'),
'default' => true,
],
'allow_customer_pause' => [
'type' => 'toggle',
'label' => __('Allow Customer Pause', 'woonoow'),
'description' => __('Allow customers to pause and resume their subscriptions', 'woonoow'),
'default' => true,
],
'max_pause_count' => [
'type' => 'number',
'label' => __('Maximum Pause Count', 'woonoow'),
'description' => __('Maximum number of times a subscription can be paused (0 = unlimited)', 'woonoow'),
'default' => 3,
'min' => 0,
'max' => 10,
],
'renewal_retry_enabled' => [
'type' => 'toggle',
'label' => __('Retry Failed Renewals', 'woonoow'),
'description' => __('Automatically retry failed renewal payments', 'woonoow'),
'default' => true,
],
'renewal_retry_days' => [
'type' => 'text',
'label' => __('Retry Days', 'woonoow'),
'description' => __('Days after failure to retry payment (comma-separated, e.g., 1,3,5)', 'woonoow'),
'placeholder' => '1,3,5',
'default' => '1,3,5',
],
'expire_after_failed_attempts' => [
'type' => 'number',
'label' => __('Max Failed Attempts', 'woonoow'),
'description' => __('Number of failed payment attempts before subscription expires', 'woonoow'),
'default' => 3,
'min' => 1,
'max' => 10,
],
'send_renewal_reminder' => [
'type' => 'toggle',
'label' => __('Send Renewal Reminders', 'woonoow'),
'description' => __('Send email reminder before subscription renewal', 'woonoow'),
'default' => true,
],
'reminder_days_before' => [
'type' => 'number',
'label' => __('Reminder Days Before', 'woonoow'),
'description' => __('Days before renewal to send reminder email', 'woonoow'),
'default' => 3,
'min' => 1,
'max' => 14,
],
];
return $schemas;
}
}