Migrating live lesson subscriptions from WooCommerce to Stripe in one afternoon
One of the trainers in our barn had been running her lesson billing on a WordPress site with WooCommerce Subscriptions and a Stripe gateway plugin. It worked, until it didn't. Plugin updates broke things. The shop UI was a maintenance burden. Cancelling a student required clicking through three admin pages. And nothing talked to the rest of the barn — student rosters lived in WooCommerce, makeup credits lived in her head, and the barn schedule had no idea who any of those students were.
We replaced it with native lesson billing in our platform — not by asking her active students to re-sign up, but by carrying their existing Stripe customers across so the takeover was invisible to them. Here's how that worked.
The constraint: do not break recurring billing
Active students were already paying her monthly through Stripe. Each had a saved card, a Stripe customer ID (cus_xxx), and a subscription that auto-renewed. Asking them to re-enter card details was off the table — that's the moment customers churn. So the migration plan was:
- Pull the customer + subscription roster from WooCommerce's MySQL.
- Match each WC subscription to a plan in our system (we already seeded her plan lineup from the WC product catalog).
- Create
LessonEnrollmentrecords in our system with the originalstripeCustomerIdattached. - Leave the existing WC Stripe subscriptions running for now — they keep billing on schedule.
- When we're confident our system works, send each student a Pay Link from our app. Stripe Checkout reuses the existing customer (no card re-entry), creates a new subscription on our system, and we cancel the WC one.
The ugly part: WooCommerce's data model
WooCommerce stores subscriptions as a custom WordPress post type (shop_subscription) with the actual fields scattered across wp_postmeta. The line items — i.e. which plan the student is on — aren't directly attached to the subscription post; they're on the renewal orders, referenced through a PHP-serialized array in postmeta called _subscription_renewal_order_ids_cache.
That meant the import script had to:
- Connect to MySQL (we ran the script over an SSH tunnel).
- For each active or on-hold subscription, fetch its postmeta and pivot it into named fields.
- Parse the PHP-serialized cache to find renewal order IDs.
- Look up each renewal order's line items to find the product name.
- Match that product name fuzzily against our seeded
LessonPlanrows. - Create
Userrecords (using billing email) andLessonEnrollmentrecords, preservingstripeCustomerId.
The unexpected: HPOS vs the old store
WooCommerce 8 has a high-performance order storage table (wp_wc_orders) that's supposed to replace the postmeta system. In this store, both stores existed and they didn't agree — the new HPOS table showed only on-hold subscriptions while the old wp_posts store had the active ones. We ended up trusting wp_posts as the source of truth because that's what was actually billing.
A small handful of subscriptions had naming inconsistencies (one was “1 Group + 1 Private per week” in our system but spelled without the plus sign in WooCommerce). We caught those manually rather than write fuzzier matching — when the universe is a couple dozen rows, manual review beats clever code.
Result
Every active and on-hold enrollment imported with the correct status, and each one carries the original Stripe customer ID. From the trainer's side, every student is visible in the trainer dashboard with full edit / makeup credit / cancel controls. From the student's side, when we send them the new Pay Link they can move to our system in one click without re-entering anything.
The whole import took about an hour to write and ten seconds to run. Going to live is a separate decision — we're testing with a couple of students first before flipping the rest. But the data is there, the customer IDs are preserved, and the trainer gets to delete WooCommerce once she's ready.