Hedge Fund Telemetry  |  Homepage  ·  All pages  ·  Running costs  ·  Cost & build plan  ·  Hosting review  ·  Plugin architecture
Hedge Fund Telemetry/ Plugin architecture/9 Aug 2026/ Technical note

How the membership system works without WooCommerce.

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.

01 The one idea that makes this safe

Stripe already is the subscription system

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.

02 What stays, what goes

ComponentFateWhy
WordPress coreKeep Still the CMS. Posts, the 1,094 trade ideas, daily sentiment, the media library.
Users tableKeep 14,139 users stay exactly where they are. We add to them, we do not migrate them.
StripeKeep Source of truth for money. All 845 active subs already live here.
Action SchedulerKeep Already reliably running 844 scheduled renewals. Battle-tested, ships standalone.
wp_track_user_activityKeep 322,115 rows of real reading behaviour. Powers retention. Nothing to rebuild.
WooCommerceRemove Only 18 products, no shipping, no tax, no cart in any real sense.
WooCommerce SubscriptionsRemove A mirror of Stripe with an admin UI nobody enjoys.
WooCommerce MembershipsRemove 49 rules in a 22,721-byte serialised option nobody can read.
AutomateWooRemove Second automation engine, overlapping with ActiveCampaign.
Mollie gatewayRemove Active with zero subscriptions on it. Dead surface.
hft-membershipNew The one plugin that replaces the four above.

03 Data model

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

Why UNIQUE(stripe_subscription_id) matters

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

04 How access is decided

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.

05 Stripe integration

Outbound — things we ask Stripe to do

Inbound — the webhooks that matter

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

Whoever owns the webhook handlers owns the revenue

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.

06 Migrating off WooCommerce

This is a read, not a move. Nothing is deleted, nothing is recreated in Stripe, and WooCommerce stays installed and functional throughout.

  1. Import plans. 11 membership plans and 18 products → hft_plans, each mapped to its existing Stripe price ID. Six dormant plans get archived rather than imported.
  2. Import subscriptions. For each of the 845 active 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.
  3. Import access rules. The 49 rules in wc_memberships_ruleshft_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.
  4. Import payment history. 17,572 orders → hft_payments, so the member record shows full history from day one.
  5. Import tags. Including repairing the 60+ malformed pipe-character roles into proper tags.
  6. Parallel run, ~4 weeks. Both systems live. A nightly job compares every subscription status, every access decision and every renewal date between Woo and ours, and emails the differences. Go/no-go is: zero unexplained differences for two consecutive weeks.
  7. Cut over. Point the front end at hft_user_can_view. Woo stays installed and idle.
  8. Remove. Only after a clean fortnight post-cutover.

The rollback

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.

07 Plugin structure

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.

08 Honest assessment

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.