Subscription module: add gateway capability flow and UX fixes

This commit is contained in:
Dwindi Ramadhana
2026-06-02 00:38:42 +07:00
parent fec786daa6
commit df969b442d
15 changed files with 2375 additions and 138 deletions

View 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.**

View File

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

View File

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

View File

@@ -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 && (

View File

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

View File

@@ -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>
))}

View File

@@ -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>

View 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.

View File

@@ -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()];
}

View File

@@ -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,
]);
}
}

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

View File

@@ -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,11 +88,50 @@ 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;
}
/**
* Create subscription from order item
*
*
* @param \WC_Order $order
* @param \WC_Order_Item_Product $item
* @return int|false Subscription ID or false on failure
@@ -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);
@@ -315,7 +383,10 @@ 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';
}
@@ -505,11 +597,17 @@ 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,
]);
}
@@ -630,19 +738,22 @@ class SubscriptionManager
/**
* Process payment for renewal order
*
* @param object $subscription
* @param \WC_Order $order
* @return bool
*/
/**
* Process payment for renewal order
*
* @param object $subscription
*
* 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 \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'));

View File

@@ -38,8 +38,15 @@ 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);
@@ -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');
}
}
}

View File

@@ -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);
}
/**
@@ -240,7 +262,7 @@ class SubscriptionScheduler
/**
* Schedule a retry for failed payment
*
*
* @param int $subscription_id
*/
public static function schedule_retry($subscription_id)
@@ -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');
}
}
}
}

View File

@@ -102,8 +102,32 @@ 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;
}
}