WordPress stays as the CMS. WooCommerce, WooCommerce Subscriptions, WooCommerce Memberships and AutomateWoo all come out. One custom plugin replaces them, talking to Stripe directly. The critical constraint throughout: 845 live subscriptions and roughly $633,000 of annual recurring revenue are already running, so nothing here is a big-bang swap.
This is the whole reason the migration is low-risk. Every one of the 845 active
subscriptions is a real sub_… object living in Stripe, with its own price,
billing cycle, next invoice date and saved card. WooCommerce Subscriptions is not
doing the recurring billing — it is mirroring what Stripe does and
adding an admin UI on top.
So we are not rebuilding a billing engine. We are replacing the mirror. Stripe keeps charging cards on exactly the same schedule throughout, and our plugin reads the same objects. No card is re-tokenised. No member re-enters payment details. No subscription is recreated. If our plugin were switched off mid-migration, every renewal would still process.
That single fact is what separates this from the version of the project that goes wrong — the one where you export subscriptions, cancel them, and ask 845 paying members to subscribe again.
| Component | Fate | Why |
|---|---|---|
| WordPress core | Keep | Still the CMS. Posts, the 1,094 trade ideas, daily sentiment, the media library. |
| Users table | Keep | 14,139 users stay exactly where they are. We add to them, we do not migrate them. |
| Stripe | Keep | Source of truth for money. All 845 active subs already live here. |
| Action Scheduler | Keep | Already reliably running 844 scheduled renewals. Battle-tested, ships standalone. |
wp_track_user_activity | Keep | 322,115 rows of real reading behaviour. Powers retention. Nothing to rebuild. |
| WooCommerce | Remove | Only 18 products, no shipping, no tax, no cart in any real sense. |
| WooCommerce Subscriptions | Remove | A mirror of Stripe with an admin UI nobody enjoys. |
| WooCommerce Memberships | Remove | 49 rules in a 22,721-byte serialised option nobody can read. |
| AutomateWoo | Remove | Second automation engine, overlapping with ActiveCampaign. |
| Mollie gateway | Remove | Active with zero subscriptions on it. Dead surface. |
| hft-membership | New | The one plugin that replaces the four above. |
Six custom tables. Deliberately flat, deliberately indexed, and deliberately
not in wp_posts/wp_postmeta — putting orders in
post meta is exactly what produced the current 1,230,751-row wp_postmeta and the
slow admin.
hft_plans the products. id, name, slug, stripe_price_id,
amount, interval, interval_count, status
hft_subscriptions one row per Stripe subscription — the mirror
id, user_id, plan_id, stripe_subscription_id,
stripe_customer_id, status, current_period_end,
cancel_at_period_end, amount, created_at
UNIQUE(stripe_subscription_id) ← the migration key
hft_access_rules the grid. plan_id, object_type, object_id, rule_type
object_type: category | page | post_type | kb_article
rule_type: content | discount
hft_payments invoice history. user_id, subscription_id,
stripe_invoice_id, amount, status, paid_at
hft_tags tag definitions + user_id pivot. replaces the
AutomateWoo→ActiveCampaign bridge
hft_events append-only audit. who did what to whom, when.
every access change, refund, comp, discount
UNIQUE(stripe_subscription_id) mattersIt is the join key for the entire migration and the guard against the worst failure mode. Import is idempotent: run it a hundred times and you get one row per Stripe subscription. A webhook arriving twice cannot create a duplicate. On a system handling live recurring revenue, that constraint is worth more than any amount of application logic.
Today the answer to "can this person see this?" is spread across WooCommerce Memberships
rules, nav-menu-roles, user-role-editor, capability checks in a
99,833-line child theme, and 60+ users carrying malformed pipe-character roles. It is decided
in at least five places.
It becomes one function:
hft_user_can_view( $user_id, $object )
1. plans = active subscriptions for $user_id (hft_subscriptions)
2. rules = access rules for those plans (hft_access_rules)
3. match = $object against rules, most specific first:
explicit per-user override
→ specific object grant
→ whole post-type grant
→ category grant
4. cache in an object-cache key busted on plan or rule change
5. return bool
Access is computed from the plans held, never stored per user. It cannot drift out of sync, because there is no second copy to drift from. The malformed pipe-roles stop mattering because roles stop being part of the access decision at all.
One hook covers the front end — the_content, template redirects and the REST API
all funnel into the same function. The access grid screen in the console writes
hft_access_rules directly, so what Tom sees in the grid is what the
site enforces.
cancel_at_period_end, which is what "pending cancel" already means for the 166 members in that state today.invoice.paid → record payment, extend access
invoice.payment_failed → mark at risk, start dunning, alert Tom
customer.subscription.updated → sync status and period end
customer.subscription.deleted → revoke access at period end
charge.refunded → record, revoke if a full refund
This is the single highest-risk piece of the build. Requirements, non-negotiable: verify the
signature on every request; make handlers idempotent keyed on the Stripe
event ID, because Stripe retries and will deliver duplicates; return 200 fast and do the
work in Action Scheduler; log every event to hft_events; and reconcile nightly
against the Stripe API so a dropped webhook surfaces the next morning rather than at renewal
time. It needs its own test plan before it goes anywhere near production.
This is a read, not a move. Nothing is deleted, nothing is recreated in Stripe, and WooCommerce stays installed and functional throughout.
hft_plans, each mapped to its existing Stripe price ID. Six dormant plans get archived rather than imported.shop_subscription posts, read _stripe_subscription_id from post meta and write a row keyed on it. Then verify every row against the Stripe API — Stripe is the truth, Woo is the hint. Anything that disagrees gets reported, not silently trusted.wc_memberships_rules → hft_access_rules, expanding categories, pages, whole post types and knowledge-base articles into explicit rows. Dead references get dropped: two rules point at pages that no longer exist.hft_payments, so the member record shows full history from day one.hft_user_can_view. Woo stays installed and idle.At every step before step 7, rollback is deactivating one plugin. WooCommerce data is never modified — we only read it. Stripe is never modified during import. That is what makes this worth doing to a system carrying $633,000 of recurring revenue.
hft-membership/
├── hft-membership.php bootstrap, activation, table install
├── includes/
│ ├── class-plans.php
│ ├── class-subscriptions.php the Stripe mirror
│ ├── class-access.php hft_user_can_view() lives here
│ ├── class-stripe-client.php thin wrapper over the PHP SDK
│ ├── class-webhooks.php signature verify + idempotency
│ ├── class-tags.php
│ ├── class-automations.php triggers, conditions, actions
│ ├── class-mailer.php SES wrapper, bounce/complaint suppression
│ └── class-migrator.php the Woo importers + reconciliation
├── admin/ the console screens already designed
├── public/ checkout, member portal, account
└── templates/ overridable in the theme
Front end is Bootstrap 5.3 — the demo already ships stock Bootstrap with a
single hft-theme.css token layer over it, so the custom WordPress theme inherits
the same markup unchanged. Nothing in the demo has to be redrawn to become the real thing.
What is genuinely easy. The data model, the access grid, the console screens, and the migration importers. This is ordinary CRUD over tables we control, and the hard thinking — the actual access rules — is already extracted and documented.
What needs real care. Webhook idempotency and reconciliation. Dunning and failed-payment retry logic. SCA/3DS re-authentication for European cards. These are the places where "mostly working" costs money quietly, and they are the reason the parallel run is four weeks rather than a weekend.
What we lose. WooCommerce's ecosystem, its reporting, and the ability to hand a problem to a vendor's support desk. For a site with 18 products, no shipping and one payment gateway, that is a smaller loss than it sounds — but it is a real one and worth saying out loud.
What we gain. One system that can see subscriptions, access, reading behaviour and email in the same query. Everything the client has actually been asking for — one screen per member, working filters, discount offers in two clicks, and alerts when a paying member goes quiet — becomes straightforward instead of impossible.