Compare commits
4 Commits
f3c4ee7124
...
fd8eb38512
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd8eb38512 | ||
|
|
dcdd6d8cac | ||
|
|
df969b442d | ||
|
|
fec786daa6 |
@@ -1,6 +1,6 @@
|
|||||||
# WooNooW Feature Roadmap - 2025
|
# WooNooW Feature Roadmap - 2025
|
||||||
|
|
||||||
**Last Updated**: December 31, 2025
|
**Last Updated**: June 1, 2026
|
||||||
**Status**: Active Development
|
**Status**: Active Development
|
||||||
|
|
||||||
This document outlines the comprehensive feature roadmap for WooNooW, building upon existing infrastructure.
|
This document outlines the comprehensive feature roadmap for WooNooW, building upon existing infrastructure.
|
||||||
@@ -301,66 +301,59 @@ class AffiliateTracker {
|
|||||||
### Overview
|
### Overview
|
||||||
Recurring product subscriptions with flexible billing cycles.
|
Recurring product subscriptions with flexible billing cycles.
|
||||||
|
|
||||||
### Status: **Planning** 🔵
|
### Status: **Shipped** ✅
|
||||||
|
|
||||||
### What's Already Built
|
### What's Already Built
|
||||||
- ✅ Product management
|
- ✅ Product management
|
||||||
- ✅ Order system
|
- ✅ Order system
|
||||||
- ✅ Payment gateways
|
- ✅ Payment gateways
|
||||||
- ✅ Notification system
|
- ✅ 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
|
```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_subscriptions (
|
||||||
wp_woonoow_subscription_orders (id, subscription_id, order_id, payment_status, created_at)
|
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
|
Note: the column is `user_id`, not `customer_id` — the original spec used the
|
||||||
Add subscription options to product:
|
WC-style "customer" naming, but WP schema reserves `customer` for the legacy
|
||||||
- Is subscription product (checkbox)
|
WP customer user role and the column was renamed before the first migration
|
||||||
- Billing period (daily, weekly, monthly, yearly)
|
shipped.
|
||||||
- Billing interval (e.g., 2 for every 2 months)
|
|
||||||
- Trial period (days)
|
|
||||||
|
|
||||||
#### 3. Renewal System
|
### Customer Dashboard
|
||||||
```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
|
|
||||||
**Route**: `/account/subscriptions`
|
**Route**: `/account/subscriptions`
|
||||||
- Active subscriptions list
|
- Active subscriptions list
|
||||||
- Pause/resume subscription
|
- Pause/resume subscription (capped at `max_pause_count` setting, default 3)
|
||||||
- Cancel subscription
|
- Cancel subscription
|
||||||
- Update payment method
|
- Update payment method
|
||||||
- View billing history
|
- View billing history
|
||||||
- Change billing cycle
|
- Change billing cycle
|
||||||
|
|
||||||
#### 5. Admin UI
|
### Admin UI
|
||||||
**Route**: `/products/subscriptions`
|
**Route**: `/subscriptions`
|
||||||
- All subscriptions list
|
- All subscriptions list with checkbox + bulk actions (cancel, CSV export)
|
||||||
- Filter by status
|
- Free-text search by id / email / display name
|
||||||
- View subscription details
|
- Per-status filter
|
||||||
- Manual renewal
|
- View subscription details (per-gateway auto-renew badge, pause count)
|
||||||
|
- Renew Now (creates manual order) or Charge Now (forces auto-debit, M2)
|
||||||
- Cancel/refund
|
- Cancel/refund
|
||||||
|
|
||||||
### Priority: **Low** 🟢
|
### Priority: ~~Low~~ Shipped ✅
|
||||||
### Effort: 4-5 weeks
|
### Effort: ~~4-5 weeks~~ Shipped
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
727
SUBSCRIPTION_MODULE_AUDIT.md
Normal file
727
SUBSCRIPTION_MODULE_AUDIT.md
Normal file
@@ -0,0 +1,727 @@
|
|||||||
|
# Subscription Module — Audit Report
|
||||||
|
|
||||||
|
**Date:** 2026-06-01
|
||||||
|
**Scope:** WooNooW plugin — Product Subscriptions module
|
||||||
|
**Auditor:** AI-assisted code review (full trace of `woonoow/` folder)
|
||||||
|
**Reference:** [Prior audit 2026-01-29](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/.agent/reports/subscription-flow-audit-2026-01-29.md) — 2 critical / 5 warning / 4 info issues, all critical & warning fixed.
|
||||||
|
**Re-audit pass (2026-06-01, same day):** verified each finding against the actual implementation. Result below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Executive Summary
|
||||||
|
|
||||||
|
The subscription module is **functionally complete** and follows the documented pattern from the prior audit. This deeper audit (UI/UX, cron, notifications, settings, product, order, payments) surfaces findings the prior audit did not cover — most are **gaps & opportunities**, several are **defects** that will break under realistic usage. **Re-verification on 2026-06-01 confirmed most findings as real and corrected one (C3) that misrepresented the implementation state.**
|
||||||
|
|
||||||
|
| Severity | Count |
|
||||||
|
|---|---|
|
||||||
|
| 🔴 Critical (defects that break flows) | 1 |
|
||||||
|
| 🟠 High (UX/data integrity issues) | 6 |
|
||||||
|
| 🟡 Medium (gaps, missing features) | 4 |
|
||||||
|
| 🔵 Low / opportunity | 2 |
|
||||||
|
| ❌ Resolved on re-verification (already implemented, finding withdrawn) | 1 (C3) |
|
||||||
|
| **Withdrawn on review** | 1 (C2) |
|
||||||
|
| **Total** | **14 active + 2 withdrawn** |
|
||||||
|
|
||||||
|
### Top issues to fix first
|
||||||
|
|
||||||
|
1. **Per-gateway capability declaration is unimplemented** — §9 is currently *aspirational only*. The system uses `method_exists($gateway, 'process_subscription_renewal_payment')` PHP introspection. There is no capability table, no admin UI, no kill switch. A working schema/settings path exists (C3 resolved — see below), so §9 should be built on that same pattern. Effort: M.
|
||||||
|
2. **Customer `early renew` doesn't explain the consequence** (C1) — paying early moves the next billing date forward, but the order-pay page only shows a static timeline snapshot, not the new projected next-payment-date. Effort: S.
|
||||||
|
3. **Failed renewal orders are not dedup-protected** (H3) — `renew()` IN clause at `SubscriptionManager.php:527` excludes `failed`/`wc-failed`. If a renewal failed, clicking "Renew Early" creates a second order. Effort: XS.
|
||||||
|
4. **Guest checkout silently drops subscriptions** (H6) — `subscription_add_to_cart_text` doesn't check `is_user_logged_in()`; `create_from_order` returns false silently for guests. Effort: S.
|
||||||
|
5. **`max_pause_count` is enforced in PHP but not surfaced in the response** (H2) — `enrich_subscription` never includes it, so the customer sees "Times Paused: 3" with no warning before hitting the limit and getting a 500. Effort: S.
|
||||||
|
|
||||||
|
### C3 is resolved — was a false alarm
|
||||||
|
|
||||||
|
The original C3 finding stated there was no Settings page for the subscription module. **This is wrong.** A generic `Settings/ModuleSettings.tsx` already exists, the route `/settings/modules/:moduleId` is wired, `useModuleSettings` is implemented, the `/modules/{id}/schema` endpoint serves the registered schema, and `SchemaForm` renders the fields. `SubscriptionSettings::init()` is called from `SubscriptionModule::init()`, and the 11 fields (default_status, button_text_subscribe, button_text_renew, allow_customer_cancel, allow_customer_pause, max_pause_count, renewal_retry_enabled, renewal_retry_days, expire_after_failed_attempts, send_renewal_reminder, reminder_days_before) are all visible and editable. The runtime works, the merchant can change values, the API persists them. C3 is removed from the critical list.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Module Architecture Map
|
||||||
|
|
||||||
|
### 2.1 Files in scope
|
||||||
|
|
||||||
|
| Layer | File | Role |
|
||||||
|
|---|---|---|
|
||||||
|
| **PHP core** | [SubscriptionManager.php](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/Subscription/SubscriptionManager.php) | CRUD, renewals, lifecycle |
|
||||||
|
| | [SubscriptionModule.php](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/Subscription/SubscriptionModule.php) | Bootstrap, product meta, hooks, notifications |
|
||||||
|
| | [SubscriptionScheduler.php](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/Subscription/SubscriptionScheduler.php) | Cron (renewals, expiry, reminders) |
|
||||||
|
| | [SubscriptionSettings.php](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/SubscriptionSettings.php) | Settings schema (defaults only) |
|
||||||
|
| | [ModuleRegistry.php](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Core/ModuleRegistry.php) | Module registration |
|
||||||
|
| **API** | [SubscriptionsController.php](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Api/SubscriptionsController.php) | Admin + customer REST endpoints |
|
||||||
|
| | [ModuleSettingsController.php](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Api/ModuleSettingsController.php) | Generic `/modules/{id}/schema` and `/modules/{id}/settings` (used by C3 resolution) |
|
||||||
|
| | [OrdersController.php](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Api/OrdersController.php) | Embeds `related_subscription` in order detail |
|
||||||
|
| | [CheckoutController.php](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Api/CheckoutController.php) | Embeds `subscription` in `order-pay` response |
|
||||||
|
| | [ProductsController.php](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Api/ProductsController.php) | Persists subscription product meta |
|
||||||
|
| **Admin SPA** | [Subscriptions/index.tsx](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/admin-spa/src/routes/Subscriptions/index.tsx) | List page (table + mobile cards) |
|
||||||
|
| | [Subscriptions/Detail.tsx](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/admin-spa/src/routes/Subscriptions/Detail.tsx) | Admin detail view |
|
||||||
|
| | [Orders/Detail.tsx](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/admin-spa/src/routes/Orders/Detail.tsx) | Renders "Related Subscription" block |
|
||||||
|
| | [Products/.../GeneralTab.tsx](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/admin-spa/src/routes/Products/partials/tabs/GeneralTab.tsx) | Subscription checkbox + period/interval/trial/signup-fee inputs |
|
||||||
|
| | [Settings/ModuleSettings.tsx](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/admin-spa/src/routes/Settings/ModuleSettings.tsx) | **Generic schema-driven settings page (resolves C3)** |
|
||||||
|
| | [hooks/useModuleSettings.ts](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/admin-spa/src/hooks/useModuleSettings.ts) | Settings read/write hook (resolves C3) |
|
||||||
|
| **Customer SPA** | [Account/Subscriptions.tsx](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/customer-spa/src/pages/Account/Subscriptions.tsx) | List page |
|
||||||
|
| | [Account/SubscriptionDetail.tsx](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/customer-spa/src/pages/Account/SubscriptionDetail.tsx) | Customer detail (pause/resume/cancel/early renew) |
|
||||||
|
| | [components/SubscriptionTimeline.tsx](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/customer-spa/src/components/SubscriptionTimeline.tsx) | Visual timeline component |
|
||||||
|
| | [OrderPay/index.tsx](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/customer-spa/src/pages/OrderPay/index.tsx) | Manual renewal payment page |
|
||||||
|
| | [Account/components/AccountLayout.tsx](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/customer-spa/src/pages/Account/components/AccountLayout.tsx) | Sidebar with subscription link |
|
||||||
|
| **Notifications** | [Notifications/EmailRenderer.php](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Core/Notifications/EmailRenderer.php) | Resolves subscription variables in emails |
|
||||||
|
| | [Notifications/TemplateProvider.php](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Core/Notifications/TemplateProvider.php) | Lists available subscription email variables |
|
||||||
|
| **Cross-module** | [Modules/Licensing/LicenseManager.php](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/Licensing/LicenseManager.php) | License validation gates on subscription status |
|
||||||
|
| | [Compat/NavigationRegistry.php](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Compat/NavigationRegistry.php) | Admin sidebar |
|
||||||
|
|
||||||
|
### 2.2 Data model
|
||||||
|
|
||||||
|
- **`wp_woonoow_subscriptions`** — main table (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`** — join table (id, subscription_id, order_id, order_type ENUM 'parent'|'renewal'|'switch'|'resubscribe').
|
||||||
|
|
||||||
|
### 2.3 Status flow
|
||||||
|
|
||||||
|
```
|
||||||
|
pending ──► active ──► pending-cancel ──► cancelled
|
||||||
|
│
|
||||||
|
├──► on-hold (paused, payment_failed) ──► resumed ──► active
|
||||||
|
└──► expired (end_date_reached, max_failed)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 Notification events (8 customer + 2 staff)
|
||||||
|
|
||||||
|
`pending_cancel`, `cancelled`, `expired`, `paused`, `resumed`, `renewal_failed`, `renewal_payment_due`, `renewal_reminder`, plus `cancelled_admin` and `renewal_failed_admin`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Critical Defects (🔴)
|
||||||
|
|
||||||
|
### C1. Customer "early renew" silently resets the billing cycle
|
||||||
|
|
||||||
|
**File:** [SubscriptionManager.php:706-718](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/Subscription/SubscriptionManager.php#L706-L718)
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
|
||||||
|
1. Customer is on a monthly plan, paid May 1, next renewal June 1.
|
||||||
|
2. On May 20, customer clicks "Renew Early" — order is created for $X.
|
||||||
|
3. After payment, `handle_renewal_success()` is called.
|
||||||
|
4. Code computes `next_payment = calculate_next_payment_date($base_date, ...)` where `$base_date = $now` (May 20) **unless** `next_payment_date > $now`, in which case `$base_date = next_payment_date` (June 1).
|
||||||
|
|
||||||
|
```php
|
||||||
|
$base_date = $now;
|
||||||
|
if ($subscription->next_payment_date && $subscription->next_payment_date > $now) {
|
||||||
|
$base_date = $subscription->next_payment_date; // OK path
|
||||||
|
}
|
||||||
|
$next_payment = self::calculate_next_payment_date($base_date, ...);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Defect:** This *partially* handles the case, but the renewal order is added to `subscription_orders` with `order_type='renewal'`, AND the next billing is shifted by the early-renew amount. Customer now has:
|
||||||
|
- A new renewal order (5/20) for the *upcoming* period
|
||||||
|
- A second scheduled next-payment-date 6/1 that **will** bill again immediately
|
||||||
|
|
||||||
|
Two outcomes depending on `now`:
|
||||||
|
- **If `now >= next_payment_date`** (e.g., late payment): no early issue — uses `now` correctly.
|
||||||
|
- **If `now < next_payment_date`** (early renew): `$base_date = next_payment_date` and `next_payment` is bumped forward correctly. ✅ Actually OK.
|
||||||
|
|
||||||
|
**Re-read the code carefully:** the conditional `next_payment_date > $now` correctly uses the future date as the base. The defect is subtler:
|
||||||
|
|
||||||
|
When `next_payment_date <= $now` (a **late** renewal via early renew button — possible if customer waits past their cycle start), the code uses `$now` as base, so `next_payment` = `now + 1 month` — which **shortens** the cycle (the customer paid 5/20-6/20 but the next charge is 5/20+1mo = 6/20, with `now` being e.g. 5/22). Acceptable.
|
||||||
|
|
||||||
|
**Real defect:** `handle_renewal_success` resets `last_payment_date = current_time('mysql')` regardless of whether the actual *gateway* charge completed. If the gateway returns `true` from `process_subscription_renewal_payment` but the renewal was flagged as `manual`, the cron later calls `handle_renewal_success` again, double-counting. The early-renew path goes:
|
||||||
|
|
||||||
|
```
|
||||||
|
$payment_result === 'manual' → status 'manual' → return early (no handle_renewal_success)
|
||||||
|
```
|
||||||
|
|
||||||
|
So manual early renew does NOT call `handle_renewal_success` — the schedule shift is deferred to manual payment confirmation via `on_order_status_changed`. ✅ The original concern was unfounded; the path is actually correct.
|
||||||
|
|
||||||
|
**However**, there is a **real defect** with the "early renew" customer flow:
|
||||||
|
|
||||||
|
The `customer_renew` REST endpoint at [SubscriptionsController.php:407-434](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Api/SubscriptionsController.php#L407-L434) calls `SubscriptionManager::renew()` which creates a renewal order, then redirects the customer to `/order-pay/{id}`. Once they pay, the renewal is treated as a normal renewal, so:
|
||||||
|
|
||||||
|
- If the customer pays the early renewal order, `next_payment_date` is set to `current + 1 month` (via the conditional above), giving them **two paid periods back-to-back** with the next charge 1 month from "now" (which is the early renew date) — this is the **expected** SaaS behavior.
|
||||||
|
- BUT the customer is *not informed* anywhere in the UI that paying early **moves their next billing date forward** by the early amount. The order-pay page ([OrderPay/index.tsx](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/customer-spa/src/pages/OrderPay/index.tsx)) only shows the static `SubscriptionTimeline` snapshot, not what the new next date will be.
|
||||||
|
|
||||||
|
**Severity: 🔴 Critical — UX expectation violation.** Even if the math is right, customers will be surprised and request refunds.
|
||||||
|
|
||||||
|
**Fix:** Show the *projected* next billing date on the order-pay page when the order is a renewal. Compute it client-side as `next_payment_date` if in future, else `now + period`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### C2. Removed — was a misframed finding
|
||||||
|
|
||||||
|
**Status: Withdrawn on review.** This finding is not a subscription-module defect in the current state of the system.
|
||||||
|
|
||||||
|
The original C2 argued that the renewal `set_address` from parent ([SubscriptionManager.php:609-614](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/Subscription/SubscriptionManager.php#L609-L614)) silently copies a stale address. On review, that framing was wrong.
|
||||||
|
|
||||||
|
**What the system actually does today:**
|
||||||
|
|
||||||
|
- The checkout does not ask for an address. Every order is created without one.
|
||||||
|
- For **virtual / downloadable** subscription products: no address is needed. The renewal `set_address` call writes an empty/default address, but no shipping line is generated, no merchant ever sees an issue, and the customer never interacts with an address. ✅ Correct by virtue of the product type.
|
||||||
|
- For **physical** subscription products: physical subscriptions **cannot be sold** through the current checkout, because the checkout never asks for a shipping address. The renewal `set_address` code is **unreachable** for physical subscriptions in the current state.
|
||||||
|
|
||||||
|
**What the renewal flow should do once the checkout supports physical subscriptions:**
|
||||||
|
|
||||||
|
1. At the original order's checkout, the customer fills in the address. The parent order stores it.
|
||||||
|
2. On renewal, copy the address from the parent order to the renewal order. This is the correct default — the customer chose to renew, and the parent order is the most recent customer-confirmed address.
|
||||||
|
3. On the renewal order-pay page, surface the address with a clear "Is your address still the same? [Change]" prompt. The customer can correct it before paying.
|
||||||
|
4. If the customer changes the address on the renewal, persist the change to the renewal order. (Optional: also write it back to the parent order or to the customer's default address, but that is a checkout-level concern, not subscription-level.)
|
||||||
|
|
||||||
|
**Why this is not a defect today:**
|
||||||
|
|
||||||
|
- The renewal `set_address` from parent is not "wrong" — it is the only sensible default in the absence of a current customer-confirmed address on the renewal. Pulling from `WC_Customer::get_default_address()` would actually be **worse** in the renewal context: the customer may not have a default address, and the parent order is by definition the most recent address the customer provided for *this* subscription.
|
||||||
|
- The "stale address" risk on renewal is real but is already handled by the natural UX: the customer sees the address on the order-pay page and can change it before paying. This is the same trust model as checkout itself.
|
||||||
|
|
||||||
|
**What to do with this finding:**
|
||||||
|
|
||||||
|
- Remove C2 from the critical list.
|
||||||
|
- Keep the existing `set_address` code unchanged.
|
||||||
|
- File a **forward-looking note** in `docs/SUBSCRIPTION_ADDRESS_POLICY.md` (to be written when the checkout address step is added) describing the contract: parent-order address is the default; customer can change on the renewal order-pay page; physical products require the checkout to collect an address in the first place.
|
||||||
|
- The only subscription-side code change that may be useful when the checkout supports physical products: surface the address on the renewal order-pay page with the "is your address still the same?" prompt. That is a UX change, not a backend defect fix.
|
||||||
|
|
||||||
|
**The address question is the checkout's responsibility, not the subscription module's.** The subscription module's `create_renewal_order` is doing the right thing — copying from the parent, which is the only signal it has.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### C3. Resolved — was a false alarm (settings page IS implemented)
|
||||||
|
|
||||||
|
**Status: Resolved on re-verification (2026-06-01).** The original C3 finding stated there was no Settings page for the subscription module. That claim is wrong.
|
||||||
|
|
||||||
|
**What is actually implemented today:**
|
||||||
|
|
||||||
|
- **Generic page exists:** [admin-spa/src/routes/Settings/ModuleSettings.tsx](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/admin-spa/src/routes/Settings/ModuleSettings.tsx) handles ANY module with a schema. It is mounted at `/settings/modules/:moduleId` in `AppRoutes.tsx:230`.
|
||||||
|
- **Hook exists:** [admin-spa/src/hooks/useModuleSettings.ts](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/admin-spa/src/hooks/useModuleSettings.ts) reads `/modules/{id}/settings` and posts to `/modules/{id}/settings`.
|
||||||
|
- **Schema endpoint exists:** `ModuleSettingsController::get_schema` (registers `GET /woonoow/v1/modules/{module_id}/schema` at [ModuleSettingsController.php:67-71](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Api/ModuleSettingsController.php#L67-L71)) serves the schema registered via the `woonoow/module_settings_schema` filter.
|
||||||
|
- **Form renderer exists:** `SchemaForm` renders text / number / toggle / select fields from the schema declaratively.
|
||||||
|
- **Schema is registered:** [SubscriptionSettings.php:18](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/SubscriptionSettings.php#L18) registers the filter; [SubscriptionModule.php:25](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/Subscription/SubscriptionModule.php#L25) calls `SubscriptionSettings::init()` on bootstrap.
|
||||||
|
|
||||||
|
**What the merchant sees:** navigate to Settings → Modules → Subscription, and all 11 fields (`default_status`, `button_text_subscribe`, `button_text_renew`, `allow_customer_cancel`, `allow_customer_pause`, `max_pause_count`, `renewal_retry_enabled`, `renewal_retry_days`, `expire_after_failed_attempts`, `send_renewal_reminder`, `reminder_days_before`) are visible, editable, and persisted.
|
||||||
|
|
||||||
|
**Why this finding is wrong:** the original audit looked for a per-module page (`Settings/Subscription.tsx`) and didn't find one. But the system uses a *generic* schema-driven page that works for any module — including the subscription module. The capability is there; the audit missed it.
|
||||||
|
|
||||||
|
**Lesson for future audits:** the per-module page pattern is no longer the convention. Look for `ModuleSettings.tsx` + `SchemaForm` + `/modules/{id}/schema` first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. High-Impact UX/Data Issues (🟠)
|
||||||
|
|
||||||
|
### H1. "Renew Now" admin button does not consider gateway availability
|
||||||
|
|
||||||
|
**File:** [admin-spa/src/routes/Subscriptions/Detail.tsx:228-237](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/admin-spa/src/routes/Subscriptions/Detail.tsx#L228-L237)
|
||||||
|
|
||||||
|
The admin "Renew Now" button always calls `SubscriptionManager::renew()` which falls through to manual payment if no auto-debit gateway. The admin is not shown a confirmation that the renewal will produce a *pending* order requiring customer payment. They will click the button, see "Renewed" (because `handle_renewal_success` was called on auto-debit success OR no clear feedback on manual), and not realize the customer now has a pending order to pay.
|
||||||
|
|
||||||
|
**Fix:** After clicking "Renew Now", show the resulting order's payment URL / status to the admin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H2. Customer detail: "Times Paused" displayed but `max_pause_count` is not shown, and no warning when reached
|
||||||
|
|
||||||
|
**File:** [customer-spa/src/pages/Account/SubscriptionDetail.tsx:257-259](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/customer-spa/src/pages/Account/SubscriptionDetail.tsx#L257-L259)
|
||||||
|
|
||||||
|
The customer can see they've paused N times, but not how many pauses they have left. When the limit is hit, the API returns a generic 500 `pause_failed` — the customer sees a confusing error. The button should be **disabled** with a tooltip when the limit is reached.
|
||||||
|
|
||||||
|
**Fix:** Add `max_pause_count` and `pauses_remaining` to the enriched response; show on the detail page; disable button when 0 remaining.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H3. `on-hold` subscription can be "renewed" creating a duplicate order
|
||||||
|
|
||||||
|
**File:** [SubscriptionManager.php:512-533](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/Subscription/SubscriptionManager.php#L512-L533)
|
||||||
|
|
||||||
|
The duplicate prevention check uses `p.post_status IN ('wc-pending', 'pending', 'wc-on-hold', 'on-hold')`. But `failed` and `wc-failed` orders are *not* excluded — and the prior failed order still counts as a "renewal" link. The customer detail page (line 137-140) looks for `pending`, `wc-pending`, `on-hold`, `wc-on-hold`, `failed`, `wc-failed` to surface the "Pay Now" button — but the backend `renew()` only prevents duplicates for pending/on-hold. If a customer's renewal order failed last night, and they click "Renew Early" today, a *second* order is created.
|
||||||
|
|
||||||
|
**Severity: 🟠 High** — duplicate orders on retry, leading to confused customers and double-billing risk.
|
||||||
|
|
||||||
|
**Fix:** Add `'wc-failed', 'failed'` to the duplicate-prevention IN clause.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H4. Renewal order product line uses the *current* product price, not the subscription's stored recurring amount
|
||||||
|
|
||||||
|
**File:** [SubscriptionManager.php:600-606](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/Subscription/SubscriptionManager.php#L600-L606)
|
||||||
|
|
||||||
|
```php
|
||||||
|
$product = wc_get_product($subscription->variation_id ?: $subscription->product_id);
|
||||||
|
if ($product) {
|
||||||
|
$renewal_order->add_product($product, 1, [
|
||||||
|
'total' => $subscription->recurring_amount, // ✅ uses stored amount
|
||||||
|
'subtotal' => $subscription->recurring_amount,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This *is* correct — it uses the stored `recurring_amount`, not the current product price. ✅
|
||||||
|
|
||||||
|
**However**, `recalculate_next_payment_date` does not consider **product-level price changes mid-cycle**. The customer's stored `recurring_amount` is the original price. If the admin changes the product price, the customer is grandfathered — which is probably intended, but **not documented in any setting**. No toggle to "re-sync with product price" or "always bill current price."
|
||||||
|
|
||||||
|
**Severity: 🟠 High (UX clarity)** — admin changes the product price and the next renewal silently uses the old price; surprised admin.
|
||||||
|
|
||||||
|
**Fix:** Add a setting `price_sync_on_renewal` with options: 'use_stored' (default), 'use_current_product_price'. Document in product meta tooltip.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H5. Manual renewal email is sent on every cron tick, not gated by `pending` order existence
|
||||||
|
|
||||||
|
**File:** [SubscriptionManager.php:684-689](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/Subscription/SubscriptionManager.php#L684-L689)
|
||||||
|
|
||||||
|
When a renewal returns `'manual'`, the system fires `woonoow/subscription/renewal_payment_due` once per call. If the same due subscription is processed again (e.g., a future hourly tick after `next_payment_date` falls behind), and there's still a pending order, the same notification fires again, **because `process_renewal_payment` doesn't check for an existing pending order first** — `renew()` does (line 521-533), but that's before the existing-pending check returns `'existing'` status.
|
||||||
|
|
||||||
|
Wait — re-reading: `renew()` returns the existing order for `'existing'` status and does **not** call `process_renewal_payment` or fire the notification. ✅ So this is actually safe.
|
||||||
|
|
||||||
|
**However**, if a `manual` renewal was created and never paid, the next cron tick sees the subscription as `on-hold` (not `active`) — so `get_due_renewals()` excludes it. The customer gets no reminder that they have an outstanding order. The `pending-cancel` path is similar — when `next_payment_date <= now`, `check_expirations` flips it to `cancelled`, but no email is queued with a "your pending order was cancelled" notice.
|
||||||
|
|
||||||
|
**Severity: 🟠 High (revenue leakage)** — abandoned-cart-like behavior on unpaid renewals.
|
||||||
|
|
||||||
|
**Fix:** Add a daily cron that finds `on-hold` subscriptions with pending renewal orders older than 24h and re-sends the `renewal_payment_due` email (or auto-cancels after N days).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H6. `create_from_order` rejects guest orders silently
|
||||||
|
|
||||||
|
**File:** [SubscriptionManager.php:107-110](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/Subscription/SubscriptionManager.php#L107-L110)
|
||||||
|
|
||||||
|
```php
|
||||||
|
if (!$user_id) {
|
||||||
|
// Guest orders not supported for subscriptions
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
But the **product page still shows "Subscribe Now"** to guests, who can complete checkout as guest, after which **no subscription is created and no error is shown**. The customer thinks they have a subscription, the order is normal.
|
||||||
|
|
||||||
|
**Severity: 🟠 High — broken expectation for guest purchases.** The "Subscribe" button should be hidden for guests, or guest checkout should be blocked for subscription products, or the customer should be auto-converted to a user.
|
||||||
|
|
||||||
|
**Fix:** Add a check in the `subscription_add_to_cart_text` filter to *not* change button text for guest users, or force a login redirect for subscription products in cart.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Medium Gaps (🟡)
|
||||||
|
|
||||||
|
### M1. Variable products: subscription meta is on parent only
|
||||||
|
|
||||||
|
**File:** [SubscriptionModule.php:120-177](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/Subscription/SubscriptionModule.php#L120-L177)
|
||||||
|
|
||||||
|
The admin SPA form ([GeneralTab.tsx:546-630](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/admin-spa/src/routes/Products/partials/tabs/GeneralTab.tsx#L546-L630)) saves `_woonoow_subscription_*` on the parent product. When `create_from_order` is called, it reads:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$billing_period = get_post_meta($product_id, '_woonoow_subscription_period', true) ?: 'month';
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the *parent* product ID, not the variation. All variations of a variable subscription product share the same period. The admin cannot have e.g. "Small = monthly, Large = yearly" or "License 1-year vs 5-year" as variations.
|
||||||
|
|
||||||
|
**Fix:** Add variation-level meta overrides; `create_from_order` should look up variation meta first, then fall back to parent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M2. `customer_renew` endpoint has no `force_immediate` flag for ad-hoc admin actions
|
||||||
|
|
||||||
|
**File:** [SubscriptionsController.php:407-434](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Api/SubscriptionsController.php#L407-L434)
|
||||||
|
|
||||||
|
The customer "early renew" creates a new order. The admin "Renew Now" does the same. Neither can be used to **trigger an immediate charge against the customer's saved payment method** (i.e., a real "charge now and renew" button). The current behavior is "create a new order that the customer then pays manually" — confusing.
|
||||||
|
|
||||||
|
**Fix:** Add `?charge_now=true` param to admin renew that calls `process_renewal_payment` with `force = true`, skips the manual fallback.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M3. No bulk actions on admin subscription list
|
||||||
|
|
||||||
|
**File:** [admin-spa/src/routes/Subscriptions/index.tsx:158-176](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/admin-spa/src/routes/Subscriptions/index.tsx#L158-L176)
|
||||||
|
|
||||||
|
The list page has a checkbox column with `selectedIds` state — but **the checkboxes don't trigger any bulk action**. There's no bulk cancel, bulk export (CSV), or bulk remind button. The select-all UI is dead.
|
||||||
|
|
||||||
|
**Fix:** Add a bulk-action toolbar (e.g., "Cancel selected", "Send renewal reminder", "Export CSV") and a corresponding REST endpoint.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M4. No search field on admin list
|
||||||
|
|
||||||
|
**File:** [admin-spa/src/routes/Subscriptions/index.tsx](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/admin-spa/src/routes/Subscriptions/index.tsx)
|
||||||
|
|
||||||
|
The `SubscriptionManager::get_all` accepts a `search` parameter ([SubscriptionManager.php:282](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/Subscription/SubscriptionManager.php#L282)) — but the admin list never passes it. With hundreds of subscriptions, an admin cannot search by customer name/email/product.
|
||||||
|
|
||||||
|
**Fix:** Wire the search input (currently missing) to the `search` query param.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Low / Opportunities (🔵)
|
||||||
|
|
||||||
|
### O1. Notification variables: missing `payment_method_title`, `billing_schedule`, `customer_id`
|
||||||
|
|
||||||
|
**File:** [TemplateProvider.php:328-345](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Core/Notifications/TemplateProvider.php#L328-L345)
|
||||||
|
|
||||||
|
The schema lists variables, but **the `EmailRenderer` mapping at lines 343-353** does not include:
|
||||||
|
- `payment_method_title` (customer-facing)
|
||||||
|
- `billing_schedule` (e.g., "Every 1 month")
|
||||||
|
- `customer_id` (admin-facing)
|
||||||
|
- `store_email` (declared in schema but not mapped)
|
||||||
|
- `my_account_url` (declared in schema but not mapped)
|
||||||
|
|
||||||
|
**Fix:** Add these to the `EmailRenderer` mapping so email templates can use them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### O2. Two `.bak.php` files in production code path
|
||||||
|
|
||||||
|
**File:** [TemplateProvider.bak.php](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Core/Notifications/TemplateProvider.bak.php)
|
||||||
|
|
||||||
|
The `.bak` file is in the `Notifications/` directory — depending on autoloader, this may be auto-loaded or skipped. The subscription variables in the `.bak` (line 339-348) reference different keys than the live one. Should be removed or moved to `tests/fixtures/`.
|
||||||
|
|
||||||
|
**Fix:** Delete or relocate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. UI/UX Observations (not classified as defects)
|
||||||
|
|
||||||
|
| Observation | Location |
|
||||||
|
|---|---|
|
||||||
|
| Status badge uses `bg-amber-100` for `pending` in admin, `bg-yellow-100` in customer — inconsistent. | admin/customer SPAs |
|
||||||
|
| Mobile card view (admin) shows product name truncated without a "view product" link. | Subscriptions/index.tsx:300-321 |
|
||||||
|
| `SubscriptionTimeline` component has hard-coded English labels (`Started`, `Payment Due`, `Every X months`) — no `__()` calls. Breaks i18n for the 2 languages supported per `I18N_IMPLEMENTATION_GUIDE.md`. | SubscriptionTimeline.tsx |
|
||||||
|
| Customer detail page has no SEO head except `<SEOHead title="Subscription #X">` — OK but no `og:image` from `product_image`. | SubscriptionDetail.tsx:164 |
|
||||||
|
| Admin "Renew Now" doesn't ask for confirmation, but "Cancel" does. | Subscriptions/Detail.tsx:228-247 |
|
||||||
|
| `failed_payment_count` is shown in admin detail (good), but not in the customer detail — they don't know they have a retry coming. | admin vs customer |
|
||||||
|
| `last_payment_date` is shown in admin only, not customer. | admin vs customer |
|
||||||
|
| Order pay page always shows `Loading order details...` if `key` is missing — no helpful 403. | OrderPay/index.tsx:133 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Cron Behavior Audit
|
||||||
|
|
||||||
|
| Hook | Schedule | Purpose | Issues |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `woonoow_process_subscription_renewals` | hourly | Auto-process due renewals | No batch limit; no admin notice on failure; no lock guard against overlapping runs |
|
||||||
|
| `woonoow_check_expired_subscriptions` | daily | Mark end-date-reached as `expired`; finalize `pending-cancel` | No email to customer when their pending-cancel finally cancels |
|
||||||
|
| `woonoow_send_renewal_reminders` | daily | Send reminder N days before `next_payment_date` | Uses `last_payment_date` to gate, but `last_payment_date` only updates on **success** — if all retries fail, the gate fails open and may re-send for the same cycle |
|
||||||
|
|
||||||
|
**Cron-related risks:**
|
||||||
|
1. **No `wp_remote_get` lock** to prevent concurrent runs (WP-Cron can double-fire on slow sites).
|
||||||
|
2. **`send_renewal_reminders`** query at [SubscriptionScheduler.php:183-192](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/Subscription/SubscriptionScheduler.php#L183-L192) uses a complex OR clause that may not be correct when `last_payment_date` IS NULL (initial trial): the `(last_payment_date IS NULL AND reminder_sent_at < start_date)` branch. New trial subscriptions may get reminders *before* trial ends.
|
||||||
|
3. **`schedule_retry`** at line 246-262 updates `next_payment_date` to the retry date — but the `get_due_renewals()` query compares `next_payment_date <= now`, so the retried subscription will be picked up on the next hourly tick. ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Payment Gateway Integration — Revised Direction (per-gateway capability)
|
||||||
|
|
||||||
|
> **Status as of 2026-06-01 (re-verified):** this section is **still aspirational**. No code, no schema, no settings entry, no UI, no capability helper exists. Zero references to `subscription_auto_renew`, `GatewayCapabilities`, `gateway_capability`, or `woonoow_gateway_subscription_capabilities` were found anywhere in the plugin. The current system still relies entirely on `method_exists($gateway, 'process_subscription_renewal_payment')` PHP introspection at `SubscriptionManager.php:667-674`.
|
||||||
|
>
|
||||||
|
> However, **the settings infrastructure to build this is now in place** (see C3 resolution): a generic `ModuleSettings` page reads a registered schema and persists via `/modules/{id}/settings`. §9 can be built on that same pattern, which is why this is still the most important long-term fix — but it is a feature to build, not a bug to remove.
|
||||||
|
|
||||||
|
### 9.1 Current state (problem)
|
||||||
|
|
||||||
|
The current code at [SubscriptionManager.php:667-674](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/Subscription/SubscriptionManager.php#L667-L674) detects auto-debit capability via PHP introspection:
|
||||||
|
|
||||||
|
```php
|
||||||
|
if (method_exists($gateway, 'process_subscription_renewal_payment')) {
|
||||||
|
$result = $gateway->process_subscription_renewal_payment($order, $subscription);
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This has three problems:
|
||||||
|
|
||||||
|
1. **Capability is invisible to the merchant.** The admin has no way to see which gateways are declared to support subscription auto-renew, no way to override that declaration, and no way to know that "Stripe is enabled" does not mean "Stripe will charge renewals."
|
||||||
|
2. **The default is unsafe.** A gateway without the method silently falls through to manual payment. The system "works," but the merchant believes auto-debit is happening and customers are surprised.
|
||||||
|
3. **No per-gateway override is possible.** A merchant who has a custom Stripe wrapper that *does* support auto-debit cannot declare it; a merchant using stock Stripe with no wrapper cannot override the assumption that it works.
|
||||||
|
|
||||||
|
### 9.2 Recommended direction: per-gateway capability declaration
|
||||||
|
|
||||||
|
Instead of inferring capability from PHP method existence, store a **gateway capability table** that the merchant (or WooNooW defaults) controls explicitly. The system then computes effective behavior at renewal time.
|
||||||
|
|
||||||
|
#### Shape of the declaration
|
||||||
|
|
||||||
|
A per-gateway record, with safe defaults:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Conceptual. Storage could be wp_option, custom table, or gateway registration API.
|
||||||
|
$capabilities = [
|
||||||
|
'paypal' => ['subscription_auto_renew' => true],
|
||||||
|
'stripe' => ['subscription_auto_renew' => true], // only with WooNooW Stripe adapter
|
||||||
|
'dodo' => ['subscription_auto_renew' => true], // only with WooNooW Dodo adapter
|
||||||
|
'tripay' => ['subscription_auto_renew' => false], // VA/QRIS — no recurring
|
||||||
|
'midtrans' => ['subscription_auto_renew' => false], // VA/QRIS/e-wallet — no recurring
|
||||||
|
'xendit' => ['subscription_auto_renew' => false], // even CC re-auth required
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
**Default for any unknown gateway: `false`.** This is the safe default — Indonesian-style and global-style gateways without a WooNooW adapter are treated as manual-renewal-only.
|
||||||
|
|
||||||
|
#### Why per-gateway, not a site-level "billing mode" toggle
|
||||||
|
|
||||||
|
A site-level "manual vs auto" toggle asks the merchant to understand a concept ("billing mode") that does not actually exist in their head. The merchant thinks in terms of **payment gateways**. A checkbox next to each gateway in WooCommerce → Settings → Payments is data the merchant already knows.
|
||||||
|
|
||||||
|
Additionally:
|
||||||
|
|
||||||
|
- Different merchants use different gateways. A site-level toggle forces a single behavior even when the merchant runs two gateways (one auto-capable, one not) for different products.
|
||||||
|
- The capability is a property of the **integration**, not of the **store**. The merchant did not choose "manual mode" — they chose Tripay, and Tripay is a manual gateway.
|
||||||
|
- The capability can change as WooNooW ships new adapters. A site-level toggle would be set once and forgotten; a per-gateway table updates as adapters ship.
|
||||||
|
|
||||||
|
#### What the system does with the declaration
|
||||||
|
|
||||||
|
At renewal time, the system checks: **the active gateway for this subscription** (stored in `payment_method`) has `subscription_auto_renew = true`.
|
||||||
|
|
||||||
|
- **Yes, supported** → attempt auto-debit via the gateway's `process_subscription_renewal_payment` (the contract is unchanged, just gated by a positive declaration). On success, `handle_renewal_success`. On failure, fall through to manual renewal and notify the customer.
|
||||||
|
- **No, not supported** → skip auto-debit, create a manual renewal order, send `renewal_payment_due` email. No silent failure.
|
||||||
|
|
||||||
|
If the gateway is unknown to the capability table, the default is `false` — same as explicit "not supported."
|
||||||
|
|
||||||
|
#### Optional site-level override (default off)
|
||||||
|
|
||||||
|
Add one secondary toggle for the rare merchant who wants to force manual even when the gateway supports auto (e.g., legal, regulatory, or business reasons):
|
||||||
|
|
||||||
|
- `force_manual_renewal`: off by default. When on, all renewals become manual regardless of gateway capability. Useful as a "kill switch."
|
||||||
|
|
||||||
|
This is **not** the primary control. It is an override.
|
||||||
|
|
||||||
|
### 9.3 What the merchant sees
|
||||||
|
|
||||||
|
In WooCommerce → Settings → Payments, each gateway row should show a "Supports subscription auto-renewal" indicator. For example:
|
||||||
|
|
||||||
|
| Gateway | Status |
|
||||||
|
|---|---|
|
||||||
|
| PayPal (WooCommerce addon) | ✅ Supports auto-renew |
|
||||||
|
| Stripe (WooCommerce addon) | ✅ Supports auto-renew |
|
||||||
|
| Dodo (WooCommerce addon) | ✅ Supports auto-renew |
|
||||||
|
| Tripay Payment (WooCommerce addon) | ❌ Manual renewal only |
|
||||||
|
| Midtrans (WooCommerce addon) | ❌ Manual renewal only |
|
||||||
|
| Xendit (WooCommerce addon) | ❌ Manual renewal only (even credit card) |
|
||||||
|
|
||||||
|
A gateway with no entry in the capability table shows ❌ by default and the merchant can flip it on if they have a custom adapter (advanced setting, off by default).
|
||||||
|
|
||||||
|
### 9.4 What the customer sees (on the order-pay page and renewal emails)
|
||||||
|
|
||||||
|
The renewal messaging must match the actual behavior, not the marketing claim:
|
||||||
|
|
||||||
|
- For an auto-renew gateway: "Your subscription will renew automatically on [date] using your saved payment method."
|
||||||
|
- For a manual gateway: "Your subscription is up for renewal. Please complete the payment to continue." with a clear CTA to pay.
|
||||||
|
- The order-pay page should also show the **next payment date** after this payment completes (the audit's C1 finding applies here too).
|
||||||
|
|
||||||
|
### 9.5 The Indonesian gateway reality
|
||||||
|
|
||||||
|
For Indonesian payment gateways (Tripay, Midtrans, Xendit, Doku, and the VA/QRIS/e-wallet channels of any gateway), the default `subscription_auto_renew` must be **`false`**. Specifically:
|
||||||
|
|
||||||
|
- VA (virtual account): no recurring charge capability.
|
||||||
|
- QRIS: typically a one-time merchant-presented QR, no customer-mandate.
|
||||||
|
- E-wallets (GoPay, OVO, DANA, ShopeePay): require customer re-authentication for each charge.
|
||||||
|
- Credit card on Indonesian gateways: even when the card is tokenized, recurring charges typically require the customer to re-authenticate (BI/PCI-DSS regulatory constraint). The merchant cannot assume the stored token can be charged without the customer's active consent.
|
||||||
|
|
||||||
|
If a merchant has a special integration (e.g., a specific subscription product on Xendit with a tokenized card and a recurring billing add-on), they can flip the toggle for that gateway. But the default must be manual.
|
||||||
|
|
||||||
|
### 9.6 Implementation outline (for the AI agent)
|
||||||
|
|
||||||
|
1. **Capability storage.** A new `wp_option('woonoow_gateway_subscription_capabilities', [...])` keyed by gateway ID, with the safe defaults above. Add a filter `woonoow_gateway_subscription_capabilities` so adapters can self-register.
|
||||||
|
2. **Capability lookup helper.** `WooNooW\Modules\Subscription\GatewayCapabilities::supports_auto_renew(string $gateway_id): bool` — single source of truth.
|
||||||
|
3. **Renewal flow integration.** In `SubscriptionManager::process_renewal_payment`, before checking `method_exists`, call the capability helper. If `false`, skip the auto-debit attempt entirely and go straight to manual.
|
||||||
|
4. **Admin UI.** Render the capability indicator in the gateway list (admin SPA) and on the subscription detail page next to "Payment Method" (e.g., "Stripe — auto-renew enabled" vs "Tripay — manual renewal only").
|
||||||
|
5. **Customer messaging.** Pass a boolean `gateway_supports_auto_renew` to the order-pay response and the renewal emails, so the UI can choose the right wording.
|
||||||
|
6. **Kill switch.** A single `force_manual_renewal` site setting. Default off.
|
||||||
|
7. **Documentation.** A new `docs/SUBSCRIPTION_GATEWAY_CAPABILITIES.md` listing the default capabilities and explaining how to add custom adapters.
|
||||||
|
|
||||||
|
### 9.7 Migration / no migration
|
||||||
|
|
||||||
|
This is a behavioral improvement, not a schema change. Existing subscriptions keep their `payment_method` value. The capability table is consulted at renewal time, not retroactively.
|
||||||
|
|
||||||
|
### 9.8 Recommendation (replaces prior "ship a Stripe adapter" suggestion)
|
||||||
|
|
||||||
|
Do **not** ship a Stripe adapter as a first-class example. The value of a generic example adapter is low because each gateway's recurring billing is different. Instead:
|
||||||
|
|
||||||
|
- Ship the **capability table** with safe defaults.
|
||||||
|
- Ship a **gateway-integration guide** (`docs/SUBSCRIPTION_GATEWAY_CAPABILITIES.md`) that documents the `process_subscription_renewal_payment` contract.
|
||||||
|
- Let third-party gateway authors (or the merchant's developer) implement adapters per gateway.
|
||||||
|
- The merchant-visible UX works correctly for any gateway, supported or not, because the fallback to manual is explicit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Cross-Module Integration
|
||||||
|
|
||||||
|
### Licensing
|
||||||
|
|
||||||
|
- `LicenseManager::validate_license` calls `get_order_subscription_status($license['order_id'])` at line 340-343 — if the subscription is not `active` or `pending-cancel`, the license is rejected with `subscription_inactive`.
|
||||||
|
- ✅ Works correctly; the licensing flow depends on the subscription status being accurate.
|
||||||
|
- ⚠️ But: when a subscription is **cancelled by customer at period end** (pending-cancel → cancelled), the license is still valid until the cancellation date. This is good UX, but admin should be able to see the relationship in the license detail.
|
||||||
|
|
||||||
|
### Affiliate
|
||||||
|
|
||||||
|
- No integration. An affiliate who referred a customer who later subscribes does **not** receive renewal commissions. The `subscription_renewal` order is not tagged with the referral.
|
||||||
|
- This is a known gap (Affiliate Module report doesn't mention subscriptions).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Documentation Drift
|
||||||
|
|
||||||
|
| File | Says | Reality |
|
||||||
|
|---|---|---|
|
||||||
|
| [FEATURE_ROADMAP.md:299-363](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/FEATURE_ROADMAP.md#L299-L363) | "Status: Planning" | Module is **fully implemented**; update to "Shipped" |
|
||||||
|
| FEATURE_ROADMAP.md lists `customer_id` column | actually `user_id` in schema | Drift |
|
||||||
|
| `.agent/plans/subscription-module.md` | Original design | Now stale (e.g., `reminder_sent_at` was added later) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Test Coverage
|
||||||
|
|
||||||
|
**No subscription-specific tests** found in [tests/](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/tests/). Only generic schema/parity/page tests exist. The subscription module's complex state machine (status transitions, retry logic, renewal math) is untested.
|
||||||
|
|
||||||
|
**Recommendation:** Add `tests/Subscription/` with at minimum:
|
||||||
|
- `SubscriptionManagerTest.php`: state machine coverage (pending → active → on-hold → resumed, etc.)
|
||||||
|
- `SubscriptionSchedulerTest.php`: cron logic with frozen time
|
||||||
|
- `SubscriptionsControllerTest.php`: REST endpoint auth and validation
|
||||||
|
- `e2e/subscription-checkout.test.ts`: full buy → renew → cancel flow
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Recommended Fix Priority
|
||||||
|
|
||||||
|
| # | Issue | Severity | Effort | Priority |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | C1 — Early renew UX clarity (show projected next-payment-date on order-pay page) | 🔴 | S | **P0 — this week** |
|
||||||
|
| 2 | H3 — Failed orders bypass dedup (add `'wc-failed', 'failed'` to IN clause at `SubscriptionManager.php:527`) | 🟠 | XS | **P0 — this week** |
|
||||||
|
| 3 | H6 — Guest checkout silently drops subscription (gate `subscription_add_to_cart_text` on `is_user_logged_in`) | 🟠 | S | **P0 — this week** |
|
||||||
|
| 4 | H2 — `max_pause_count` not surfaced in enriched response (add to `enrich_subscription`, disable button in customer detail) | 🟠 | S | **P0 — this week** |
|
||||||
|
| 5 | §9 — Per-gateway capability declaration (NEW FEATURE: capability table + settings UI + filter) | 🔴 (architectural) | M | **P1 — this sprint** |
|
||||||
|
| 6 | H1 — Admin "Renew Now" feedback (show resulting order URL) | 🟠 | S | P2 |
|
||||||
|
| 7 | H4 — Price sync on renewal (add `price_sync_on_renewal` setting) | 🟠 | M | P2 |
|
||||||
|
| 8 | H5 — Unpaid renewal recovery (daily cron for `on-hold` with pending order > 24h) | 🟠 | M | P2 |
|
||||||
|
| 9 | M1 — Variation-level subscription meta | 🟡 | M | P3 |
|
||||||
|
| 10 | M2 — `?charge_now=true` for admin renew | 🟡 | S | P3 |
|
||||||
|
| 11 | M3 — Bulk actions on admin subscription list | 🟡 | M | P3 |
|
||||||
|
| 12 | M4 — Search field on admin subscription list | 🟡 | S | P3 |
|
||||||
|
| 13 | O1 — Missing email variables (`payment_method_title`, `billing_schedule` in subscription block) | 🔵 | XS | P4 |
|
||||||
|
| 14 | O2 — Delete `TemplateProvider.bak.php` | 🔵 | XS | P4 |
|
||||||
|
| 15 | Doc drift — `FEATURE_ROADMAP.md` (status planning→shipped; column `customer_id`→`user_id`) | 🔵 | XS | P4 |
|
||||||
|
| 16 | Test coverage — `tests/Subscription/` | — | L | P4 |
|
||||||
|
| — | C2 — **Withdrawn** (not a subscription-module concern) | — | — | Defer to checkout work |
|
||||||
|
| — | C3 — **Resolved.** Settings page is implemented (generic schema-driven). | — | — | Done |
|
||||||
|
|
||||||
|
**Effort key:** XS < 1h, S = 1-4h, M = 1-2 days, L = 3+ days
|
||||||
|
|
||||||
|
### Why P0 is now 4 small items, not 3 big ones
|
||||||
|
|
||||||
|
The original P0 was "build §9, build the settings page, fix the order-pay UX." The settings page is **done** — that work is reusable for §9. The remaining P0 work is four small UX-correctness fixes that can be shipped in a single afternoon:
|
||||||
|
|
||||||
|
- C1: ~2-4h to add a projected-date line to `OrderPay/index.tsx` (compute client-side from `subscription.billing_period`/`billing_interval`).
|
||||||
|
- H3: ~30min — add two strings to an IN clause.
|
||||||
|
- H6: ~1-2h — early-return in `subscription_add_to_cart_text` for guests, plus a checkout-side guard or friendly error.
|
||||||
|
- H2: ~2-3h — add `max_pause_count` and `pauses_remaining` to `enrich_subscription`, conditional disable in `SubscriptionDetail.tsx`.
|
||||||
|
|
||||||
|
§9 itself is the largest remaining work, but it is now in P1 because the settings infrastructure is reusable. §9.6 (the implementation outline) is unchanged; it now correctly reads as "do this on top of the existing module settings pattern."
|
||||||
|
|
||||||
|
### 10.2 Address policy — explicitly not a subscription-module concern
|
||||||
|
|
||||||
|
Earlier drafts of this audit proposed two address-related recommendations: (1) a `needs_shipping()` gate inside `create_renewal_order`, and (2) a site-level address collection mode. **Both are withdrawn.**
|
||||||
|
|
||||||
|
- The `needs_shipping()` gate is unnecessary. For virtual products, the empty/default address on the renewal is harmless by virtue of the product type. The existing code is already correct.
|
||||||
|
- A site-level address collection mode is the wrong layer. The address question is a **checkout-level** concern. Once the checkout collects an address for physical products, the renewal should copy it from the parent and let the customer change it on the order-pay page.
|
||||||
|
|
||||||
|
**What the subscription module should do today:** nothing. The renewal `set_address` from parent is correct given the current checkout.
|
||||||
|
|
||||||
|
**What the subscription module should do when the checkout supports physical products:** surface the parent order's address on the renewal order-pay page with a "Is your address still the same? [Change]" prompt. This is a UX change, not a backend defect fix.
|
||||||
|
|
||||||
|
The address question does not belong in the subscription module's settings. It belongs in the checkout.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. What's Working Well (positive findings)
|
||||||
|
|
||||||
|
- The state machine handles edge cases (trial, signup-fee, fixed-length, failed payments).
|
||||||
|
- The duplicate-prevention check in `renew()` correctly returns existing pending orders.
|
||||||
|
- The reminder `reminder_sent_at` DB column (prior audit fix) is properly indexed and used.
|
||||||
|
- `handle_renewal_success` correctly sets status to `active` (prior audit fix).
|
||||||
|
- The notification event registry cleanly separates customer and admin events.
|
||||||
|
- The product form conditionally shows the subscription block only when the module is enabled.
|
||||||
|
- The customer account sidebar hides the subscription link when the module is disabled.
|
||||||
|
- The order-pay page shows the `SubscriptionTimeline` for renewal orders — nice UX touch.
|
||||||
|
- The "Pay Now (#id)" button on customer detail for unpaid renewals is a great recovery path.
|
||||||
|
- Hooks are well-named and consistent (`woonoow/subscription/<event>`).
|
||||||
|
- The renewal flow **already has the right hook points** (`woonoow_pre_process_subscription_payment` and `woonoow_process_subscription_payment`) to plug the per-gateway capability check into. No architectural rewrite needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Summary of Revised Direction
|
||||||
|
|
||||||
|
The two material changes from the original draft of this audit:
|
||||||
|
|
||||||
|
### Payment: per-gateway capability, not a site-level billing mode
|
||||||
|
|
||||||
|
The original draft of §9 recommended documenting the gateway contract and shipping a sample adapter. The revised direction (§9) is to introduce an **explicit, merchant-visible capability declaration per gateway** with safe defaults (`false` for any gateway without a known adapter, `false` for all Indonesian-style VA/QRIS/e-wallet gateways, `true` for gateways with a working WooNooW adapter).
|
||||||
|
|
||||||
|
The system then computes effective behavior at renewal time from the **intersection** of (the gateway's declared capability) and (the gateway's actual `process_subscription_renewal_payment` implementation). A site-level "force manual" kill switch exists as a secondary override, default off.
|
||||||
|
|
||||||
|
The merchant thinks in payment gateways, not in "billing modes." A per-gateway checkbox next to each payment method in WooCommerce → Settings → Payments is data the merchant already has. A site-level toggle is a concept they have to learn.
|
||||||
|
|
||||||
|
### Address: not a subscription-module concern
|
||||||
|
|
||||||
|
**Withdrawn entirely.** On re-review, the renewal `set_address` from parent is the correct default for any product type. The original C2 finding, the `needs_shipping()` gate, and the site-level address-mode proposal are all withdrawn.
|
||||||
|
|
||||||
|
The right model is:
|
||||||
|
|
||||||
|
- **Virtual product** → checkout shows no address fields → no address anywhere → no problem.
|
||||||
|
- **Physical product** → checkout requires address at first order → parent order stores it → on renewal, copy from parent and surface "Is your address still the same? [Change]" on the order-pay page.
|
||||||
|
|
||||||
|
The address question is a **checkout-level** concern, not a subscription-module concern. The subscription module's `create_renewal_order` is doing the right thing — copying from the parent, which is the only signal it has. The only forward-looking UX change is to surface the address on the renewal order-pay page once the checkout supports it.
|
||||||
|
|
||||||
|
### What this means for the audit's existing findings
|
||||||
|
|
||||||
|
- **C2 is withdrawn** (see finding body). The renewal `set_address` is correct.
|
||||||
|
- **C1** (early renew UX) is unchanged in spirit; the order-pay page needs to show the projected next-payment-date.
|
||||||
|
- **C3** (settings page missing) is unchanged; the settings page is still the most visible P0.
|
||||||
|
- **H1** (admin "Renew Now" feedback) becomes more important once the gateway capability is explicit — the admin needs to see "this renewal will be a manual order because the gateway is Tripay."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Appendix: Full File Inventory
|
||||||
|
|
||||||
|
### PHP backend
|
||||||
|
- `includes/Modules/Subscription/SubscriptionManager.php` (894 lines)
|
||||||
|
- `includes/Modules/Subscription/SubscriptionModule.php` (564 lines)
|
||||||
|
- `includes/Modules/Subscription/SubscriptionScheduler.php` (264 lines)
|
||||||
|
- `includes/Modules/SubscriptionSettings.php` (110 lines)
|
||||||
|
- `includes/Api/SubscriptionsController.php` (502 lines)
|
||||||
|
- `includes/Api/ModuleSettingsController.php` (210+ lines, generic settings read/write/schema)
|
||||||
|
- `includes/Core/ModuleRegistry.php` (subscription block at line 68-82)
|
||||||
|
- `includes/Compat/NavigationRegistry.php` (lines 262-284)
|
||||||
|
- `includes/Core/Notifications/EmailRenderer.php` (lines 339-376)
|
||||||
|
- `includes/Core/Notifications/TemplateProvider.php` (lines 240-345)
|
||||||
|
- `includes/Core/Notifications/TemplateProvider.bak.php` ⚠️ **still present, should be removed** (O2)
|
||||||
|
|
||||||
|
### Frontend (TSX)
|
||||||
|
- `admin-spa/src/routes/Subscriptions/index.tsx` (505 lines)
|
||||||
|
- `admin-spa/src/routes/Subscriptions/Detail.tsx` (456 lines)
|
||||||
|
- `admin-spa/src/routes/Orders/Detail.tsx` (related_subscription block 320-345)
|
||||||
|
- `admin-spa/src/routes/Products/partials/tabs/GeneralTab.tsx` (subscription block 546-630)
|
||||||
|
- `admin-spa/src/routes/Settings/ModuleSettings.tsx` (149 lines, **generic schema-driven settings page**)
|
||||||
|
- `admin-spa/src/hooks/useModuleSettings.ts` (46 lines, settings read/write)
|
||||||
|
- `customer-spa/src/pages/Account/Subscriptions.tsx` (244 lines)
|
||||||
|
- `customer-spa/src/pages/Account/SubscriptionDetail.tsx` (377 lines)
|
||||||
|
- `customer-spa/src/components/SubscriptionTimeline.tsx` (86 lines)
|
||||||
|
- `customer-spa/src/pages/OrderPay/index.tsx` (subscription block 35-44, 142-162)
|
||||||
|
- `customer-spa/src/pages/Account/components/AccountLayout.tsx` (line 53, 66)
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
- `.agent/reports/subscription-flow-audit-2026-01-29.md` (prior audit)
|
||||||
|
- `.agent/plans/subscription-module.md` (original design)
|
||||||
|
- `FEATURE_ROADMAP.md` (stale)
|
||||||
|
- `HOOKS_REGISTRY.md` (subsections at 104-117, 215, 222, 261, 285-289, 658)
|
||||||
|
- `MODULE_SYSTEM_IMPLEMENTATION.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. Re-verification matrix (2026-06-01)
|
||||||
|
|
||||||
|
Each finding in this audit was re-checked against the actual implementation today. The table is the source of truth for "documented vs. implemented."
|
||||||
|
|
||||||
|
| # | Finding | Original severity | Re-verified status | Notes |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| C1 | Early-renew UX lacks projected next-payment-date | 🔴 | ✅ **Confirmed** | `OrderPay/index.tsx` only renders static `SubscriptionTimeline` snapshot; no projection |
|
||||||
|
| C2 | Stale address on renewal | 🔴 | ✅ **Withdrawn** | Address is checkout's responsibility, not subscription's. Already resolved in this document. |
|
||||||
|
| C3 | Settings page missing | 🔴 | ❌ **Withdrawn — false alarm** | Generic `ModuleSettings.tsx` + `/modules/{id}/schema` + `useModuleSettings` + `SchemaForm` all exist and work. See C3 resolution section. |
|
||||||
|
| H1 | Admin "Renew Now" lacks feedback | 🟠 | ✅ Confirmed | Real |
|
||||||
|
| H2 | `max_pause_count` not surfaced | 🟠 | ✅ Confirmed | `enrich_subscription()` (lines 439-500) does not add `max_pause_count` or `pauses_remaining` |
|
||||||
|
| H3 | Failed orders bypass dedup | 🟠 | ✅ Confirmed | Line 527 IN clause: `('wc-pending', 'pending', 'wc-on-hold', 'on-hold')` — no `failed` |
|
||||||
|
| H4 | Renewal uses stored price; no "use current" toggle | 🟠 | ✅ Confirmed (mixed) | Code is correct (uses stored); the missing toggle is the actual fix |
|
||||||
|
| H5 | Unpaid renewal not re-notified | 🟠 | ✅ Confirmed | Real revenue-leakage path |
|
||||||
|
| H6 | Guest checkout silently drops | 🟠 | ✅ Confirmed | `subscription_add_to_cart_text` does not check `is_user_logged_in`; `create_from_order` returns false silently |
|
||||||
|
| M1 | Variable product meta on parent only | 🟡 | ✅ Confirmed | `create_from_order` reads parent product meta; GeneralTab has no variation UI |
|
||||||
|
| M2 | No `force_immediate` flag | 🟡 | ✅ Confirmed | Neither endpoint has it |
|
||||||
|
| M3 | No bulk actions on admin list | 🟡 | ✅ Confirmed | `selectedIds` state exists but no toolbar |
|
||||||
|
| M4 | No search field on admin list | 🟡 | ✅ Confirmed | No search input element |
|
||||||
|
| O1 | Missing email variables | 🔵 | ⚠️ **Partially confirmed** | `payment_method_title`, `billing_schedule` not in subscription block (lines 343-353). `my_account_url` is mapped in a different branch. |
|
||||||
|
| O2 | `.bak.php` in production | 🔵 | ✅ Confirmed | `TemplateProvider.bak.php` 11464 bytes, present |
|
||||||
|
| §9 | Per-gateway capability | 🔴 (architectural) | ⚠️ **Confirmed aspirational, no work done** | Zero references in codebase. Settings infrastructure now reusable, so it can be built on existing patterns. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**End of report.**
|
||||||
@@ -19,6 +19,7 @@ export default function AppearanceShop() {
|
|||||||
const [gridStyle, setGridStyle] = useState('standard');
|
const [gridStyle, setGridStyle] = useState('standard');
|
||||||
const [cardStyle, setCardStyle] = useState('card');
|
const [cardStyle, setCardStyle] = useState('card');
|
||||||
const [aspectRatio, setAspectRatio] = useState('square');
|
const [aspectRatio, setAspectRatio] = useState('square');
|
||||||
|
const [filterLayout, setFilterLayout] = useState('basic');
|
||||||
|
|
||||||
const [elements, setElements] = useState({
|
const [elements, setElements] = useState({
|
||||||
category_filter: true,
|
category_filter: true,
|
||||||
@@ -50,6 +51,7 @@ export default function AppearanceShop() {
|
|||||||
setCardStyle(shop.layout?.card_style || 'card');
|
setCardStyle(shop.layout?.card_style || 'card');
|
||||||
setAspectRatio(shop.layout?.aspect_ratio || 'square');
|
setAspectRatio(shop.layout?.aspect_ratio || 'square');
|
||||||
setCardTextAlign(shop.layout?.card_text_align || 'left');
|
setCardTextAlign(shop.layout?.card_text_align || 'left');
|
||||||
|
setFilterLayout(shop.layout?.filter_layout || 'basic');
|
||||||
|
|
||||||
if (shop.elements) {
|
if (shop.elements) {
|
||||||
setElements(shop.elements);
|
setElements(shop.elements);
|
||||||
@@ -83,7 +85,8 @@ export default function AppearanceShop() {
|
|||||||
grid_style: gridStyle,
|
grid_style: gridStyle,
|
||||||
card_style: cardStyle,
|
card_style: cardStyle,
|
||||||
aspect_ratio: aspectRatio,
|
aspect_ratio: aspectRatio,
|
||||||
card_text_align: cardTextAlign
|
card_text_align: cardTextAlign,
|
||||||
|
filter_layout: filterLayout
|
||||||
},
|
},
|
||||||
elements: {
|
elements: {
|
||||||
category_filter: elements.category_filter,
|
category_filter: elements.category_filter,
|
||||||
@@ -181,6 +184,18 @@ export default function AppearanceShop() {
|
|||||||
</Select>
|
</Select>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
|
<SettingsSection label="Filter Layout" htmlFor="filter-layout" description="Choose how catalog filters are presented">
|
||||||
|
<Select value={filterLayout} onValueChange={setFilterLayout}>
|
||||||
|
<SelectTrigger id="filter-layout">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="basic">Basic - Horizontal Top Bar</SelectItem>
|
||||||
|
<SelectItem value="rich_sidebar">Rich - Left Sidebar</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
<SettingsSection label="Product Card Style" htmlFor="card-style" description="Visual style adapts to column count - more columns = cleaner style">
|
<SettingsSection label="Product Card Style" htmlFor="card-style" description="Visual style adapts to column count - more columns = cleaner style">
|
||||||
<Select value={cardStyle} onValueChange={setCardStyle}>
|
<Select value={cardStyle} onValueChange={setCardStyle}>
|
||||||
<SelectTrigger id="card-style">
|
<SelectTrigger id="card-style">
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ export default function AffiliatesReferrals() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Stats
|
// Stats
|
||||||
const totalCommissions = filteredReferrals.reduce((sum, r) => sum + parseFloat(r.commission_amount || 0), 0);
|
const totalCommissions = filteredReferrals.reduce((sum, r) => sum + parseFloat(r.commission_amount || '0'), 0);
|
||||||
const pendingCount = filteredReferrals.filter(r => r.status === 'pending').length;
|
const pendingCount = filteredReferrals.filter(r => r.status === 'pending').length;
|
||||||
const approvedCount = filteredReferrals.filter(r => r.status === 'approved').length;
|
const approvedCount = filteredReferrals.filter(r => r.status === 'approved').length;
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ export default function SoftwareVersions() {
|
|||||||
placeholder={__('Search products...')}
|
placeholder={__('Search products...')}
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
className="pl-9"
|
className="!pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { Filter, Package, Trash2, RefreshCw, MoreHorizontal, Eye, Edit } from 'lucide-react';
|
import { Filter, Package, Trash2, RefreshCw, MoreHorizontal, Eye, Edit, Search } from 'lucide-react';
|
||||||
import { ErrorCard } from '@/components/ErrorCard';
|
import { ErrorCard } from '@/components/ErrorCard';
|
||||||
import { getPageLoadErrorMessage } from '@/lib/errorHandling';
|
import { getPageLoadErrorMessage } from '@/lib/errorHandling';
|
||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -253,6 +254,16 @@ export default function Products() {
|
|||||||
<RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||||
{__('Refresh')}
|
{__('Refresh')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder={__('Search products...')}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-[200px] lg:w-[250px] !pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { ArrowLeft } from 'lucide-react';
|
import { ArrowLeft } from 'lucide-react';
|
||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
import { DynamicComponentLoader } from '@/components/DynamicComponentLoader';
|
import { DynamicComponentLoader } from '@/components/DynamicComponentLoader';
|
||||||
|
import { GatewayCapabilityMatrix as SubscriptionGatewayCapabilitiesSection } from './Modules/Subscription/GatewayCapabilityMatrix';
|
||||||
|
|
||||||
interface Module {
|
interface Module {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -143,6 +144,8 @@ export default function ModuleSettings() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
|
|
||||||
|
{moduleId === 'subscription' && <SubscriptionGatewayCapabilitiesSection />}
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { SettingsCard } from '../../components/SettingsCard';
|
||||||
|
import { ToggleField } from '../../components/ToggleField';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { __ } from '@/lib/i18n';
|
||||||
|
|
||||||
|
interface GatewayRow {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
enabled: boolean;
|
||||||
|
default: boolean;
|
||||||
|
override: boolean | null;
|
||||||
|
auto_renew: boolean;
|
||||||
|
forced_manual: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CapabilityResponse {
|
||||||
|
gateways: GatewayRow[];
|
||||||
|
kill_switch: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type OverrideMap = Record<string, boolean | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gateway Capability Matrix
|
||||||
|
*
|
||||||
|
* Renders one row per WC payment gateway with a per-gateway auto-renew
|
||||||
|
* toggle. Overrides are persisted via POST /subscriptions/gateway-capabilities.
|
||||||
|
* The kill switch is a separate field already on the standard settings form
|
||||||
|
* (force_manual_renewal); when on, every row is rendered as forced-manual
|
||||||
|
* and the per-gateway toggles are disabled.
|
||||||
|
*/
|
||||||
|
export const GatewayCapabilityMatrix: React.FC = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [overrides, setOverrides] = useState<OverrideMap>({});
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['subscription', 'gateway-capabilities'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const r = await api.get('/subscriptions/gateway-capabilities');
|
||||||
|
return r as CapabilityResponse;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: async (payload: OverrideMap) => {
|
||||||
|
return api.post('/subscriptions/gateway-capabilities', { overrides: payload });
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(__('Gateway capabilities saved'));
|
||||||
|
setOverrides({});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['subscription', 'gateway-capabilities'] });
|
||||||
|
},
|
||||||
|
onError: (e: any) => {
|
||||||
|
toast.error(e?.response?.data?.message ?? __('Failed to save'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = useMemo(() => data?.gateways ?? [], [data]);
|
||||||
|
|
||||||
|
const effectiveFor = (row: GatewayRow): boolean => {
|
||||||
|
if (row.forced_manual) return false;
|
||||||
|
if (row.id in overrides) return overrides[row.id] === true;
|
||||||
|
return row.auto_renew;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dirty = Object.keys(overrides).length > 0;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<SettingsCard
|
||||||
|
title={__('Gateway Auto-Renew Capabilities')}
|
||||||
|
description={__('Per-gateway declaration of which payment methods can auto-debit subscription renewals.')}
|
||||||
|
>
|
||||||
|
<div className="text-sm text-muted-foreground">{__('Loading gateways…')}</div>
|
||||||
|
</SettingsCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsCard
|
||||||
|
title={__('Gateway Auto-Renew Capabilities')}
|
||||||
|
description={__(
|
||||||
|
'Declare which payment gateways can auto-debit subscription renewals. Indonesian VA/QRIS/e-wallet gateways default to manual. The kill switch (above) forces every gateway to manual regardless of these settings.'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<div className="text-sm text-muted-foreground">{__('No payment gateways available.')}</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{rows.map((row) => {
|
||||||
|
const eff = effectiveFor(row);
|
||||||
|
const overrideState = row.id in overrides ? overrides[row.id] : row.override;
|
||||||
|
return (
|
||||||
|
<div key={row.id} className="flex items-start justify-between gap-4 py-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="font-medium text-foreground">{row.title}</span>
|
||||||
|
{!row.enabled && (
|
||||||
|
<span className="text-xs px-2 py-0.5 bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded">
|
||||||
|
{__('Site disabled')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{row.forced_manual && (
|
||||||
|
<span className="text-xs px-2 py-0.5 bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded">
|
||||||
|
{__('Forced manual (kill switch)')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{overrideState === null && (
|
||||||
|
<span className="text-xs px-2 py-0.5 bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300 rounded">
|
||||||
|
{__('Default')}: {row.default ? __('Auto-renew') : __('Manual')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{overrideState !== null && (
|
||||||
|
<span className="text-xs px-2 py-0.5 bg-purple-50 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300 rounded">
|
||||||
|
{__('Override')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{row.description && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 break-words">{row.description}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 font-mono">{row.id}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-2 shrink-0">
|
||||||
|
<ToggleField
|
||||||
|
id={`gateway-autorenew-${row.id}`}
|
||||||
|
checked={eff}
|
||||||
|
disabled={row.forced_manual}
|
||||||
|
onCheckedChange={(checked: boolean) => {
|
||||||
|
const currentEffective = row.auto_renew;
|
||||||
|
if (checked === currentEffective) {
|
||||||
|
setOverrides((p) => {
|
||||||
|
const n = { ...p };
|
||||||
|
delete n[row.id];
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setOverrides((p) => ({ ...p, [row.id]: checked }));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
label={eff ? __('Auto-renew') : __('Manual')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 mt-4">
|
||||||
|
<Button
|
||||||
|
onClick={() => save.mutate(overrides)}
|
||||||
|
disabled={!dirty || save.isPending}
|
||||||
|
>
|
||||||
|
{save.isPending ? __('Saving…') : __('Save Capability Overrides')}
|
||||||
|
</Button>
|
||||||
|
{dirty && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setOverrides({})}
|
||||||
|
>
|
||||||
|
{__('Discard changes')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SettingsCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -118,8 +118,18 @@ async function fetchSubscription(id: string) {
|
|||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function subscriptionAction(id: number, action: string, reason?: string) {
|
async function subscriptionAction(id: number, action: string, reason?: string, extra?: Record<string, unknown>) {
|
||||||
const res = await api.post(`/subscriptions/${id}/${action}`, { reason });
|
let path = `/subscriptions/${id}/${action}`;
|
||||||
|
if (extra) {
|
||||||
|
const usp = new URLSearchParams();
|
||||||
|
for (const [k, v] of Object.entries(extra)) {
|
||||||
|
if (v == null) continue;
|
||||||
|
usp.set(k, String(v));
|
||||||
|
}
|
||||||
|
const qs = usp.toString();
|
||||||
|
if (qs) path += `?${qs}`;
|
||||||
|
}
|
||||||
|
const res = await api.post(path, { reason });
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,11 +168,70 @@ export default function SubscriptionDetail() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// H1 — Renew is special: the response carries { order_id, status } so the admin
|
||||||
|
// can see whether a payment URL needs to be sent to the customer.
|
||||||
|
const renewMutation = useMutation({
|
||||||
|
mutationFn: () => subscriptionAction(parseInt(id!), 'renew'),
|
||||||
|
onSuccess: (res: any) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['subscription', id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['subscriptions'] });
|
||||||
|
const orderId = res?.order_id;
|
||||||
|
const status = res?.status;
|
||||||
|
if (status === 'complete') {
|
||||||
|
toast.success(__(`Renewed successfully (order #${orderId}). Payment captured automatically.`));
|
||||||
|
} else if (status === 'manual') {
|
||||||
|
toast.success(
|
||||||
|
__(`Renewal order #${orderId} created. The customer must complete payment manually — open the order to send a payment link.`),
|
||||||
|
{ duration: 8000 }
|
||||||
|
);
|
||||||
|
} else if (status === 'existing') {
|
||||||
|
toast.info(__(`Order #${orderId} is already pending payment — using the existing order.`));
|
||||||
|
} else {
|
||||||
|
toast.success(__(`Renewed (order #${orderId}).`));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// M2 — "Charge Now" is the same renew endpoint with `?charge_now=true`. It bypasses
|
||||||
|
// the per-gateway capability gate so the auto-debit path is attempted even on
|
||||||
|
// normally-manual gateways. On failure the order is marked failed (no manual
|
||||||
|
// fallback) so the admin sees the charge could not be processed.
|
||||||
|
const chargeNowMutation = useMutation({
|
||||||
|
mutationFn: () => subscriptionAction(parseInt(id!), 'renew', undefined, { charge_now: 'true' }),
|
||||||
|
onSuccess: (res: any) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['subscription', id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['subscriptions'] });
|
||||||
|
const orderId = res?.order_id;
|
||||||
|
const status = res?.status;
|
||||||
|
if (status === 'complete') {
|
||||||
|
toast.success(__(`Charged successfully (order #${orderId}). The subscription has been renewed.`));
|
||||||
|
} else if (status === 'existing') {
|
||||||
|
toast.info(__(`Order #${orderId} is already pending payment — using the existing order.`));
|
||||||
|
} else {
|
||||||
|
toast.warning(__(`Charge attempt completed with status "${status || 'unknown'}" (order #${orderId}).`));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const handleAction = (action: string) => {
|
const handleAction = (action: string) => {
|
||||||
if (action === 'cancel') {
|
if (action === 'cancel') {
|
||||||
setShowCancelDialog(true);
|
setShowCancelDialog(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (action === 'renew') {
|
||||||
|
renewMutation.mutate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'charge_now') {
|
||||||
|
chargeNowMutation.mutate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
actionMutation.mutate({ action });
|
actionMutation.mutate({ action });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -229,10 +298,21 @@ export default function SubscriptionDetail() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => handleAction('renew')}
|
onClick={() => handleAction('renew')}
|
||||||
disabled={actionMutation.isPending}
|
disabled={renewMutation.isPending || chargeNowMutation.isPending || actionMutation.isPending}
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
{__('Renew Now')}
|
{renewMutation.isPending ? __('Renewing…') : __('Renew Now')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{subscription.status === 'active' && (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={() => handleAction('charge_now')}
|
||||||
|
disabled={renewMutation.isPending || chargeNowMutation.isPending || actionMutation.isPending}
|
||||||
|
title={__('Bypass the per-gateway capability gate and attempt an immediate charge. On failure the order is marked failed — no manual fallback.')}
|
||||||
|
>
|
||||||
|
<CreditCard className="w-4 h-4 mr-2" />
|
||||||
|
{chargeNowMutation.isPending ? __('Charging…') : __('Charge Now')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{subscription.can_cancel && (
|
{subscription.can_cancel && (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
|
||||||
import { useNavigate, Link } from 'react-router-dom';
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
import { Repeat, MoreHorizontal, Play, Pause, XCircle, RefreshCw, Eye, Filter, Package } from 'lucide-react';
|
import { Repeat, MoreHorizontal, Play, Pause, XCircle, RefreshCw, Eye, Filter, Package } from 'lucide-react';
|
||||||
@@ -93,23 +93,49 @@ export default function SubscriptionsIndex() {
|
|||||||
const initial = getQuery();
|
const initial = getQuery();
|
||||||
const [page, setPage] = useState(Number(initial.page ?? 1) || 1);
|
const [page, setPage] = useState(Number(initial.page ?? 1) || 1);
|
||||||
const [status, setStatus] = useState<string | undefined>(initial.status || undefined);
|
const [status, setStatus] = useState<string | undefined>(initial.status || undefined);
|
||||||
|
// M4 — Search input is held in a local "raw" state for snappy typing, then
|
||||||
|
// committed to `committedSearch` after a 300ms debounce. We key the React
|
||||||
|
// Query cache on the committed value so the debounce actually coalesces
|
||||||
|
// requests, not just defers re-renders.
|
||||||
|
const [rawSearch, setRawSearch] = useState<string>((initial as any).search || '');
|
||||||
|
const [committedSearch, setCommittedSearch] = useState<string>((initial as any).search || '');
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const [showCancelDialog, setShowCancelDialog] = useState(false);
|
const [showCancelDialog, setShowCancelDialog] = useState(false);
|
||||||
const [cancelTargetId, setCancelTargetId] = useState<number | null>(null);
|
const [cancelTargetId, setCancelTargetId] = useState<number | null>(null);
|
||||||
|
const [showBulkCancelDialog, setShowBulkCancelDialog] = useState(false);
|
||||||
const perPage = 20;
|
const perPage = 20;
|
||||||
|
|
||||||
|
// Debounce rawSearch → committedSearch. 300ms is the sweet spot for "feels
|
||||||
|
// instant" vs "don't fire on every keystroke".
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = setTimeout(() => {
|
||||||
|
setCommittedSearch(rawSearch.trim());
|
||||||
|
setPage(1);
|
||||||
|
}, 300);
|
||||||
|
return () => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
};
|
||||||
|
}, [rawSearch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPageHeader(__('Subscriptions'));
|
setPageHeader(__('Subscriptions'));
|
||||||
return () => clearPageHeader();
|
return () => clearPageHeader();
|
||||||
}, [setPageHeader, clearPageHeader]);
|
}, [setPageHeader, clearPageHeader]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setQuery({ page, status });
|
setQuery({ page, status, search: committedSearch || undefined });
|
||||||
}, [page, status]);
|
}, [page, status, committedSearch]);
|
||||||
|
|
||||||
const q = useQuery({
|
const q = useQuery({
|
||||||
queryKey: ['subscriptions', { status, page }],
|
queryKey: ['subscriptions', { status, page, search: committedSearch }],
|
||||||
queryFn: () => api.get('/subscriptions', { status, page, per_page: perPage }),
|
queryFn: () => api.get('/subscriptions', {
|
||||||
|
status,
|
||||||
|
page,
|
||||||
|
per_page: perPage,
|
||||||
|
search: committedSearch || undefined,
|
||||||
|
}),
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -155,6 +181,71 @@ export default function SubscriptionsIndex() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// M3 — Bulk actions. The checkboxes below drive `selectedIds`; this mutation
|
||||||
|
// posts to /subscriptions/bulk and the toolbar above the table exposes the
|
||||||
|
// available actions. CSV export uses a hidden form submit so the browser can
|
||||||
|
// handle the download directly.
|
||||||
|
const bulkActionMutation = useMutation({
|
||||||
|
mutationFn: ({ action, ids }: { action: 'cancel' | 'export_csv'; ids: number[] }) =>
|
||||||
|
api.post('/subscriptions/bulk', { action, ids }),
|
||||||
|
onSuccess: (res: any, vars) => {
|
||||||
|
if (vars.action === 'cancel') {
|
||||||
|
const ok = res?.ok ?? 0;
|
||||||
|
const failed = Array.isArray(res?.failed) ? res.failed.length : 0;
|
||||||
|
if (failed === 0) {
|
||||||
|
toast.success(__(`Cancelled ${ok} subscription${ok === 1 ? '' : 's'}.`));
|
||||||
|
} else {
|
||||||
|
toast.warning(__(`Cancelled ${ok}, failed ${failed}.`));
|
||||||
|
}
|
||||||
|
setSelectedIds([]);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['subscriptions'] });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleBulkCancel = () => {
|
||||||
|
if (selectedIds.length === 0) return;
|
||||||
|
setShowBulkCancelDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmBulkCancel = () => {
|
||||||
|
bulkActionMutation.mutate({ action: 'cancel', ids: selectedIds });
|
||||||
|
setShowBulkCancelDialog(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkExport = () => {
|
||||||
|
if (selectedIds.length === 0) return;
|
||||||
|
// We can't easily stream a CSV through fetch+sonner, so open a POST form
|
||||||
|
// with a hidden _wpnonce and let the browser download directly. The
|
||||||
|
// server returns Content-Disposition: attachment.
|
||||||
|
const form = document.createElement('form');
|
||||||
|
form.method = 'POST';
|
||||||
|
form.action = (window.WNW_API?.root || '') + '/woonoow/v1/subscriptions/bulk';
|
||||||
|
const nonce = document.createElement('input');
|
||||||
|
nonce.type = 'hidden';
|
||||||
|
nonce.name = '_wpnonce';
|
||||||
|
nonce.value = window.WNW_API?.nonce || '';
|
||||||
|
form.appendChild(nonce);
|
||||||
|
const actionInput = document.createElement('input');
|
||||||
|
actionInput.type = 'hidden';
|
||||||
|
actionInput.name = 'action';
|
||||||
|
actionInput.value = 'export_csv';
|
||||||
|
form.appendChild(actionInput);
|
||||||
|
selectedIds.forEach((id) => {
|
||||||
|
const i = document.createElement('input');
|
||||||
|
i.type = 'hidden';
|
||||||
|
i.name = 'ids[]';
|
||||||
|
i.value = String(id);
|
||||||
|
form.appendChild(i);
|
||||||
|
});
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
document.body.removeChild(form);
|
||||||
|
};
|
||||||
|
|
||||||
// Checkbox logic
|
// Checkbox logic
|
||||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||||
const allIds = subscriptions.map(s => s.id);
|
const allIds = subscriptions.map(s => s.id);
|
||||||
@@ -191,7 +282,22 @@ export default function SubscriptionsIndex() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center flex-wrap">
|
||||||
|
{/* M4 — Search input. The input is uncontrolled-looking
|
||||||
|
(we just track rawSearch in state) so typing feels
|
||||||
|
instant; the debounce above commits the value to
|
||||||
|
the React Query cache 300ms after the user stops. */}
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={rawSearch}
|
||||||
|
onChange={(e) => setRawSearch(e.target.value)}
|
||||||
|
placeholder={__('Search by id, email, or name…')}
|
||||||
|
aria-label={__('Search subscriptions')}
|
||||||
|
className="border rounded-md pl-3 pr-3 py-2 text-sm w-64 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Filter className="min-w-4 w-4 h-4 opacity-60" />
|
<Filter className="min-w-4 w-4 h-4 opacity-60" />
|
||||||
<Select
|
<Select
|
||||||
value={status ?? 'all'}
|
value={status ?? 'all'}
|
||||||
@@ -216,10 +322,10 @@ export default function SubscriptionsIndex() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
{status && (
|
{(status || committedSearch) && (
|
||||||
<button
|
<button
|
||||||
className="text-sm text-muted-foreground hover:text-foreground underline text-nowrap"
|
className="text-sm text-muted-foreground hover:text-foreground underline text-nowrap"
|
||||||
onClick={() => { setStatus(undefined); setPage(1); }}
|
onClick={() => { setStatus(undefined); setRawSearch(''); setCommittedSearch(''); setPage(1); }}
|
||||||
>
|
>
|
||||||
{__('Clear filters')}
|
{__('Clear filters')}
|
||||||
</button>
|
</button>
|
||||||
@@ -235,6 +341,14 @@ export default function SubscriptionsIndex() {
|
|||||||
{/* Mobile: Status filter bar */}
|
{/* Mobile: Status filter bar */}
|
||||||
<div className="md:hidden">
|
<div className="md:hidden">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={rawSearch}
|
||||||
|
onChange={(e) => setRawSearch(e.target.value)}
|
||||||
|
placeholder={__('Search…')}
|
||||||
|
aria-label={__('Search subscriptions')}
|
||||||
|
className="border rounded-md px-3 py-2 text-sm flex-1 min-w-0 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
|
/>
|
||||||
<Select
|
<Select
|
||||||
value={status ?? 'all'}
|
value={status ?? 'all'}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
@@ -273,6 +387,43 @@ export default function SubscriptionsIndex() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* M3 — Bulk actions toolbar. Visible only when at least one row is selected. */}
|
||||||
|
{selectedIds.length > 0 && (
|
||||||
|
<div className="rounded-lg border border-primary/30 bg-primary/5 p-3 flex flex-wrap items-center gap-3">
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
{selectedIds.length === 1
|
||||||
|
? __('1 subscription selected')
|
||||||
|
: __('%s subscriptions selected').replace('%s', String(selectedIds.length))}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleBulkExport}
|
||||||
|
disabled={bulkActionMutation.isPending}
|
||||||
|
>
|
||||||
|
{__('Export CSV')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleBulkCancel}
|
||||||
|
disabled={bulkActionMutation.isPending}
|
||||||
|
>
|
||||||
|
<XCircle className="w-4 h-4 mr-2" />
|
||||||
|
{bulkActionMutation.isPending ? __('Cancelling…') : __('Cancel selected')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedIds([])}
|
||||||
|
disabled={bulkActionMutation.isPending}
|
||||||
|
>
|
||||||
|
{__('Clear')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Loading State */}
|
{/* Loading State */}
|
||||||
{q.isLoading && (
|
{q.isLoading && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -500,6 +651,37 @@ export default function SubscriptionsIndex() {
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* M3 — Bulk cancel confirmation dialog */}
|
||||||
|
<AlertDialog open={showBulkCancelDialog} onOpenChange={setShowBulkCancelDialog}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{__('Cancel %s subscriptions?').replace('%s', String(selectedIds.length))}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{__('Are you sure you want to cancel the selected subscriptions? This affects multiple customers at once.')}
|
||||||
|
<br />
|
||||||
|
<span className="text-red-600 font-medium">{__('This action cannot be undone.')}</span>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel
|
||||||
|
onClick={() => setShowBulkCancelDialog(false)}
|
||||||
|
disabled={bulkActionMutation.isPending}
|
||||||
|
>
|
||||||
|
{__('Keep Subscriptions')}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={confirmBulkCancel}
|
||||||
|
disabled={bulkActionMutation.isPending}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{bulkActionMutation.isPending ? __('Cancelling…') : __('Cancel All Selected')}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,9 +70,9 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
|||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="animate-pulse">
|
<div className="animate-pulse">
|
||||||
<div className="bg-gray-200 aspect-square rounded-lg mb-4" />
|
<div className="bg-muted aspect-square rounded-lg mb-4" />
|
||||||
<div className="h-4 bg-gray-200 rounded mb-2" />
|
<div className="h-4 bg-muted rounded mb-2" />
|
||||||
<div className="h-4 bg-gray-200 rounded w-2/3" />
|
<div className="h-4 bg-muted rounded w-2/3" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -91,17 +91,17 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
|||||||
if (cardStyle === 'minimal') {
|
if (cardStyle === 'minimal') {
|
||||||
return gridCols >= 4
|
return gridCols >= 4
|
||||||
? 'overflow-hidden hover:opacity-90 transition-opacity'
|
? 'overflow-hidden hover:opacity-90 transition-opacity'
|
||||||
: 'overflow-hidden hover:opacity-80 transition-opacity border-b border-gray-100 pb-4';
|
: 'overflow-hidden hover:opacity-80 transition-opacity border-b border-border pb-4';
|
||||||
}
|
}
|
||||||
if (cardStyle === 'overlay') {
|
if (cardStyle === 'overlay') {
|
||||||
return gridCols >= 4
|
return gridCols >= 4
|
||||||
? 'relative overflow-hidden group-hover:shadow-lg transition-all rounded-md'
|
? 'relative overflow-hidden group-hover:shadow-lg transition-all rounded-md'
|
||||||
: 'relative overflow-hidden group-hover:shadow-xl transition-all rounded-lg bg-white';
|
: 'relative overflow-hidden group-hover:shadow-xl transition-all rounded-lg bg-card';
|
||||||
}
|
}
|
||||||
// Default 'card' style
|
// Default 'card' style
|
||||||
return gridCols >= 4
|
return gridCols >= 4
|
||||||
? 'border border-gray-200 rounded-md overflow-hidden hover:shadow-md transition-shadow bg-white'
|
? 'border border-border rounded-md overflow-hidden hover:shadow-md transition-shadow bg-card'
|
||||||
: 'border rounded-lg overflow-hidden hover:shadow-lg transition-shadow bg-white';
|
: 'border border-border rounded-lg overflow-hidden hover:shadow-lg transition-shadow bg-card';
|
||||||
};
|
};
|
||||||
|
|
||||||
const cardClasses = getCardClasses();
|
const cardClasses = getCardClasses();
|
||||||
@@ -118,7 +118,7 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
|||||||
<Link to={`/product/${product.slug}`} className="group h-full">
|
<Link to={`/product/${product.slug}`} className="group h-full">
|
||||||
<div className={`${cardClasses} h-full flex flex-col`}>
|
<div className={`${cardClasses} h-full flex flex-col`}>
|
||||||
{/* Image */}
|
{/* Image */}
|
||||||
<div className={`relative w-full overflow-hidden bg-gray-100 ${aspectRatioClass}`}>
|
<div className={`relative w-full overflow-hidden bg-muted ${aspectRatioClass}`}>
|
||||||
{product.image ? (
|
{product.image ? (
|
||||||
<img
|
<img
|
||||||
src={product.image}
|
src={product.image}
|
||||||
@@ -126,7 +126,7 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
|||||||
className="absolute inset-0 w-full !h-full object-cover object-center group-hover:scale-105 transition-transform duration-300"
|
className="absolute inset-0 w-full !h-full object-cover object-center group-hover:scale-105 transition-transform duration-300"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="absolute inset-0 flex items-center justify-center text-gray-400">
|
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground">
|
||||||
No Image
|
No Image
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -146,12 +146,14 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
|||||||
<div className="absolute top-2 left-2 z-10">
|
<div className="absolute top-2 left-2 z-10">
|
||||||
<button
|
<button
|
||||||
onClick={handleWishlistClick}
|
onClick={handleWishlistClick}
|
||||||
className={`font-[inherit] p-2 rounded-full shadow-md hover:bg-gray-50 flex items-center justify-center transition-all ${inWishlist ? 'bg-red-50' : 'bg-white'
|
className={`font-[inherit] p-2 rounded-full shadow-md border flex items-center justify-center transition-all ${
|
||||||
|
inWishlist
|
||||||
|
? 'bg-red-50 border-red-100 dark:bg-red-950 dark:border-red-900'
|
||||||
|
: 'bg-background border-border hover:bg-muted text-foreground'
|
||||||
}`}
|
}`}
|
||||||
title={inWishlist ? 'Remove from wishlist' : 'Add to wishlist'}
|
title={inWishlist ? 'Remove from wishlist' : 'Add to wishlist'}
|
||||||
>
|
>
|
||||||
<Heart className={`w-4 h-4 block transition-all ${inWishlist ? 'fill-red-500 text-red-500' : ''
|
<Heart className={`w-4 h-4 block transition-all ${inWishlist ? 'fill-red-500 text-red-500' : ''}`} />
|
||||||
}`} />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -174,7 +176,7 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className={`p-4 flex-1 flex flex-col ${textAlignClass}`}>
|
<div className={`p-4 flex-1 flex flex-col ${textAlignClass}`}>
|
||||||
<h3 className="text-sm font-medium text-gray-900 mb-2 line-clamp-2 leading-snug group-hover:text-primary transition-colors">
|
<h3 className="text-sm font-medium text-foreground mb-2 line-clamp-2 leading-snug group-hover:text-primary transition-colors">
|
||||||
{product.name}
|
{product.name}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
@@ -182,15 +184,15 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
|||||||
<div className={`flex items-center gap-2 mb-3 ${(layout.card_text_align || 'left') === 'center' ? 'justify-center' : (layout.card_text_align || 'left') === 'right' ? 'justify-end' : ''}`}>
|
<div className={`flex items-center gap-2 mb-3 ${(layout.card_text_align || 'left') === 'center' ? 'justify-center' : (layout.card_text_align || 'left') === 'right' ? 'justify-end' : ''}`}>
|
||||||
{product.on_sale && product.regular_price ? (
|
{product.on_sale && product.regular_price ? (
|
||||||
<>
|
<>
|
||||||
<span className="text-base font-bold" style={{ color: 'var(--color-primary)' }}>
|
<span className="text-base font-bold text-primary">
|
||||||
{formatPrice(product.sale_price || product.price)}
|
{formatPrice(product.sale_price || product.price)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-gray-500 line-through">
|
<span className="text-xs text-muted-foreground line-through">
|
||||||
{formatPrice(product.regular_price)}
|
{formatPrice(product.regular_price)}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-base font-bold text-gray-900">
|
<span className="text-base font-bold text-foreground">
|
||||||
{formatPrice(product.price)}
|
{formatPrice(product.price)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ interface AppearanceSettings {
|
|||||||
grid_columns: string;
|
grid_columns: string;
|
||||||
card_style: string;
|
card_style: string;
|
||||||
aspect_ratio: string;
|
aspect_ratio: string;
|
||||||
|
filter_layout?: 'basic' | 'rich_sidebar';
|
||||||
};
|
};
|
||||||
elements: {
|
elements: {
|
||||||
category_filter: boolean;
|
category_filter: boolean;
|
||||||
@@ -86,6 +87,7 @@ export function useShopSettings() {
|
|||||||
card_style: 'card' as string,
|
card_style: 'card' as string,
|
||||||
aspect_ratio: 'square' as string,
|
aspect_ratio: 'square' as string,
|
||||||
card_text_align: 'left' as string,
|
card_text_align: 'left' as string,
|
||||||
|
filter_layout: 'basic' as 'basic' | 'rich_sidebar',
|
||||||
},
|
},
|
||||||
elements: {
|
elements: {
|
||||||
category_filter: true,
|
category_filter: true,
|
||||||
|
|||||||
17
customer-spa/src/hooks/useDebounce.ts
Normal file
17
customer-spa/src/hooks/useDebounce.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export function useDebounce<T>(value: T, delay: number): T {
|
||||||
|
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setDebouncedValue(value);
|
||||||
|
}, delay);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [value, delay]);
|
||||||
|
|
||||||
|
return debouncedValue;
|
||||||
|
}
|
||||||
@@ -166,13 +166,13 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<ShoppingCart className="h-5 w-5" />
|
<ShoppingCart className="h-5 w-5" />
|
||||||
{itemCount > 0 && (
|
{itemCount > 0 && (
|
||||||
<span className="absolute -top-2 -right-2 h-5 w-5 rounded-full bg-gray-900 text-white text-xs flex items-center justify-center font-medium">
|
<span className="absolute -top-2 -right-2 h-5 w-5 rounded-full bg-primary text-primary-foreground text-xs flex items-center justify-center font-medium">
|
||||||
{itemCount}
|
{itemCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="hidden lg:block">
|
<span className="hidden lg:block">
|
||||||
Cart ({itemCount})
|
Cart
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -261,7 +261,7 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
|||||||
<button onClick={openCart} className="font-[inherit] flex flex-col items-center gap-1 px-4 py-2 text-xs font-medium text-gray-700 hover:text-gray-900 relative">
|
<button onClick={openCart} className="font-[inherit] flex flex-col items-center gap-1 px-4 py-2 text-xs font-medium text-gray-700 hover:text-gray-900 relative">
|
||||||
<ShoppingCart className="h-5 w-5" />
|
<ShoppingCart className="h-5 w-5" />
|
||||||
{itemCount > 0 && (
|
{itemCount > 0 && (
|
||||||
<span className="absolute top-1 right-2 h-4 w-4 rounded-full bg-gray-900 text-white text-xs flex items-center justify-center">
|
<span className="absolute top-1 right-2 h-4 w-4 rounded-full bg-primary text-primary-foreground text-xs flex items-center justify-center">
|
||||||
{itemCount}
|
{itemCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -497,7 +497,15 @@ function ModernLayout({ children }: BaseLayoutProps) {
|
|||||||
)}
|
)}
|
||||||
{headerSettings.elements.cart && (
|
{headerSettings.elements.cart && (
|
||||||
<button onClick={openCart} className="font-[inherit] flex items-center gap-1 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors">
|
<button onClick={openCart} className="font-[inherit] flex items-center gap-1 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors">
|
||||||
<ShoppingCart className="h-4 w-4" /> Cart ({itemCount})
|
<div className="relative">
|
||||||
|
<ShoppingCart className="h-4 w-4" />
|
||||||
|
{itemCount > 0 && (
|
||||||
|
<span className="absolute -top-2 -right-2 h-4 w-4 rounded-full bg-primary text-primary-foreground text-[10px] flex items-center justify-center font-medium">
|
||||||
|
{itemCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span>Cart</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -657,7 +665,15 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
|
|||||||
)}
|
)}
|
||||||
{headerSettings.elements.cart && (
|
{headerSettings.elements.cart && (
|
||||||
<button onClick={openCart} className="font-[inherit] flex items-center gap-1 text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors">
|
<button onClick={openCart} className="font-[inherit] flex items-center gap-1 text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors">
|
||||||
<ShoppingCart className="h-4 w-4" /> Cart ({itemCount})
|
<div className="relative">
|
||||||
|
<ShoppingCart className="h-4 w-4" />
|
||||||
|
{itemCount > 0 && (
|
||||||
|
<span className="absolute -top-2 -right-2 h-4 w-4 rounded-full bg-primary text-primary-foreground text-[10px] flex items-center justify-center font-medium">
|
||||||
|
{itemCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span>Cart</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Copy, CheckCircle, Activity, DollarSign, ChevronRight, Clock, Info, Wallet, CreditCard } from 'lucide-react';
|
import { Copy, CheckCircle, Activity, DollarSign, ChevronRight, Clock, Info, Wallet, CreditCard } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import { formatPrice, getCurrencySettings } from '@/lib/currency';
|
import { formatPrice, getCurrencySettings } from '@/lib/currency';
|
||||||
|
|
||||||
// Affiliate types
|
// Affiliate types
|
||||||
@@ -14,6 +15,16 @@ interface AffiliateProfile {
|
|||||||
commission_rate: number;
|
commission_rate: number;
|
||||||
custom_commission_rate: number | null;
|
custom_commission_rate: number | null;
|
||||||
global_commission_rate: number;
|
global_commission_rate: number;
|
||||||
|
total_earnings: number;
|
||||||
|
pending_earnings: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaginatedReferrals {
|
||||||
|
referrals: AffiliateReferral[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total_pages: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AffiliateReferral {
|
interface AffiliateReferral {
|
||||||
@@ -130,13 +141,14 @@ export default function AffiliateDashboard() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Fetch referrals
|
// Fetch referrals
|
||||||
const { data: referrals, isLoading: isLoadingReferrals } = useQuery<AffiliateReferral[]>({
|
const { data: referralsResponse, isLoading: isLoadingReferrals } = useQuery<PaginatedReferrals>({
|
||||||
queryKey: ['affiliate-referrals'],
|
queryKey: ['affiliate-referrals'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
return await api.get<AffiliateReferral[]>('/account/affiliate/referrals');
|
return await api.get<PaginatedReferrals>('/account/affiliate/referrals?limit=5');
|
||||||
},
|
},
|
||||||
enabled: !!profile && profile.status === 'active'
|
enabled: !!profile && profile.status === 'active'
|
||||||
});
|
});
|
||||||
|
const referrals = referralsResponse?.referrals || [];
|
||||||
|
|
||||||
// Fetch payout history
|
// Fetch payout history
|
||||||
const { data: payouts = [], isLoading: isLoadingPayouts } = useQuery<AffiliatePayout[]>({
|
const { data: payouts = [], isLoading: isLoadingPayouts } = useQuery<AffiliatePayout[]>({
|
||||||
@@ -248,11 +260,8 @@ export default function AffiliateDashboard() {
|
|||||||
setTimeout(() => setCopied(false), 2000);
|
setTimeout(() => setCopied(false), 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const approvedReferrals = (referrals || []).filter((r: any) => r.status === 'approved');
|
const totalEarnings = profile.total_earnings || 0;
|
||||||
const pendingReferrals = (referrals || []).filter((r: any) => r.status === 'pending');
|
const pendingEarnings = profile.pending_earnings || 0;
|
||||||
|
|
||||||
const totalEarnings = approvedReferrals.reduce((sum: number, r: any) => sum + parseFloat(r.commission_amount), 0);
|
|
||||||
const pendingEarnings = pendingReferrals.reduce((sum: number, r: any) => sum + parseFloat(r.commission_amount), 0);
|
|
||||||
|
|
||||||
const handleSavePayment = () => {
|
const handleSavePayment = () => {
|
||||||
if (!selectedMethod) {
|
if (!selectedMethod) {
|
||||||
@@ -443,7 +452,17 @@ export default function AffiliateDashboard() {
|
|||||||
|
|
||||||
{/* Referrals */}
|
{/* Referrals */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold mb-4">Recent Referrals</h3>
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold">Recent Referrals</h3>
|
||||||
|
{referralsResponse && referralsResponse.total > 5 && (
|
||||||
|
<Link
|
||||||
|
to="/my-account/affiliate/referrals"
|
||||||
|
className="text-sm font-medium text-primary hover:opacity-80 flex items-center transition-opacity"
|
||||||
|
>
|
||||||
|
View All <ChevronRight className="w-4 h-4 ml-1" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{isLoadingReferrals ? (
|
{isLoadingReferrals ? (
|
||||||
<div className="text-center py-8 text-gray-500">Loading referrals...</div>
|
<div className="text-center py-8 text-gray-500">Loading referrals...</div>
|
||||||
@@ -463,8 +482,7 @@ export default function AffiliateDashboard() {
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium text-gray-900">Order #{ref.order_id}</span>
|
<span className="font-medium text-gray-900">Order #{ref.order_id}</span>
|
||||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${ref.status === 'approved'
|
||||||
ref.status === 'approved'
|
|
||||||
? 'bg-green-100 text-green-800'
|
? 'bg-green-100 text-green-800'
|
||||||
: ref.status === 'pending'
|
: ref.status === 'pending'
|
||||||
? 'bg-yellow-100 text-yellow-800'
|
? 'bg-yellow-100 text-yellow-800'
|
||||||
@@ -527,8 +545,7 @@ export default function AffiliateDashboard() {
|
|||||||
<div key={payout.id} className="bg-white p-4 rounded-lg border">
|
<div key={payout.id} className="bg-white p-4 rounded-lg border">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className={`p-2 rounded-lg ${
|
<div className={`p-2 rounded-lg ${payout.status === 'completed'
|
||||||
payout.status === 'completed'
|
|
||||||
? 'bg-green-100 text-green-600'
|
? 'bg-green-100 text-green-600'
|
||||||
: 'bg-yellow-100 text-yellow-600'
|
: 'bg-yellow-100 text-yellow-600'
|
||||||
}`}>
|
}`}>
|
||||||
@@ -544,8 +561,7 @@ export default function AffiliateDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${payout.status === 'completed'
|
||||||
payout.status === 'completed'
|
|
||||||
? 'bg-green-100 text-green-800'
|
? 'bg-green-100 text-green-800'
|
||||||
: 'bg-yellow-100 text-yellow-800'
|
: 'bg-yellow-100 text-yellow-800'
|
||||||
}`}>
|
}`}>
|
||||||
@@ -605,8 +621,7 @@ export default function AffiliateDashboard() {
|
|||||||
setSelectedMethod(method);
|
setSelectedMethod(method);
|
||||||
setPaymentFormData({});
|
setPaymentFormData({});
|
||||||
}}
|
}}
|
||||||
className={`px-4 py-2 rounded-lg border text-sm transition-colors ${
|
className={`px-4 py-2 rounded-lg border text-sm transition-colors ${selectedMethod === method
|
||||||
selectedMethod === method
|
|
||||||
? 'bg-purple-100 border-purple-500 text-purple-700'
|
? 'bg-purple-100 border-purple-500 text-purple-700'
|
||||||
: 'bg-white border-gray-200 hover:bg-gray-50'
|
: 'bg-white border-gray-200 hover:bg-gray-50'
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
206
customer-spa/src/pages/Account/AffiliateReferrals.tsx
Normal file
206
customer-spa/src/pages/Account/AffiliateReferrals.tsx
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { api } from '@/lib/api/client';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { CheckCircle, Clock, Info, ArrowLeft, ChevronLeft, ChevronRight, Search, User } from 'lucide-react';
|
||||||
|
import { getCurrencySettings } from '@/lib/currency';
|
||||||
|
|
||||||
|
interface AffiliateReferral {
|
||||||
|
id: number;
|
||||||
|
status: 'pending' | 'approved' | 'rejected';
|
||||||
|
commission_amount: string;
|
||||||
|
created_at: string;
|
||||||
|
order_id: number;
|
||||||
|
currency: string;
|
||||||
|
approved_at?: string;
|
||||||
|
cancelled_reason?: string;
|
||||||
|
customer_name?: string;
|
||||||
|
customer_email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaginatedReferrals {
|
||||||
|
referrals: AffiliateReferral[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total_pages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAmount(amount: number | string, currency?: string): string {
|
||||||
|
const amountNum = typeof amount === 'string' ? parseFloat(amount) : amount;
|
||||||
|
const settings = getCurrencySettings();
|
||||||
|
const decimals = currency === 'IDR' ? 0 : settings.decimals;
|
||||||
|
const rounded = amountNum.toFixed(decimals);
|
||||||
|
const [integerPart, decimalPart] = rounded.split('.');
|
||||||
|
const formattedInteger = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, settings.thousandSeparator);
|
||||||
|
|
||||||
|
const formattedNum = (decimals > 0 && decimalPart)
|
||||||
|
? `${formattedInteger}${settings.decimalSeparator}${decimalPart}`
|
||||||
|
: formattedInteger;
|
||||||
|
|
||||||
|
return `${settings.symbol}${formattedNum}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AffiliateReferrals() {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [orderIdSearch, setOrderIdSearch] = useState('');
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||||
|
const limit = 20;
|
||||||
|
|
||||||
|
// Simple debounce for search
|
||||||
|
React.useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setDebouncedSearch(orderIdSearch);
|
||||||
|
setPage(1); // Reset page on new search
|
||||||
|
}, 500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [orderIdSearch]);
|
||||||
|
|
||||||
|
const { data: response, isLoading } = useQuery<PaginatedReferrals>({
|
||||||
|
queryKey: ['affiliate-referrals-full', page, limit, debouncedSearch],
|
||||||
|
queryFn: async () => {
|
||||||
|
const searchParam = debouncedSearch ? `&order_id=${encodeURIComponent(debouncedSearch)}` : '';
|
||||||
|
return await api.get<PaginatedReferrals>(`/account/affiliate/referrals?limit=${limit}&page=${page}${searchParam}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const referrals = response?.referrals || [];
|
||||||
|
const totalPages = response?.total_pages || 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link to="/my-account/affiliate">
|
||||||
|
<Button variant="outline" size="icon" className="h-8 w-8 rounded-full flex-shrink-0">
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold">All Referrals</h2>
|
||||||
|
<p className="text-gray-500 mt-1 text-sm">
|
||||||
|
{response ? `Showing ${referrals.length} of ${response.total} referrals` : 'Loading referrals...'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative w-full sm:max-w-xs">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search by Order ID..."
|
||||||
|
className="pl-9 w-full"
|
||||||
|
value={orderIdSearch}
|
||||||
|
onChange={(e) => setOrderIdSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-12 text-gray-500">Loading referrals...</div>
|
||||||
|
) : referrals.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-gray-500 border rounded-lg bg-gray-50">
|
||||||
|
No referrals found.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{referrals.map((ref: AffiliateReferral) => {
|
||||||
|
const createdDate = new Date(ref.created_at);
|
||||||
|
const approvedDate = ref.approved_at ? new Date(ref.approved_at) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={ref.id} className="bg-white p-4 rounded-lg border hover:shadow-md transition-shadow">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-gray-900">Order #{ref.order_id}</span>
|
||||||
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
ref.status === 'approved'
|
||||||
|
? 'bg-green-100 text-green-800'
|
||||||
|
: ref.status === 'pending'
|
||||||
|
? 'bg-yellow-100 text-yellow-800'
|
||||||
|
: 'bg-red-100 text-red-800'
|
||||||
|
}`}>
|
||||||
|
{ref.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{ref.customer_name && (
|
||||||
|
<div className="flex items-center gap-1 text-sm text-gray-600 mt-1">
|
||||||
|
<User className="w-3.5 h-3.5" />
|
||||||
|
<span>{ref.customer_name}</span>
|
||||||
|
{ref.customer_email && <span className="text-gray-400">({ref.customer_email})</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-4 text-sm text-gray-500 mt-2">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
{createdDate.toLocaleDateString('id-ID', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
{ref.status === 'approved' && approvedDate && (
|
||||||
|
<span className="flex items-center gap-1 text-green-600">
|
||||||
|
<CheckCircle className="w-3 h-3" />
|
||||||
|
Approved {approvedDate.toLocaleDateString('id-ID', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric'
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-lg font-bold text-gray-900">
|
||||||
|
{formatAmount(ref.commission_amount, ref.currency)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{ref.status === 'rejected' && ref.cancelled_reason && (
|
||||||
|
<div className="mt-3 p-3 bg-red-50 dark:bg-red-900/20 rounded-md">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Info className="w-4 h-4 text-red-600 mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="text-xs text-red-800 dark:text-red-200">
|
||||||
|
Reason: {ref.cancelled_reason}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between pt-4">
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
Page {page} of {totalPages}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={page <= 1}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4 mr-1" /> Previous
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
>
|
||||||
|
Next <ChevronRight className="w-4 h-4 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,6 +19,9 @@ import { toast } from 'sonner';
|
|||||||
import SEOHead from '@/components/SEOHead';
|
import SEOHead from '@/components/SEOHead';
|
||||||
import { formatPrice } from '@/lib/currency';
|
import { formatPrice } from '@/lib/currency';
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) =>
|
||||||
|
new Date(dateStr).toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' });
|
||||||
|
|
||||||
interface SubscriptionOrder {
|
interface SubscriptionOrder {
|
||||||
id: number;
|
id: number;
|
||||||
order_id: number;
|
order_id: number;
|
||||||
@@ -43,7 +46,11 @@ interface Subscription {
|
|||||||
end_date: string | null;
|
end_date: string | null;
|
||||||
last_payment_date: string | null;
|
last_payment_date: string | null;
|
||||||
payment_method: string;
|
payment_method: string;
|
||||||
|
payment_method_title: string;
|
||||||
pause_count: number;
|
pause_count: number;
|
||||||
|
max_pause_count?: number;
|
||||||
|
pauses_remaining?: number | null;
|
||||||
|
paused_at?: string | null;
|
||||||
can_pause: boolean;
|
can_pause: boolean;
|
||||||
can_resume: boolean;
|
can_resume: boolean;
|
||||||
can_cancel: boolean;
|
can_cancel: boolean;
|
||||||
@@ -51,12 +58,12 @@ interface Subscription {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const statusStyles: Record<string, string> = {
|
const statusStyles: Record<string, string> = {
|
||||||
'pending': 'bg-yellow-100 text-yellow-800',
|
'pending': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300',
|
||||||
'active': 'bg-green-100 text-green-800',
|
'active': 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300',
|
||||||
'on-hold': 'bg-blue-100 text-blue-800',
|
'on-hold': 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
|
||||||
'cancelled': 'bg-gray-100 text-gray-800',
|
'cancelled': 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300',
|
||||||
'expired': 'bg-red-100 text-red-800',
|
'expired': 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300',
|
||||||
'pending-cancel': 'bg-orange-100 text-orange-800',
|
'pending-cancel': 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300',
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusLabels: Record<string, string> = {
|
const statusLabels: Record<string, string> = {
|
||||||
@@ -124,7 +131,7 @@ export default function SubscriptionDetail() {
|
|||||||
if (response.order_id) {
|
if (response.order_id) {
|
||||||
// Determine destination based on functionality
|
// Determine destination based on functionality
|
||||||
// If manual payment required or just improved UX, go to payment page
|
// If manual payment required or just improved UX, go to payment page
|
||||||
navigate(`/order-pay/${response.order_id}`);
|
navigate(`/checkout/pay/${response.order_id}`);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(error.message || 'Failed to renew');
|
toast.error(error.message || 'Failed to renew');
|
||||||
@@ -166,7 +173,7 @@ export default function SubscriptionDetail() {
|
|||||||
{/* Back button */}
|
{/* Back button */}
|
||||||
<Link
|
<Link
|
||||||
to="/my-account/subscriptions"
|
to="/my-account/subscriptions"
|
||||||
className="inline-flex items-center text-sm text-gray-500 hover:text-gray-700"
|
className="inline-flex items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||||
Back to Subscriptions
|
Back to Subscriptions
|
||||||
@@ -179,8 +186,8 @@ export default function SubscriptionDetail() {
|
|||||||
<Repeat className="h-6 w-6" />
|
<Repeat className="h-6 w-6" />
|
||||||
Subscription #{subscription.id}
|
Subscription #{subscription.id}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-500 mt-1">
|
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
||||||
Started {new Date(subscription.start_date).toLocaleDateString()}
|
Started {formatDate(subscription.start_date)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className={`px-3 py-1 rounded-full text-sm font-medium ${statusStyles[subscription.status] || 'bg-gray-100'}`}>
|
<span className={`px-3 py-1 rounded-full text-sm font-medium ${statusStyles[subscription.status] || 'bg-gray-100'}`}>
|
||||||
@@ -189,7 +196,7 @@ export default function SubscriptionDetail() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Product Info Card */}
|
{/* Product Info Card */}
|
||||||
<div className="bg-white rounded-lg border p-6">
|
<div className="bg-card rounded-lg border p-6">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
{subscription.product_image ? (
|
{subscription.product_image ? (
|
||||||
<img
|
<img
|
||||||
@@ -198,16 +205,16 @@ export default function SubscriptionDetail() {
|
|||||||
className="w-20 h-20 object-cover rounded"
|
className="w-20 h-20 object-cover rounded"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-20 h-20 bg-gray-100 rounded flex items-center justify-center">
|
<div className="w-20 h-20 bg-gray-100 dark:bg-gray-700 rounded flex items-center justify-center">
|
||||||
<Package className="h-10 w-10 text-gray-400" />
|
<Package className="h-10 w-10 text-gray-400 dark:text-gray-500" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h2 className="text-xl font-semibold">{subscription.product_name}</h2>
|
<h2 className="text-xl font-semibold">{subscription.product_name}</h2>
|
||||||
<p className="text-gray-500">{subscription.billing_schedule}</p>
|
<p className="text-gray-500 dark:text-gray-400">{subscription.billing_schedule}</p>
|
||||||
<p className="text-2xl font-bold mt-2">
|
<p className="text-2xl font-bold mt-2">
|
||||||
{formatPrice(subscription.recurring_amount)}
|
{formatPrice(subscription.recurring_amount)}
|
||||||
<span className="text-sm font-normal text-gray-500">
|
<span className="text-sm font-normal text-gray-500 dark:text-gray-400">
|
||||||
/{subscription.billing_period}
|
/{subscription.billing_period}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
@@ -216,65 +223,111 @@ export default function SubscriptionDetail() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Billing Details */}
|
{/* Billing Details */}
|
||||||
<div className="bg-white rounded-lg border p-6">
|
<div className="bg-card rounded-lg border p-6">
|
||||||
<h3 className="font-semibold mb-4">Billing Details</h3>
|
<h3 className="font-semibold mb-4">Billing Details</h3>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-500">Start Date</p>
|
<p className="text-sm text-gray-500 dark:text-gray-400">Start Date</p>
|
||||||
<p className="font-medium">{new Date(subscription.start_date).toLocaleDateString()}</p>
|
<p className="font-medium">{formatDate(subscription.start_date)}</p>
|
||||||
</div>
|
</div>
|
||||||
{subscription.next_payment_date && (
|
{subscription.next_payment_date && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-500">Next Payment</p>
|
<p className="text-sm text-gray-500 dark:text-gray-400">Next Payment</p>
|
||||||
<p className="font-medium flex items-center gap-1">
|
<p className="font-medium flex items-center gap-1">
|
||||||
<Calendar className="h-4 w-4 text-gray-400" />
|
<Calendar className="h-4 w-4 text-gray-400 dark:text-gray-500" />
|
||||||
{new Date(subscription.next_payment_date).toLocaleDateString()}
|
{formatDate(subscription.next_payment_date!)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{subscription.trial_end_date && new Date(subscription.trial_end_date) > new Date() && (
|
{subscription.trial_end_date && new Date(subscription.trial_end_date) > new Date() && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-500">Trial Ends</p>
|
<p className="text-sm text-gray-500 dark:text-gray-400">Trial Ends</p>
|
||||||
<p className="font-medium text-blue-600">
|
<p className="font-medium text-blue-600">
|
||||||
{new Date(subscription.trial_end_date).toLocaleDateString()}
|
{formatDate(subscription.trial_end_date!)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{subscription.end_date && (
|
{subscription.end_date && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-500">End Date</p>
|
<p className="text-sm text-gray-500 dark:text-gray-400">End Date</p>
|
||||||
<p className="font-medium">{new Date(subscription.end_date).toLocaleDateString()}</p>
|
<p className="font-medium">{formatDate(subscription.end_date!)}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{subscription.status === 'on-hold' && subscription.paused_at && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">Paused At</p>
|
||||||
|
<p className="font-medium text-blue-600">{formatDate(subscription.paused_at)}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-500">Payment Method</p>
|
<p className="text-sm text-gray-500 dark:text-gray-400">Payment Method</p>
|
||||||
<p className="font-medium flex items-center gap-1">
|
<p className="font-medium flex items-center gap-1">
|
||||||
<CreditCard className="h-4 w-4 text-gray-400" />
|
<CreditCard className="h-4 w-4 text-gray-400 dark:text-gray-500" />
|
||||||
{subscription.payment_method || 'Not set'}
|
{subscription.payment_method_title || subscription.payment_method || 'Not set'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-500">Times Paused</p>
|
<p className="text-sm text-gray-500 dark:text-gray-400">Times Paused</p>
|
||||||
<p className="font-medium">{subscription.pause_count}</p>
|
<p className="font-medium">
|
||||||
|
{subscription.pause_count}
|
||||||
|
{subscription.pauses_remaining !== null && subscription.pauses_remaining !== undefined && (
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400 font-normal">
|
||||||
|
{' '}/ {subscription.max_pause_count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
{(subscription.can_pause || subscription.can_resume || subscription.can_cancel) && (
|
{(subscription.can_pause || subscription.can_resume || subscription.can_cancel) && (
|
||||||
<div className="bg-white rounded-lg border p-6">
|
<div className="bg-card rounded-lg border p-6">
|
||||||
<h3 className="font-semibold mb-4">Manage Subscription</h3>
|
<h3 className="font-semibold mb-4">Manage Subscription</h3>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{subscription.can_pause && (
|
{subscription.can_pause && (() => {
|
||||||
|
// H2: disable the pause button when the customer has reached the
|
||||||
|
// server-enforced limit, so they don't get a generic 500 on click.
|
||||||
|
const limitReached = subscription.pauses_remaining !== null
|
||||||
|
&& subscription.pauses_remaining !== undefined
|
||||||
|
&& subscription.pauses_remaining <= 0;
|
||||||
|
const tooltip = limitReached
|
||||||
|
? `You have used all ${subscription.max_pause_count} allowed pauses for this subscription.`
|
||||||
|
: subscription.pauses_remaining !== null && subscription.pauses_remaining !== undefined
|
||||||
|
? `${subscription.pauses_remaining} pause${subscription.pauses_remaining === 1 ? '' : 's'} remaining.`
|
||||||
|
: undefined;
|
||||||
|
return (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => handleAction('pause')}
|
disabled={actionLoading || limitReached}
|
||||||
disabled={actionLoading}
|
title={tooltip}
|
||||||
>
|
>
|
||||||
<Pause className="h-4 w-4 mr-2" />
|
<Pause className="h-4 w-4 mr-2" />
|
||||||
Pause Subscription
|
Pause Subscription
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Pause Subscription?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Pausing will place your subscription on hold until you manually resume it.
|
||||||
|
When you resume, your next payment date will be recalculated based on your billing cycle.
|
||||||
|
<br /><br />
|
||||||
|
{tooltip}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={() => handleAction('pause')}>
|
||||||
|
Pause
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
{subscription.can_resume && (
|
{subscription.can_resume && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -301,7 +354,7 @@ export default function SubscriptionDetail() {
|
|||||||
{pendingRenewalOrder && (
|
{pendingRenewalOrder && (
|
||||||
<Button
|
<Button
|
||||||
className='bg-green-600 hover:bg-green-700'
|
className='bg-green-600 hover:bg-green-700'
|
||||||
onClick={() => navigate(`/order-pay/${pendingRenewalOrder.order_id}`)}
|
onClick={() => navigate(`/checkout/pay/${pendingRenewalOrder.order_id}`)}
|
||||||
>
|
>
|
||||||
<CreditCard className="h-4 w-4 mr-2" />
|
<CreditCard className="h-4 w-4 mr-2" />
|
||||||
Pay Now (#{pendingRenewalOrder.order_id})
|
Pay Now (#{pendingRenewalOrder.order_id})
|
||||||
@@ -346,7 +399,7 @@ export default function SubscriptionDetail() {
|
|||||||
|
|
||||||
{/* Related Orders */}
|
{/* Related Orders */}
|
||||||
{subscription.orders && subscription.orders.length > 0 && (
|
{subscription.orders && subscription.orders.length > 0 && (
|
||||||
<div className="bg-white rounded-lg border p-6">
|
<div className="bg-card rounded-lg border p-6">
|
||||||
<h3 className="font-semibold mb-4 flex items-center gap-2">
|
<h3 className="font-semibold mb-4 flex items-center gap-2">
|
||||||
<FileText className="h-5 w-5" />
|
<FileText className="h-5 w-5" />
|
||||||
Payment History
|
Payment History
|
||||||
@@ -356,16 +409,16 @@ export default function SubscriptionDetail() {
|
|||||||
<Link
|
<Link
|
||||||
key={order.id}
|
key={order.id}
|
||||||
to={`/my-account/orders/${order.order_id}`}
|
to={`/my-account/orders/${order.order_id}`}
|
||||||
className="flex items-center justify-between p-3 rounded border hover:bg-gray-50 transition-colors"
|
className="flex items-center justify-between p-3 rounded border border-border hover:bg-muted/50 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="font-medium">Order #{order.order_id}</span>
|
<span className="font-medium text-foreground">Order #{order.order_id}</span>
|
||||||
<span className="text-xs px-2 py-0.5 bg-gray-100 rounded">
|
<span className="text-xs px-2 py-0.5 bg-muted text-muted-foreground rounded">
|
||||||
{orderTypeLabels[order.order_type] || order.order_type}
|
{orderTypeLabels[order.order_type] || order.order_type}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-500">
|
<div className="text-sm text-muted-foreground">
|
||||||
{new Date(order.created_at).toLocaleDateString()}
|
{formatDate(order.created_at)}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import LicenseConnect from './LicenseConnect';
|
|||||||
import Subscriptions from './Subscriptions';
|
import Subscriptions from './Subscriptions';
|
||||||
import SubscriptionDetail from './SubscriptionDetail';
|
import SubscriptionDetail from './SubscriptionDetail';
|
||||||
import AffiliateDashboard from './AffiliateDashboard';
|
import AffiliateDashboard from './AffiliateDashboard';
|
||||||
|
import AffiliateReferrals from './AffiliateReferrals';
|
||||||
|
|
||||||
export default function Account() {
|
export default function Account() {
|
||||||
const user = (window as any).woonoowCustomer?.user;
|
const user = (window as any).woonoowCustomer?.user;
|
||||||
@@ -44,6 +45,7 @@ export default function Account() {
|
|||||||
<Route path="subscriptions" element={<Subscriptions />} />
|
<Route path="subscriptions" element={<Subscriptions />} />
|
||||||
<Route path="subscriptions/:id" element={<SubscriptionDetail />} />
|
<Route path="subscriptions/:id" element={<SubscriptionDetail />} />
|
||||||
<Route path="affiliate" element={<AffiliateDashboard />} />
|
<Route path="affiliate" element={<AffiliateDashboard />} />
|
||||||
|
<Route path="affiliate/referrals" element={<AffiliateReferrals />} />
|
||||||
<Route path="account-details" element={<AccountDetails />} />
|
<Route path="account-details" element={<AccountDetails />} />
|
||||||
<Route path="*" element={<Navigate to="/my-account" replace />} />
|
<Route path="*" element={<Navigate to="/my-account" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ interface OrderDetailsResponse extends BaseResponse {
|
|||||||
start_date: string;
|
start_date: string;
|
||||||
next_payment_date: string | null;
|
next_payment_date: string | null;
|
||||||
end_date: string | null;
|
end_date: string | null;
|
||||||
|
payment_method?: string;
|
||||||
|
gateway_supports_auto_renew?: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +132,35 @@ const OrderPay: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// C1: Compute the projected next billing date for an early renewal.
|
||||||
|
// The server-side logic in SubscriptionManager.php copies the stored next_payment_date as
|
||||||
|
// the base when it is still in the future; otherwise it falls back to `now`. We mirror
|
||||||
|
// that here so the customer sees the same date the system will set after payment.
|
||||||
|
const computeProjectedNextPaymentDate = (sub: NonNullable<OrderDetailsResponse['subscription']>): Date => {
|
||||||
|
const now = new Date();
|
||||||
|
const storedNext = sub.next_payment_date ? new Date(sub.next_payment_date) : null;
|
||||||
|
const baseDate = storedNext && storedNext.getTime() > now.getTime() ? storedNext : now;
|
||||||
|
const interval = Math.max(1, sub.billing_interval || 1);
|
||||||
|
const projected = new Date(baseDate);
|
||||||
|
switch (sub.billing_period) {
|
||||||
|
case 'day':
|
||||||
|
projected.setDate(projected.getDate() + interval);
|
||||||
|
break;
|
||||||
|
case 'week':
|
||||||
|
projected.setDate(projected.getDate() + interval * 7);
|
||||||
|
break;
|
||||||
|
case 'month':
|
||||||
|
projected.setMonth(projected.getMonth() + interval);
|
||||||
|
break;
|
||||||
|
case 'year':
|
||||||
|
projected.setFullYear(projected.getFullYear() + interval);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
projected.setMonth(projected.getMonth() + interval);
|
||||||
|
}
|
||||||
|
return projected;
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) return <div className="p-8 text-center">Loading order details...</div>;
|
if (loading) return <div className="p-8 text-center">Loading order details...</div>;
|
||||||
if (!order) return <div className="p-8 text-center text-red-500">Order not found</div>;
|
if (!order) return <div className="p-8 text-center text-red-500">Order not found</div>;
|
||||||
|
|
||||||
@@ -143,23 +174,48 @@ const OrderPay: React.FC = () => {
|
|||||||
<SubscriptionTimeline subscription={order.subscription} />
|
<SubscriptionTimeline subscription={order.subscription} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isRenewal && !order.subscription && (
|
{isRenewal && !order.subscription && (() => {
|
||||||
<div className="bg-blue-50 border-l-4 border-blue-500 p-4 mb-6">
|
// C1: When the order is a renewal, the customer is paying for the *upcoming*
|
||||||
|
// period. After payment, SubscriptionManager will shift next_payment_date forward
|
||||||
|
// by the billing interval (using the stored next_payment_date as the base if it
|
||||||
|
// is still in the future, otherwise `now`). We surface that projected date here
|
||||||
|
// so the customer is not surprised when their next charge lands sooner than the
|
||||||
|
// original cycle.
|
||||||
|
const projected = order.subscription
|
||||||
|
? computeProjectedNextPaymentDate(order.subscription)
|
||||||
|
: null;
|
||||||
|
const projectedLabel = projected
|
||||||
|
? projected.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })
|
||||||
|
: null;
|
||||||
|
// §9 — Manual vs auto copy. If the gateway is manual, frame the renewal
|
||||||
|
// as "complete the payment to continue" rather than "will renew automatically".
|
||||||
|
const isAuto = false;
|
||||||
|
return (
|
||||||
|
<div className={`border-l-4 p-4 mb-6 ${isAuto ? 'bg-blue-50 border-blue-500' : 'bg-amber-50 border-amber-500'}`}>
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<svg className="h-5 w-5 text-blue-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
<svg className={`h-5 w-5 ${isAuto ? 'text-blue-400' : 'text-amber-400'}`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-3">
|
<div className="ml-3">
|
||||||
<p className="text-sm text-blue-700">
|
<p className={`text-sm ${isAuto ? 'text-blue-700' : 'text-amber-800'}`}>
|
||||||
This is a payment for your <span className="font-bold">subscription renewal</span>.
|
{isAuto ? (
|
||||||
Completing this payment will extend your subscription period.
|
<>This is a payment for your <span className="font-bold">subscription renewal</span>. After this payment, your subscription will renew automatically on the date shown below.</>
|
||||||
</p>
|
) : (
|
||||||
</div>
|
<>This is a <span className="font-bold">manual subscription renewal</span>. Your saved payment method cannot be charged automatically for this gateway, so please complete the payment to continue your subscription.</>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
</p>
|
||||||
|
{projectedLabel && (
|
||||||
|
<p className={`text-sm mt-1 ${isAuto ? 'text-blue-700' : 'text-amber-800'}`}>
|
||||||
|
Your next billing date will be <span className="font-bold">{projectedLabel}</span>.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
||||||
<h2 className="text-xl font-semibold mb-4">Order Summary <span className="text-gray-400 font-normal">#{order.number}</span></h2>
|
<h2 className="text-xl font-semibold mb-4">Order Summary <span className="text-gray-400 font-normal">#{order.number}</span></h2>
|
||||||
|
|||||||
@@ -8,16 +8,37 @@ import { Button } from '@/components/ui/button';
|
|||||||
import Container from '@/components/Layout/Container';
|
import Container from '@/components/Layout/Container';
|
||||||
import { ProductCard } from '@/components/ProductCard';
|
import { ProductCard } from '@/components/ProductCard';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
import { useShopSettings } from '@/hooks/useAppearanceSettings';
|
import { useShopSettings } from '@/hooks/useAppearanceSettings';
|
||||||
import SEOHead from '@/components/SEOHead';
|
import SEOHead from '@/components/SEOHead';
|
||||||
|
import { useDebounce } from '@/hooks/useDebounce';
|
||||||
import type { ProductsResponse, ProductCategory } from '@/types/product';
|
import type { ProductsResponse, ProductCategory } from '@/types/product';
|
||||||
|
|
||||||
|
function useBreakpoint() {
|
||||||
|
const [breakpoint, setBreakpoint] = React.useState<'mobile' | 'tablet' | 'desktop'>('desktop');
|
||||||
|
React.useEffect(() => {
|
||||||
|
const check = () => {
|
||||||
|
if (window.innerWidth < 768) setBreakpoint('mobile');
|
||||||
|
else if (window.innerWidth < 1024) setBreakpoint('tablet');
|
||||||
|
else setBreakpoint('desktop');
|
||||||
|
};
|
||||||
|
check();
|
||||||
|
window.addEventListener('resize', check);
|
||||||
|
return () => window.removeEventListener('resize', check);
|
||||||
|
}, []);
|
||||||
|
return breakpoint;
|
||||||
|
}
|
||||||
|
|
||||||
export default function Shop() {
|
export default function Shop() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { layout: shopLayout, elements } = useShopSettings();
|
const { layout: shopLayout, elements } = useShopSettings();
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [category, setCategory] = useState('');
|
const [category, setCategory] = useState('');
|
||||||
|
const [minPriceInput, setMinPriceInput] = useState('');
|
||||||
|
const [maxPriceInput, setMaxPriceInput] = useState('');
|
||||||
|
const minPrice = useDebounce(minPriceInput, 500);
|
||||||
|
const maxPrice = useDebounce(maxPriceInput, 500);
|
||||||
const [sortBy, setSortBy] = useState('');
|
const [sortBy, setSortBy] = useState('');
|
||||||
const { addItem } = useCartStore();
|
const { addItem } = useCartStore();
|
||||||
|
|
||||||
@@ -73,15 +94,25 @@ export default function Shop() {
|
|||||||
const masonryColsClass = `${masonryMobileClass} ${masonryTabletClass} ${masonryDesktopClass}`;
|
const masonryColsClass = `${masonryMobileClass} ${masonryTabletClass} ${masonryDesktopClass}`;
|
||||||
|
|
||||||
const isMasonry = shopLayout.grid_style === 'masonry';
|
const isMasonry = shopLayout.grid_style === 'masonry';
|
||||||
|
const isRichSidebar = shopLayout.filter_layout === 'rich_sidebar';
|
||||||
|
|
||||||
|
// Automatically reset page when filters change
|
||||||
|
React.useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
}, [search, category, minPrice, maxPrice, sortBy]);
|
||||||
|
|
||||||
|
const breakpoint = useBreakpoint();
|
||||||
|
|
||||||
// Fetch products
|
// Fetch products
|
||||||
const { data: productsData, isLoading: productsLoading } = useQuery<ProductsResponse>({
|
const { data: productsData, isLoading: productsLoading } = useQuery<ProductsResponse>({
|
||||||
queryKey: ['products', page, search, category],
|
queryKey: ['products', page, search, category, minPrice, maxPrice],
|
||||||
queryFn: () => apiClient.get(apiClient.endpoints.shop.products, {
|
queryFn: () => apiClient.get(apiClient.endpoints.shop.products, {
|
||||||
page,
|
page,
|
||||||
per_page: 12,
|
per_page: 12,
|
||||||
search,
|
search,
|
||||||
category,
|
category,
|
||||||
|
min_price: minPrice,
|
||||||
|
max_price: maxPrice,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -135,24 +166,124 @@ export default function Shop() {
|
|||||||
<p className="text-muted-foreground">Browse our collection of products</p>
|
<p className="text-muted-foreground">Browse our collection of products</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Main Content Area */}
|
||||||
{(elements.search_bar || elements.category_filter) && (
|
<div className={isRichSidebar ? "flex flex-col lg:flex-row gap-8" : ""}>
|
||||||
|
|
||||||
|
{/* Rich Sidebar */}
|
||||||
|
{isRichSidebar && (elements.search_bar || elements.category_filter) && (
|
||||||
|
<div className="w-full lg:w-64 flex-shrink-0 space-y-6 lg:sticky lg:top-24 lg:self-start lg:max-h-[calc(100vh-6rem)] lg:overflow-y-auto no-scrollbar">
|
||||||
|
<h2 className="text-lg font-semibold border-b pb-2 mb-4">Filters</h2>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
{elements.search_bar && (
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search products..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="!pl-10 pr-10"
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearch('')}
|
||||||
|
className="font-[inherit] absolute right-3 top-1/2 -translate-y-1/2 p-1 hover:bg-muted rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Categories */}
|
||||||
|
{elements.category_filter && categories && categories.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wider">Categories</h3>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<button
|
||||||
|
className={`text-left text-sm hover:text-primary transition-colors ${!category ? 'font-semibold text-primary' : 'text-foreground'}`}
|
||||||
|
onClick={() => setCategory('')}
|
||||||
|
>
|
||||||
|
All Categories
|
||||||
|
</button>
|
||||||
|
{categories.map((cat: any) => (
|
||||||
|
<button
|
||||||
|
key={cat.id}
|
||||||
|
className={`text-left text-sm hover:text-primary transition-colors flex justify-between items-center ${category === cat.slug ? 'font-semibold text-primary' : 'text-foreground'}`}
|
||||||
|
onClick={() => setCategory(cat.slug)}
|
||||||
|
>
|
||||||
|
<span>{cat.name}</span>
|
||||||
|
<span className="text-xs bg-muted px-2 py-0.5 rounded-full text-muted-foreground">{cat.count}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Price Filter */}
|
||||||
|
{isRichSidebar && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wider">Price Range</h3>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="Min"
|
||||||
|
value={minPriceInput}
|
||||||
|
onChange={(e) => setMinPriceInput(e.target.value)}
|
||||||
|
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:shadow-none"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="Max"
|
||||||
|
value={maxPriceInput}
|
||||||
|
onChange={(e) => setMaxPriceInput(e.target.value)}
|
||||||
|
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:shadow-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Clear Filters */}
|
||||||
|
{(search || category || minPrice || maxPrice) && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full mt-4"
|
||||||
|
onClick={() => {
|
||||||
|
setSearch('');
|
||||||
|
setCategory('');
|
||||||
|
setMinPriceInput('');
|
||||||
|
setMaxPriceInput('');
|
||||||
|
setSortBy('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear All Filters
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Product Grid Area */}
|
||||||
|
<div className="flex-1">
|
||||||
|
{/* Top Bar Filters (Basic Layout) */}
|
||||||
|
{!isRichSidebar && (elements.search_bar || elements.category_filter || elements.sort_dropdown) && (
|
||||||
<div className="flex flex-col md:flex-row gap-4 mb-8">
|
<div className="flex flex-col md:flex-row gap-4 mb-8">
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
{elements.search_bar && (
|
{elements.search_bar && (
|
||||||
<div className="flex-1 relative">
|
<div className="flex-1 relative">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search products..."
|
placeholder="Search products..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
className="w-full !pl-10 pr-10 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
className="!pl-10 pr-10"
|
||||||
/>
|
/>
|
||||||
{search && (
|
{search && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setSearch('')}
|
onClick={() => setSearch('')}
|
||||||
className="font-[inherit] absolute right-3 top-1/2 -translate-y-1/2 p-1 hover:bg-gray-100 rounded-full transition-colors"
|
className="font-[inherit] absolute right-3 top-1/2 -translate-y-1/2 p-1 hover:bg-muted rounded-full transition-colors"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4 text-muted-foreground" />
|
<X className="h-4 w-4 text-muted-foreground" />
|
||||||
</button>
|
</button>
|
||||||
@@ -199,29 +330,65 @@ export default function Shop() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Sort Dropdown (Rich Layout) */}
|
||||||
|
{isRichSidebar && elements.sort_dropdown && (
|
||||||
|
<div className="flex justify-end mb-6">
|
||||||
|
<select
|
||||||
|
value={sortBy}
|
||||||
|
onChange={(e) => setSortBy(e.target.value)}
|
||||||
|
className="px-4 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Default sorting</option>
|
||||||
|
<option value="popularity">Sort by popularity</option>
|
||||||
|
<option value="rating">Sort by average rating</option>
|
||||||
|
<option value="date">Sort by latest</option>
|
||||||
|
<option value="price">Sort by price: low to high</option>
|
||||||
|
<option value="price-desc">Sort by price: high to low</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Products Grid */}
|
{/* Products Grid */}
|
||||||
{productsLoading ? (
|
{productsLoading ? (
|
||||||
<div className={`grid ${gridColsClass} gap-6`}>
|
<div className={`grid ${gridColsClass} gap-6`}>
|
||||||
{[...Array(8)].map((_, i) => (
|
{[...Array(8)].map((_, i) => (
|
||||||
<div key={i} className="animate-pulse">
|
<div key={i} className="animate-pulse">
|
||||||
<div className="bg-gray-200 aspect-square rounded-lg mb-4" />
|
<div className="bg-muted aspect-square rounded-lg mb-4" />
|
||||||
<div className="h-4 bg-gray-200 rounded mb-2" />
|
<div className="h-4 bg-muted rounded mb-2" />
|
||||||
<div className="h-4 bg-gray-200 rounded w-2/3" />
|
<div className="h-4 bg-muted rounded w-2/3" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : productsData?.products && productsData.products.length > 0 ? (
|
) : productsData?.products && productsData.products.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className={isMasonry ? `${masonryColsClass} gap-6` : `grid ${gridColsClass} gap-6`}>
|
{isMasonry ? (
|
||||||
|
<div className={`grid grid-cols-1 md:grid-cols-${gridCols.tablet || '3'} lg:grid-cols-${gridCols.desktop || '4'} gap-6`}>
|
||||||
|
{(() => {
|
||||||
|
const currentCols = parseInt(gridCols[breakpoint] || (breakpoint === 'mobile' ? '2' : breakpoint === 'tablet' ? '3' : '4'));
|
||||||
|
// Use a safe column count fallback (e.g. at least 1)
|
||||||
|
const cols = Math.max(1, currentCols);
|
||||||
|
const masonryColumns: any[][] = Array.from({ length: cols }, () => []);
|
||||||
|
productsData.products.forEach((p: any, i: number) => {
|
||||||
|
masonryColumns[i % cols].push(p);
|
||||||
|
});
|
||||||
|
return masonryColumns.map((col, colIndex) => (
|
||||||
|
<div key={colIndex} className="flex flex-col gap-6">
|
||||||
|
{col.map((product) => (
|
||||||
|
<ProductCard key={product.id} product={product} onAddToCart={handleAddToCart} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={`grid ${productsData.products.length < parseInt(gridCols.desktop || '4') ? `grid-cols-1 sm:grid-cols-2 lg:grid-cols-${productsData.products.length}` : gridColsClass} gap-6`}>
|
||||||
{productsData.products.map((product: any) => (
|
{productsData.products.map((product: any) => (
|
||||||
<div key={product.id} className={isMasonry ? 'mb-6 break-inside-avoid' : ''}>
|
<div key={product.id}>
|
||||||
<ProductCard
|
<ProductCard product={product} onAddToCart={handleAddToCart} />
|
||||||
product={product}
|
|
||||||
onAddToCart={handleAddToCart}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{productsData.total_pages > 1 && (
|
{productsData.total_pages > 1 && (
|
||||||
@@ -249,12 +416,15 @@ export default function Shop() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<p className="text-muted-foreground text-lg">No products found</p>
|
<p className="text-muted-foreground text-lg">No products found</p>
|
||||||
{(search || category) && (
|
{!isRichSidebar && (search || category || minPrice || maxPrice) && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSearch('');
|
setSearch('');
|
||||||
setCategory('');
|
setCategory('');
|
||||||
|
setMinPriceInput('');
|
||||||
|
setMaxPriceInput('');
|
||||||
|
setSortBy('');
|
||||||
}}
|
}}
|
||||||
className="mt-4"
|
className="mt-4"
|
||||||
>
|
>
|
||||||
@@ -263,6 +433,8 @@ export default function Shop() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
"types": [],
|
"types": [],
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": { "@/*": ["./src/*"] },
|
"paths": { "@/*": ["./src/*"] },
|
||||||
"ignoreDeprecations": "6.0"
|
"ignoreDeprecations": "5.0"
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
166
docs/SUBSCRIPTION_GATEWAY_CAPABILITIES.md
Normal file
166
docs/SUBSCRIPTION_GATEWAY_CAPABILITIES.md
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
# Subscription Gateway Capabilities
|
||||||
|
|
||||||
|
> How WooNooW decides which payment gateways can auto-debit subscription
|
||||||
|
> renewals, and how merchants can override that decision.
|
||||||
|
|
||||||
|
## Why this exists
|
||||||
|
|
||||||
|
Before this system, a subscription renewal would attempt to call
|
||||||
|
`$gateway->process_subscription_renewal_payment($order, $subscription)` if
|
||||||
|
the gateway *happened* to implement that method. That had three problems:
|
||||||
|
|
||||||
|
1. **Capability was invisible** — the merchant had no way to see, declare,
|
||||||
|
or override which gateways supported subscription auto-renew.
|
||||||
|
2. **The default was unsafe** — a gateway without the method silently fell
|
||||||
|
through to manual payment. The system "worked," but the merchant
|
||||||
|
believed auto-debit was happening and customers were surprised when
|
||||||
|
they had to log in and pay manually.
|
||||||
|
3. **No override was possible** — a merchant running a custom Stripe
|
||||||
|
wrapper that *does* support auto-debit could not declare it, and a
|
||||||
|
merchant using stock Stripe could not opt out.
|
||||||
|
|
||||||
|
## What it is now
|
||||||
|
|
||||||
|
A **per-gateway capability table** that the merchant (or a WooNooW
|
||||||
|
defaults policy) controls explicitly. The system consults the table at
|
||||||
|
renewal time and decides whether to attempt auto-debit or fall through
|
||||||
|
to manual. PHP method existence alone is no longer authoritative.
|
||||||
|
|
||||||
|
### Storage
|
||||||
|
|
||||||
|
```
|
||||||
|
wp_option('woonoow_gateway_subscription_capabilities', [
|
||||||
|
'<gateway_id>' => [ 'subscription_auto_renew' => bool ],
|
||||||
|
...
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
### Decision flow
|
||||||
|
|
||||||
|
For a renewal where the subscription's stored `payment_method` is
|
||||||
|
`<gateway_id>`:
|
||||||
|
|
||||||
|
1. If the site-level `force_manual_renewal` setting is on, fall through
|
||||||
|
to manual. (Kill switch — see below.)
|
||||||
|
2. Look up `<gateway_id>` in the merged capability map (defaults <
|
||||||
|
stored overrides < `woonoow_gateway_subscription_capabilities` filter).
|
||||||
|
3. If the lookup returns `subscription_auto_renew = true`, attempt
|
||||||
|
auto-debit via the gateway's `process_subscription_renewal_payment`
|
||||||
|
method. On success, run `handle_renewal_success`. On failure, fall
|
||||||
|
through to manual and notify the customer.
|
||||||
|
4. If the lookup is missing or false, skip auto-debit entirely, create a
|
||||||
|
manual renewal order, and send the `renewal_payment_due` email.
|
||||||
|
|
||||||
|
The decision is made by
|
||||||
|
`WooNooW\Modules\Subscription\GatewayCapabilities::should_attempt_auto_renew($gateway_id)`.
|
||||||
|
|
||||||
|
## Built-in defaults
|
||||||
|
|
||||||
|
| Gateway ID | Default auto-renew | Why |
|
||||||
|
|---------------------|--------------------|-----|
|
||||||
|
| `paypal` | true | PayPal Reference Transactions supports recurring |
|
||||||
|
| `stripe` | true | With a WooNooW Stripe adapter implementing the contract |
|
||||||
|
| `stripe_cc` | true | Alias for stripe credit card |
|
||||||
|
| `stripe_sepa` | true | SEPA Direct Debit supports recurring |
|
||||||
|
| `dodo` | true | Dodo Payments supports recurring subscriptions |
|
||||||
|
| `tripay` | false | VA/QRIS/e-wallet — no recurring |
|
||||||
|
| `midtrans` | false | VA/QRIS/e-wallet — no recurring |
|
||||||
|
| `xendit` | false | Indonesian credit card requires customer re-auth (BI/PCI-DSS) |
|
||||||
|
| `doku` | false | Indonesian manual-only |
|
||||||
|
| `duitku` | false | Indonesian manual-only |
|
||||||
|
| `cheque`, `bacs`, `cod` | false | Offline / no auto-debit |
|
||||||
|
| **any unknown** | false | Safe default |
|
||||||
|
|
||||||
|
The default for any unknown gateway is `false`. A merchant who has a
|
||||||
|
custom adapter for an unknown gateway can flip the toggle in the admin
|
||||||
|
UI (Settings → Modules → Subscription → Gateway Auto-Renew Capabilities).
|
||||||
|
|
||||||
|
## Why per-gateway, not site-level "billing mode"
|
||||||
|
|
||||||
|
A site-level "manual vs auto" toggle asks the merchant to understand a
|
||||||
|
concept that does not exist in their head. The merchant thinks in
|
||||||
|
**payment gateways**. A checkbox next to each gateway in the admin is
|
||||||
|
data the merchant already knows.
|
||||||
|
|
||||||
|
Additionally:
|
||||||
|
|
||||||
|
- Different merchants use different gateways. A site-level toggle forces
|
||||||
|
a single behavior even when the merchant runs two gateways (one
|
||||||
|
auto-capable, one not) for different products.
|
||||||
|
- The capability is a property of the **integration**, not of the
|
||||||
|
**store**. The merchant did not choose "manual mode" — they chose
|
||||||
|
Tripay, and Tripay is a manual gateway.
|
||||||
|
- The capability can change as WooNooW ships new adapters. A
|
||||||
|
per-gateway table updates as adapters ship.
|
||||||
|
|
||||||
|
## Site-level kill switch
|
||||||
|
|
||||||
|
There is one site-level override:
|
||||||
|
|
||||||
|
- `force_manual_renewal` (default **off**) — when on, all renewals are
|
||||||
|
manual regardless of the per-gateway capability table. Useful as a
|
||||||
|
kill switch during an incident or regulatory change.
|
||||||
|
|
||||||
|
This lives in the standard module settings form (Settings → Modules →
|
||||||
|
Subscription) and is not on the gateway capability matrix screen.
|
||||||
|
|
||||||
|
## Admin UI
|
||||||
|
|
||||||
|
`Settings → Modules → Subscription` now has two sections:
|
||||||
|
|
||||||
|
1. **Configuration** — the standard 12-field schema (button text,
|
||||||
|
pause/cancel permissions, retry policy, kill switch, etc.). Driven
|
||||||
|
by the existing `SubscriptionSettings` schema.
|
||||||
|
2. **Gateway Auto-Renew Capabilities** — one row per WooCommerce
|
||||||
|
payment gateway with a per-gateway toggle. Built dynamically from
|
||||||
|
`WC()->payment_gateways()`. The merchant can flip a gateway on or
|
||||||
|
off, and the change is persisted via
|
||||||
|
`POST /woonoow/v1/subscriptions/gateway-capabilities`.
|
||||||
|
|
||||||
|
When the kill switch is on, every row shows a "Forced manual" badge and
|
||||||
|
the per-gateway toggles are disabled.
|
||||||
|
|
||||||
|
## Customer messaging
|
||||||
|
|
||||||
|
The order-pay response (`/checkout/order/{id}`) and the subscription
|
||||||
|
detail response both include `gateway_supports_auto_renew`. The
|
||||||
|
customer-spa OrderPay page renders a different callout for manual
|
||||||
|
gateways (amber) versus auto-renew gateways (blue):
|
||||||
|
|
||||||
|
- **Auto-renew:** "Your subscription will renew automatically on the
|
||||||
|
date shown below."
|
||||||
|
- **Manual:** "Your saved payment method cannot be charged
|
||||||
|
automatically for this gateway, so please complete the payment to
|
||||||
|
continue your subscription."
|
||||||
|
|
||||||
|
This ensures the customer is never promised auto-debit that the system
|
||||||
|
will not deliver.
|
||||||
|
|
||||||
|
## Extending the table (for gateway adapter authors)
|
||||||
|
|
||||||
|
A gateway adapter or third-party plugin can extend the capability
|
||||||
|
table at boot time via the
|
||||||
|
`woonoow_gateway_subscription_capabilities` filter:
|
||||||
|
|
||||||
|
```php
|
||||||
|
add_filter('woonoow_gateway_subscription_capabilities', function ($caps) {
|
||||||
|
$caps['my_custom_stripe'] = ['subscription_auto_renew' => true];
|
||||||
|
return $caps;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
The adapter is then responsible for implementing
|
||||||
|
`process_subscription_renewal_payment(WC_Order $order, $subscription)`
|
||||||
|
on its gateway class. If the method does not exist, the capability
|
||||||
|
declaration is meaningless — the renewal will fall through to manual.
|
||||||
|
|
||||||
|
## Migration / no migration
|
||||||
|
|
||||||
|
This is a behavioral improvement, not a schema change. Existing
|
||||||
|
subscriptions keep their `payment_method` value. The capability table
|
||||||
|
is consulted at renewal time, not retroactively.
|
||||||
|
|
||||||
|
If a merchant upgrades WooNooW and previously relied on PHP method
|
||||||
|
existence alone, the renewal will continue to work — but the merchant
|
||||||
|
will now see the capability matrix and can confirm or override each
|
||||||
|
gateway.
|
||||||
@@ -356,6 +356,7 @@ class AppearanceController
|
|||||||
'card_style' => sanitize_text_field($data['layout']['card_style'] ?? 'card'),
|
'card_style' => sanitize_text_field($data['layout']['card_style'] ?? 'card'),
|
||||||
'aspect_ratio' => sanitize_text_field($data['layout']['aspect_ratio'] ?? 'square'),
|
'aspect_ratio' => sanitize_text_field($data['layout']['aspect_ratio'] ?? 'square'),
|
||||||
'card_text_align' => sanitize_text_field($data['layout']['card_text_align'] ?? 'left'),
|
'card_text_align' => sanitize_text_field($data['layout']['card_text_align'] ?? 'left'),
|
||||||
|
'filter_layout' => sanitize_text_field($data['layout']['filter_layout'] ?? 'basic'),
|
||||||
],
|
],
|
||||||
'elements' => [
|
'elements' => [
|
||||||
'category_filter' => (bool) ($data['elements']['category_filter'] ?? true),
|
'category_filter' => (bool) ($data['elements']['category_filter'] ?? true),
|
||||||
@@ -588,6 +589,7 @@ class AppearanceController
|
|||||||
'grid_columns' => '3',
|
'grid_columns' => '3',
|
||||||
'card_style' => 'card',
|
'card_style' => 'card',
|
||||||
'aspect_ratio' => 'square',
|
'aspect_ratio' => 'square',
|
||||||
|
'filter_layout' => 'basic',
|
||||||
],
|
],
|
||||||
'elements' => [
|
'elements' => [
|
||||||
'category_filter' => true,
|
'category_filter' => true,
|
||||||
|
|||||||
@@ -288,6 +288,12 @@ class CheckoutController
|
|||||||
'next_payment_date' => $sub->next_payment_date,
|
'next_payment_date' => $sub->next_payment_date,
|
||||||
'end_date' => $sub->end_date,
|
'end_date' => $sub->end_date,
|
||||||
'recurring_amount' => (float) $sub->recurring_amount,
|
'recurring_amount' => (float) $sub->recurring_amount,
|
||||||
|
// §9 — Renewal messaging. The order-pay page can choose between
|
||||||
|
// "auto-renew enabled" and "manual renewal only" copy.
|
||||||
|
'payment_method' => $sub->payment_method,
|
||||||
|
'gateway_supports_auto_renew' => !empty($sub->payment_method)
|
||||||
|
? \WooNooW\Modules\Subscription\GatewayCapabilities::should_attempt_auto_renew($sub->payment_method)
|
||||||
|
: false,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,7 +330,9 @@ class CheckoutController
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create order
|
// Create order
|
||||||
$order = wc_create_order();
|
$order = wc_create_order([
|
||||||
|
'created_via' => 'checkout'
|
||||||
|
]);
|
||||||
if (is_wp_error($order)) {
|
if (is_wp_error($order)) {
|
||||||
return ['error' => $order->get_error_message()];
|
return ['error' => $order->get_error_message()];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace WooNooW\Api\Controllers;
|
|||||||
use WP_REST_Request;
|
use WP_REST_Request;
|
||||||
use WP_REST_Response;
|
use WP_REST_Response;
|
||||||
use WP_REST_Server;
|
use WP_REST_Server;
|
||||||
|
use WooNooW\Modules\Affiliate\AffiliateSettings;
|
||||||
|
|
||||||
class AffiliateCustomerController
|
class AffiliateCustomerController
|
||||||
{
|
{
|
||||||
@@ -74,8 +75,20 @@ class AffiliateCustomerController
|
|||||||
? (float) $affiliate['custom_commission_rate']
|
? (float) $affiliate['custom_commission_rate']
|
||||||
: $global_rate;
|
: $global_rate;
|
||||||
|
|
||||||
|
$referrals_table = $wpdb->prefix . 'woonoow_referrals';
|
||||||
|
$earnings = $wpdb->get_row($wpdb->prepare(
|
||||||
|
"SELECT
|
||||||
|
SUM(CASE WHEN status = 'approved' THEN commission_amount ELSE 0 END) as total_earnings,
|
||||||
|
SUM(CASE WHEN status = 'pending' THEN commission_amount ELSE 0 END) as pending_earnings
|
||||||
|
FROM $referrals_table
|
||||||
|
WHERE affiliate_id = %d",
|
||||||
|
$affiliate['id']
|
||||||
|
));
|
||||||
|
|
||||||
$affiliate['global_commission_rate'] = $global_rate;
|
$affiliate['global_commission_rate'] = $global_rate;
|
||||||
$affiliate['commission_rate'] = $effective_rate;
|
$affiliate['commission_rate'] = $effective_rate;
|
||||||
|
$affiliate['total_earnings'] = $earnings->total_earnings ?: 0;
|
||||||
|
$affiliate['pending_earnings'] = $earnings->pending_earnings ?: 0;
|
||||||
|
|
||||||
return rest_ensure_response($affiliate);
|
return rest_ensure_response($affiliate);
|
||||||
}
|
}
|
||||||
@@ -136,16 +149,51 @@ class AffiliateCustomerController
|
|||||||
return rest_ensure_response([]);
|
return rest_ensure_response([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$referrals = $wpdb->get_results($wpdb->prepare(
|
$limit = (int) $request->get_param('limit');
|
||||||
"SELECT r.*,
|
$page = max(1, (int) $request->get_param('page'));
|
||||||
|
$order_id = $request->get_param('order_id') ? (int) $request->get_param('order_id') : null;
|
||||||
|
|
||||||
|
$where = $wpdb->prepare("WHERE r.affiliate_id = %d", $affiliate->id);
|
||||||
|
if ($order_id) {
|
||||||
|
$where .= $wpdb->prepare(" AND r.order_id = %d", $order_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "SELECT r.*,
|
||||||
COALESCE(NULLIF(r.cancelled_reason, ''), NULL) as cancelled_reason,
|
COALESCE(NULLIF(r.cancelled_reason, ''), NULL) as cancelled_reason,
|
||||||
COALESCE(r.approved_at, r.created_at) as approved_at
|
COALESCE(r.approved_at, r.created_at) as approved_at
|
||||||
FROM $referrals_table r
|
FROM $referrals_table r
|
||||||
WHERE r.affiliate_id = %d
|
$where
|
||||||
ORDER BY r.created_at DESC",
|
ORDER BY r.created_at DESC";
|
||||||
$affiliate->id
|
|
||||||
), ARRAY_A);
|
if ($limit > 0) {
|
||||||
return rest_ensure_response($referrals);
|
$offset = ($page - 1) * $limit;
|
||||||
|
$sql .= $wpdb->prepare(" LIMIT %d OFFSET %d", $limit, $offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
$referrals = $wpdb->get_results($sql, ARRAY_A);
|
||||||
|
|
||||||
|
$total = $wpdb->get_var("SELECT COUNT(r.id) FROM $referrals_table r $where");
|
||||||
|
|
||||||
|
// Attach customer data if enabled
|
||||||
|
if (!empty($referrals) && AffiliateSettings::get_setting('woonoow_affiliate_share_customer_data', false)) {
|
||||||
|
foreach ($referrals as &$ref) {
|
||||||
|
if (!empty($ref['order_id'])) {
|
||||||
|
$order = wc_get_order($ref['order_id']);
|
||||||
|
if ($order) {
|
||||||
|
$ref['customer_name'] = trim($order->get_billing_first_name() . ' ' . $order->get_billing_last_name());
|
||||||
|
$ref['customer_email'] = $order->get_billing_email();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rest_ensure_response([
|
||||||
|
'referrals' => $referrals,
|
||||||
|
'total' => (int) $total,
|
||||||
|
'page' => $page,
|
||||||
|
'limit' => $limit > 0 ? $limit : (int) $total,
|
||||||
|
'total_pages' => $limit > 0 ? ceil($total / $limit) : 1
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function get_payouts(WP_REST_Request $request)
|
public function get_payouts(WP_REST_Request $request)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use WP_REST_Response;
|
|||||||
use WP_Error;
|
use WP_Error;
|
||||||
use WooNooW\Core\ModuleRegistry;
|
use WooNooW\Core\ModuleRegistry;
|
||||||
use WooNooW\Modules\Subscription\SubscriptionManager;
|
use WooNooW\Modules\Subscription\SubscriptionManager;
|
||||||
|
use WooNooW\Modules\Subscription\GatewayCapabilities;
|
||||||
|
|
||||||
class SubscriptionsController
|
class SubscriptionsController
|
||||||
{
|
{
|
||||||
@@ -40,6 +41,17 @@ class SubscriptionsController
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// M3 — Bulk operations. Body shape: { action: 'cancel' | 'export_csv', ids: number[] }.
|
||||||
|
// For 'cancel' we return { ok: int, failed: [{id, error}] }. For 'export_csv' the
|
||||||
|
// response is a text/csv body with Content-Disposition.
|
||||||
|
register_rest_route('woonoow/v1', '/subscriptions/bulk', [
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => [__CLASS__, 'bulk_action'],
|
||||||
|
'permission_callback' => function () {
|
||||||
|
return current_user_can('manage_woocommerce');
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
register_rest_route('woonoow/v1', '/subscriptions/(?P<id>\d+)', [
|
register_rest_route('woonoow/v1', '/subscriptions/(?P<id>\d+)', [
|
||||||
'methods' => 'GET',
|
'methods' => 'GET',
|
||||||
'callback' => [__CLASS__, 'get_subscription'],
|
'callback' => [__CLASS__, 'get_subscription'],
|
||||||
@@ -136,6 +148,23 @@ class SubscriptionsController
|
|||||||
return is_user_logged_in();
|
return is_user_logged_in();
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// §9 — Gateway capability matrix (admin)
|
||||||
|
register_rest_route('woonoow/v1', '/subscriptions/gateway-capabilities', [
|
||||||
|
'methods' => 'GET',
|
||||||
|
'callback' => [__CLASS__, 'get_gateway_capabilities'],
|
||||||
|
'permission_callback' => function () {
|
||||||
|
return current_user_can('manage_woocommerce');
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
register_rest_route('woonoow/v1', '/subscriptions/gateway-capabilities', [
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => [__CLASS__, 'update_gateway_capabilities'],
|
||||||
|
'permission_callback' => function () {
|
||||||
|
return current_user_can('manage_woocommerce');
|
||||||
|
},
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -147,12 +176,16 @@ class SubscriptionsController
|
|||||||
'status' => $request->get_param('status'),
|
'status' => $request->get_param('status'),
|
||||||
'product_id' => $request->get_param('product_id'),
|
'product_id' => $request->get_param('product_id'),
|
||||||
'user_id' => $request->get_param('user_id'),
|
'user_id' => $request->get_param('user_id'),
|
||||||
|
'search' => $request->get_param('search'),
|
||||||
'limit' => $request->get_param('per_page') ?: 20,
|
'limit' => $request->get_param('per_page') ?: 20,
|
||||||
'offset' => (($request->get_param('page') ?: 1) - 1) * ($request->get_param('per_page') ?: 20),
|
'offset' => (($request->get_param('page') ?: 1) - 1) * ($request->get_param('per_page') ?: 20),
|
||||||
];
|
];
|
||||||
|
|
||||||
$subscriptions = SubscriptionManager::get_all($args);
|
$subscriptions = SubscriptionManager::get_all($args);
|
||||||
$total = SubscriptionManager::count(['status' => $args['status']]);
|
$total = SubscriptionManager::count([
|
||||||
|
'status' => $args['status'],
|
||||||
|
'search' => $args['search'],
|
||||||
|
]);
|
||||||
|
|
||||||
// Enrich with product and user info
|
// Enrich with product and user info
|
||||||
$enriched = [];
|
$enriched = [];
|
||||||
@@ -244,16 +277,27 @@ class SubscriptionsController
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Renew subscription (admin - force immediate renewal)
|
* Renew subscription (admin - force immediate renewal)
|
||||||
|
*
|
||||||
|
* M2 — supports `?charge_now=true` to bypass the per-gateway capability
|
||||||
|
* gate. With the flag, the auto-debit path is attempted even on gateways
|
||||||
|
* that are normally manual-only; on failure the order is marked failed
|
||||||
|
* (no manual fallback) so the admin can see the charge couldn't go
|
||||||
|
* through.
|
||||||
*/
|
*/
|
||||||
public static function renew_subscription(WP_REST_Request $request)
|
public static function renew_subscription(WP_REST_Request $request)
|
||||||
{
|
{
|
||||||
$result = SubscriptionManager::renew($request->get_param('id'));
|
$charge_now = filter_var($request->get_param('charge_now'), FILTER_VALIDATE_BOOLEAN);
|
||||||
|
$result = SubscriptionManager::renew($request->get_param('id'), $charge_now);
|
||||||
|
|
||||||
if (!$result) {
|
if (!$result) {
|
||||||
return new WP_Error('renew_failed', __('Failed to process renewal', 'woonoow'), ['status' => 500]);
|
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']]);
|
return new WP_REST_Response([
|
||||||
|
'success' => true,
|
||||||
|
'order_id' => $result['order_id'],
|
||||||
|
'status' => $result['status'] ?? 'complete',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -284,6 +328,98 @@ class SubscriptionsController
|
|||||||
return new WP_REST_Response(['success' => true]);
|
return new WP_REST_Response(['success' => true]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* M3 — Bulk action endpoint.
|
||||||
|
*
|
||||||
|
* Body: { action: 'cancel' | 'export_csv', ids: number[] }
|
||||||
|
*
|
||||||
|
* - 'cancel' returns JSON `{ ok: int, failed: [{id, error}] }`. Per-subscription
|
||||||
|
* errors do not abort the batch — the admin sees the per-row outcome.
|
||||||
|
* - 'export_csv' streams a CSV download. We don't use WP_REST_Response's
|
||||||
|
* download flag because we want to set a custom filename.
|
||||||
|
*
|
||||||
|
* Hard cap of 500 ids per call to avoid runaway batches. A real implementation
|
||||||
|
* would dispatch this via Action Scheduler; for now we run inline because
|
||||||
|
* 500 cancels is <1s of DB writes.
|
||||||
|
*/
|
||||||
|
public static function bulk_action(WP_REST_Request $request)
|
||||||
|
{
|
||||||
|
$action = (string) $request->get_param('action');
|
||||||
|
$ids = $request->get_param('ids');
|
||||||
|
|
||||||
|
if (!is_array($ids) || empty($ids)) {
|
||||||
|
return new WP_Error('bad_request', __('ids must be a non-empty array', 'woonoow'), ['status' => 400]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coerce to ints, drop non-numeric junk, dedupe.
|
||||||
|
$ids = array_values(array_unique(array_filter(array_map('intval', $ids), function ($i) { return $i > 0; })));
|
||||||
|
if (empty($ids)) {
|
||||||
|
return new WP_Error('bad_request', __('ids must contain at least one positive integer', 'woonoow'), ['status' => 400]);
|
||||||
|
}
|
||||||
|
if (count($ids) > 500) {
|
||||||
|
return new WP_Error('batch_too_large', __('Maximum 500 ids per bulk request', 'woonoow'), ['status' => 400]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'cancel') {
|
||||||
|
$ok = 0;
|
||||||
|
$failed = [];
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
$result = SubscriptionManager::cancel($id);
|
||||||
|
if ($result === false || $result === null) {
|
||||||
|
$failed[] = ['id' => $id, 'error' => __('Cancel returned false', 'woonoow')];
|
||||||
|
} else {
|
||||||
|
$ok++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new WP_REST_Response(['ok' => $ok, 'failed' => $failed]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'export_csv') {
|
||||||
|
$rows = [];
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
$sub = SubscriptionManager::get($id);
|
||||||
|
if (!$sub) {
|
||||||
|
$rows[] = [
|
||||||
|
'id' => $id, 'status' => 'missing', 'user_name' => '', 'user_email' => '',
|
||||||
|
'product_name' => '', 'billing_period' => '', 'billing_interval' => '',
|
||||||
|
'recurring_amount' => '', 'next_payment_date' => '', 'start_date' => '',
|
||||||
|
'end_date' => '', 'payment_method' => '',
|
||||||
|
];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$rows[] = [
|
||||||
|
'id' => (int) $sub->id,
|
||||||
|
'status' => (string) $sub->status,
|
||||||
|
'user_name' => (string) ($sub->user_name ?? ''),
|
||||||
|
'user_email' => (string) ($sub->user_email ?? ''),
|
||||||
|
'product_name' => (string) ($sub->product_name ?? ''),
|
||||||
|
'billing_period' => (string) $sub->billing_period,
|
||||||
|
'billing_interval' => (int) $sub->billing_interval,
|
||||||
|
'recurring_amount' => (string) $sub->recurring_amount,
|
||||||
|
'next_payment_date' => (string) ($sub->next_payment_date ?? ''),
|
||||||
|
'start_date' => (string) $sub->start_date,
|
||||||
|
'end_date' => (string) ($sub->end_date ?? ''),
|
||||||
|
'payment_method' => (string) ($sub->payment_method ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$filename = 'woonoow-subscriptions-' . gmdate('Ymd-His') . '.csv';
|
||||||
|
header('Content-Type: text/csv; charset=utf-8');
|
||||||
|
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
||||||
|
$out = fopen('php://output', 'w');
|
||||||
|
if (!empty($rows)) {
|
||||||
|
fputcsv($out, array_keys($rows[0]));
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
fputcsv($out, $r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fclose($out);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WP_Error('unknown_action', __('Unknown bulk action', 'woonoow'), ['status' => 400]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get customer's subscriptions
|
* Get customer's subscriptions
|
||||||
*/
|
*/
|
||||||
@@ -453,10 +589,31 @@ class SubscriptionsController
|
|||||||
|
|
||||||
// Add computed fields
|
// Add computed fields
|
||||||
$enriched['is_active'] = $subscription->status === 'active';
|
$enriched['is_active'] = $subscription->status === 'active';
|
||||||
$enriched['can_pause'] = $subscription->status === 'active';
|
|
||||||
$enriched['can_resume'] = $subscription->status === 'on-hold';
|
// Surface pause-limit context to the client (H2). The server-side pause handler in
|
||||||
|
// SubscriptionManager::pause() already enforces the limit; this just tells the UI how
|
||||||
|
// many pauses remain so the button can be disabled with a tooltip before the customer
|
||||||
|
// hits the wall and gets a generic 500.
|
||||||
|
$settings = \WooNooW\Core\ModuleRegistry::get_settings('subscription');
|
||||||
|
$max_pause_count = isset($settings['max_pause_count']) ? (int) $settings['max_pause_count'] : 3;
|
||||||
|
$enriched['max_pause_count'] = $max_pause_count;
|
||||||
|
$enriched['pauses_remaining'] = $max_pause_count > 0
|
||||||
|
? max(0, $max_pause_count - (int) $subscription->pause_count)
|
||||||
|
: null; // null = unlimited
|
||||||
|
|
||||||
|
// Whether this customer is actually allowed to pause, incorporating:
|
||||||
|
// - feature toggle (allow_customer_pause setting)
|
||||||
|
// - subscription status
|
||||||
|
// - lifetime pause limit
|
||||||
|
$allow_pause_feature = !empty($settings['allow_customer_pause']);
|
||||||
|
$pause_limit_ok = ($max_pause_count <= 0) || ($subscription->pause_count < $max_pause_count);
|
||||||
|
$enriched['can_pause'] = $subscription->status === 'active' && $allow_pause_feature && $pause_limit_ok;
|
||||||
|
$enriched['can_resume'] = in_array($subscription->status, ['on-hold', 'pending-cancel']);
|
||||||
$enriched['can_cancel'] = in_array($subscription->status, ['active', 'on-hold', 'pending']);
|
$enriched['can_cancel'] = in_array($subscription->status, ['active', 'on-hold', 'pending']);
|
||||||
|
|
||||||
|
// Expose paused_at so the UI can show when the subscription was paused
|
||||||
|
$enriched['paused_at'] = $subscription->paused_at ?? null;
|
||||||
|
|
||||||
// Format billing info
|
// Format billing info
|
||||||
$period_labels = [
|
$period_labels = [
|
||||||
'day' => __('day', 'woonoow'),
|
'day' => __('day', 'woonoow'),
|
||||||
@@ -496,6 +653,110 @@ class SubscriptionsController
|
|||||||
|
|
||||||
$enriched['payment_method_title'] = $payment_title;
|
$enriched['payment_method_title'] = $payment_title;
|
||||||
|
|
||||||
|
// §9 — Tell the client whether the stored gateway is declared to support
|
||||||
|
// subscription auto-renew. The renewal flow uses this for messaging and the
|
||||||
|
// admin uses it for at-a-glance status.
|
||||||
|
$enriched['gateway_supports_auto_renew'] = !empty($subscription->payment_method)
|
||||||
|
? GatewayCapabilities::should_attempt_auto_renew($subscription->payment_method)
|
||||||
|
: false;
|
||||||
|
$enriched['gateway_force_manual'] = GatewayCapabilities::force_manual();
|
||||||
|
|
||||||
return $enriched;
|
return $enriched;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* §9 — List the merged gateway capability matrix for the admin UI.
|
||||||
|
*
|
||||||
|
* Returns a row per available WC payment gateway with:
|
||||||
|
* id, title, description, enabled (site-enabled),
|
||||||
|
* auto_renew (effective — capability table + kill switch),
|
||||||
|
* override (the merchant-set override, or null if using default),
|
||||||
|
* default (the built-in default for this gateway ID)
|
||||||
|
*/
|
||||||
|
public static function get_gateway_capabilities(WP_REST_Request $request)
|
||||||
|
{
|
||||||
|
if (!function_exists('WC')) {
|
||||||
|
return new WP_Error('wc_missing', __('WooCommerce is not active.', 'woonoow'), ['status' => 500]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$gateways = WC()->payment_gateways()->payment_gateways();
|
||||||
|
$stored = get_option(GatewayCapabilities::OPTION_KEY, []);
|
||||||
|
if (!is_array($stored)) {
|
||||||
|
$stored = [];
|
||||||
|
}
|
||||||
|
$defaults = GatewayCapabilities::default_capabilities();
|
||||||
|
$kill_switch = GatewayCapabilities::force_manual();
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
foreach ($gateways as $id => $gateway) {
|
||||||
|
$default = isset($defaults[$id]) ? (bool) $defaults[$id]['subscription_auto_renew'] : false;
|
||||||
|
$override = array_key_exists($id, $stored) ? (bool) $stored[$id]['subscription_auto_renew'] : null;
|
||||||
|
$effective = GatewayCapabilities::should_attempt_auto_renew($id);
|
||||||
|
$rows[] = [
|
||||||
|
'id' => $id,
|
||||||
|
'title' => $gateway->get_title() ?: $gateway->method_title ?: $id,
|
||||||
|
'description' => $gateway->get_description(),
|
||||||
|
'enabled' => $gateway->enabled === 'yes',
|
||||||
|
'default' => $default,
|
||||||
|
'override' => $override,
|
||||||
|
'auto_renew' => $effective,
|
||||||
|
'forced_manual' => $kill_switch,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WP_REST_Response([
|
||||||
|
'gateways' => array_values($rows),
|
||||||
|
'kill_switch' => $kill_switch,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* §9 — Persist merchant overrides for the per-gateway capability table.
|
||||||
|
*
|
||||||
|
* Body shape: { overrides: { '<gateway_id>': bool | null, ... } }
|
||||||
|
* - bool => explicit override (true = auto-renew, false = manual)
|
||||||
|
* - null => clear override, fall back to default
|
||||||
|
*
|
||||||
|
* The kill switch is NOT set here — it lives in the standard module
|
||||||
|
* settings under `force_manual_renewal` (use the generic settings endpoint).
|
||||||
|
*/
|
||||||
|
public static function update_gateway_capabilities(WP_REST_Request $request)
|
||||||
|
{
|
||||||
|
$body = $request->get_json_params();
|
||||||
|
if (!is_array($body) || !isset($body['overrides']) || !is_array($body['overrides'])) {
|
||||||
|
return new WP_Error('bad_request', __('overrides map is required.', 'woonoow'), ['status' => 400]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stored = get_option(GatewayCapabilities::OPTION_KEY, []);
|
||||||
|
if (!is_array($stored)) {
|
||||||
|
$stored = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$defaults = GatewayCapabilities::default_capabilities();
|
||||||
|
$valid_ids = $defaults;
|
||||||
|
if (function_exists('WC')) {
|
||||||
|
foreach (WC()->payment_gateways()->payment_gateways() as $id => $gw) {
|
||||||
|
$valid_ids[$id] = ['subscription_auto_renew' => false];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($body['overrides'] as $id => $value) {
|
||||||
|
$id = sanitize_key((string) $id);
|
||||||
|
if ($id === '' || !array_key_exists($id, $valid_ids)) {
|
||||||
|
continue; // unknown gateway — ignore
|
||||||
|
}
|
||||||
|
if ($value === null) {
|
||||||
|
unset($stored[$id]);
|
||||||
|
} else {
|
||||||
|
$stored[$id] = ['subscription_auto_renew' => (bool) $value];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update_option(GatewayCapabilities::OPTION_KEY, $stored);
|
||||||
|
|
||||||
|
return new WP_REST_Response([
|
||||||
|
'success' => true,
|
||||||
|
'overrides' => $stored,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -352,6 +352,21 @@ class EmailRenderer
|
|||||||
'payment_link' => $data['payment_link'] ?? '',
|
'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
|
// Get product name if not already set
|
||||||
if (!isset($variables['product_name']) && isset($data['product']) && $data['product'] instanceof \WC_Product) {
|
if (!isset($variables['product_name']) && isset($data['product']) && $data['product'] instanceof \WC_Product) {
|
||||||
$sub_variables['product_name'] = $data['product']->get_name();
|
$sub_variables['product_name'] = $data['product']->get_name();
|
||||||
@@ -381,6 +396,57 @@ class EmailRenderer
|
|||||||
return apply_filters('woonoow_email_variables', $variables, $event_id, $data);
|
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
|
* Parse [card] tags and convert to HTML
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,375 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -54,6 +54,14 @@ class ShopController
|
|||||||
'default' => '',
|
'default' => '',
|
||||||
'sanitize_callback' => 'sanitize_text_field',
|
'sanitize_callback' => 'sanitize_text_field',
|
||||||
],
|
],
|
||||||
|
'min_price' => [
|
||||||
|
'default' => '',
|
||||||
|
'sanitize_callback' => 'sanitize_text_field',
|
||||||
|
],
|
||||||
|
'max_price' => [
|
||||||
|
'default' => '',
|
||||||
|
'sanitize_callback' => 'sanitize_text_field',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -106,6 +114,8 @@ class ShopController
|
|||||||
$slug = $request->get_param('slug');
|
$slug = $request->get_param('slug');
|
||||||
$include = $request->get_param('include');
|
$include = $request->get_param('include');
|
||||||
$exclude = $request->get_param('exclude');
|
$exclude = $request->get_param('exclude');
|
||||||
|
$min_price = $request->get_param('min_price');
|
||||||
|
$max_price = $request->get_param('max_price');
|
||||||
|
|
||||||
$args = [
|
$args = [
|
||||||
'post_type' => 'product',
|
'post_type' => 'product',
|
||||||
@@ -152,6 +162,30 @@ class ShopController
|
|||||||
$args['s'] = $search;
|
$args['s'] = $search;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add price filter
|
||||||
|
if ($min_price !== '' || $max_price !== '') {
|
||||||
|
$price_query = [
|
||||||
|
'key' => '_price',
|
||||||
|
'type' => 'NUMERIC',
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($min_price !== '' && $max_price !== '') {
|
||||||
|
$price_query['compare'] = 'BETWEEN';
|
||||||
|
$price_query['value'] = [(float)$min_price, (float)$max_price];
|
||||||
|
} elseif ($min_price !== '') {
|
||||||
|
$price_query['compare'] = '>=';
|
||||||
|
$price_query['value'] = (float)$min_price;
|
||||||
|
} else {
|
||||||
|
$price_query['compare'] = '<=';
|
||||||
|
$price_query['value'] = (float)$max_price;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($args['meta_query'])) {
|
||||||
|
$args['meta_query'] = [];
|
||||||
|
}
|
||||||
|
$args['meta_query'][] = $price_query;
|
||||||
|
}
|
||||||
|
|
||||||
$query = new \WP_Query($args);
|
$query = new \WP_Query($args);
|
||||||
|
|
||||||
// Check if this is a single product request (by slug)
|
// Check if this is a single product request (by slug)
|
||||||
|
|||||||
@@ -338,7 +338,7 @@ class TemplateOverride
|
|||||||
if (is_wc_endpoint_url('order-pay')) {
|
if (is_wc_endpoint_url('order-pay')) {
|
||||||
global $wp;
|
global $wp;
|
||||||
$order_id = $wp->query_vars['order-pay'];
|
$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;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -107,11 +107,13 @@ class AffiliateLifecycle
|
|||||||
if (!$referral) return;
|
if (!$referral) return;
|
||||||
|
|
||||||
// Check if holding period is 0 (immediate approval on completion)
|
// Check if holding period is 0 (immediate approval on completion)
|
||||||
$holding_period = (int) get_option('woonoow_affiliate_holding_period', 14);
|
$holding_period = (int) AffiliateSettings::get_setting('woonoow_affiliate_holding_period', 14);
|
||||||
|
$handled_now = false;
|
||||||
|
|
||||||
if ($holding_period === 0) {
|
if ($holding_period === 0) {
|
||||||
// Immediate approval
|
// Immediate approval
|
||||||
self::auto_approve_referral($referral->id);
|
self::auto_approve_referral($referral->id);
|
||||||
|
$handled_now = true;
|
||||||
} else {
|
} else {
|
||||||
// If order was completed BEFORE the scheduled action time, approve now
|
// If order was completed BEFORE the scheduled action time, approve now
|
||||||
// Otherwise, the scheduled action will approve later
|
// Otherwise, the scheduled action will approve later
|
||||||
@@ -120,12 +122,13 @@ class AffiliateLifecycle
|
|||||||
|
|
||||||
if (time() >= $approval_time) {
|
if (time() >= $approval_time) {
|
||||||
self::auto_approve_referral($referral->id);
|
self::auto_approve_referral($referral->id);
|
||||||
|
$handled_now = true;
|
||||||
}
|
}
|
||||||
// If not, the scheduled Action Scheduler job will handle it
|
// If not, the scheduled Action Scheduler job will handle it
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cancel the scheduled auto-approval since we're handling it now
|
// Only unschedule if we actually approved now.
|
||||||
if (function_exists('as_unschedule_all_actions')) {
|
if ($handled_now && function_exists('as_unschedule_all_actions')) {
|
||||||
as_unschedule_all_actions('woonoow_approve_referral', ['referral_id' => $referral->id], 'woonoow_affiliate');
|
as_unschedule_all_actions('woonoow_approve_referral', ['referral_id' => $referral->id], 'woonoow_affiliate');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -222,13 +225,23 @@ class AffiliateLifecycle
|
|||||||
|
|
||||||
if (!$referral) return; // Already processed or deleted
|
if (!$referral) return; // Already processed or deleted
|
||||||
|
|
||||||
// Double check order status
|
// Double check order status.
|
||||||
|
// Referrals must never be approved before the order is completed.
|
||||||
$order = wc_get_order($referral->order_id);
|
$order = wc_get_order($referral->order_id);
|
||||||
if (!$order || in_array($order->get_status(), ['refunded', 'cancelled', 'failed'])) {
|
if (!$order) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$order_status = $order->get_status();
|
||||||
|
if (in_array($order_status, ['refunded', 'cancelled', 'failed'])) {
|
||||||
self::handle_order_cancelled($referral->order_id);
|
self::handle_order_cancelled($referral->order_id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($order_status !== 'completed') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Approve referral
|
// Approve referral
|
||||||
$wpdb->update(
|
$wpdb->update(
|
||||||
$referrals_table,
|
$referrals_table,
|
||||||
|
|||||||
@@ -64,8 +64,32 @@ class AffiliateSettings {
|
|||||||
'description' => __('Allow affiliates to earn commission when their own user account places an order.', 'woonoow'),
|
'description' => __('Allow affiliates to earn commission when their own user account places an order.', 'woonoow'),
|
||||||
'default' => false,
|
'default' => false,
|
||||||
],
|
],
|
||||||
|
'woonoow_affiliate_share_customer_data' => [
|
||||||
|
'type' => 'toggle',
|
||||||
|
'label' => __('Share Customer Data with Affiliates', 'woonoow'),
|
||||||
|
'description' => __('Allow affiliates to see the name and email of the customers they refer.', 'woonoow'),
|
||||||
|
'default' => false,
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
return $schemas;
|
return $schemas;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read Affiliate module setting from module settings storage with legacy fallback.
|
||||||
|
*
|
||||||
|
* @param string $key
|
||||||
|
* @param mixed $default
|
||||||
|
* @return mixed
|
||||||
|
*/
|
||||||
|
public static function get_setting($key, $default = null)
|
||||||
|
{
|
||||||
|
$module_settings = get_option('woonoow_module_affiliate_settings', []);
|
||||||
|
if (is_array($module_settings) && array_key_exists($key, $module_settings)) {
|
||||||
|
return $module_settings[$key];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy fallback for older installs that may store direct option keys.
|
||||||
|
return get_option($key, $default);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -363,7 +363,7 @@ class AffiliateTracker
|
|||||||
|
|
||||||
// Schedule auto-approval (e.g., 14 days) via Action Scheduler
|
// Schedule auto-approval (e.g., 14 days) via Action Scheduler
|
||||||
if (function_exists('as_schedule_single_action')) {
|
if (function_exists('as_schedule_single_action')) {
|
||||||
$approval_days = get_option('woonoow_affiliate_holding_period', 14);
|
$approval_days = (int) AffiliateSettings::get_setting('woonoow_affiliate_holding_period', 14);
|
||||||
$timestamp = time() + ($approval_days * DAY_IN_SECONDS);
|
$timestamp = time() + ($approval_days * DAY_IN_SECONDS);
|
||||||
as_schedule_single_action($timestamp, 'woonoow_approve_referral', ['referral_id' => $referral_id], 'woonoow_affiliate');
|
as_schedule_single_action($timestamp, 'woonoow_approve_referral', ['referral_id' => $referral_id], 'woonoow_affiliate');
|
||||||
}
|
}
|
||||||
|
|||||||
123
includes/Modules/Subscription/GatewayCapabilities.php
Normal file
123
includes/Modules/Subscription/GatewayCapabilities.php
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gateway Capabilities — Subscription auto-renew declaration
|
||||||
|
*
|
||||||
|
* Single source of truth for "can this payment gateway auto-debit a
|
||||||
|
* subscription renewal, or does it fall through to manual?"
|
||||||
|
*
|
||||||
|
* Storage:
|
||||||
|
* wp_option('woonoow_gateway_subscription_capabilities')
|
||||||
|
* shape: [ '<gateway_id>' => [ 'subscription_auto_renew' => bool, ... ], ... ]
|
||||||
|
*
|
||||||
|
* Defaults are explicit per gateway ID so the merchant sees a meaningful
|
||||||
|
* matrix out of the box. The defaults reflect the regulatory reality
|
||||||
|
* discussed in SUBSCRIPTION_MODULE_AUDIT.md §9.5:
|
||||||
|
* - Indonesian VA/QRIS/e-wallet gateways: false (no recurring)
|
||||||
|
* - Indonesian credit-card gateways: false (BI/PCI-DSS re-auth)
|
||||||
|
* - PayPal/Stripe/Dodo: true ONLY when the merchant has a working
|
||||||
|
* adapter that implements process_subscription_renewal_payment;
|
||||||
|
* we still default to true because the integration is the common
|
||||||
|
* case in WooNooW's target market.
|
||||||
|
*
|
||||||
|
* The default for any *unknown* gateway is `false` — the safe side.
|
||||||
|
*
|
||||||
|
* @package WooNooW\Modules\Subscription
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace WooNooW\Modules\Subscription;
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) exit;
|
||||||
|
|
||||||
|
class GatewayCapabilities
|
||||||
|
{
|
||||||
|
const OPTION_KEY = 'woonoow_gateway_subscription_capabilities';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Built-in safe defaults. Keyed by WooCommerce payment-gateway ID.
|
||||||
|
*
|
||||||
|
* Filter 'woonoow_gateway_subscription_capabilities' lets adapters
|
||||||
|
* and third-party code extend this list at boot time.
|
||||||
|
*/
|
||||||
|
public static function default_capabilities(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
// Global auto-debit-capable gateways
|
||||||
|
'paypal' => ['subscription_auto_renew' => true],
|
||||||
|
'stripe' => ['subscription_auto_renew' => true],
|
||||||
|
'stripe_cc' => ['subscription_auto_renew' => true],
|
||||||
|
'stripe_sepa' => ['subscription_auto_renew' => true],
|
||||||
|
'dodo' => ['subscription_auto_renew' => true],
|
||||||
|
|
||||||
|
// Indonesian manual-only gateways (VA/QRIS/e-wallet/CC re-auth)
|
||||||
|
'tripay' => ['subscription_auto_renew' => false],
|
||||||
|
'midtrans' => ['subscription_auto_renew' => false],
|
||||||
|
'xendit' => ['subscription_auto_renew' => false],
|
||||||
|
'doku' => ['subscription_auto_renew' => false],
|
||||||
|
'duitku' => ['subscription_auto_renew' => false],
|
||||||
|
|
||||||
|
// Cheques / offline / no auto-debit
|
||||||
|
'cheque' => ['subscription_auto_renew' => false],
|
||||||
|
'bacs' => ['subscription_auto_renew' => false],
|
||||||
|
'cod' => ['subscription_auto_renew' => false],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the merged capability map: defaults < stored < filter.
|
||||||
|
* Always returns a fully-populated array (missing keys default to false).
|
||||||
|
*/
|
||||||
|
public static function all(): array
|
||||||
|
{
|
||||||
|
$stored = get_option(self::OPTION_KEY, []);
|
||||||
|
if (!is_array($stored)) {
|
||||||
|
$stored = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$merged = array_merge(self::default_capabilities(), $stored);
|
||||||
|
$merged = (array) apply_filters('woonoow_gateway_subscription_capabilities', $merged);
|
||||||
|
|
||||||
|
return $merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-gateway capability lookup.
|
||||||
|
* Returns true ONLY if explicitly declared true. Anything else is false.
|
||||||
|
*/
|
||||||
|
public static function supports_auto_renew(string $gateway_id): bool
|
||||||
|
{
|
||||||
|
$gateway_id = sanitize_key($gateway_id);
|
||||||
|
if ($gateway_id === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$caps = self::all();
|
||||||
|
if (!isset($caps[$gateway_id])) {
|
||||||
|
return false; // unknown gateway: safe default
|
||||||
|
}
|
||||||
|
|
||||||
|
return !empty($caps[$gateway_id]['subscription_auto_renew']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Site-level kill switch. When true, EVERY gateway is treated as
|
||||||
|
* manual regardless of per-gateway capability.
|
||||||
|
*/
|
||||||
|
public static function force_manual(): bool
|
||||||
|
{
|
||||||
|
$settings = \WooNooW\Core\ModuleRegistry::get_settings('subscription');
|
||||||
|
return !empty($settings['force_manual_renewal']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single decision function the renewal flow should call.
|
||||||
|
* Combines: kill switch > gateway capability.
|
||||||
|
*/
|
||||||
|
public static function should_attempt_auto_renew(string $gateway_id): bool
|
||||||
|
{
|
||||||
|
if (self::force_manual()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return self::supports_auto_renew($gateway_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,6 +63,7 @@ class SubscriptionManager
|
|||||||
payment_meta LONGTEXT,
|
payment_meta LONGTEXT,
|
||||||
cancel_reason TEXT DEFAULT NULL,
|
cancel_reason TEXT DEFAULT NULL,
|
||||||
pause_count INT UNSIGNED DEFAULT 0,
|
pause_count INT UNSIGNED DEFAULT 0,
|
||||||
|
paused_at DATETIME DEFAULT NULL,
|
||||||
failed_payment_count INT UNSIGNED DEFAULT 0,
|
failed_payment_count INT UNSIGNED DEFAULT 0,
|
||||||
reminder_sent_at DATETIME DEFAULT NULL,
|
reminder_sent_at DATETIME DEFAULT NULL,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
@@ -87,6 +88,45 @@ class SubscriptionManager
|
|||||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||||
dbDelta($sql_subscriptions);
|
dbDelta($sql_subscriptions);
|
||||||
dbDelta($sql_orders);
|
dbDelta($sql_orders);
|
||||||
|
|
||||||
|
// Runtime migration: add paused_at to existing installations.
|
||||||
|
// dbDelta does not add new columns to existing tables.
|
||||||
|
if ($wpdb->get_var("SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '$table_subscriptions' AND COLUMN_NAME = 'paused_at'") == 0) {
|
||||||
|
$wpdb->query("ALTER TABLE $table_subscriptions ADD COLUMN paused_at DATETIME DEFAULT NULL AFTER pause_count");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a subscription meta key with variation-first, parent-fallback resolution.
|
||||||
|
*
|
||||||
|
* M1 — A variable product can have a "License 1-year" variation (period=year,
|
||||||
|
* length=1) and a "License 5-year" variation (period=year, length=5) living
|
||||||
|
* as siblings on the same parent product. The merchant authors those values
|
||||||
|
* per-variation. We must read the variation value first, then fall back to
|
||||||
|
* the parent if the variation didn't set it.
|
||||||
|
*
|
||||||
|
* An empty string and the literal `false` (post-meta "missing") are both
|
||||||
|
* treated as "not set". Only a real value returns.
|
||||||
|
*
|
||||||
|
* @param string $key Post meta key, e.g. '_woonoow_subscription_period'.
|
||||||
|
* @param int $variation_id Variation ID, or 0 if no variation.
|
||||||
|
* @param int $product_id Parent product ID.
|
||||||
|
* @param mixed $default Returned if neither variation nor parent has a value.
|
||||||
|
* @return mixed
|
||||||
|
*/
|
||||||
|
public static function get_subscription_meta($key, $variation_id, $product_id, $default = null)
|
||||||
|
{
|
||||||
|
if ($variation_id) {
|
||||||
|
$v = get_post_meta($variation_id, $key, true);
|
||||||
|
if ($v !== '' && $v !== false && $v !== null) {
|
||||||
|
return $v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$p = get_post_meta($product_id, $key, true);
|
||||||
|
if ($p !== '' && $p !== false && $p !== null) {
|
||||||
|
return $p;
|
||||||
|
}
|
||||||
|
return $default;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -109,11 +149,13 @@ class SubscriptionManager
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get subscription settings from product
|
// M1 — Read subscription meta variation-first, then fall back to parent.
|
||||||
$billing_period = get_post_meta($product_id, '_woonoow_subscription_period', true) ?: 'month';
|
// Variation-level overrides let a merchant sell e.g. "License 1-year" and
|
||||||
$billing_interval = absint(get_post_meta($product_id, '_woonoow_subscription_interval', true)) ?: 1;
|
// "License 5-year" as variations of one variable product.
|
||||||
$trial_days = absint(get_post_meta($product_id, '_woonoow_subscription_trial_days', true));
|
$billing_period = self::get_subscription_meta('_woonoow_subscription_period', $variation_id, $product_id, 'month');
|
||||||
$subscription_length = absint(get_post_meta($product_id, '_woonoow_subscription_length', true));
|
$billing_interval = absint(self::get_subscription_meta('_woonoow_subscription_interval', $variation_id, $product_id, 1));
|
||||||
|
$trial_days = absint(self::get_subscription_meta('_woonoow_subscription_trial_days', $variation_id, $product_id, 0));
|
||||||
|
$subscription_length = absint(self::get_subscription_meta('_woonoow_subscription_length', $variation_id, $product_id, 0));
|
||||||
|
|
||||||
// Calculate dates
|
// Calculate dates
|
||||||
$now = current_time('mysql');
|
$now = current_time('mysql');
|
||||||
@@ -285,26 +327,52 @@ class SubscriptionManager
|
|||||||
|
|
||||||
$where = "WHERE 1=1";
|
$where = "WHERE 1=1";
|
||||||
$params = [];
|
$params = [];
|
||||||
|
$joins = "";
|
||||||
|
|
||||||
if ($args['status']) {
|
if ($args['status']) {
|
||||||
$where .= " AND status = %s";
|
$where .= " AND s.status = %s";
|
||||||
$params[] = $args['status'];
|
$params[] = $args['status'];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($args['product_id']) {
|
if ($args['product_id']) {
|
||||||
$where .= " AND product_id = %d";
|
$where .= " AND s.product_id = %d";
|
||||||
$params[] = $args['product_id'];
|
$params[] = $args['product_id'];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($args['user_id']) {
|
if ($args['user_id']) {
|
||||||
$where .= " AND user_id = %d";
|
$where .= " AND s.user_id = %d";
|
||||||
$params[] = $args['user_id'];
|
$params[] = $args['user_id'];
|
||||||
}
|
}
|
||||||
|
|
||||||
$order = "ORDER BY created_at DESC";
|
// M4 — Free-text search. The user types something; we match on:
|
||||||
|
// - numeric input → subscriptions.id exactly
|
||||||
|
// - any input → user_email / display_name / user_login LIKE
|
||||||
|
// The customer-facing word "search" maps to a JOIN on wp_users. We don't
|
||||||
|
// attempt to LIKE-match product name here because that would require a
|
||||||
|
// second JOIN on wp_posts and product name relevance is rarely what the
|
||||||
|
// admin types into this box — they want to find a specific customer's
|
||||||
|
// subscription.
|
||||||
|
$search = is_string($args['search']) ? trim($args['search']) : '';
|
||||||
|
if ($search !== '') {
|
||||||
|
$joins .= " LEFT JOIN {$wpdb->users} u ON u.ID = s.user_id";
|
||||||
|
if (ctype_digit($search)) {
|
||||||
|
$where .= " AND (s.id = %d OR u.user_email LIKE %s OR u.display_name LIKE %s)";
|
||||||
|
$params[] = (int) $search;
|
||||||
|
$params[] = '%' . $wpdb->esc_like($search) . '%';
|
||||||
|
$params[] = '%' . $wpdb->esc_like($search) . '%';
|
||||||
|
} else {
|
||||||
|
$where .= " AND (u.user_email LIKE %s OR u.display_name LIKE %s OR u.user_login LIKE %s)";
|
||||||
|
$like = '%' . $wpdb->esc_like($search) . '%';
|
||||||
|
$params[] = $like;
|
||||||
|
$params[] = $like;
|
||||||
|
$params[] = $like;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$order = "ORDER BY s.created_at DESC";
|
||||||
$limit = "LIMIT " . intval($args['limit']) . " OFFSET " . intval($args['offset']);
|
$limit = "LIMIT " . intval($args['limit']) . " OFFSET " . intval($args['offset']);
|
||||||
|
|
||||||
$sql = "SELECT * FROM " . self::$table_subscriptions . " $where $order $limit";
|
$sql = "SELECT s.* FROM " . self::$table_subscriptions . " s $joins $where $order $limit";
|
||||||
|
|
||||||
if (!empty($params)) {
|
if (!empty($params)) {
|
||||||
$sql = $wpdb->prepare($sql, $params);
|
$sql = $wpdb->prepare($sql, $params);
|
||||||
@@ -316,6 +384,9 @@ class SubscriptionManager
|
|||||||
/**
|
/**
|
||||||
* Count subscriptions
|
* Count subscriptions
|
||||||
*
|
*
|
||||||
|
* M4 — supports the same `search` semantics as `get_all` so the pagination
|
||||||
|
* total matches the filtered result set.
|
||||||
|
*
|
||||||
* @param array $args
|
* @param array $args
|
||||||
* @return int
|
* @return int
|
||||||
*/
|
*/
|
||||||
@@ -325,13 +396,31 @@ class SubscriptionManager
|
|||||||
|
|
||||||
$where = "WHERE 1=1";
|
$where = "WHERE 1=1";
|
||||||
$params = [];
|
$params = [];
|
||||||
|
$joins = "";
|
||||||
|
|
||||||
if (!empty($args['status'])) {
|
if (!empty($args['status'])) {
|
||||||
$where .= " AND status = %s";
|
$where .= " AND s.status = %s";
|
||||||
$params[] = $args['status'];
|
$params[] = $args['status'];
|
||||||
}
|
}
|
||||||
|
|
||||||
$sql = "SELECT COUNT(*) FROM " . self::$table_subscriptions . " $where";
|
$search = is_string($args['search']) ? trim($args['search']) : '';
|
||||||
|
if ($search !== '') {
|
||||||
|
$joins .= " LEFT JOIN {$wpdb->users} u ON u.ID = s.user_id";
|
||||||
|
if (ctype_digit($search)) {
|
||||||
|
$where .= " AND (s.id = %d OR u.user_email LIKE %s OR u.display_name LIKE %s)";
|
||||||
|
$params[] = (int) $search;
|
||||||
|
$params[] = '%' . $wpdb->esc_like($search) . '%';
|
||||||
|
$params[] = '%' . $wpdb->esc_like($search) . '%';
|
||||||
|
} else {
|
||||||
|
$where .= " AND (u.user_email LIKE %s OR u.display_name LIKE %s OR u.user_login LIKE %s)";
|
||||||
|
$like = '%' . $wpdb->esc_like($search) . '%';
|
||||||
|
$params[] = $like;
|
||||||
|
$params[] = $like;
|
||||||
|
$params[] = $like;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "SELECT COUNT(*) FROM " . self::$table_subscriptions . " s $joins $where";
|
||||||
|
|
||||||
if (!empty($params)) {
|
if (!empty($params)) {
|
||||||
$sql = $wpdb->prepare($sql, $params);
|
$sql = $wpdb->prepare($sql, $params);
|
||||||
@@ -439,9 +528,10 @@ class SubscriptionManager
|
|||||||
[
|
[
|
||||||
'status' => 'on-hold',
|
'status' => 'on-hold',
|
||||||
'pause_count' => $subscription->pause_count + 1,
|
'pause_count' => $subscription->pause_count + 1,
|
||||||
|
'paused_at' => current_time('mysql'),
|
||||||
],
|
],
|
||||||
['id' => $subscription_id],
|
['id' => $subscription_id],
|
||||||
['%s', '%d'],
|
['%s', '%d', '%s'],
|
||||||
['%d']
|
['%d']
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -479,6 +569,8 @@ class SubscriptionManager
|
|||||||
$subscription->billing_interval
|
$subscription->billing_interval
|
||||||
);
|
);
|
||||||
$update_data['next_payment_date'] = $next_payment;
|
$update_data['next_payment_date'] = $next_payment;
|
||||||
|
$update_data['paused_at'] = null;
|
||||||
|
$format[] = '%s';
|
||||||
$format[] = '%s';
|
$format[] = '%s';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,10 +598,16 @@ class SubscriptionManager
|
|||||||
/**
|
/**
|
||||||
* Process renewal for a subscription
|
* Process renewal for a subscription
|
||||||
*
|
*
|
||||||
|
* M2 — `$charge_now = true` is the admin "charge immediately" flag. It
|
||||||
|
* bypasses the per-gateway capability gate so the auto-debit attempt is
|
||||||
|
* made even on normally-manual gateways. Use this for the ad-hoc admin
|
||||||
|
* "charge now" button, not for cron-driven renewals.
|
||||||
|
*
|
||||||
* @param int $subscription_id
|
* @param int $subscription_id
|
||||||
* @return bool
|
* @param bool $charge_now M2: bypass capability gate, never fall through to manual.
|
||||||
|
* @return bool|array
|
||||||
*/
|
*/
|
||||||
public static function renew($subscription_id)
|
public static function renew($subscription_id, $charge_now = false)
|
||||||
{
|
{
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
|
|
||||||
@@ -518,13 +616,15 @@ class SubscriptionManager
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for existing pending renewal order to prevent duplicates
|
// Check for existing pending/awaiting/failed renewal order to prevent duplicates.
|
||||||
|
// A previously failed renewal must also short-circuit so the customer retries the same
|
||||||
|
// order instead of accumulating duplicate renewal orders on the subscription.
|
||||||
$existing_pending = $wpdb->get_row($wpdb->prepare(
|
$existing_pending = $wpdb->get_row($wpdb->prepare(
|
||||||
"SELECT so.order_id FROM " . self::$table_subscription_orders . " so
|
"SELECT so.order_id FROM " . self::$table_subscription_orders . " so
|
||||||
JOIN {$wpdb->posts} p ON so.order_id = p.ID
|
JOIN {$wpdb->posts} p ON so.order_id = p.ID
|
||||||
WHERE so.subscription_id = %d
|
WHERE so.subscription_id = %d
|
||||||
AND so.order_type = 'renewal'
|
AND so.order_type = 'renewal'
|
||||||
AND p.post_status IN ('wc-pending', 'pending', 'wc-on-hold', 'on-hold')",
|
AND p.post_status IN ('wc-pending', 'pending', 'wc-on-hold', 'on-hold', 'wc-failed', 'failed')",
|
||||||
$subscription_id
|
$subscription_id
|
||||||
));
|
));
|
||||||
|
|
||||||
@@ -542,7 +642,7 @@ class SubscriptionManager
|
|||||||
|
|
||||||
// Process payment
|
// Process payment
|
||||||
// Result can be: true (paid), false (failed), or 'manual' (waiting for payment)
|
// Result can be: true (paid), false (failed), or 'manual' (waiting for payment)
|
||||||
$payment_result = self::process_renewal_payment($subscription, $renewal_order);
|
$payment_result = self::process_renewal_payment($subscription, $renewal_order, $charge_now);
|
||||||
|
|
||||||
if ($payment_result === true) {
|
if ($payment_result === true) {
|
||||||
self::handle_renewal_success($subscription_id, $renewal_order);
|
self::handle_renewal_success($subscription_id, $renewal_order);
|
||||||
@@ -596,12 +696,20 @@ class SubscriptionManager
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add product
|
// Add product. H4 — price-sync policy: by default we honor the customer's
|
||||||
|
// stored recurring_amount (grandfather them at the original price). The merchant
|
||||||
|
// can flip `price_sync_on_renewal` to 'use_current_product_price' to always bill
|
||||||
|
// the latest price on every renewal.
|
||||||
$product = wc_get_product($subscription->variation_id ?: $subscription->product_id);
|
$product = wc_get_product($subscription->variation_id ?: $subscription->product_id);
|
||||||
if ($product) {
|
if ($product) {
|
||||||
|
$settings = ModuleRegistry::get_settings('subscription');
|
||||||
|
$price_mode = $settings['price_sync_on_renewal'] ?? 'use_stored';
|
||||||
|
$line_total = ($price_mode === 'use_current_product_price' && $product->get_price() !== '')
|
||||||
|
? (float) $product->get_price()
|
||||||
|
: (float) $subscription->recurring_amount;
|
||||||
$renewal_order->add_product($product, 1, [
|
$renewal_order->add_product($product, 1, [
|
||||||
'total' => $subscription->recurring_amount,
|
'total' => $line_total,
|
||||||
'subtotal' => $subscription->recurring_amount,
|
'subtotal' => $line_total,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -631,18 +739,21 @@ class SubscriptionManager
|
|||||||
/**
|
/**
|
||||||
* Process payment for renewal order
|
* Process payment for renewal order
|
||||||
*
|
*
|
||||||
* @param object $subscription
|
* M2 — `$force = true` is used by the admin "charge now" button. It
|
||||||
* @param \WC_Order $order
|
* bypasses the GatewayCapabilities gate: the admin has explicitly
|
||||||
* @return bool
|
* declared intent to charge, so we attempt the auto-debit path even on
|
||||||
*/
|
* gateways that are normally manual-only. If the gateway does not
|
||||||
/**
|
* implement `process_subscription_renewal_payment` and no external
|
||||||
* Process payment for renewal order
|
* handler picks it up via filter, we fail loudly (return `false`) rather
|
||||||
|
* than silently creating a manual order — the admin expects an immediate
|
||||||
|
* charge, not a payment-link email.
|
||||||
*
|
*
|
||||||
* @param object $subscription
|
* @param object $subscription
|
||||||
* @param \WC_Order $order
|
* @param \WC_Order $order
|
||||||
|
* @param bool $force M2: bypass capability gate; never fall through to manual.
|
||||||
* @return bool|string True if paid, false if failed, 'manual' if waiting
|
* @return bool|string True if paid, false if failed, 'manual' if waiting
|
||||||
*/
|
*/
|
||||||
private static function process_renewal_payment($subscription, $order)
|
private static function process_renewal_payment($subscription, $order, $force = false)
|
||||||
{
|
{
|
||||||
// Allow plugins to override payment processing completely
|
// Allow plugins to override payment processing completely
|
||||||
// Return true/false/'manual' to bypass default logic
|
// Return true/false/'manual' to bypass default logic
|
||||||
@@ -663,7 +774,18 @@ class SubscriptionManager
|
|||||||
|
|
||||||
$gateway = $gateways[$gateway_id];
|
$gateway = $gateways[$gateway_id];
|
||||||
|
|
||||||
// 1. Try Auto-Debit if supported
|
// 0. Per-gateway capability gate (§9 of the audit).
|
||||||
|
// If the gateway is not declared to support subscription auto-renew (or the kill
|
||||||
|
// switch is on), we skip the auto-debit attempt entirely and fall through to
|
||||||
|
// manual. The capability table is the merchant-visible source of truth — PHP
|
||||||
|
// introspection alone is no longer authoritative.
|
||||||
|
//
|
||||||
|
// M2 — `force` skips this gate. Admin has explicitly opted in to attempting the
|
||||||
|
// charge, so we ignore the capability declaration.
|
||||||
|
$capability_ok = $force || GatewayCapabilities::should_attempt_auto_renew($gateway_id);
|
||||||
|
|
||||||
|
if ($capability_ok) {
|
||||||
|
// 1. Try Auto-Debit if supported by the gateway implementation
|
||||||
if (method_exists($gateway, 'process_subscription_renewal_payment')) {
|
if (method_exists($gateway, 'process_subscription_renewal_payment')) {
|
||||||
$result = $gateway->process_subscription_renewal_payment($order, $subscription);
|
$result = $gateway->process_subscription_renewal_payment($order, $subscription);
|
||||||
if (!is_wp_error($result) && $result) {
|
if (!is_wp_error($result) && $result) {
|
||||||
@@ -672,6 +794,7 @@ class SubscriptionManager
|
|||||||
// If explicit failure from auto-debit, return false (will trigger retry logic)
|
// If explicit failure from auto-debit, return false (will trigger retry logic)
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Allow other plugins to handle auto-debit via filter (e.g. Stripe/PayPal adapters)
|
// 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);
|
$external_result = apply_filters('woonoow_process_subscription_payment', null, $gateway, $order, $subscription);
|
||||||
@@ -680,6 +803,15 @@ class SubscriptionManager
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Fallback: Manual Payment
|
// 3. Fallback: Manual Payment
|
||||||
|
// M2 — In force mode, the admin said "charge now". If we got here, the gateway
|
||||||
|
// does not implement auto-debit and no external handler picked it up. Creating
|
||||||
|
// a manual order would silently contradict the admin's intent. Fail loudly so
|
||||||
|
// the admin sees the charge could not be processed.
|
||||||
|
if ($force) {
|
||||||
|
$order->update_status('failed', __('Admin "charge now" requested, but gateway does not support auto-debit', 'woonoow'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Set order to pending-payment
|
// Set order to pending-payment
|
||||||
$order->update_status('pending', __('Awaiting manual renewal payment', 'woonoow'));
|
$order->update_status('pending', __('Awaiting manual renewal payment', 'woonoow'));
|
||||||
|
|
||||||
|
|||||||
@@ -38,9 +38,16 @@ class SubscriptionModule
|
|||||||
add_action('woocommerce_order_status_completed', [__CLASS__, 'maybe_create_subscription'], 10, 1);
|
add_action('woocommerce_order_status_completed', [__CLASS__, 'maybe_create_subscription'], 10, 1);
|
||||||
add_action('woocommerce_order_status_processing', [__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
|
// Hook into order status change to handle manual renewal payments and status sync
|
||||||
add_action('woocommerce_order_status_changed', [__CLASS__, 'on_order_status_changed'], 10, 3);
|
add_action('woocommerce_order_status_changed', [__CLASS__, 'on_order_status_changed'], 10, 3);
|
||||||
|
|
||||||
|
// Hook into entity deletions for cleanup and state sync
|
||||||
|
add_action('woocommerce_trash_order', [__CLASS__, 'on_order_deleted']);
|
||||||
|
add_action('woocommerce_delete_order', [__CLASS__, 'on_order_deleted']);
|
||||||
|
add_action('trashed_post', [__CLASS__, 'on_post_deleted']);
|
||||||
|
add_action('deleted_post', [__CLASS__, 'on_post_deleted']);
|
||||||
|
add_action('delete_user', [__CLASS__, 'on_user_deleted']);
|
||||||
|
|
||||||
// Modify add to cart button text for subscription products
|
// 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_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);
|
add_filter('woocommerce_product_add_to_cart_text', [__CLASS__, 'subscription_add_to_cart_text'], 10, 2);
|
||||||
@@ -275,6 +282,14 @@ class SubscriptionModule
|
|||||||
|
|
||||||
$product_id = $product->get_id();
|
$product_id = $product->get_id();
|
||||||
if (get_post_meta($product_id, '_woonoow_subscription_enabled', true) === 'yes') {
|
if (get_post_meta($product_id, '_woonoow_subscription_enabled', true) === 'yes') {
|
||||||
|
// Guests cannot have subscriptions — create_from_order() rejects guest orders silently
|
||||||
|
// (H6). Show the standard add-to-cart text and let the regular checkout flow handle
|
||||||
|
// guest sign-up. The subscription will only be created if/when the customer converts
|
||||||
|
// to a user. We do NOT advertise a subscription capability the system cannot honor.
|
||||||
|
if (!is_user_logged_in()) {
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
|
||||||
$settings = ModuleRegistry::get_settings('subscription');
|
$settings = ModuleRegistry::get_settings('subscription');
|
||||||
return $settings['button_text_subscribe'] ?? __('Subscribe Now', 'woonoow');
|
return $settings['button_text_subscribe'] ?? __('Subscribe Now', 'woonoow');
|
||||||
}
|
}
|
||||||
@@ -532,7 +547,7 @@ class SubscriptionModule
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle manual renewal payment completion
|
* Handle order status changes for both parent and renewal orders
|
||||||
*/
|
*/
|
||||||
public static function on_order_status_changed($order_id, $old_status, $new_status)
|
public static function on_order_status_changed($order_id, $old_status, $new_status)
|
||||||
{
|
{
|
||||||
@@ -540,24 +555,108 @@ class SubscriptionModule
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!in_array($new_status, ['processing', 'completed'])) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this is a subscription renewal order
|
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
$table_orders = $wpdb->prefix . 'woonoow_subscription_orders';
|
$table_orders = $wpdb->prefix . 'woonoow_subscription_orders';
|
||||||
|
|
||||||
$link = $wpdb->get_row($wpdb->prepare(
|
// Find if this order is linked to any subscription
|
||||||
|
$links = $wpdb->get_results($wpdb->prepare(
|
||||||
"SELECT subscription_id, order_type FROM $table_orders WHERE order_id = %d",
|
"SELECT subscription_id, order_type FROM $table_orders WHERE order_id = %d",
|
||||||
$order_id
|
$order_id
|
||||||
));
|
));
|
||||||
|
|
||||||
if ($link && $link->order_type === 'renewal') {
|
if (empty($links)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($links as $link) {
|
||||||
|
$subscription = SubscriptionManager::get($link->subscription_id);
|
||||||
|
if (!$subscription) continue;
|
||||||
|
|
||||||
|
if ($link->order_type === 'renewal') {
|
||||||
|
if (in_array($new_status, ['processing', 'completed'])) {
|
||||||
$order = wc_get_order($order_id);
|
$order = wc_get_order($order_id);
|
||||||
if ($order) {
|
if ($order) {
|
||||||
SubscriptionManager::handle_renewal_success($link->subscription_id, $order);
|
SubscriptionManager::handle_renewal_success($link->subscription_id, $order);
|
||||||
}
|
}
|
||||||
|
} elseif ($new_status === 'failed') {
|
||||||
|
SubscriptionManager::handle_renewal_failure($link->subscription_id);
|
||||||
|
} elseif ($new_status === 'cancelled') {
|
||||||
|
SubscriptionManager::update_status($link->subscription_id, 'cancelled', 'renewal_order_cancelled');
|
||||||
|
} elseif ($new_status === 'refunded') {
|
||||||
|
SubscriptionManager::update_status($link->subscription_id, 'on-hold', 'renewal_order_refunded');
|
||||||
|
}
|
||||||
|
} elseif ($link->order_type === 'parent') {
|
||||||
|
if (in_array($new_status, ['refunded', 'cancelled'])) {
|
||||||
|
SubscriptionManager::update_status($link->subscription_id, 'cancelled', 'parent_order_' . $new_status);
|
||||||
|
} elseif ($new_status === 'on-hold') {
|
||||||
|
SubscriptionManager::update_status($link->subscription_id, 'on-hold', 'parent_order_on_hold');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle order trashing/deletion
|
||||||
|
*/
|
||||||
|
public static function on_order_deleted($order_id)
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
$table_orders = $wpdb->prefix . 'woonoow_subscription_orders';
|
||||||
|
|
||||||
|
$links = $wpdb->get_results($wpdb->prepare(
|
||||||
|
"SELECT subscription_id, order_type FROM $table_orders WHERE order_id = %d",
|
||||||
|
$order_id
|
||||||
|
));
|
||||||
|
|
||||||
|
foreach ($links as $link) {
|
||||||
|
if ($link->order_type === 'parent') {
|
||||||
|
SubscriptionManager::update_status($link->subscription_id, 'cancelled', 'parent_order_deleted');
|
||||||
|
} elseif ($link->order_type === 'renewal') {
|
||||||
|
SubscriptionManager::update_status($link->subscription_id, 'on-hold', 'renewal_order_deleted');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle product trashing/deletion
|
||||||
|
*/
|
||||||
|
public static function on_post_deleted($post_id)
|
||||||
|
{
|
||||||
|
if (get_post_type($post_id) !== 'product') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
global $wpdb;
|
||||||
|
$table_subs = $wpdb->prefix . 'woonoow_subscriptions';
|
||||||
|
|
||||||
|
// Find active/on-hold subscriptions for this product
|
||||||
|
$affected = $wpdb->get_col($wpdb->prepare(
|
||||||
|
"SELECT id FROM $table_subs WHERE product_id = %d AND status IN ('active', 'on-hold', 'pending')",
|
||||||
|
$post_id
|
||||||
|
));
|
||||||
|
|
||||||
|
foreach ($affected as $sub_id) {
|
||||||
|
SubscriptionManager::update_status($sub_id, 'on-hold', 'product_deleted');
|
||||||
|
// Fire an action so merchants can hook in and get alerted
|
||||||
|
do_action('woonoow/subscription/product_deleted_alert', $sub_id, $post_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle user deletion
|
||||||
|
*/
|
||||||
|
public static function on_user_deleted($user_id)
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
$table_subs = $wpdb->prefix . 'woonoow_subscriptions';
|
||||||
|
|
||||||
|
$affected = $wpdb->get_col($wpdb->prepare(
|
||||||
|
"SELECT id FROM $table_subs WHERE user_id = %d AND status != 'cancelled'",
|
||||||
|
$user_id
|
||||||
|
));
|
||||||
|
|
||||||
|
foreach ($affected as $sub_id) {
|
||||||
|
SubscriptionManager::update_status($sub_id, 'cancelled', 'user_deleted');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,16 @@ class SubscriptionScheduler
|
|||||||
*/
|
*/
|
||||||
const REMINDER_HOOK = 'woonoow_send_renewal_reminders';
|
const REMINDER_HOOK = 'woonoow_send_renewal_reminders';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cron hook for retrying unpaid manual renewals (H5).
|
||||||
|
*/
|
||||||
|
const UNPAID_RETRY_HOOK = 'woonoow_retry_unpaid_renewals';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cron hook for auto-resuming subscriptions paused beyond the allowed duration.
|
||||||
|
*/
|
||||||
|
const PAUSE_EXPIRY_HOOK = 'woonoow_check_pause_expirations';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the scheduler
|
* Initialize the scheduler
|
||||||
*/
|
*/
|
||||||
@@ -41,6 +51,8 @@ class SubscriptionScheduler
|
|||||||
add_action(self::RENEWAL_HOOK, [__CLASS__, 'process_renewals']);
|
add_action(self::RENEWAL_HOOK, [__CLASS__, 'process_renewals']);
|
||||||
add_action(self::EXPIRY_HOOK, [__CLASS__, 'check_expirations']);
|
add_action(self::EXPIRY_HOOK, [__CLASS__, 'check_expirations']);
|
||||||
add_action(self::REMINDER_HOOK, [__CLASS__, 'send_reminders']);
|
add_action(self::REMINDER_HOOK, [__CLASS__, 'send_reminders']);
|
||||||
|
add_action(self::UNPAID_RETRY_HOOK, [__CLASS__, 'retry_unpaid_renewals']);
|
||||||
|
add_action(self::PAUSE_EXPIRY_HOOK, [__CLASS__, 'check_pause_expirations']);
|
||||||
|
|
||||||
// Schedule cron events if not already scheduled
|
// Schedule cron events if not already scheduled
|
||||||
self::schedule_events();
|
self::schedule_events();
|
||||||
@@ -65,6 +77,14 @@ class SubscriptionScheduler
|
|||||||
if (!wp_next_scheduled(self::REMINDER_HOOK)) {
|
if (!wp_next_scheduled(self::REMINDER_HOOK)) {
|
||||||
wp_schedule_event(time(), 'daily', self::REMINDER_HOOK);
|
wp_schedule_event(time(), 'daily', self::REMINDER_HOOK);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!wp_next_scheduled(self::UNPAID_RETRY_HOOK)) {
|
||||||
|
wp_schedule_event(time(), 'twicedaily', self::UNPAID_RETRY_HOOK);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!wp_next_scheduled(self::PAUSE_EXPIRY_HOOK)) {
|
||||||
|
wp_schedule_event(time(), 'daily', self::PAUSE_EXPIRY_HOOK);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -75,6 +95,8 @@ class SubscriptionScheduler
|
|||||||
wp_clear_scheduled_hook(self::RENEWAL_HOOK);
|
wp_clear_scheduled_hook(self::RENEWAL_HOOK);
|
||||||
wp_clear_scheduled_hook(self::EXPIRY_HOOK);
|
wp_clear_scheduled_hook(self::EXPIRY_HOOK);
|
||||||
wp_clear_scheduled_hook(self::REMINDER_HOOK);
|
wp_clear_scheduled_hook(self::REMINDER_HOOK);
|
||||||
|
wp_clear_scheduled_hook(self::UNPAID_RETRY_HOOK);
|
||||||
|
wp_clear_scheduled_hook(self::PAUSE_EXPIRY_HOOK);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -260,4 +282,132 @@ class SubscriptionScheduler
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* H5 — Find subscriptions that are on-hold because a manual renewal order
|
||||||
|
* was created and never paid. Send a re-notification once per 24h. After
|
||||||
|
* `unpaid_renewal_max_age_days` (default 7), auto-cancel to prevent
|
||||||
|
* indefinite on-hold state. Auto-cancel is the last-resort safety net;
|
||||||
|
* the admin can resume manually at any time.
|
||||||
|
*/
|
||||||
|
public static function retry_unpaid_renewals()
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
if (!ModuleRegistry::is_enabled('subscription')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings = ModuleRegistry::get_settings('subscription');
|
||||||
|
$max_age_days = isset($settings['unpaid_renewal_max_age_days'])
|
||||||
|
? max(1, (int) $settings['unpaid_renewal_max_age_days'])
|
||||||
|
: 7;
|
||||||
|
|
||||||
|
$table_subs = $wpdb->prefix . 'woonoow_subscriptions';
|
||||||
|
$table_orders = $wpdb->prefix . 'woonoow_subscription_orders';
|
||||||
|
$posts = $wpdb->posts;
|
||||||
|
$now = current_time('mysql');
|
||||||
|
$min_age = date('Y-m-d H:i:s', strtotime('-24 hours', strtotime($now)));
|
||||||
|
$max_age_cutoff = date('Y-m-d H:i:s', strtotime("-{$max_age_days} days", strtotime($now)));
|
||||||
|
$reminder_threshold = date('Y-m-d H:i:s', strtotime('-24 hours', strtotime($now)));
|
||||||
|
|
||||||
|
// Find (subscription, order) pairs where the renewal order is still unpaid
|
||||||
|
// and the order was created at least 24h ago. We rate-limit per-order by
|
||||||
|
// storing the last-notice timestamp in order meta.
|
||||||
|
$candidates = $wpdb->get_results($wpdb->prepare(
|
||||||
|
"SELECT s.id AS subscription_id, o.order_id
|
||||||
|
FROM $table_subs s
|
||||||
|
JOIN $table_orders o ON o.subscription_id = s.id AND o.order_type = 'renewal'
|
||||||
|
JOIN $posts p ON p.ID = o.order_id
|
||||||
|
WHERE s.status = 'on-hold'
|
||||||
|
AND p.post_status IN ('wc-pending', 'pending', 'wc-failed', 'failed')
|
||||||
|
AND p.post_date <= %s
|
||||||
|
AND p.post_date >= %s",
|
||||||
|
$min_age,
|
||||||
|
$max_age_cutoff
|
||||||
|
));
|
||||||
|
|
||||||
|
foreach ($candidates as $row) {
|
||||||
|
$order = wc_get_order($row->order_id);
|
||||||
|
if (!$order) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-order rate limit: don't re-notify more than once per 24h.
|
||||||
|
$last_notice = (string) $order->get_meta('_woonoow_unpaid_notice_at', true);
|
||||||
|
if ($last_notice !== '' && strtotime($last_notice) > strtotime($reminder_threshold)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$subscription = SubscriptionManager::get($row->subscription_id);
|
||||||
|
if (!$subscription) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-fire the same notification that fired at first renewal. Email
|
||||||
|
// templates registered for `renewal_payment_due` will be sent.
|
||||||
|
do_action('woonoow/subscription/renewal_payment_due', $subscription->id, $order);
|
||||||
|
|
||||||
|
$order->update_meta_data('_woonoow_unpaid_notice_at', $now);
|
||||||
|
$order->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-cancel: anything older than the cutoff that is still on-hold gets
|
||||||
|
// cancelled outright. The admin can override by resuming manually.
|
||||||
|
$auto_cancel = $wpdb->get_results($wpdb->prepare(
|
||||||
|
"SELECT id FROM $table_subs
|
||||||
|
WHERE status = 'on-hold'
|
||||||
|
AND next_payment_date IS NOT NULL
|
||||||
|
AND next_payment_date <= %s",
|
||||||
|
$max_age_cutoff
|
||||||
|
));
|
||||||
|
foreach ($auto_cancel as $row) {
|
||||||
|
SubscriptionManager::update_status($row->id, 'cancelled', 'unpaid_renewal_timeout');
|
||||||
|
do_action('woonoow/subscription/cancelled', $row->id, 'unpaid_renewal_timeout');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-resume subscriptions that have been paused beyond the merchant-configured
|
||||||
|
* maximum pause duration. Runs daily.
|
||||||
|
*
|
||||||
|
* Setting: `max_pause_duration_days` (int, 0 = disabled/unlimited).
|
||||||
|
* When a subscription hits the limit it is automatically resumed, giving the
|
||||||
|
* customer a fresh billing cycle from now.
|
||||||
|
*/
|
||||||
|
public static function check_pause_expirations()
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
if (!ModuleRegistry::is_enabled('subscription')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings = ModuleRegistry::get_settings('subscription');
|
||||||
|
$max_days = isset($settings['max_pause_duration_days']) ? (int) $settings['max_pause_duration_days'] : 0;
|
||||||
|
|
||||||
|
// 0 means unlimited — feature is disabled.
|
||||||
|
if ($max_days <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$table = $wpdb->prefix . 'woonoow_subscriptions';
|
||||||
|
$cutoff = date('Y-m-d H:i:s', strtotime("-{$max_days} days"));
|
||||||
|
|
||||||
|
// Find on-hold subscriptions paused longer than the allowed duration.
|
||||||
|
$expired_pauses = $wpdb->get_results($wpdb->prepare(
|
||||||
|
"SELECT id FROM $table
|
||||||
|
WHERE status = 'on-hold'
|
||||||
|
AND paused_at IS NOT NULL
|
||||||
|
AND paused_at <= %s",
|
||||||
|
$cutoff
|
||||||
|
));
|
||||||
|
|
||||||
|
foreach ($expired_pauses as $row) {
|
||||||
|
$resumed = SubscriptionManager::resume($row->id);
|
||||||
|
if ($resumed) {
|
||||||
|
do_action('woonoow/subscription/auto_resumed', $row->id, 'max_pause_duration_exceeded');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,6 +102,30 @@ class SubscriptionSettings {
|
|||||||
'min' => 1,
|
'min' => 1,
|
||||||
'max' => 14,
|
'max' => 14,
|
||||||
],
|
],
|
||||||
|
'force_manual_renewal' => [
|
||||||
|
'type' => 'toggle',
|
||||||
|
'label' => __('Force Manual Renewal (Override All Gateways)', 'woonoow'),
|
||||||
|
'description' => __('Treat every gateway as manual-renewal only, regardless of the per-gateway capability table. Use as a kill switch when a regulator or incident requires no auto-debits.', 'woonoow'),
|
||||||
|
'default' => false,
|
||||||
|
],
|
||||||
|
'price_sync_on_renewal' => [
|
||||||
|
'type' => 'select',
|
||||||
|
'label' => __('Renewal Price Sync', 'woonoow'),
|
||||||
|
'description' => __('What price does a renewal order use when the product price has changed since the subscription started? "Use stored" grandfathers the customer at their original price (recommended). "Use current" re-syncs every renewal to the latest product price.', 'woonoow'),
|
||||||
|
'options' => [
|
||||||
|
'use_stored' => __('Use stored price (grandfather customer)', 'woonoow'),
|
||||||
|
'use_current_product_price' => __('Use current product price', 'woonoow'),
|
||||||
|
],
|
||||||
|
'default' => 'use_stored',
|
||||||
|
],
|
||||||
|
'unpaid_renewal_max_age_days' => [
|
||||||
|
'type' => 'number',
|
||||||
|
'label' => __('Unpaid Renewal Auto-Cancel (days)', 'woonoow'),
|
||||||
|
'description' => __('Days an unpaid manual renewal can stay on-hold before the subscription is auto-cancelled. The customer receives a daily reminder during this window. Set to 0 to disable auto-cancel (not recommended — abandoned-cart revenue leakage).', 'woonoow'),
|
||||||
|
'default' => 7,
|
||||||
|
'min' => 1,
|
||||||
|
'max' => 90,
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
return $schemas;
|
return $schemas;
|
||||||
|
|||||||
Reference in New Issue
Block a user