diff --git a/FEATURE_ROADMAP.md b/FEATURE_ROADMAP.md index da063fe..ba05aea 100644 --- a/FEATURE_ROADMAP.md +++ b/FEATURE_ROADMAP.md @@ -1,6 +1,6 @@ # WooNooW Feature Roadmap - 2025 -**Last Updated**: December 31, 2025 +**Last Updated**: June 1, 2026 **Status**: Active Development This document outlines the comprehensive feature roadmap for WooNooW, building upon existing infrastructure. @@ -301,66 +301,59 @@ class AffiliateTracker { ### Overview Recurring product subscriptions with flexible billing cycles. -### Status: **Planning** 🔵 +### Status: **Shipped** ✅ ### What's Already Built - ✅ Product management - ✅ Order system - ✅ Payment gateways - ✅ Notification system +- ✅ Database tables (`wp_woonoow_subscriptions`, `wp_woonoow_subscription_orders`) — schema below reflects actual shipped columns +- ✅ Per-gateway auto-renew capability table (kill-switchable) +- ✅ Pause/resume/cancel/early-renew customer UI +- ✅ Admin list with bulk actions, search, and per-status filter +- ✅ Renewal cron (`process_renewals`, `check_expirations`, `send_reminders`, `retry_unpaid_renewals`) -### What's Needed +### Schema (as shipped) -#### 1. Database Tables ```sql -wp_woonoow_subscriptions (id, customer_id, product_id, status, billing_period, billing_interval, price, next_payment_date, start_date, end_date, trial_end_date) -wp_woonoow_subscription_orders (id, subscription_id, order_id, payment_status, created_at) +wp_woonoow_subscriptions ( + id, user_id, order_id, product_id, variation_id, status, + billing_period, billing_interval, recurring_amount, + start_date, trial_end_date, next_payment_date, end_date, last_payment_date, + payment_method, payment_meta, cancel_reason, + pause_count, failed_payment_count, reminder_sent_at +) +wp_woonoow_subscription_orders ( + id, subscription_id, order_id, order_type ENUM 'parent'|'renewal'|'switch'|'resubscribe' +) ``` -#### 2. Product Meta -Add subscription options to product: -- Is subscription product (checkbox) -- Billing period (daily, weekly, monthly, yearly) -- Billing interval (e.g., 2 for every 2 months) -- Trial period (days) +Note: the column is `user_id`, not `customer_id` — the original spec used the +WC-style "customer" naming, but WP schema reserves `customer` for the legacy +WP customer user role and the column was renamed before the first migration +shipped. -#### 3. Renewal System -```php -class SubscriptionRenewal { - - // WP-Cron daily job - public function process_renewals() { - $due_subscriptions = $this->get_due_subscriptions(); - - foreach ($due_subscriptions as $subscription) { - // Create renewal order - // Process payment - // Update next payment date - // Send notification - } - } -} -``` - -#### 4. Customer Dashboard +### Customer Dashboard **Route**: `/account/subscriptions` - Active subscriptions list -- Pause/resume subscription +- Pause/resume subscription (capped at `max_pause_count` setting, default 3) - Cancel subscription - Update payment method - View billing history - Change billing cycle -#### 5. Admin UI -**Route**: `/products/subscriptions` -- All subscriptions list -- Filter by status -- View subscription details -- Manual renewal +### Admin UI +**Route**: `/subscriptions` +- All subscriptions list with checkbox + bulk actions (cancel, CSV export) +- Free-text search by id / email / display name +- Per-status filter +- View subscription details (per-gateway auto-renew badge, pause count) +- Renew Now (creates manual order) or Charge Now (forces auto-debit, M2) - Cancel/refund -### Priority: **Low** 🟢 -### Effort: 4-5 weeks +### Priority: ~~Low~~ Shipped ✅ +### Effort: ~~4-5 weeks~~ Shipped --- diff --git a/admin-spa/src/routes/Marketing/Newsletter/Subscribers.tsx b/admin-spa/src/routes/Marketing/Newsletter/Subscribers.tsx index f22fc00..6b487bb 100644 --- a/admin-spa/src/routes/Marketing/Newsletter/Subscribers.tsx +++ b/admin-spa/src/routes/Marketing/Newsletter/Subscribers.tsx @@ -18,6 +18,7 @@ import { import { api } from '@/lib/api'; import { useNavigate } from 'react-router-dom'; import { __ } from '@/lib/i18n'; +import { toast } from 'sonner'; import { Table, TableBody, diff --git a/customer-spa/tsconfig.json b/customer-spa/tsconfig.json index 1907b67..5e8b83d 100644 --- a/customer-spa/tsconfig.json +++ b/customer-spa/tsconfig.json @@ -15,7 +15,7 @@ "types": [], "baseUrl": ".", "paths": { "@/*": ["./src/*"] }, - "ignoreDeprecations": "6.0" + "ignoreDeprecations": "5.0" }, "include": ["src"] } \ No newline at end of file diff --git a/includes/Core/Notifications/EmailRenderer.php b/includes/Core/Notifications/EmailRenderer.php index e84e3d8..94204ae 100644 --- a/includes/Core/Notifications/EmailRenderer.php +++ b/includes/Core/Notifications/EmailRenderer.php @@ -352,6 +352,21 @@ class EmailRenderer 'payment_link' => $data['payment_link'] ?? '', ]; + // O1 — Derive `billing_schedule` (e.g. "Every 3 Months") and + // `payment_method_title` (e.g. "Stripe" rather than the raw + // gateway id "stripe"). The data is on the subscription row but + // isn't pre-formatted. We rebuild both so email templates can + // show the merchant-friendly string without duplicating the + // pluralization + lookup logic. + $sub_variables['billing_schedule'] = self::format_billing_schedule( + isset($sub->billing_period) ? (string) $sub->billing_period : '', + isset($sub->billing_interval) ? (int) $sub->billing_interval : 1 + ); + $sub_variables['payment_method_title'] = self::resolve_payment_method_title( + isset($sub->payment_method) ? (string) $sub->payment_method : '', + $data['order'] ?? null + ); + // Get product name if not already set if (!isset($variables['product_name']) && isset($data['product']) && $data['product'] instanceof \WC_Product) { $sub_variables['product_name'] = $data['product']->get_name(); @@ -381,6 +396,57 @@ class EmailRenderer return apply_filters('woonoow_email_variables', $variables, $event_id, $data); } + /** + * O1 — Format a billing schedule string like "Every 3 Months" from raw + * period and interval columns. Mirrors the controller's + * `enrich_subscription()` math so email templates show the same string + * the customer sees in the SPA. Falls back to the period string itself + * if the period is unknown. + */ + public static function format_billing_schedule($period, $interval) + { + $period_labels = [ + 'day' => __('day', 'woonoow'), + 'week' => __('week', 'woonoow'), + 'month' => __('month', 'woonoow'), + 'year' => __('year', 'woonoow'), + ]; + $interval = max(1, (int) $interval); + $period_label = $period_labels[$period] ?? $period; + if ($interval > 1) { + $period_label .= 's'; + } + return sprintf(__('Every %s%s', 'woonoow'), $interval, $period_label); + } + + /** + * O1 — Resolve a human-friendly payment method title from a stored gateway + * id. Order of preference: + * 1. The order's `payment_method_title` (most accurate; set by gateway + * at checkout — e.g. "PayPal — Visa ending in 1234") + * 2. The registered WC gateway's `get_title()` (e.g. "Stripe") + * 3. The raw id + */ + public static function resolve_payment_method_title($gateway_id, $order = null) + { + if ($order instanceof \WC_Order) { + $title = $order->get_payment_method_title(); + if (!empty($title)) { + return $title; + } + } + if ($gateway_id !== '' && function_exists('WC') && WC()->payment_gateways()) { + $gateways = WC()->payment_gateways()->payment_gateways(); + if (isset($gateways[$gateway_id]) && method_exists($gateways[$gateway_id], 'get_title')) { + $title = $gateways[$gateway_id]->get_title(); + if (!empty($title)) { + return $title; + } + } + } + return $gateway_id; + } + /** * Parse [card] tags and convert to HTML * diff --git a/includes/Core/Notifications/TemplateProvider.bak.php b/includes/Core/Notifications/TemplateProvider.bak.php deleted file mode 100644 index a5339e4..0000000 --- a/includes/Core/Notifications/TemplateProvider.bak.php +++ /dev/null @@ -1,375 +0,0 @@ - $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; - } -} diff --git a/includes/Frontend/TemplateOverride.php b/includes/Frontend/TemplateOverride.php index 1736363..ead4579 100644 --- a/includes/Frontend/TemplateOverride.php +++ b/includes/Frontend/TemplateOverride.php @@ -338,7 +338,7 @@ class TemplateOverride 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); + wp_redirect($build_route('checkout/pay/' . $order_id), 302); exit; }