Subscription module: add gateway capability flow and UX fixes
This commit is contained in:
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.**
|
||||
@@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { DynamicComponentLoader } from '@/components/DynamicComponentLoader';
|
||||
import { GatewayCapabilityMatrix as SubscriptionGatewayCapabilitiesSection } from './Modules/Subscription/GatewayCapabilityMatrix';
|
||||
|
||||
interface Module {
|
||||
id: string;
|
||||
@@ -143,6 +144,8 @@ export default function ModuleSettings() {
|
||||
</div>
|
||||
)}
|
||||
</SettingsCard>
|
||||
|
||||
{moduleId === 'subscription' && <SubscriptionGatewayCapabilitiesSection />}
|
||||
</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;
|
||||
}
|
||||
|
||||
async function subscriptionAction(id: number, action: string, reason?: string) {
|
||||
const res = await api.post(`/subscriptions/${id}/${action}`, { reason });
|
||||
async function subscriptionAction(id: number, action: string, reason?: string, extra?: Record<string, unknown>) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
if (action === 'cancel') {
|
||||
setShowCancelDialog(true);
|
||||
return;
|
||||
}
|
||||
if (action === 'renew') {
|
||||
renewMutation.mutate();
|
||||
return;
|
||||
}
|
||||
if (action === 'charge_now') {
|
||||
chargeNowMutation.mutate();
|
||||
return;
|
||||
}
|
||||
actionMutation.mutate({ action });
|
||||
};
|
||||
|
||||
@@ -229,10 +298,21 @@ export default function SubscriptionDetail() {
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleAction('renew')}
|
||||
disabled={actionMutation.isPending}
|
||||
disabled={renewMutation.isPending || chargeNowMutation.isPending || actionMutation.isPending}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
{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 { useNavigate, Link } from 'react-router-dom';
|
||||
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 [page, setPage] = useState(Number(initial.page ?? 1) || 1);
|
||||
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 [showCancelDialog, setShowCancelDialog] = useState(false);
|
||||
const [cancelTargetId, setCancelTargetId] = useState<number | null>(null);
|
||||
const [showBulkCancelDialog, setShowBulkCancelDialog] = useState(false);
|
||||
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(() => {
|
||||
setPageHeader(__('Subscriptions'));
|
||||
return () => clearPageHeader();
|
||||
}, [setPageHeader, clearPageHeader]);
|
||||
|
||||
useEffect(() => {
|
||||
setQuery({ page, status });
|
||||
}, [page, status]);
|
||||
setQuery({ page, status, search: committedSearch || undefined });
|
||||
}, [page, status, committedSearch]);
|
||||
|
||||
const q = useQuery({
|
||||
queryKey: ['subscriptions', { status, page }],
|
||||
queryFn: () => api.get('/subscriptions', { status, page, per_page: perPage }),
|
||||
queryKey: ['subscriptions', { status, page, search: committedSearch }],
|
||||
queryFn: () => api.get('/subscriptions', {
|
||||
status,
|
||||
page,
|
||||
per_page: perPage,
|
||||
search: committedSearch || undefined,
|
||||
}),
|
||||
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
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
const allIds = subscriptions.map(s => s.id);
|
||||
@@ -191,7 +282,22 @@ export default function SubscriptionsIndex() {
|
||||
</button>
|
||||
</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" />
|
||||
<Select
|
||||
value={status ?? 'all'}
|
||||
@@ -216,10 +322,10 @@ export default function SubscriptionsIndex() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{status && (
|
||||
{(status || committedSearch) && (
|
||||
<button
|
||||
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')}
|
||||
</button>
|
||||
@@ -235,6 +341,14 @@ export default function SubscriptionsIndex() {
|
||||
{/* Mobile: Status filter bar */}
|
||||
<div className="md:hidden">
|
||||
<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
|
||||
value={status ?? 'all'}
|
||||
onValueChange={(v) => {
|
||||
@@ -273,6 +387,43 @@ export default function SubscriptionsIndex() {
|
||||
</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 */}
|
||||
{q.isLoading && (
|
||||
<div className="space-y-3">
|
||||
@@ -500,6 +651,37 @@ export default function SubscriptionsIndex() {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@ import { toast } from 'sonner';
|
||||
import SEOHead from '@/components/SEOHead';
|
||||
import { formatPrice } from '@/lib/currency';
|
||||
|
||||
const formatDate = (dateStr: string) =>
|
||||
new Date(dateStr).toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
|
||||
interface SubscriptionOrder {
|
||||
id: number;
|
||||
order_id: number;
|
||||
@@ -43,7 +46,11 @@ interface Subscription {
|
||||
end_date: string | null;
|
||||
last_payment_date: string | null;
|
||||
payment_method: string;
|
||||
payment_method_title: string;
|
||||
pause_count: number;
|
||||
max_pause_count?: number;
|
||||
pauses_remaining?: number | null;
|
||||
paused_at?: string | null;
|
||||
can_pause: boolean;
|
||||
can_resume: boolean;
|
||||
can_cancel: boolean;
|
||||
@@ -51,12 +58,12 @@ interface Subscription {
|
||||
}
|
||||
|
||||
const statusStyles: Record<string, string> = {
|
||||
'pending': 'bg-yellow-100 text-yellow-800',
|
||||
'active': 'bg-green-100 text-green-800',
|
||||
'on-hold': 'bg-blue-100 text-blue-800',
|
||||
'cancelled': 'bg-gray-100 text-gray-800',
|
||||
'expired': 'bg-red-100 text-red-800',
|
||||
'pending-cancel': 'bg-orange-100 text-orange-800',
|
||||
'pending': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300',
|
||||
'active': 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300',
|
||||
'on-hold': 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
|
||||
'cancelled': 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300',
|
||||
'expired': 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300',
|
||||
'pending-cancel': 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300',
|
||||
};
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
@@ -124,7 +131,7 @@ export default function SubscriptionDetail() {
|
||||
if (response.order_id) {
|
||||
// Determine destination based on functionality
|
||||
// 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) {
|
||||
toast.error(error.message || 'Failed to renew');
|
||||
@@ -166,7 +173,7 @@ export default function SubscriptionDetail() {
|
||||
{/* Back button */}
|
||||
<Link
|
||||
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" />
|
||||
Back to Subscriptions
|
||||
@@ -179,8 +186,8 @@ export default function SubscriptionDetail() {
|
||||
<Repeat className="h-6 w-6" />
|
||||
Subscription #{subscription.id}
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
Started {new Date(subscription.start_date).toLocaleDateString()}
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
||||
Started {formatDate(subscription.start_date)}
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{/* 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">
|
||||
{subscription.product_image ? (
|
||||
<img
|
||||
@@ -198,16 +205,16 @@ export default function SubscriptionDetail() {
|
||||
className="w-20 h-20 object-cover rounded"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-20 h-20 bg-gray-100 rounded flex items-center justify-center">
|
||||
<Package className="h-10 w-10 text-gray-400" />
|
||||
<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 dark:text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<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">
|
||||
{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}
|
||||
</span>
|
||||
</p>
|
||||
@@ -216,65 +223,111 @@ export default function SubscriptionDetail() {
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Start Date</p>
|
||||
<p className="font-medium">{new Date(subscription.start_date).toLocaleDateString()}</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Start Date</p>
|
||||
<p className="font-medium">{formatDate(subscription.start_date)}</p>
|
||||
</div>
|
||||
{subscription.next_payment_date && (
|
||||
<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">
|
||||
<Calendar className="h-4 w-4 text-gray-400" />
|
||||
{new Date(subscription.next_payment_date).toLocaleDateString()}
|
||||
<Calendar className="h-4 w-4 text-gray-400 dark:text-gray-500" />
|
||||
{formatDate(subscription.next_payment_date!)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{subscription.trial_end_date && new Date(subscription.trial_end_date) > new Date() && (
|
||||
<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">
|
||||
{new Date(subscription.trial_end_date).toLocaleDateString()}
|
||||
{formatDate(subscription.trial_end_date!)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{subscription.end_date && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">End Date</p>
|
||||
<p className="font-medium">{new Date(subscription.end_date).toLocaleDateString()}</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">End Date</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>
|
||||
<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">
|
||||
<CreditCard className="h-4 w-4 text-gray-400" />
|
||||
{subscription.payment_method || 'Not set'}
|
||||
<CreditCard className="h-4 w-4 text-gray-400 dark:text-gray-500" />
|
||||
{subscription.payment_method_title || subscription.payment_method || 'Not set'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Times Paused</p>
|
||||
<p className="font-medium">{subscription.pause_count}</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Times Paused</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>
|
||||
|
||||
{/* Actions */}
|
||||
{(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>
|
||||
<div className="flex items-center gap-3">
|
||||
{subscription.can_pause && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleAction('pause')}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<Pause className="h-4 w-4 mr-2" />
|
||||
Pause Subscription
|
||||
</Button>
|
||||
)}
|
||||
{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
|
||||
variant="outline"
|
||||
disabled={actionLoading || limitReached}
|
||||
title={tooltip}
|
||||
>
|
||||
<Pause className="h-4 w-4 mr-2" />
|
||||
Pause Subscription
|
||||
</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 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -301,7 +354,7 @@ export default function SubscriptionDetail() {
|
||||
{pendingRenewalOrder && (
|
||||
<Button
|
||||
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" />
|
||||
Pay Now (#{pendingRenewalOrder.order_id})
|
||||
@@ -346,7 +399,7 @@ export default function SubscriptionDetail() {
|
||||
|
||||
{/* Related Orders */}
|
||||
{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">
|
||||
<FileText className="h-5 w-5" />
|
||||
Payment History
|
||||
@@ -356,16 +409,16 @@ export default function SubscriptionDetail() {
|
||||
<Link
|
||||
key={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">
|
||||
<span className="font-medium">Order #{order.order_id}</span>
|
||||
<span className="text-xs px-2 py-0.5 bg-gray-100 rounded">
|
||||
<span className="font-medium text-foreground">Order #{order.order_id}</span>
|
||||
<span className="text-xs px-2 py-0.5 bg-muted text-muted-foreground rounded">
|
||||
{orderTypeLabels[order.order_type] || order.order_type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{new Date(order.created_at).toLocaleDateString()}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{formatDate(order.created_at)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
@@ -40,6 +40,8 @@ interface OrderDetailsResponse extends BaseResponse {
|
||||
start_date: string;
|
||||
next_payment_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 (!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} />
|
||||
)}
|
||||
|
||||
{isRenewal && !order.subscription && (
|
||||
<div className="bg-blue-50 border-l-4 border-blue-500 p-4 mb-6">
|
||||
<div className="flex">
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-blue-700">
|
||||
This is a payment for your <span className="font-bold">subscription renewal</span>.
|
||||
Completing this payment will extend your subscription period.
|
||||
</p>
|
||||
{isRenewal && !order.subscription && (() => {
|
||||
// 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-shrink-0">
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className={`text-sm ${isAuto ? 'text-blue-700' : 'text-amber-800'}`}>
|
||||
{isAuto ? (
|
||||
<>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.</>
|
||||
) : (
|
||||
<>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.</>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
|
||||
<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>
|
||||
|
||||
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.
|
||||
@@ -288,6 +288,12 @@ class CheckoutController
|
||||
'next_payment_date' => $sub->next_payment_date,
|
||||
'end_date' => $sub->end_date,
|
||||
'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
|
||||
$order = wc_create_order();
|
||||
$order = wc_create_order([
|
||||
'created_via' => 'checkout'
|
||||
]);
|
||||
if (is_wp_error($order)) {
|
||||
return ['error' => $order->get_error_message()];
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ use WP_REST_Response;
|
||||
use WP_Error;
|
||||
use WooNooW\Core\ModuleRegistry;
|
||||
use WooNooW\Modules\Subscription\SubscriptionManager;
|
||||
use WooNooW\Modules\Subscription\GatewayCapabilities;
|
||||
|
||||
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+)', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'get_subscription'],
|
||||
@@ -136,6 +148,23 @@ class SubscriptionsController
|
||||
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'),
|
||||
'product_id' => $request->get_param('product_id'),
|
||||
'user_id' => $request->get_param('user_id'),
|
||||
'search' => $request->get_param('search'),
|
||||
'limit' => $request->get_param('per_page') ?: 20,
|
||||
'offset' => (($request->get_param('page') ?: 1) - 1) * ($request->get_param('per_page') ?: 20),
|
||||
];
|
||||
|
||||
$subscriptions = SubscriptionManager::get_all($args);
|
||||
$total = SubscriptionManager::count(['status' => $args['status']]);
|
||||
$total = SubscriptionManager::count([
|
||||
'status' => $args['status'],
|
||||
'search' => $args['search'],
|
||||
]);
|
||||
|
||||
// Enrich with product and user info
|
||||
$enriched = [];
|
||||
@@ -244,16 +277,27 @@ class SubscriptionsController
|
||||
|
||||
/**
|
||||
* 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)
|
||||
{
|
||||
$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) {
|
||||
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]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@@ -453,10 +589,31 @@ class SubscriptionsController
|
||||
|
||||
// Add computed fields
|
||||
$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']);
|
||||
|
||||
// Expose paused_at so the UI can show when the subscription was paused
|
||||
$enriched['paused_at'] = $subscription->paused_at ?? null;
|
||||
|
||||
// Format billing info
|
||||
$period_labels = [
|
||||
'day' => __('day', 'woonoow'),
|
||||
@@ -496,6 +653,110 @@ class SubscriptionsController
|
||||
|
||||
$enriched['payment_method_title'] = $payment_title;
|
||||
|
||||
// §9 — Tell the client whether the stored gateway is declared to support
|
||||
// subscription auto-renew. The renewal flow uses this for messaging and the
|
||||
// admin uses it for at-a-glance status.
|
||||
$enriched['gateway_supports_auto_renew'] = !empty($subscription->payment_method)
|
||||
? GatewayCapabilities::should_attempt_auto_renew($subscription->payment_method)
|
||||
: false;
|
||||
$enriched['gateway_force_manual'] = GatewayCapabilities::force_manual();
|
||||
|
||||
return $enriched;
|
||||
}
|
||||
|
||||
/**
|
||||
* §9 — List the merged gateway capability matrix for the admin UI.
|
||||
*
|
||||
* Returns a row per available WC payment gateway with:
|
||||
* id, title, description, enabled (site-enabled),
|
||||
* auto_renew (effective — capability table + kill switch),
|
||||
* override (the merchant-set override, or null if using default),
|
||||
* default (the built-in default for this gateway ID)
|
||||
*/
|
||||
public static function get_gateway_capabilities(WP_REST_Request $request)
|
||||
{
|
||||
if (!function_exists('WC')) {
|
||||
return new WP_Error('wc_missing', __('WooCommerce is not active.', 'woonoow'), ['status' => 500]);
|
||||
}
|
||||
|
||||
$gateways = WC()->payment_gateways()->payment_gateways();
|
||||
$stored = get_option(GatewayCapabilities::OPTION_KEY, []);
|
||||
if (!is_array($stored)) {
|
||||
$stored = [];
|
||||
}
|
||||
$defaults = GatewayCapabilities::default_capabilities();
|
||||
$kill_switch = GatewayCapabilities::force_manual();
|
||||
|
||||
$rows = [];
|
||||
foreach ($gateways as $id => $gateway) {
|
||||
$default = isset($defaults[$id]) ? (bool) $defaults[$id]['subscription_auto_renew'] : false;
|
||||
$override = array_key_exists($id, $stored) ? (bool) $stored[$id]['subscription_auto_renew'] : null;
|
||||
$effective = GatewayCapabilities::should_attempt_auto_renew($id);
|
||||
$rows[] = [
|
||||
'id' => $id,
|
||||
'title' => $gateway->get_title() ?: $gateway->method_title ?: $id,
|
||||
'description' => $gateway->get_description(),
|
||||
'enabled' => $gateway->enabled === 'yes',
|
||||
'default' => $default,
|
||||
'override' => $override,
|
||||
'auto_renew' => $effective,
|
||||
'forced_manual' => $kill_switch,
|
||||
];
|
||||
}
|
||||
|
||||
return new WP_REST_Response([
|
||||
'gateways' => array_values($rows),
|
||||
'kill_switch' => $kill_switch,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* §9 — Persist merchant overrides for the per-gateway capability table.
|
||||
*
|
||||
* Body shape: { overrides: { '<gateway_id>': bool | null, ... } }
|
||||
* - bool => explicit override (true = auto-renew, false = manual)
|
||||
* - null => clear override, fall back to default
|
||||
*
|
||||
* The kill switch is NOT set here — it lives in the standard module
|
||||
* settings under `force_manual_renewal` (use the generic settings endpoint).
|
||||
*/
|
||||
public static function update_gateway_capabilities(WP_REST_Request $request)
|
||||
{
|
||||
$body = $request->get_json_params();
|
||||
if (!is_array($body) || !isset($body['overrides']) || !is_array($body['overrides'])) {
|
||||
return new WP_Error('bad_request', __('overrides map is required.', 'woonoow'), ['status' => 400]);
|
||||
}
|
||||
|
||||
$stored = get_option(GatewayCapabilities::OPTION_KEY, []);
|
||||
if (!is_array($stored)) {
|
||||
$stored = [];
|
||||
}
|
||||
|
||||
$defaults = GatewayCapabilities::default_capabilities();
|
||||
$valid_ids = $defaults;
|
||||
if (function_exists('WC')) {
|
||||
foreach (WC()->payment_gateways()->payment_gateways() as $id => $gw) {
|
||||
$valid_ids[$id] = ['subscription_auto_renew' => false];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($body['overrides'] as $id => $value) {
|
||||
$id = sanitize_key((string) $id);
|
||||
if ($id === '' || !array_key_exists($id, $valid_ids)) {
|
||||
continue; // unknown gateway — ignore
|
||||
}
|
||||
if ($value === null) {
|
||||
unset($stored[$id]);
|
||||
} else {
|
||||
$stored[$id] = ['subscription_auto_renew' => (bool) $value];
|
||||
}
|
||||
}
|
||||
|
||||
update_option(GatewayCapabilities::OPTION_KEY, $stored);
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'overrides' => $stored,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
cancel_reason TEXT DEFAULT NULL,
|
||||
pause_count INT UNSIGNED DEFAULT 0,
|
||||
paused_at DATETIME DEFAULT NULL,
|
||||
failed_payment_count INT UNSIGNED DEFAULT 0,
|
||||
reminder_sent_at DATETIME DEFAULT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
@@ -87,6 +88,45 @@ class SubscriptionManager
|
||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||
dbDelta($sql_subscriptions);
|
||||
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;
|
||||
}
|
||||
|
||||
// Get subscription settings from product
|
||||
$billing_period = get_post_meta($product_id, '_woonoow_subscription_period', true) ?: 'month';
|
||||
$billing_interval = absint(get_post_meta($product_id, '_woonoow_subscription_interval', true)) ?: 1;
|
||||
$trial_days = absint(get_post_meta($product_id, '_woonoow_subscription_trial_days', true));
|
||||
$subscription_length = absint(get_post_meta($product_id, '_woonoow_subscription_length', true));
|
||||
// M1 — Read subscription meta variation-first, then fall back to parent.
|
||||
// Variation-level overrides let a merchant sell e.g. "License 1-year" and
|
||||
// "License 5-year" as variations of one variable product.
|
||||
$billing_period = self::get_subscription_meta('_woonoow_subscription_period', $variation_id, $product_id, 'month');
|
||||
$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
|
||||
$now = current_time('mysql');
|
||||
@@ -285,26 +327,52 @@ class SubscriptionManager
|
||||
|
||||
$where = "WHERE 1=1";
|
||||
$params = [];
|
||||
$joins = "";
|
||||
|
||||
if ($args['status']) {
|
||||
$where .= " AND status = %s";
|
||||
$where .= " AND s.status = %s";
|
||||
$params[] = $args['status'];
|
||||
}
|
||||
|
||||
if ($args['product_id']) {
|
||||
$where .= " AND product_id = %d";
|
||||
$where .= " AND s.product_id = %d";
|
||||
$params[] = $args['product_id'];
|
||||
}
|
||||
|
||||
if ($args['user_id']) {
|
||||
$where .= " AND user_id = %d";
|
||||
$where .= " AND s.user_id = %d";
|
||||
$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']);
|
||||
|
||||
$sql = "SELECT * FROM " . self::$table_subscriptions . " $where $order $limit";
|
||||
$sql = "SELECT s.* FROM " . self::$table_subscriptions . " s $joins $where $order $limit";
|
||||
|
||||
if (!empty($params)) {
|
||||
$sql = $wpdb->prepare($sql, $params);
|
||||
@@ -316,6 +384,9 @@ class SubscriptionManager
|
||||
/**
|
||||
* Count subscriptions
|
||||
*
|
||||
* M4 — supports the same `search` semantics as `get_all` so the pagination
|
||||
* total matches the filtered result set.
|
||||
*
|
||||
* @param array $args
|
||||
* @return int
|
||||
*/
|
||||
@@ -325,13 +396,31 @@ class SubscriptionManager
|
||||
|
||||
$where = "WHERE 1=1";
|
||||
$params = [];
|
||||
$joins = "";
|
||||
|
||||
if (!empty($args['status'])) {
|
||||
$where .= " AND status = %s";
|
||||
$where .= " AND s.status = %s";
|
||||
$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)) {
|
||||
$sql = $wpdb->prepare($sql, $params);
|
||||
@@ -437,11 +526,12 @@ class SubscriptionManager
|
||||
$updated = $wpdb->update(
|
||||
self::$table_subscriptions,
|
||||
[
|
||||
'status' => 'on-hold',
|
||||
'status' => 'on-hold',
|
||||
'pause_count' => $subscription->pause_count + 1,
|
||||
'paused_at' => current_time('mysql'),
|
||||
],
|
||||
['id' => $subscription_id],
|
||||
['%s', '%d'],
|
||||
['%s', '%d', '%s'],
|
||||
['%d']
|
||||
);
|
||||
|
||||
@@ -479,6 +569,8 @@ class SubscriptionManager
|
||||
$subscription->billing_interval
|
||||
);
|
||||
$update_data['next_payment_date'] = $next_payment;
|
||||
$update_data['paused_at'] = null;
|
||||
$format[] = '%s';
|
||||
$format[] = '%s';
|
||||
}
|
||||
|
||||
@@ -506,10 +598,16 @@ class SubscriptionManager
|
||||
/**
|
||||
* Process renewal for a subscription
|
||||
*
|
||||
* @param int $subscription_id
|
||||
* @return bool
|
||||
* 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 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;
|
||||
|
||||
@@ -518,13 +616,15 @@ class SubscriptionManager
|
||||
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(
|
||||
"SELECT so.order_id FROM " . self::$table_subscription_orders . " so
|
||||
JOIN {$wpdb->posts} p ON so.order_id = p.ID
|
||||
WHERE so.subscription_id = %d
|
||||
AND so.order_type = 'renewal'
|
||||
AND p.post_status IN ('wc-pending', 'pending', 'wc-on-hold', 'on-hold')",
|
||||
AND p.post_status IN ('wc-pending', 'pending', 'wc-on-hold', 'on-hold', 'wc-failed', 'failed')",
|
||||
$subscription_id
|
||||
));
|
||||
|
||||
@@ -542,7 +642,7 @@ class SubscriptionManager
|
||||
|
||||
// Process 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) {
|
||||
self::handle_renewal_success($subscription_id, $renewal_order);
|
||||
@@ -596,12 +696,20 @@ class SubscriptionManager
|
||||
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);
|
||||
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, [
|
||||
'total' => $subscription->recurring_amount,
|
||||
'subtotal' => $subscription->recurring_amount,
|
||||
'total' => $line_total,
|
||||
'subtotal' => $line_total,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -631,18 +739,21 @@ class SubscriptionManager
|
||||
/**
|
||||
* Process payment for renewal order
|
||||
*
|
||||
* @param object $subscription
|
||||
* @param \WC_Order $order
|
||||
* @return bool
|
||||
*/
|
||||
/**
|
||||
* Process payment for renewal order
|
||||
* M2 — `$force = true` is used by the admin "charge now" button. It
|
||||
* bypasses the GatewayCapabilities gate: the admin has explicitly
|
||||
* 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
|
||||
* 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 bool $force M2: bypass capability gate; never fall through to manual.
|
||||
* @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
|
||||
// Return true/false/'manual' to bypass default logic
|
||||
@@ -663,14 +774,26 @@ class SubscriptionManager
|
||||
|
||||
$gateway = $gateways[$gateway_id];
|
||||
|
||||
// 1. Try Auto-Debit if supported
|
||||
if (method_exists($gateway, 'process_subscription_renewal_payment')) {
|
||||
$result = $gateway->process_subscription_renewal_payment($order, $subscription);
|
||||
if (!is_wp_error($result) && $result) {
|
||||
return true;
|
||||
// 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')) {
|
||||
$result = $gateway->process_subscription_renewal_payment($order, $subscription);
|
||||
if (!is_wp_error($result) && $result) {
|
||||
return true;
|
||||
}
|
||||
// If explicit failure from auto-debit, return false (will trigger retry logic)
|
||||
return false;
|
||||
}
|
||||
// If explicit failure from auto-debit, return false (will trigger retry logic)
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. Allow other plugins to handle auto-debit via filter (e.g. Stripe/PayPal adapters)
|
||||
@@ -680,6 +803,15 @@ class SubscriptionManager
|
||||
}
|
||||
|
||||
// 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
|
||||
$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_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);
|
||||
|
||||
// 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
|
||||
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);
|
||||
@@ -275,6 +282,14 @@ class SubscriptionModule
|
||||
|
||||
$product_id = $product->get_id();
|
||||
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');
|
||||
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)
|
||||
{
|
||||
@@ -540,24 +555,108 @@ class SubscriptionModule
|
||||
return;
|
||||
}
|
||||
|
||||
if (!in_array($new_status, ['processing', 'completed'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is a subscription renewal order
|
||||
global $wpdb;
|
||||
$table_orders = $wpdb->prefix . 'woonoow_subscription_orders';
|
||||
|
||||
$link = $wpdb->get_row($wpdb->prepare(
|
||||
// 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",
|
||||
$order_id
|
||||
));
|
||||
|
||||
if ($link && $link->order_type === 'renewal') {
|
||||
$order = wc_get_order($order_id);
|
||||
if ($order) {
|
||||
SubscriptionManager::handle_renewal_success($link->subscription_id, $order);
|
||||
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);
|
||||
if ($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';
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@@ -41,6 +51,8 @@ class SubscriptionScheduler
|
||||
add_action(self::RENEWAL_HOOK, [__CLASS__, 'process_renewals']);
|
||||
add_action(self::EXPIRY_HOOK, [__CLASS__, 'check_expirations']);
|
||||
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
|
||||
self::schedule_events();
|
||||
@@ -65,6 +77,14 @@ class SubscriptionScheduler
|
||||
if (!wp_next_scheduled(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::EXPIRY_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,
|
||||
'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;
|
||||
|
||||
Reference in New Issue
Block a user