Back to Admin Dashboard
BSM Developer Portal
Stripe Integration Setup Guide
Complete reference for setting up, configuring and maintaining the BSM Stripe payment integration. Covers Stripe account setup, the dynamic tier architecture, tier change and member management rules, and the full test protocol.
v3.0 — Updated June 2026
Build Status
DONE
  • All four tiers (Individual, Family, Instructor, Business) read live from bsm_stripe_products; no hardcoded “Partner” naming anywhere
  • Checkout, plans page, upgrade page, account page
  • Cancellation, with automatic sub-member detach and notification email
  • Sub-member invite, accept, and remove
  • Account deletion, with Stripe cancellation on delete
  • Invoice history and name/email change, now on-site
  • T10 — full end-to-end test as a brand new user, with real signups and real subscriptions in Stripe Sandbox
OUTSTANDING
  • WordPress database cleanup: six legacy wp_options rows (bsm_stripe_price_individual_monthly, bsm_stripe_price_individual_annual, bsm_stripe_price_family_monthly, bsm_stripe_price_family_annual, bsm_stripe_price_partner_monthly, bsm_stripe_price_partner_annual) are no longer read by any file but have not been deleted. Agreed decision: leave them in place — deleting them carries more risk (an unreviewed file elsewhere on the server could still reference them) than benefit (they cost nothing sitting unused). Revisit any time via Section J below.
  • Payment method update: remains on the Stripe-hosted billing portal, not moved on-site. Agreed decision: Stripe Elements already keeps raw card data off the server regardless of where it’s embedded, so building this on-site adds maintenance burden with no real security gain. Not a task to complete — a permanent decision.
ADD EDITS PRODUCTS FAST
The 5-step version – setting up a brand new membership tier from scratch
1
Create the product in Stripe (not on the site)

Log into your Stripe Dashboard. Create a new Product and give it one Price for monthly billing, and a second Price for annual billing if you want that option too. This step can only be done in Stripe – the site has no “create new tier” button, on purpose, since prices in Stripe can never be edited once made, only replaced.

Stripe login Email: info@bodysleepmind.com
Password: xxxxxxxxxx
Creating a new product in the Stripe dashboard
Click the image to view it full size
  1. Log into dashboard.stripe.com
  2. In the left sidebar, click Product catalog (sometimes labelled “Products”)
  3. Click the + Add product button, top right
  4. Fill in the name (e.g. “Corporate Wellness”), description, and set the price – pick “Recurring” so it charges monthly/annually like your other tiers. You can add both a monthly and an annual price on the same screen
  5. Click Save
2
Open the BSM Products admin panel

Go to /admin-stripe-product-mini-crud/ on your site. It automatically pulls in every active product from your Stripe account – your new one will already be listed, even though the site doesn’t know about it yet.

3
Fill in the site-only details

Next to your new product, set: how many member seats it includes, whether it should show a “Most Popular” badge, and what order it appears in on the pricing page. These details live only on your site, not in Stripe.

4
Confirm and save

Click through the confirm steps. This does two things at once: updates the product info in Stripe, and copies everything (including the Price IDs) into the site’s own tier list.

5
Done

Your new tier now appears automatically on /plans-page/, and the checkout button for it works immediately. Nothing else to configure – no code, no other files.

One rule to remember If you ever need to change a price, you can’t edit the old one. Create a brand new Price in Stripe for that same product, then repeat Steps 2-4 so the site picks up the new Price ID.
⚡ Critical Architecture Rules — Do Not Break These
1
PHP functions must be defined before they are called. Conditional functions inside if(!function_exists()) blocks are NOT hoisted by PHP. The webhook file defines all functions at the TOP of the file before any execution code. Never move them below the execution code or you will get a fatal 500 error.
2
The webhook is fired via functions.php using template_redirect — NOT via WPCode. The WPCode webhook snippet must stay INACTIVE. Only the functions.php handler fires the webhook. The WPCode snippet fires too late in the WordPress lifecycle and produces a false 200 without actually running the PHP.
3
Never resend old Stripe webhook events to test GOD JSON updates. The webhook file verifies the Stripe signature timestamp and rejects events older than 5 minutes. Always do a fresh payment test. Resending old events will give a signature FAIL and the GOD JSON will not update.
4
page-protect.php runs everywhere. upgrade-page.php is page content only. The WPCode snippet that runs everywhere must include page-protect.php. The upgrade page content is loaded separately via shortcode on the /upgrade-page/ WordPress page. Never swap these or the upgrade page will show on every page of the site.
5
When a user upgrades, the old subscription is cancelled automatically by bsm-stripe-checkout.php. Do not manually cancel subscriptions in Stripe when a user upgrades. The checkout file handles this. Stripe then fires a customer.subscription.deleted event for that old subscription object — this is Stripe’s standard event name whenever a subscription object stops existing, whether from a genuine cancellation or as a side-effect of an upgrade. The webhook tells the two cases apart by checking the deleted sub ID against what is currently stored in GOD JSON: if it matches, it is a real cancellation and is processed; if it does not match, GOD JSON has already moved on to the new subscription from the upgrade, so the event is stale and ignored. This is a Stripe subscription-object event and has no relationship to account deletion — see Rule 8.
8
Cancel and delete account are two completely different actions. Cancel (via /cancel-subscription/ on-site, or the Stripe billing portal) ends the paid subscription only — the user's account, login, and all GOD JSON wellness data are fully retained, and they continue as a free user. Delete account (via /account-delete-my-account/) is permanent and irreversible — the entire user record, login, and all wellness data are removed. Cancelling never deletes data. Deleting always removes everything regardless of subscription status.
6
Stripe is the single source of truth for tier definitions. BSM is the single source of truth for user state. Tier names, descriptions, prices, seat counts and display order all live in Stripe. User membership status, tier assignment and sub-member relationships all live in GOD JSON. Neither system reaches into the other’s domain.
7
The bsm_stripe_products table is the only place BSM reads tier data at runtime. No public-facing PHP file reads from the Stripe API on page load. The table is populated and kept current using the BSM Stripe Products mini-CRUD — a front-end page at /admin-stripe-product-mini-crud/, gated by an admin-only check inside the page itself, not a native WordPress admin screen — and by webhooks. Fast as any local DB query, zero Stripe API calls on page load ever.
1
Stripe Account Setup
Steps 1–5 — Done once in Stripe dashboard before any code is written
1
Create or access your Stripe account
✓ Done

You need a Stripe account before anything else. All setup must be done in SANDBOX (test) mode — never live mode during setup.

  1. Go to https://stripe.com and sign in (or create an account)
  2. Look at the top right of the Stripe dashboard — it should say SANDBOX with a toggle. Make sure you are in sandbox mode before doing anything else
  3. If it says LIVE — click the toggle to switch to sandbox. Live mode uses real money.
Every step in this guide must be completed in SANDBOX (TEST) MODE. Sandbox mode is completely safe — no real money moves.
2
Get your API keys
✓ Done

API keys are how BSM talks to Stripe. You need two: a publishable key (safe for front end) and a secret key (server only, never share).

Where to find this in Stripe Stripe dashboard → bottom footer bar → Developers → API keys
  1. Copy your Publishable key — starts with pk_test_... — safe for frontend use
  2. Copy your Secret key — starts with sk_test_... — server only, never paste this anywhere public
  3. Store both securely — you will need them in Step 6
The secret key is like a password. Anyone who has it can charge cards and access your Stripe account. Never put it in a publicly visible file, never share it in email, never commit it to GitHub.
3
Create products in Stripe — the dynamic tier architecture
✓ Done

Each BSM membership tier is a Stripe product. Stripe is the single source of truth for all tier definitions — name, description, price, seat count, display order, and featured status. BSM reads this data into a local database table and never calls the Stripe API on page load.

Adding a new tier is a zero-code process: create the product in Stripe, set the metadata fields below, and it appears on the site automatically within seconds. Removing a tier means archiving it in Stripe — it disappears from the site immediately.

Where to do this in Stripe Stripe dashboard → left menu → Product catalogue → + Add product

For each product set the following:

Name
The tier display name, e.g. Individual. BSM auto-generates the internal tier key from this using a slug function — “Individual” becomes individual. Marketing never needs to set a tier key manually.
Description
The card description shown to users on the plans page, e.g. Your personal wellness ecosystem. Everything personalised to you.
Prices
Add two recurring prices per product — one monthly and one annual. Set currency to GBP and billing period accordingly.

Metadata fields — exactly three, set on each product:

seats
Integer. Number of sub-member seats this tier includes. 0 for solo plans (Individual). 4 for Family. 28 for Instructor. 100 for Business.
featured
true or false. Only one product should be true at a time. The featured product shows the “Most popular” badge on the plans page.
order
Integer. Controls left-to-right display order on the plans page. 1 = first paid card (after Free). Lower numbers appear first. Free is always hardcoded as position 0.
The Free tier is never a Stripe product. It is hardcoded as the first card on the plans page and always shows £0. It exists only in BSM, not in Stripe.

Current live tiers:

Individual
£14.99/mo
Personal wellness ecosystem — 1 member
seats: 0
featured: false
order: 1
tier key: individual
Family
£19.99/mo
Family ecosystem — 1 + 4 members
seats: 4
featured: true
order: 2
tier key: family
Instructor
£29.99/mo
Instructor ecosystem — 1 + 10 clients
seats: 10
featured: false
order: 3
tier key: instructor
4
Set up the Customer Portal
✓ Done

The customer portal is a Stripe-hosted page where subscribers can manage their own subscription — update their card, view invoices, or cancel. Users access it via the Manage Billing button on their BSM account page.

Where to find this in Stripe Stripe dashboard → top-right gear icon (Settings) → Billing → Customer portal
  1. Enable the portal
  2. Under Functionality enable: Update payment methods, Cancel subscriptions, View billing history
  3. Under Cancellation set to: Cancel at end of billing period — users keep access until their paid period runs out
  4. Under Business information → Return URL enter: https://bodysleepmind.com/account/
  5. Click Save
The portal URL shown to users is always billing.stripe.com/... — this is normal and expected. It is Stripe's hosted page. The “Sandbox” badge will disappear when you switch to live mode.
5
Set up the Webhook endpoint
✓ Done

A webhook is how Stripe tells BSM when something happens. BSM listens to eleven events: five for user subscription state and six for keeping the local product/price table in sync with Stripe.

Where to find this in Stripe Stripe dashboard → footer bar → Developers → Webhooks → + Add endpoint
  1. Endpoint URL: https://bodysleepmind.com/bsm-stripe-webhook/
  2. Under Select events add all eleven events listed below
  3. Click Add endpoint
  4. On the next screen find Signing secret and click Reveal
  5. Copy the signing secret immediately — starts with whsec_... — this is only shown once

User subscription events (5):

checkout.session.completed — payment succeeded, set user to paid, write tier and Stripe IDs to GOD JSON
customer.subscription.updated — subscription status changed
customer.subscription.deleted — subscription ended, revert user to active
invoice.payment_succeeded — monthly renewal paid, confirm paid status
invoice.payment_failed — renewal failed, revert user to active

Product and price sync events (6):

product.created — new tier in Stripe, upsert row in bsm_stripe_products
product.updated — tier name, description or metadata changed, update row
product.deleted — tier archived in Stripe, set active = 0, card disappears from site
price.created — new price added, update price ID and amount on row
price.updated — price amount changed, update amount on row
price.deleted — price removed, clear price ID and amount on row
The signing secret proves to our PHP that an event really came from Stripe. Copy it immediately. If you leave the page without copying it you will need to roll it, which requires updating the WordPress option.
2
BSM WordPress Configuration
Steps 6–9 — Done once in WordPress admin
6
Store your keys in WordPress
✓ Done

Three Stripe credentials are stored as WordPress options — never hardcoded in PHP files. No Price IDs are stored as WordPress options; those live in the bsm_stripe_products database table managed by webhooks.

WPCode PHP snippet — run once then deactivate immediately
<?php
update_option('bsm_stripe_secret_key',       'sk_test_YOUR_KEY_HERE');
update_option('bsm_stripe_publishable_key',  'pk_test_YOUR_KEY_HERE');
update_option('bsm_stripe_webhook_secret',   'whsec_YOUR_SECRET_HERE');
?>
Deactivate this snippet in WPCode immediately after running. The three keys are safely in the WordPress database. The snippet itself is no longer needed.
7
Create the bsm_stripe_products table
✓ Done

BSM reads all tier data at runtime from a local WordPress database table. This table is never edited manually — it is created on setup and kept current by Stripe webhooks. No Stripe API calls are made on page load ever.

ColumnTypeNotes
tier_keyvarchar(100)Primary key. Auto-generated slug from Stripe product name via sanitize_title(). e.g. individual
namevarchar(255)Display name from Stripe product name field
descriptiontextCard description from Stripe product description field
seatsintFrom Stripe metadata: seats
featuredtinyint(1)From Stripe metadata: featured (true/false → 1/0)
display_orderintFrom Stripe metadata: order
price_monthly_idvarchar(100)Stripe Price ID for monthly billing
price_monthly_amountintAmount in pence
price_annual_idvarchar(100)Stripe Price ID for annual billing
price_annual_amountintAmount in pence
activetinyint(1)1 = active and shown. 0 = archived in Stripe, hidden from site.
updated_atdatetimeTimestamp of last webhook sync
Table creation SQL — run once via WPCode or phpMyAdmin
CREATE TABLE IF NOT EXISTS bsm_stripe_products (
    tier_key             VARCHAR(100) NOT NULL,
    name                 VARCHAR(255) NOT NULL DEFAULT '',
    description          TEXT,
    seats                INT NOT NULL DEFAULT 0,
    featured             TINYINT(1) NOT NULL DEFAULT 0,
    display_order        INT NOT NULL DEFAULT 0,
    price_monthly_id     VARCHAR(100) NOT NULL DEFAULT '',
    price_monthly_amount INT NOT NULL DEFAULT 0,
    price_annual_id      VARCHAR(100) NOT NULL DEFAULT '',
    price_annual_amount  INT NOT NULL DEFAULT 0,
    active               TINYINT(1) NOT NULL DEFAULT 1,
    updated_at           DATETIME NOT NULL,
    PRIMARY KEY (tier_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
8
Populate the table using the Stripe Products admin panel
✓ Done

The table is populated and kept current using a dedicated front-end admin page rather than a one-time script. This was a deliberate choice: a script you run once and never see again is opaque and easy to forget how to re-run if something needs fixing. A standing page keeps a human in the loop permanently, shows live Stripe data side by side with what BSM currently has stored, and requires explicit confirmation before anything changes. This is not a native WordPress wp-admin screen — it is a regular front-end page, the same architecture as every other Stripe file in this build, reached at its own URL rather than through the WordPress admin sidebar.

Where to find this in WordPress /admin-stripe-product-mini-crud/ — a real WordPress page, not the wp-admin sidebar. Reached today via a “BSM Admin” button that only renders on admin account menus — an internal links page lists this alongside other staff tools (user management, deletion, Stripe config).
No server-side permission check exists in this file yet — confirmed directly from its own header comment. Today's protection is obscurity only: the page is simply never linked anywhere a non-admin would see it. Anyone who knows or guesses the URL can reach it regardless of role. The planned fix, before launch: extend page-protect.php to explicitly block non-admins from every bsm-admin-* URL, the same whitelist mechanism already protecting every other page in this build.
  1. Open the page. It fetches live data from Stripe on every load — name, description, metadata, and prices for every active product
  2. For first-time population, the right-hand column will show “Not yet in local table” for every row
  3. Review the fields. If everything looks correct as-is, no edits are needed — just proceed to Save Changes to write the current Stripe values into the table for the first time
  4. Click Save Changes → confirm the warning → review the diff screen → click the green Implement Changes button
  5. The right-hand column should now show “In sync” for every tier
This same page is used for all future tier edits — renaming a tier, changing seat counts, switching which tier is featured, or changing display order. It is not a one-time setup tool, it is the permanent admin interface for managing tiers without ever needing to touch code or the database directly.
Prices are intentionally read-only in this panel. Stripe prices are permanent once created — changing a price means creating a new Price object in Stripe, which does not retroactively change what existing subscribers are charged. Price changes are made directly in the Stripe dashboard; this panel will pick up the new price automatically on next implementation.
The Implement step uses the Post/Redirect/Get pattern: after writing to Stripe and the table, the page redirects to a clean URL rather than rendering the result directly from the form submission. This means refreshing the success screen is always safe — it just re-fetches the same page, with no leftover form data to ever resubmit. Without this, refreshing the result screen would silently resend the same Stripe update a second time, which can revert a field back to its previous value if the form was loaded before a later change was made elsewhere.
9
functions.php — the Stripe handlers
✓ Done

Seven Stripe/billing PHP files are fired via functions.php using WordPress's template_redirect hook. This is the correct approach because template_redirect fires before any page output is sent, which allows the PHP to set HTTP response codes and redirect headers correctly. WPCode auto-insert runs too late in the lifecycle.

Current working state in functions.php — do not change
/*******************stripe checkout***************************/
add_action('template_redirect', 'bsm_stripe_checkout_handler');
function bsm_stripe_checkout_handler() {
    if (!is_page('checkout')) { return; }
    include( get_stylesheet_directory() . '/stripe/bsm-stripe-checkout.php' );
}

/*******************stripe webhook***************************/
add_action('template_redirect', 'bsm_webhook_real_handler');
function bsm_webhook_real_handler() {
    if (!is_page('bsm-stripe-webhook')) { return; }
    include( get_stylesheet_directory() . '/stripe/bsm-stripe-webhook.php' );
}

/*******************stripe portal***************************/
add_action('template_redirect', 'bsm_stripe_portal_handler');
function bsm_stripe_portal_handler() {
    if (!is_page('billing-portal')) { return; }
    include( get_stylesheet_directory() . '/stripe/bsm-stripe-portal.php' );
}

/*******************stripe cancel***************************/
add_action('template_redirect', 'bsm_stripe_cancel_handler');
function bsm_stripe_cancel_handler() {
    if (!is_page('cancel-subscription')) { return; }
    include( get_stylesheet_directory() . '/stripe/bsm-stripe-cancel.php' );
}

/*******************stripe undo cancel***************************/
add_action('template_redirect', 'bsm_stripe_undo_cancel_handler');
function bsm_stripe_undo_cancel_handler() {
    if (!is_page('undo-cancel')) { return; }
    include( get_stylesheet_directory() . '/stripe/bsm-stripe-undo-cancel.php' );
}

/*******************billing history***************************/
add_action('template_redirect', 'bsm_billing_history_handler');
function bsm_billing_history_handler() {
    if (!is_page('billing-history')) { return; }
    include( get_stylesheet_directory() . '/stripe/bsm-billing-history.php' );
}

/*******************update details***************************/
add_action('template_redirect', 'bsm_update_details_handler');
function bsm_update_details_handler() {
    if (!is_page('update-details')) { return; }
    include( get_stylesheet_directory() . '/stripe/bsm-update-details.php' );
}
The first three handlers (checkout, webhook, portal) were the original setup. Cancel, undo-cancel, billing-history and update-details were added later in the same build, once those pages moved on-site — each follows the identical pattern, so adding a new handler is always just copying this same five-line shape with a new slug and a new include path.
The WPCode webhook snippet (Payment Stripe Webhook) must remain INACTIVE. If activated alongside the functions.php handler the file fires twice — the second firing has no Stripe signature header and causes a PHP fatal error producing a 500 response to Stripe.
Dynamic Nature of Product/Tier Creation, Editing and Deletion

Stripe is the single source of truth for every tier. Nothing about a tier — its existence, its name, its price — is ever defined in code. This section explains exactly what that means in practice: how a brand-new tier comes into being, what the internal admin panel can and cannot touch, and the precise steps to change a price.

How a new tier dynamically comes into being

A tier is never created in this codebase. It is created in Stripe, and the site simply notices and reflects it:

  1. Someone with Stripe access creates a new Product in the Stripe Dashboard (Product catalog → Add product), giving it a name, description, and at least one price
  2. The moment that product is saved, Stripe fires a product.created webhook event to our endpoint
  3. The webhook handler (Part 3) receives this event and inserts a brand-new row into bsm_stripe_products — this is the only place a tier “exists” as far as our code is concerned
  4. That new row is immediately visible in the internal admin panel below, ready to have its tier_key, seats, featured status and display order set
  5. Once saved there, it appears automatically on the Plans page and Upgrade page — no code change, no deployment, no developer involvement required

The reverse is just as direct: archiving a product in Stripe fires product.updated with active: false, which the webhook reflects by setting that row’s active column to 0 — it disappears from every customer-facing page immediately, without being deleted from the table (so its history and any past subscriptions referencing it remain intact).

🔧 What the internal admin panel (the “CRUD”) can and cannot edit

Staff refer to bsm-stripe-product-crud.php as “the CRUD” — not technically accurate (it cannot Create or Delete a product, only Read and Update), but that is the name in everyday use, so this guide uses it too.

FieldEditable here?Where it actually lives
nameYesEdited here, pushed to Stripe via bsm_admin_stripe_post()
descriptionYesEdited here, pushed to Stripe
seatsYesLocal only — stored as Stripe product metadata, not a native Stripe field
featuredYesLocal only — Stripe metadata
display_orderYesLocal only — Stripe metadata
price_monthly_amountNo — read onlySynced FROM Stripe, never written back. See price-change steps below.
price_annual_amountNo — read onlySynced FROM Stripe, never written back.

Creating a brand-new product and deleting one are both also not possible from this panel — both must happen in Stripe’s own dashboard first, as described above.

💰 How to actually change a price

There is no “change price” button anywhere — not in this panel, not in Stripe itself. Stripe prices are immutable by design: once created, a price object can never be edited, only archived. A price change is therefore always a three-step replace operation:

  1. In Stripe Dashboard, open the product, click Add another price, and create the new price at the new amount
  2. Archive the old price (still on the product page) so it can no longer be selected for any new subscription — existing subscribers on the old price are unaffected and keep paying the old amount until they themselves change plan or re-subscribe
  3. Return to the internal admin panel and click Save Changes for that product — this re-fetches the now-current price from Stripe and writes the new amount into price_monthly_amount / price_annual_amount, which is what the Plans page and Upgrade page actually display
Skipping step 3 means the live site keeps showing the old price even though Stripe itself has moved on — the admin panel only re-reads prices when explicitly saved, it does not poll Stripe continuously.
3
Server Files — What Does What
All Stripe PHP lives in /wp-content/themes/tt24child/stripe/
📁
File map and responsibilities
💳
bsm-stripe-checkout.php
Fires when a user clicks a plan button. Reads the tier and period from the URL (?tier=individual&period=monthly), looks up the correct Price ID from bsm_stripe_products, cancels any existing subscription, creates a Stripe Checkout Session and redirects the user to Stripe's hosted payment page.
Triggered by: /checkout/?tier=individual or /checkout/?tier=family or /checkout/?tier=instructor
📡
bsm-stripe-webhook.php
Receives POST events from Stripe. Verifies the Stripe signature. Handles five user subscription events (updates GOD JSON) and six product/price events (upserts bsm_stripe_products table). All helper functions are defined at the TOP of the file before any code calls them.
Critical rule: functions must be defined before they are called. Never move definitions below the exit statement.
👤
bsm-stripe-portal.php
Fires when a user visits /billing-portal/. Reads the user's stripe_customer_id from GOD JSON, creates a Stripe Billing Portal session, and redirects to Stripe. The user never sees the /billing-portal/ WordPress page — they are immediately redirected.
Unaffected by the dynamic tier architecture. No changes needed.
🎛
bsm-stripe-product-crud.php
Front-end page at /admin-stripe-product-mini-crud/ — not a native wp-admin screen. Reached today via a “BSM Admin” button shown only on admin account menus; no server-side permission check exists in the file itself yet, confirmed from its own header comment. Fetches live Stripe data on every load, shown alongside the current bsm_stripe_products row for comparison. Admin can edit name, description, seats, featured, and order. Prices are read-only. Every change requires two confirmations — a warning screen, then a field-by-field diff screen — before a final green Implement Changes button writes to both Stripe and the local table in the same action.
This is the only place tier data is ever edited. There is no separate one-time setup script — this same page does first-time population and every ongoing change.
📄
bsm-plans-page.php
The page a user sees when choosing or changing their plan. Queries bsm_stripe_products directly (active = 1, ordered by display_order) and builds one card per row. The Free tier is not a Stripe product, so it is the one card defined directly in this file rather than read from the table. Every paid card's button links to /checkout/?tier=X&period=Y — there is no separate "reactivate" path; switching tiers during a cancellation notice period uses the exact same Switch/Upgrade button as any other tier change.
Zero Stripe API calls on page load. A new tier appears here automatically the moment it has a synced row in the table.
📄
upgrade-page.php
Same dynamic-table-driven card pattern as bsm-plans-page.php, but shown only to free users — this is the genuine free-to-paid entry point, the only route a brand-new user has into becoming paid for the first time. Not interchangeable with the plans page, which is for already-paid users changing tier. Has no “current paid plan” branching at all, since that state never applies here.
Built and proven against two real brand-new signups. Hero rewritten for a warmer, calmer tone — a soft “have a look below” nudge replacing a hard-sell CTA button, education-first rather than pressure-first.
🛑
bsm-stripe-cancel.php
On-site cancellation at /cancel-subscription/, replacing the need to send users to the Stripe billing portal for this action. Sets cancel_at_period_end=true via direct API call. Shows a real impact screen listing every active sub-member by name and email if the user's tier has seats. Sends an HTML email to every active sub-member on confirm, with a CTA linking to the plans page.
Deliberately writes nothing to GOD JSON itself — relies entirely on the webhook's subscription.updated handler, so there is exactly one code path that ever writes subscription_cancelled_date.
bsm-stripe-undo-cancel.php
On-site reversal at /undo-cancel/. Sets cancel_at_period_end=false via the same API pattern. Same architecture as the cancel file — no direct GOD JSON write, relies on the webhook.
Live-tested through multiple full cancel/undo-cancel round trips, including across a real tier change.
👥
bsm-manage-sub-members.php
Seat management at /manage-sub-members/. Invite form, member list with separate Invited/Joined columns and a mobile card-stack layout, and a remove action. Reads tier label and seat limit live from bsm_stripe_products. Sends a styled HTML invite email with a role-aware opening line (Family/Instructor/Business) and an explicit, standalone confidentiality callout.
Pre-existing file had a live unconditional test override forcing every visitor into a fake paid-Family view — removed. Old hardcoded partner-naming/seat-limit arrays also removed.
📝
onboard-spa.php
The wellness study and account-creation flow. Now also reads a bsm_invite token from the URL on page load, pre-filling the name/email fields shown later in the flow. On save_all, if an invite token is present: writes parent_user_id and member_role to the new user's own GOD JSON, and updates the matching entry in the inviter's sub_members[] to active with the confirmed name, email and join date.
Whatever the invitee actually confirms is the source of truth, even if different from what the master originally typed.
👤
account_page_index.php
The account page at /account/. Reads tier label and seat limit live from bsm_stripe_products. Lists real sub-members with name, email and Active/Invited status when they exist (this was a genuine bug fixed today — the file previously never read sub_members[] at all, so it always showed “No members added yet” even when members existed). Links to cancel, undo-cancel, plans, invoices, update-details, and the billing portal.
“Manage billing” is now split into three distinct links rather than one vague one — see Part 2's billing portal section.
🧾
bsm-billing-history.php
Read-only invoice list at /billing-history/. Calls GET /v1/invoices?customer={id}. Shows date, description, amount, status, and a PDF download link per invoice. No write operations of any kind.
Live-tested with two real invoices, both displaying correctly with working downloads.
bsm-update-details.php
Name/email update at /update-details/. Writes to three places together on save: the WordPress user record, GOD JSON, and Stripe's customer record if one exists. The Stripe write is fire-and-forget — if it fails, the save still succeeds (WordPress and GOD JSON are correct, which is what the user sees) with an amber warning rather than a blocked save.
Payment method itself deliberately stays on the Stripe-hosted portal — see Part 2.
💀
bsm-delete-account-spa.php & bsm-admin-user-delete.php
Self-delete and admin-delete-another-user, two separate files. Both now cancel any live Stripe subscription immediately (not at period end) before wiping data. The admin tool additionally detaches every active sub-member if the deleted user was a master, and shows their names on its own confirmation screen before the admin commits.
See Part 6 for the full detail, including what genuinely differs between the two files and what is still outstanding (no email sent to anyone on either path).
🛡
page-protect.php
Whitelist page protection. Public slugs require no login. Free slugs require login only. Everything else requires user_status === paid in GOD JSON. No tier-specific logic — protection is binary paid/not-paid.
Two real gaps found and fixed today via live testing, not code review: plans-page and account were missing from free_slugs (a free sub-member clicking a correctly-linked email landed on upgrade-page instead, since both pages have their own correctly-built free-user view that this whitelist prevented them reaching). A silent wp_redirect on every unwhitelisted page was also replaced with a visible interstitial stating the exact blocked slug, specifically to make any future whitelist gap immediately obvious rather than a confusing silent bounce.
How the flow connects
User clicks plan
/checkout/?tier=X
checkout.php reads bsm_stripe_products
Stripe hosted checkout
Payment succeeds
Stripe fires webhook
webhook.php updates GOD JSON
User status = paid
Stripe product updated
Stripe fires webhook
webhook.php upserts table
Site reflects change instantly
Plans page loads
Reads bsm_stripe_products
Builds cards dynamically
Zero Stripe API calls
User clicks Cancel
cancel.php sets cancel_at_period_end
Stripe fires subscription.updated
webhook.php sets cancelled date
Billing period genuinely ends
Stripe fires subscription.deleted
webhook.php reverts user_status to free
Every active sub-member auto-detached
Master invites someone
manage-sub-members.php emails invite link
Invitee completes onboard-spa.php
Both GOD JSONs updated, entry flips to active
4
GOD JSON Key Glossary
What every field in the membership-parameters block actually means and who reads/writes it

The "MEMBERSHIP PARAMETERS": "__PSEUDO_PARENT__" line is just a section divider — it groups the keys below it for human readability when inspecting a record directly in phpMyAdmin. It provides no function and is never read or written by any code. The same applies to the separate "SYSTEM_TOKENS_METADATA": "__PSEUDO_PARENT__" divider seen grouping the AI cost-tracking fields elsewhere in the same record.

Keys with confirmed real usage in code
  • membership_tier — the user’s tier_key (e.g. individual, family). Read by the account page, plans page, manage-sub-members.php and the cancel confirmation screen to look up the matching row in bsm_stripe_products. Written by the webhook on checkout/upgrade.
  • membership_seats — count of entries in sub_members[], kept in sync by manage-sub-members.php every time an invite is sent or a member is removed. Not itself the seat limit — the limit comes from the table, this is the count in use.
  • sub_members[] — array of every person this user has invited. Each entry holds email, name, status (invited or active), user_id, invite_token, invited_date, joined_date. Written by manage-sub-members.php on invite/remove, and updated by onboard-spa.php when an invitee completes their own onboarding.
  • stripe_customer_id / stripe_subscription_id — the live Stripe IDs for this user. Written by the webhook on checkout completion, read by the cancel/undo-cancel files to know which subscription to act on.
  • stripe_next_billing — the date the current billing period ends, shown on the account page as “Active until” / “Next billing date”. Written by the webhook, reading current_period_end from the correct nested location in the Stripe payload.
  • subscription_start_date / subscription_cancelled_date — set by the webhook at checkout and at the moment cancel_at_period_end is toggled, respectively. subscription_cancelled_date being non-empty is what the account/plans pages check to show the “Cancelling” badge.
  • registration_source — confirmed written as "stripe_checkout" on a real paid signup. Only that one value has actually been observed; whether other values exist for other signup paths is not confirmed.
  • parent_user_id / member_role — set on a sub-member’s own GOD JSON by onboard-spa.php when they complete onboarding via an invite link. member_role is member (Family), client (Instructor) or employee (Business). Both cleared back to empty when the master removes that sub-member.
⚠️ Keys present in the schema but not touched by anything built so far

These keys exist in real GOD JSON dumps, all currently sitting empty, but no file reviewed so far reads or writes any of them. Their intended purpose is inferred from the key name only and has not been confirmed:

  • stripe_payment_method, stripe_last4 — presumably intended to cache the card type/last 4 digits for display without an extra Stripe API call, but nothing currently populates them.
  • email_verified, email_verified_date, email_verify_reminder_sent — presumably an email verification flow, separate from the welcome/verification emails sent by onboard-spa.php (which use a separate WordPress user-meta token, not these GOD JSON fields).
  • registration_date — distinct from the confirmed subscription_start_date; presumably meant to record account creation date regardless of payment status, but not seen written anywhere.
  • subscription_end_date, subscription_cancel_reasonsubscription_end_date was seen explicitly set to empty string by the checkout handler on a fresh signup (clearing any stale value), but never seen populated with a real date. subscription_cancel_reason has no observed write path at all — the current cancel flow does not collect a reason.
  • pwa_installed, pwa_install_date, pwa_prompt_shown, pwa_prompt_date — presumably tracking for a Progressive Web App install prompt, entirely unrelated to the Stripe/membership work.
  • last_login_date — presumably set on every login, not reviewed.
  • partner_profile_slug, partner_profile_live, partner_bio, partner_region — named after the old “Partner” tier (now renamed Instructor) and likely predate the tier rename. May relate to a professional profile page feature for Instructor-tier users, but no file implementing it has been reviewed.

This glossary only covers the membership-parameters block. Other GOD JSON sections (wellness study results, onboarding snapshot, AI cost tracking) exist in the same record but are outside this scope.

5
Tier Change & Member Management
The rules governing all subscription changes and sub-member behaviour
📋
The Golden Rule

Any cancellation or primary account deletion — voluntary or, at period end, automatic — is a full reset of the group, with no exceptions. Every active sub-member is detached, regardless of how many seats the new state would allow. There is no logic anywhere in the system that calculates which members “fit” and which do not — that calculation does not exist by design. No auto-reinstatement. No magic. The primary account holder must resubscribe and re-invite every member from scratch, one at a time, from the seats management page.

BSM manages all tier and member logic. Stripe is only the payment processor. Stripe never knows about seats, sub-members, or group resets — that is all BSM's domain via GOD JSON.
🔄
Tier change flow — what is actually built versus genuinely untested
Upgrade (any tier to a higher tier)
  • User clicks a higher tier on the plans page → routed to /checkout/?tier=X&period=Y
  • bsm-stripe-checkout.php cancels the existing Stripe subscription immediately
  • A new Stripe Checkout Session is created for the new tier
  • On payment success the webhook updates GOD JSON: new tier_key, new sub ID, new stripe_next_billing, user_status stays paid
  • New seats become available immediately. Existing sub-members are unaffected, since nothing about their own data changes.
Genuinely tested: a real upgrade from Individual to Family today, with a working cancel/undo-cancel cycle run again afterward on the new tier to confirm both still worked post-upgrade.
Downgrade between two paid tiers (e.g. Family → Individual)
Genuinely untested. Do not assume the bullet points below are confirmed fact. This goes through the same checkout-based mechanism as upgrade (old subscription cancelled, new one created), so it is reasonable to expect every active sub-member would be left orphaned — their parent_user_id would still point at the primary, but nothing currently runs the detachment logic on this specific path the way it does for a genuine cancellation or deletion. This needs deliberate testing before it can be documented as known behaviour, not assumed from how the similar paths work.
Downgrade to free (cancellation)
  • User clicks Cancel subscription on the account page → routed to /cancel-subscription/
  • If the user has active sub-members, the confirmation screen lists each one by name and email and states they will lose access too on the same date
  • Single-click confirm — no typed confirmation step exists today
  • On confirm: Stripe API call sets cancel_at_period_end: true. Every active sub-member is emailed immediately. The primary holder sees an on-screen success message only — no email to them.
  • User keeps full paid access until the period genuinely ends
  • At period end Stripe fires customer.subscription.deleted → webhook reverts the primary's user_status to active (free), and for every active sub-member clears parent_user_id and member_role on their own GOD JSON. The primary's sub_members[] array is emptied entirely — entries are removed, not flagged with a status.
👥
Sub-member status rules — as actually implemented
The real system uses exactly two statuses for a sub_members[] entry: invited (sent, not yet accepted) and active (accepted and joined). There is no third “lapsed” status anywhere in the real code. When a sub-member’s access ends — whether by manual removal, full-group reset, or the primary deleting their account — their entry is removed from the array entirely, and their own GOD JSON has parent_user_id and member_role cleared back to empty strings. They simply become an ordinary free user.
🟡 When a sub-member is detached, by any path
  • Their entry is removed from the primary's sub_members[] array
  • parent_user_id and member_role are cleared to empty strings on their own GOD JSON
  • Sub-member retains full GOD JSON and all wellness data — nothing of their own is ever deleted by this
  • Sub-member drops to ordinary free tier access — treated exactly as any free user from this point
  • No suspension, no login block, no data loss
Not yet built: a contextual dashboard notice explaining why their access ended. Today, the only signal a sub-member gets is the cancellation email sent at the moment of confirmation (if their detachment came via a genuine cancellation) — there is no in-app message at all if they are manually removed, or if they are detached via a path that has no email (see the genuinely-untested downgrade case above).
🟢 GOD JSON fields for sub-member tracking
  • member_role — set when a user joins a group via an accepted invite. The real values in use are member (Family), client (Instructor), and employee (Business) — set by onboard-spa.php based on the inviter's tier at the moment the invite was sent.
  • parent_user_id — the WordPress user ID of their primary account holder. Used to identify which group they belong to.
  • Both fields are cleared back to empty strings whenever a sub-member is detached, by any path
📧 When a sub-member independently upgrades or deletes their own account
Not yet built, in either case. No notification is sent to the primary holder when a sub-member independently upgrades (no code currently removes them from the primary's sub_members[] on this path at all) or when they delete their own account (the self-delete file has no sub-member-specific logic whatsoever — it only ever touches the deleting user's own data). In both cases the primary's seat count will not reflect reality until they manually notice and remove the now-orphaned entry themselves via manage-sub-members.php.
Master user manually removing a sub-member
  • This is a distinct, deliberate action available to the primary holder on the seats management page — separate from the automatic full-group reset triggered by cancellation or account deletion
  • The primary holder can remove any individual active sub-member at any time, for any reason, without affecting their own subscription or any other member
  • On removal: the entry is deleted from sub_members[], and parent_user_id/member_role are cleared on the removed member's own GOD JSON
  • The freed seat becomes immediately available for the primary holder to invite someone else into, without needing to resubscribe
  • This manual removal is the only single-seat detachment path that has actually been built and proven — live-tested today with a real removal
Master user cancellation flow — as actually built and tested

Cancellation for a primary account holder with active sub-members shows the real impact before they confirm. This is the genuine, live flow in bsm-stripe-cancel.php, proven with real users today — not an aspirational design.

1️⃣ The confirmation screen
  • Single screen, shown before anything is cancelled
  • If the master has active sub-members: states the exact count, lists each one by name and email, and explains plainly that they will lose access too and become free users on the same date
  • If the master has no active sub-members (or is on the Individual tier, which never has any): the screen is simpler — just the standard “you’ll keep access until period end” message
  • Two buttons: Yes, cancel my subscription (red) and Never mind, keep my plan (teal)
  • Confirmation is a single click — there is no typed-confirmation step (typing “CANCEL” or an email address). This is a deliberate gap, not an oversight: see the note below.
2️⃣ What happens on confirm
  • Stripe API call sets cancel_at_period_end: true on the live subscription — the subscription itself keeps running, billing continues as normal, until the period genuinely ends
  • Every active sub-member is sent a styled HTML email immediately, stating the real end date, reassuring them their account and data are untouched, and a link to the Plans page encouraging them to start their own plan if they want to keep their access
  • The primary holder sees a plain success message on screen (“Your subscription is set to cancel…”) — no email is currently sent to the primary holder themselves, only to their sub-members. This is a genuine gap, listed below.
  • The account page and plans page both immediately reflect the “Cancelling” state, with a working “Stop the cancellation” link
3️⃣ What happens later, at the actual period end
  • Stripe fires customer.subscription.deleted when the billing period genuinely ends
  • The webhook reverts the primary’s user_status to free, and automatically detaches every active sub-member — clearing parent_user_id and member_role on each one’s own GOD JSON, and emptying the primary’s sub_members[] array
  • No further email is sent at this point to either the primary or the sub-members — the only cancellation-related email in the system today is the one sent to sub-members at the moment of confirmation, described above
Genuinely outstanding, not yet built: a typed-confirmation step before the final click; an email to the primary holder confirming their own cancellation went through and listing who was affected; a second email to the primary at the moment the subscription actually ends; a contextual dashboard notice for a sub-member after they’ve genuinely lapsed (today they just become a normal free user with no in-app explanation, only the email already sent at cancellation time); and a notification to the primary when a sub-member independently upgrades or deletes their own account, freeing a seat.
Individual plan cancellation (no possible sub-members) always uses the simpler version of the confirmation screen described above — there has never been a separate “Step 1” to skip, since the same file handles both cases by checking the active sub-member count.
📣
Email notification summary

Of the four email triggers originally designed for this flow, only one is actually built and sending today. The other three are real, intended future work — not yet implemented.

EventWho receives itStatusContent
Primary cancels subscription Active sub-members only ✅ Built & tested HTML email, sent immediately on confirm. States the real end date, reassures data is safe, links to the Plans page. The primary holder themselves receives no email — only an on-screen success message.
Subscription period ends Primary ❌ Not built Intended: confirm the subscription has genuinely ended, that members have already been notified, and that a fresh invite is needed for anyone the primary wants to keep in their group.
Sub-member independently upgrades Primary ❌ Not built Intended: “[Name] has upgraded independently and left your group. Free seat available.”
Sub-member deletes account Primary ❌ Not built Intended: “[Name] has deleted their account. Free seat available.”
6
Account Deletion
The irreversible, nuclear action — entirely distinct from cancellation
Cancel vs Delete — never the same action
Critical distinction

Cancel ends a paid subscription. The account, login, and every piece of GOD JSON wellness data survive untouched — the user simply continues as a free user. It is fully reversible at any time by resubscribing.

Delete account is permanent and irreversible. The entire user record — login, GOD JSON, all wellness data, all history — is removed from the system. There is no recovery path. Once confirmed, it cannot be undone by resubscribing, by support, or by any other mechanism.

These two actions must never share a confirmation screen, a button, or a code path. A user choosing to cancel their subscription must never end up on the delete-account flow by mistake, and vice versa.
💣
When a master/primary account holder deletes their account
Consequences for the primary holder
  • Active Stripe subscription is cancelled immediately — not at end of billing period. Deletion does not wait out a notice period the way a cancellation does.
  • The user record, GOD JSON, and all wellness data are permanently removed
  • The WordPress user account is removed and the login can never be reused to recover this data
👥 Consequences for their sub-members
  • Same detachment treatment as a natural subscription end — every active sub-member has parent_user_id and member_role cleared on their own GOD JSON, and the primary's sub_members[] array is emptied. Built today in bsm-admin-user-delete.php (mirroring the logic already proven in the webhook).
  • Sub-members keep their own accounts and all of their own wellness data. Only the primary holder's account and data are removed. A sub-member's account is never deleted by the primary holder deleting theirs.
  • Because the primary account is gone, there is no group left to be re-invited into. Any sub-member wanting to continue in a group must be invited fresh into a different paid account, or start their own.
Genuinely outstanding, not yet built: no email is sent to affected sub-members when this happens, in either the self-delete or admin-delete file. No on-screen impact list showing affected sub-members is shown to the deleting user before they confirm, in either file — the admin tool does show a count and names of active sub-members on its own confirmation screen (built today), but the self-delete flow shows nothing about sub-members at all.
👥
When a sub-member deletes their own account
What happens
  • Only that individual sub-member's account and GOD JSON data are removed. The primary holder's subscription, account, and all other sub-members are completely unaffected.
  • This goes through the self-delete file, which has no sub-member-specific logic at all — it only ever touches the deleting user's own data, so a sub-member is never removed from the primary's sub_members[] array by this action. The primary's seat count will not reflect the departure until they manually remove the now-orphaned entry themselves via manage-sub-members.php.
Genuinely outstanding, not yet built: the primary is not notified when this happens, and the freed seat is not automatically reflected — the orphaned sub_members[] entry (now pointing at a deleted user_id) stays in the primary's list until manually removed. This is a real, currently-unhandled edge case.
The one thing that is correctly true today: a sub-member deleting their own account never cascades to anyone else's account or data, because they are not the source of the group's paid status.
🔒
Confirmation flow — as actually built (the two files differ)

Self-delete and admin-delete are two separate files with two genuinely different confirmation mechanisms — not one shared flow.

👤 bsm-delete-account-spa.php (self-delete)
  1. 4-screen flow: a feedback reason tile, a list of what will be lost, an admin-only warning screen (skipped for non-admins), then a final screen
  2. The final screen requires typing the word DELETE into a text field to unlock the Continue button on screen 2 — this gate exists, but on the final screen itself the actual submission is a 10-second countdown button, not a second typed field. The hidden form field sent to the server is always the literal value “DELETE”, set automatically by the page, not typed by the user at the point of submission.
  3. No impact screen showing affected sub-members exists in this file at all
  4. Hard-blocked for user ID 1 at the server level regardless of what the front end does
🛡️ bsm-admin-user-delete.php (admin deletes another user)
  1. Search by ID, username, or email, then a found-user screen showing their real BSM status and, if relevant, their live Stripe subscription and active sub-members by name
  2. Final confirmation screen genuinely requires typing the word DELETE into a text field — the submit button stays disabled until that exact text is entered
  3. Admin cannot delete their own account through this tool (separate check from the user-ID-1 protection in the self-delete file)
Found during this review, not yet fixed: this file's own “Member Role” status display compares member_role against the values 'owner' and 'sub_member', but nothing built today ever writes those values — the real role values in use are 'member', 'client', and 'employee' (set by onboard-spa.php). This specific status line on the admin search screen will never correctly display, though it does not affect the actual deletion or detachment logic, which reads the real GOD JSON fields correctly elsewhere.
7
Test Cards
Use these in Stripe sandbox — they never charge a real card
💳
Stripe sandbox test card numbers

These card numbers only work in sandbox mode. Use any future expiry date (e.g. 12/30), any 3-digit CVC (e.g. 123), and any postcode.

What you are testingCard numberResult
Successful payment — use this for most tests4242 4242 4242 4242Payment succeeds
Card declined4000 0000 0000 0002Declined
Insufficient funds4000 0000 0000 9995Declined
Requires 3D Secure4000 0025 0000 3155Extra step required
Failed renewal4000 0000 0000 0341Attaches but fails charge
8
Test Protocol — T1 to T10
Run all tests in order. Do not skip ahead. Each test confirms a dependency for the next.
📋
Current test status
T1 — Keys verified
All WordPress options confirmed
T2 — Checkout creates
Stripe hosted page loads correctly
T3 — Payment + GOD JSON
user_status=paid, Stripe IDs stored
T4 — Webhook 200 OK
All events delivered, signature verified
T5 — Access control
Free users blocked, paid users pass
T6 — Billing portal
Portal opens, shows correct data
T7 — Declined card
Stripe shows error, GOD JSON unchanged
T8 — Cancellation
GOD JSON reverted, cancel date set
T9 — All four tiers
Individual, Family, Instructor, Business confirmed
T10 — Full end-to-end
Brand new user, full journey — confirmed with real signups
🧪
Before you begin testing — set up three windows
  1. Window 1 — BSM website in incognito: Open a private/incognito browser window. This is where you act as a test user. Incognito ensures no cached login sessions.
  2. Window 2 — Stripe Workbench: Stripe dashboard → Developers → Webhooks → click your endpoint → Event deliveries tab. Watch webhook events fire here in real time.
  3. Window 3 — phpMyAdmin: Log into hosting control panel, open phpMyAdmin, have the assessment table open. Check GOD JSON after each payment to confirm updates.
Never resend old Stripe webhook events to test GOD JSON updates. The webhook verifies the event timestamp is less than 5 minutes old. Resending an old event will fail signature verification. Always do a fresh payment test.
T1
Keys stored correctly
✓ PASS

Verify WordPress can read the three Stripe credentials. Create a temporary WPCode PHP snippet set to Run Everywhere, paste the verification code below, visit any page, confirm all three values show, then deactivate immediately.

<?php
$keys = array('bsm_stripe_secret_key','bsm_stripe_publishable_key','bsm_stripe_webhook_secret');
foreach ($keys as $k) {
    $v = get_option($k);
    echo $k . ': ' . ($v ? substr($v,0,15).'...' : 'MISSING') . "\n";
}
✅ Pass criteria
All three keys show a value. Secret key starts sk_test_, publishable key starts pk_test_, webhook secret starts whsec_.
T2
Checkout session creates
✓ PASS

Confirm checkout PHP creates a Stripe session and redirects to Stripe's hosted payment page. Do NOT pay yet — just confirm the page loads with correct product name and GBP price, and the user email is pre-filled.

✅ Pass criteria
Stripe checkout page loads with correct product name and GBP price. Email is pre-populated. No error messages.
T3
Successful payment — GOD JSON updated
✓ PASS

Complete a full test payment with card 4242 4242 4242 4242 and verify GOD JSON shows: user_status=paid, membership_tier populated, stripe_customer_id (cus_), stripe_subscription_id (sub_), subscription_start_date=today, subscription_cancelled_date empty.

✅ Pass criteria
All six GOD JSON fields updated correctly within seconds of payment. Stripe shows checkout.session.completed fired.
T4
Webhook received and processed correctly
✓ PASS

In Stripe Developers → Webhooks → Event deliveries, confirm checkout.session.completed and invoice.payment_succeeded both show 200 OK. If you see 500 ERR, the most common cause is PHP function definition order in bsm-stripe-webhook.php.

✅ Pass criteria
All webhook deliveries show 200 OK. No 500 errors. Delivery status shows Delivered.
T5
Access control
✓ PASS

Paid user can access paid pages. Free user (user_status=active) is redirected to /upgrade-page/ when hitting paid content. Admin bypasses all protection — always test with a real subscriber account, not admin.

✅ Pass criteria
Paid user accesses paid pages. Free user redirected to /upgrade-page/. Upgrade page shows correct plan options.
T6
Billing portal opens and functions
✓ PASS

Manage billing on account page redirects to billing.stripe.com immediately. Portal shows current plan, price, next billing date, payment method. Return link sends user back to /account/.

✅ Pass criteria
Portal opens showing correct subscription. Return link works. No blank pages shown.
T7
Declined card — GOD JSON unchanged
✓ PASS

Use declined card 4000 0000 0000 0002. Stripe shows “Your card was declined”. User stays on Stripe checkout page. GOD JSON completely unchanged, user_status still active.

✅ Pass criteria
Stripe shows decline error. GOD JSON unchanged. user_status remains active.
T8
Cancellation — GOD JSON reverts
✓ PASS

Cancel via the on-site flow at /cancel-subscription/ (the original version of this test used the Stripe billing portal, before cancellation moved on-site — see Part 5 for the full real flow including the sub-member impact screen and notification email). Account page shows “Cancelling” with the real end date. When period ends and customer.subscription.deleted fires: user_status reverts to active, subscription_cancelled_date populated, every active sub-member auto-detached, paid pages become inaccessible.

✅ Pass criteria
On-site cancellation screen shows the real cancellation date and any affected sub-members. GOD JSON reverts correctly on period end, including sub-member detachment.
T9
All four tiers + upgrade flow
✓ PASS

Repeat T3 for Family, Instructor and Business tiers. Test upgrade: Individual user clicks Family plan — old sub cancelled automatically, new checkout created. After upgrade only one active subscription in Stripe portal. GOD JSON membership_tier matches tier chosen.

✅ Pass criteria
All four tiers checkout successfully. Upgrade flow leaves only one active subscription. membership_tier in GOD JSON matches tier chosen.
T10
Full end-to-end flow as a brand new user
✓ PASS

The final test. A completely fresh user with no existing data goes through the entire journey from landing on the site for the first time through to paid access. No shortcuts, no admin involvement. Confirmed genuinely passed with two real brand-new signups (“Samuel” and “Sammy”) going through this exact flow.

  1. Open a fresh incognito window — no existing session, no cookies
  2. Go to /onboard-spa/ and complete all onboarding screens as a brand new guest
  3. Create an account — use a real email address you can check
  4. Confirm the account creation email arrives
  5. Complete the triage assessment with Katie on /assessment-triage/
  6. Navigate to /upgrade-page/ (not /plans-page/ — that page is only for already-paid users changing tier; /upgrade-page/ is the only route a free user has into becoming paid for the first time) and click Unlock my ecosystem on Individual
  7. Pay with test card 4242 4242 4242 4242
  8. Confirm you land on /dashboard/
  9. Check phpMyAdmin — GOD JSON shows user_status=paid, membership_tier=individual, stripe IDs populated, stripe_next_billing populated with a real date
  10. Try visiting a paid page — confirm it loads
  11. Go to /account/ — confirm subscription section shows correct tier and billing info
  12. Open billing portal — confirm it shows the correct subscription, or use the on-site invoice history at /billing-history/
  13. Log out — confirm redirected to /login/
  14. Log back in — confirm still paid, lands on /dashboard/
🏁 Confirmed passed. The Stripe integration is complete and proven against real brand-new signups, not just sandbox theory.
9
Going Live Checklist
T10 has passed — review this checklist before switching Stripe to live mode
🚀
Switching from sandbox to live payments

Going live means real cards will be charged. Do not do this until all 10 tests have passed in sandbox.

  1. In Stripe dashboard switch to LIVE MODE (toggle top right)
  2. Get live API keys from Developers → API keys — starts with sk_live_ and pk_live_
  3. Create the three live products in Stripe Product catalogue with the same metadata fields and real GBP prices
  4. Create a new live webhook endpoint at the same URL with all eleven events. Copy the new live signing secret.
  5. Run the Step 6 snippet again with live credentials only (three keys). Deactivate immediately.
  6. Open the BSM Stripe Products admin panel and run through Save Changes → confirm → Implement Changes once to populate bsm_stripe_products from the live products
  7. Do one final test payment with a real card to confirm end-to-end works in live mode
  8. Refund that test charge from the Stripe dashboard
The live webhook signing secret is different from the sandbox one. You must update bsm_stripe_webhook_secret in WordPress options with the new live value or all live webhook events will fail signature verification.
🚧
Appendix — Build Working Document
This appendix is a private working document listing every code change required to implement the architecture described in this guide. It will be deleted from this file once the build is complete. The guide above is the permanent record.
📋 Job list at a glance
  1. ✅ Dynamic tier table & admin panel, with Post/Redirect/Get fix (Sections A, I)
  2. ✅ Webhook product/price sync, all 6 events (Section B)
  3. ✅ Checkout reads price from table, and now also populates next billing date at signup (Section C)
  4. ✅ Plans page reads tiers from table, no legacy Reactivate/Upgrade wording (Section D)
  5. ✅ Upgrade page (the genuine free-to-paid entry point) reads tiers from table, proven against two real brand-new signups (Section E)
  6. ✅ On-site cancel and undo-cancel — no Stripe billing portal needed (Section N)
  7. ✅ Account page reads tier label & seat limit from table (Section F)
  8. ✅ Sub-member invite, onboard-accept, and detach-on-remove — full chain proven with real users (Section A2)
  9. ✅ User Management Guide for BSM admin — built, with feature/tier matrix and product navigation map (Section P)
  10. ✅ Tier change lifecycle — cancel mechanism, automatic sub-member detach on subscription end, impact screen with real names, and HTML notification email to affected sub-members, all done (Section L)
  11. ✅ page-protect.php whitelist gap fixed — plans-page and account added to free_slugs, so free/sub-member users land on the page’s own correctly-built free-user view instead of being redirected to upgrade-page before it renders (Section Q)
  12. ✅ Account deletion flow — Stripe immediate cancellation and sub-member auto-detach added to both self-delete and admin-delete scripts (Section M)
  13. 📝 WordPress options cleanup — deliberately left in place, not deleted; see Section J for the reasoning, revisitable any time
  14. ✅ Billing portal decision made and built — invoices and name/email change moved fully on-site; payment method deliberately stays on the Stripe portal, since card collection there carries no real benefit to building it ourselves and Stripe’s hosted flow already handles every edge case (Section O)
  15. ✅ Sub-member invite email now has an explicit, standalone confidentiality callout — states plainly that nobody, including the master who invited them, can ever see their personal wellness data
DONE — bsm-stripe-product-crud.php built (replaces the originally planned one-time sync script)

The plan in this section originally called for a one-time CLI/WPCode sync script. During the build this was upgraded to a permanent admin panel instead, since a script that runs once and disappears is opaque and hard to safely re-run. The panel does everything the script would have done, plus ongoing editing with a two-step confirmation flow.

  1. File created: /stripe/bsm-stripe-product-crud.php, built as a front-end page (not wp-admin) included via the same WPCode pattern as the other Stripe files. Reached today only via a “BSM Admin” button that renders exclusively on admin account menus — this is obscurity, not a real permission boundary, since no current_user_can() check exists in the file itself. Planned before launch: extend page-protect.php to explicitly block non-admins from every bsm-admin-* URL.
  2. Fetches live products via GET /v1/products?active=true&limit=100 and matches prices via GET /v1/prices?product={id}&active=true on every page load
  3. Generates tier_key via sanitize_title($product['name']), exactly as specced
  4. Editable: name, description, seats, featured, order. Read-only: both prices, with explanatory warning text
  5. Three-stage flow: review/edit → warning confirmation → field-level diff confirmation → green Implement Changes button
  6. Implement Changes pushes to Stripe via POST /v1/products/{id} then immediately upserts the bsm_stripe_products row in the same request, so the two can never drift from using this tool
  7. Handles tier_key changes on rename (deletes old row, inserts fresh under new slug) to avoid orphaned rows
  8. Live-tested and confirmed: edited Family seats 4→5 through the full warn/confirm/implement flow, verified persisted correctly to both Stripe and the table
  9. Post/Redirect/Get fix applied: the Implement step now redirects to a clean GET URL with results carried in a short-lived transient, rather than rendering the result directly from the POST. This was added after a real bug was found and reproduced live: refreshing the old result screen silently resubmitted the same form data, which could revert a field back to a stale value if it had changed elsewhere in the meantime. Confirmed fixed — refreshing the result screen after Implement no longer resubmits anything.
Registered and confirmed working — the admin panel is live and has been used for real edits.
DONE — manage-sub-members.php, onboard-spa.php invite acceptance, full chain proven with real users

This file already existed with a genuinely working invite/remove flow (22,945 bytes) — it was not built from scratch. Three real problems were found and fixed, then the missing other half of the chain (invite acceptance, which lives in onboard-spa.php, not this file) was built and proven end to end with real signups.

manage-sub-members.php fixes

  1. Removed an active unconditional test override that was forcing every single visitor to see themselves as a paid Family member with 4 seats, regardless of their real status — this was live and affecting every real user before today
  2. Removed hardcoded $tier_labels and $seat_limits arrays (only knew about family and partner, with partner wrongly set to 10 seats instead of Instructor's real 28), replaced with the same live table lookup used everywhere else: SELECT name, seats FROM bsm_stripe_products WHERE tier_key = %s AND active = 1
  3. Removed hardcoded “Family or Partner” / “1 + 4 members” / “1 + 10 members” wording from the locked and individual-plan states, replaced with generic wording that stays accurate regardless of how many tiers exist
  4. Rewrote the invite email as a proper HTML email: role-aware opening line (Family invite says “as a member of”, Instructor says “as a client of”, Business says “as an employee of”), full feature list, styled CTA button, and a personal sign-off using the inviter's own first name rather than “The BodySleepMind Team” — this is framed as a personal gift, not a corporate notice
  5. Split the single “Joined” column into separate “Invited” and “Joined” columns in the members table, since a master previously could not tell at a glance who had actually completed onboarding versus who was still just a pending invite
  6. Built a proper mobile card-stack layout for the members table (each row becomes a card with labelled fields) rather than the previous approach of simply hiding columns on small screens

onboard-spa.php — invite token acceptance (the missing other half)

  1. The invite email links to /onboard-spa/?bsm_invite={token}. The onboarder previously had no awareness of this parameter at all — it would run as a completely generic signup with no connection back to the inviter.
  2. On page load, the token is now read from the URL and the stored transient is fetched, pre-filling the name and email fields shown on screens 6 and 7 with whatever the master originally typed — fully editable, since whatever the invitee confirms becomes the source of truth
  3. The token travels through the form submission via a hidden field so it is available inside the save_all AJAX handler
  4. After the new user's own GOD JSON row is created and written (this has to happen first — the row does not exist until this point), two further writes happen: the new user's own GOD JSON gets parent_user_id set to the inviter's user ID and member_role set based on tier (Family → member, Instructor → client, Business → employee); separately, the master's sub_members[] entry is found by matching either the original invite email or the confirmed email, then updated to status: "active", the real user_id, the confirmed name and email, and joined_date
  5. Invite tokens are deliberately not deleted after use (low risk, no observed downside, and they may get reused in edge cases) — left in place per explicit decision

Removal behaviour, confirmed correct by design and by test

  1. Removing an active sub-member clears parent_user_id and member_role on their own GOD JSON only — their account, user_status, and all their own wellness data are completely untouched. They become an ordinary free user, nothing is deleted.
  2. The master's sub_members[] entry for that person is deleted entirely (not marked “lapsed” — simply removed from the array) and membership_seats decrements, freeing the seat for a new invite
Live-tested completely with two real users on a real Family account: “James” was invited and left pending (correctly visible with an Invited date and no Joined date, exactly the gap this section's column split was built to surface). “William” was invited, completed the full onboarder including changing nothing about the pre-filled name/email, and was confirmed via direct GOD JSON inspection on both sides: his own record showed parent_user_id matching the master's real user ID and member_role: "member"; the master's record showed his entry flipped to active with his real user_id and a populated joined_date. William was then removed by the master and confirmed via GOD JSON to still hold a normal free account (user_status: "active", parent_user_id and member_role both cleared) while the master's sub_members[] array correctly dropped to empty and membership_seats reset to 0.

Known gap, deliberately not fixed today

An invited entry with no user_id sits in the master's sub_members[] list indefinitely if the invitee never completes onboarding — there is no auto-expiry or resend mechanism. The master can manually remove a stale invited entry using the existing Remove button, which works correctly on invited (not just active) entries. A “resend invite” button and/or token-expiry cleanup is a worthwhile future addition, not yet built.

DONE — bsm-stripe-webhook.php — 6 new event handlers added and live-tested
  1. Added helper function bsm_stripe_upsert_product_row($product_id) at the top of the file (inside the function definitions block). Fetches the full product from Stripe API, generates tier_key via sanitize_title, reads metadata, fetches its prices, upserts the bsm_stripe_products row.
  2. Added helper function bsm_stripe_deactivate_product_row($product_id) — sets active = 0, matched directly by stripe_product_id so it still works even when the product can no longer be fetched from Stripe
  3. Handles product.created, product.updated: call bsm_stripe_upsert_product_row($event_data['id'])
  4. Handles product.deleted: call bsm_stripe_deactivate_product_row($event_data['id'])
  5. Handles price.created, price.updated, price.deleted: identify the parent product via $event_data['product'], call bsm_stripe_upsert_product_row for that product. price.deleted naturally clears the deleted price’s ID/amount since the upsert only ever reads currently-active prices.
  6. The four other existing event handlers (subscription.updated, subscription.deleted, invoice.payment_succeeded, invoice.payment_failed) are completely untouched by this round of work — only additive changes, same if ($event_type === '...') style as the rest of the file
  7. checkout.session.completed updated separately (see Section N for the related cancel/undo-cancel work that prompted finding this): the Checkout Session object has no billing-period dates of its own — those live on the Subscription object, not the session, so stripe_next_billing was staying blank for every brand-new signup until a later subscription.updated event happened to fire. The handler now makes one extra API call via the existing bsm_webhook_stripe_get() helper to fetch the actual subscription object right after checkout completes, reads current_period_end from items.data[0] (same nested location fix as the subscription.updated handler), and writes it into stripe_next_billing immediately. Confirmed via two real new signups: the first showed a blank next billing date (the bug, caught in testing), the second — after this fix — correctly showed a real date from the moment of signup.
  8. ⚠️ This fix is forward-only. Any user who signed up before this fix was deployed will still have a blank stripe_next_billing until they go through a cancel/undo-cancel cycle (which exercises the already-fixed subscription.updated path) or the field is backfilled manually.
  9. Live-tested twice and confirmed working both directions: editing an existing product’s metadata (Instructor seats 20→22→24→25, exercising the UPDATE branch) and creating a brand new product from scratch (Business, seats=100, exercising the INSERT branch for a tier with no prior row) both correctly produced a 200 OK webhook delivery and the matching row change in bsm_stripe_products, confirmed directly in phpMyAdmin both times.
All 11 events (5 original + 6 new) are subscribed on the live webhook endpoint in Stripe Dashboard and confirmed firing correctly.
DONE — bsm-stripe-checkout.php — reads price ID from table
  1. Removed the get_option('bsm_stripe_price_' . $tier . '_' . $period) lookup
  2. Removed the hardcoded $valid_tiers array entirely — this array still referenced the old “partner” tier name rather than “instructor”, so it was already stale
  3. Replaced both with a single query: SELECT * FROM bsm_stripe_products WHERE tier_key = %s AND active = 1 LIMIT 1. If no row is found, the tier does not exist or is inactive and the user is redirected to the plans page exactly as the old invalid-tier path did.
  4. Reads price_monthly_id or price_annual_id off that row depending on the $period parameter
  5. All other logic in the file is unchanged — subscription cancellation, Stripe customer matching, checkout session creation
A new product becomes purchasable through checkout the moment it has a synced row in the table — no code change ever needed here again. See Section B for a related fix to checkout.session.completed in the webhook file, which ensures the next billing date is populated immediately at signup.
DONE — bsm-plans-page.php — reads tiers from table
  1. Removed the bsm_get_stripe_prices() function and all transient caching/live Stripe API call logic
  2. Removed the hardcoded $tiers array (individual, family, partner entries)
  3. Replaced with: SELECT * FROM bsm_stripe_products WHERE active = 1 ORDER BY display_order ASC
  4. Builds the $tiers array dynamically from the query results — label and description come straight from the table's name/description columns (which sync from Stripe), prices formatted via bsm_format_price() (kept, still needed), featured flag drives the “Most popular” badge
  5. Free tier kept hardcoded as the first entry, prepended before the DB results — it is not a Stripe product and never will be. Its button behaviour (Current plan / Downgrade to free / In cancellation period) remains fully dynamic, driven by user_status and cancellation_pending, exactly as before.
  6. Seats label changed to format “Max: X Members” (or “Max: 1 Member” for solo tiers) generated from the table's raw seats integer, applied consistently to both the dynamic tiers and the hardcoded Free tier
  7. Removed the legacy “Reactivate with X” button wording entirely. Per the Golden Rule there is no reactivation concept anywhere in this system — switching tiers while in a cancellation notice period now shows the exact same “Switch to X” / “Upgrade to X” wording as any other tier change, linking to the same /checkout/?tier=X&period=Y URL.
Live-tested: Individual, Family, Instructor and the newly-added Business tier all render correctly with live data, correct seat counts, descriptions, and featured badge.
DONE — upgrade-page.php is the genuine free-to-paid entry point, now table-driven

Not interchangeable with the plans page. bsm-plans-page.php (Section D) is where an already-paid user changes tier. upgrade-page.php is the only route a brand-new free user has into becoming paid for the first time.

  1. Removed bsm_get_stripe_prices() and the hardcoded $tiers array (had the old partner tier and stale “1 + 10 members” wording)
  2. Replaced with the same DB query for active tiers ordered by display_order, used identically on the plans page
  3. Free tier kept hardcoded and prepended, same as the plans page (it is not a Stripe product)
  4. Kept bsm_format_price(); added the same bsm_seats_label() helper producing “Max: X Members”, matching the plans page exactly
  5. No “current paid plan” branching was carried over from the plans page, since this page is only ever shown to free users — there is no such state to handle here
Live-tested with two genuine brand-new signups (not tier changes on an already-paid account): a first user (“Samuel”) signed up as Individual through the real upgrade page button, confirmed paid in GOD JSON with correct tier name and real Stripe IDs. A second user (“Sammy”, same email re-used after a full account deletion of the first) signed up Individual then upgraded to Family, both steps confirmed correct in GOD JSON including a populated stripe_next_billing date (see Section C for the related checkout fix that made that possible).
DONE — account_page_index.php reads tier label & seat limit from table
  1. Removed the hardcoded $tier_labels array (had gone stale — still said “Partner” after the Stripe-side rename to Instructor)
  2. Removed the hardcoded $seat_limits array (also stale — said 10 for the old Partner tier, real Instructor seats is 28)
  3. Replaced both with a single query: SELECT name, seats FROM bsm_stripe_products WHERE tier_key = %s AND active = 1 LIMIT 1 using the user's $membership_tier, falling back to “Individual” / 0 seats if no row matches (e.g. a stale tier_key with no corresponding product)
  4. Removed three more hardcoded tier-name mentions in body copy (the change-plan link subtitle, two My-Members empty-state messages), replaced with fully generic wording that names no specific tier, so new tiers never make this copy stale again
  5. Fixed three broken/wrong links found at the same time: the cancellation banner's “reactivate via billing portal” link now reads “Stop the cancellation” and points to /undo-cancel/; the “Cancel subscription” button now points to /cancel-subscription/ instead of the never-built /bsm-stripe-cancel/; the danger-row “Reactivate” button is now “Stop cancellation” linking to /undo-cancel/
Live-tested: correctly shows “Stop the cancellation”, “CANCELLING” badge, “Active until [date]”, and “Stop cancellation →” through a real cancel/undo-cancel cycle, confirmed via screenshots.
🛡 page-protect.php — no changes needed
  1. Protection is binary: paid vs not-paid
  2. No tier-specific logic exists in this file
  3. No changes required
👤 bsm-stripe-portal.php — no changes needed
  1. Reads stripe_customer_id from GOD JSON only
  2. Has no knowledge of tiers, products or prices
  3. No changes required
DONE — bsm_stripe_products table includes stripe_product_id column
  1. stripe_product_id VARCHAR(100) NOT NULL DEFAULT '' column included in the CREATE TABLE SQL from the start (see Step 7)
  2. Stores the native Stripe product ID (e.g. prod_Abc123...) alongside the BSM tier_key slug
  3. Used by both the admin panel and the webhook's upsert/deactivate functions to match rows by Stripe ID rather than regenerating the slug, which matters for product.deleted events where the product can no longer be fetched
  4. Verified via SHOW INDEX — PRIMARY on tier_key only, separate non-unique index on stripe_product_id
📝 DECISION MADE — leave the six legacy WordPress options in place

Confirmed via direct text search of upgrade-page.php, bsm-plans-page.php and bsm-stripe-checkout.php: none of the three live pricing files reference any of these six option names anymore, all now read from bsm_stripe_products instead. Deleting them was offered but deliberately declined — sitting unused in wp_options costs nothing, and a destructive DELETE against a live table carries more risk than benefit for a purely cosmetic cleanup, especially since neither of us can fully rule out some other file on the server (one never reviewed in this build) still referencing them.

  1. These six remain in the database, inert and unused: bsm_stripe_price_individual_monthly, bsm_stripe_price_individual_annual, bsm_stripe_price_family_monthly, bsm_stripe_price_family_annual, bsm_stripe_price_partner_monthly, bsm_stripe_price_partner_annual
  2. Keep forever, still genuinely live: bsm_stripe_secret_key, bsm_stripe_publishable_key, bsm_stripe_webhook_secret
  3. If revisited later: run a SELECT first to record the existing values, then DELETE FROM wp_options WHERE option_name IN (...) listing the six names above. Not urgent, not blocking anything.
This appendix is being kept in place specifically so decisions like this one stay visible and revisitable, rather than being lost once the build is “finished.” Treat it as a live job list / changelog, not a one-time scratchpad to delete.
DONE (mostly) — Tier change lifecycle, cancellation and the Golden Rule (Part 5)

The core of this is now built and proven with real users. What remains is listed separately below as genuinely outstanding.

Built and proven

  1. sub_members[], member_role and parent_user_id all exist in the real GOD JSON schema, confirmed against live data
  2. bsm-stripe-cancel.php: the confirmation screen now counts only active sub-members (a pending invite never had access to lose) and lists each one by name and email before the master confirms — tested live, confirmed correct
  3. On confirm, every active sub-member is sent a styled HTML email immediately, stating the real end date (pulled from stripe_next_billing), reassuring them their account and data are untouched, and a CTA button to /plans-page/ encouraging them to start their own Individual or Family plan. The master's own co-ordinator role label in that email is pulled live from bsm_stripe_products.name (with Business reframed to “Employer” for this one sentence only), so a renamed or newly added tier flows through with no code change.
  4. customer.subscription.deleted handler in the webhook now auto-detaches every active sub-member the moment the master's subscription genuinely ends — clears parent_user_id and member_role on each sub-member's own GOD JSON, and empties the master's sub_members[] array. This mirrors the manual single-remove logic in manage-sub-members.php, just triggered automatically instead of by a button click.
  5. Manual single-member removal by the master (Section A2) was already done and proven separately, and is unaffected by the above — both paths now exist side by side correctly

Still genuinely outstanding

  1. The cancel confirmation is still a single-click confirm, not a typed-confirmation step (type “CANCEL” or their email). Worth deciding whether the current impact screen with a named list is sufficient friction, or whether typed confirmation is still wanted on top of it.
  2. No contextual dashboard notice exists yet for a sub-member after they've actually lapsed (e.g. “Your membership via Sammy has ended”) — they currently just become a normal free user with no in-app explanation, only the email sent at cancellation time
  3. Downgrade between two paid tiers (e.g. Family → Individual) has not been tested against this lifecycle at all — does switching tiers via the plans page correctly trigger the same sub-member detachment, or does it only happen on a genuine cancellation? This needs checking directly, not assumed.
  4. No email is sent to the master themselves confirming the cancellation went through and listing who was affected — only the sub-members are emailed currently
  5. No notification exists for a sub-member independently upgrading and leaving the group, or deleting their own account — the master is not told a seat has freed up in either case
DONE (mostly) — Account deletion, Stripe cancellation and sub-member detach (Part 6)

Two separate real scripts exist for this, not one: bsm-delete-account-spa.php (self-delete, with a 4-step friction flow and a typed “DELETE” confirmation, already includes admin-self-deletion protection and a hard block on user ID 1) and bsm-admin-user-delete.php (an admin tool to delete any other user, with its own search-then-confirm flow and typed confirmation). Both were confirmed to have zero Stripe references before today's fix.

Built today

  1. Both files now cancel any live Stripe subscription immediately (not at period end) before the data wipe runs — deletion is itself immediate and irreversible, so the subscription must end the same way, otherwise a card keeps being billed with no account behind it
  2. The Stripe call is deliberately fire-and-forget: if it fails, the data wipe still proceeds, since the person has already confirmed deletion and a failed Stripe call becomes a billing support issue to chase afterwards, not a reason to block someone's own deletion request
  3. bsm-admin-user-delete.php additionally now detaches every active sub-member if the deleted user was a master — clears parent_user_id and member_role on each sub-member's own GOD JSON before the master's row is wiped, identical to the logic already proven in the webhook's customer.subscription.deleted handler. Without this, a sub-member would be left pointing at a parent that no longer exists.
  4. Added a visible impact summary to the admin's confirmation screen, shown before they commit: if the target has a live Stripe subscription, a note that it will be cancelled; if they're a master, the real names and emails of every active sub-member who will be detached
  5. Self-delete (bsm-delete-account-spa.php) only ever affects the deleting user's own subscription — it has no sub-member detach logic, since a sub-member deleting themselves was already correctly understood to never cascade to anyone else (they were never the source of any group's paid status)

Still genuinely outstanding

  1. Neither file sends any notification email on deletion — a master deleting their own account does not notify their sub-members the way cancellation now does (Section L); an admin deleting a master likewise sends nothing to affected sub-members
  2. No notification to a master when a sub-member independently deletes their own account, even though that frees a seat
  3. page-protect.php whitelist gap found and fixed for the delete-account page (see Section Q) — the real live slug is confirmed as account-delete-my-account (the older delete-account slug referenced elsewhere was a stale naming, now removed from the whitelist)
DONE — on-site cancel and undo-cancel, no Stripe billing portal needed

Built because the user explicitly does not want people leaving the site to manage cancellation. Both files are deliberately lightweight MVP versions — a single warning card with one confirm action — not the full heavyweight impact-screen/typed-confirmation flow described in Part 4 (Section L), which is still a separate future build item.

  1. bsm-stripe-cancel.php (new file, /cancel-subscription/): single confirm screen, calls Stripe directly to set cancel_at_period_end: true on the user's subscription. Deliberately does not write GOD JSON itself — relies entirely on the webhook to record the change, so there is exactly one code path that ever writes subscription_cancelled_date. Checks the user's current tier's seat count and, if greater than zero, adds an explicit bolded warning that invited members will also lose access and need re-inviting.
  2. bsm-stripe-undo-cancel.php (new file, /undo-cancel/): same lightweight pattern, calls Stripe to set cancel_at_period_end: false, again relies entirely on the webhook rather than writing GOD JSON directly.
  3. bsm-stripe-webhook.php updated: the existing customer.subscription.updated handler did not previously read cancel_at_period_end at all, meaning it had no way to ever set subscription_cancelled_date at the moment of scheduling (only customer.subscription.deleted set it, which fires later at actual period end). Now reads cancel_at_period_end and sets/clears subscription_cancelled_date accordingly on every subscription update.
  4. Real bug found and fixed: current_period_end does not exist at the top level of the subscription object in API version 2026-05-27.dahlia — it moved to items.data[0].current_period_end. The handler now reads it from the correct nested location (with a fallback to the old top-level location), which is what makes stripe_next_billing / “Active until [date]” actually populate.
  5. Real bug found and fixed, unrelated to the above: a Post/Redirect/Get-style issue did not apply here directly, but a genuine deployment-confusion bug did — testing repeatedly showed stale wording (“reactivate”, “billing portal”) despite files being edited, which traced back to the live plans-page file simply never having been re-uploaded after the early steps of this build (only the two new cancel/undo-cancel files had been deployed; the plans-page swap was prepared but the walkthrough never reached it). Lesson: when live behaviour does not match the latest code, check the actual deployed file content directly before assuming a logic bug.
  6. Plans page: removed a redundant warning (“Switching plans cancels your subscription…”) that was showing on every other tier's card regardless of relevance. The real warning now lives in exactly one place — the cancel confirmation screen itself, at the actual decision point.
Live-tested multiple times: cancel → undo-cancel on an Individual subscription, and separately cancel → undo-cancel after upgrading to Family, both confirmed working end-to-end with the correct status reflected on both the plans page and account page throughout.
DONE — Billing portal decision: invoices and details moved on-site, payment method stays on Stripe

Decision made: the Stripe billing portal is not for users at all going forward. Two of the three remaining functions moved fully on-site; the third (payment method) deliberately stays on Stripe's hosted flow, not as an oversight but as a considered choice.

  1. bsm-billing-history.php (new file, /billing-history/): read-only invoice list, calling GET /v1/invoices?customer={id} via the same Stripe GET helper pattern already proven in the webhook. Shows date, description, amount, status badge, and a direct PDF download link per invoice. No write operations of any kind — lowest-risk of the three builds. Live-tested with two real invoices on Sammy's account, both displaying correctly with working downloads.
  2. bsm-update-details.php (new file, /update-details/): updates name and email in three places together on save — the WordPress user record, GOD JSON (first_name/email), and Stripe's customer record if one exists. Deliberately fire-and-forget on the Stripe write: if that call fails, the save still succeeds (WordPress and GOD JSON are correct, which is what the user actually sees) and an amber warning notes the Stripe side will be corrected separately, rather than blocking the whole save over a third-party hiccup. Live-tested: name change saved and persisted correctly on reload.
  3. Payment method update deliberately stays on the Stripe portal, not moved on-site. Reasoning: Stripe Elements already keeps raw card data off the server entirely (the card field is a Stripe-hosted iframe regardless of where it's embedded), so building it “on-site” would not actually reduce risk — it would only add maintenance burden (Stripe JS SDK upkeep) and reinvent edge cases (3D Secure, declined updates, expired cards) that Stripe's own hosted flow already handles. This was a considered decision, not the leftover default.
  4. account_page_index.php updated: the old single “Manage billing” link is now three distinct links — “View invoices” → /billing-history/, “Update my name or email” → /update-details/, “Update payment method” → /billing-portal/ (explicitly labelled “via Stripe” so it reads as a deliberate choice, not an unfinished link)
  5. page-protect.php whitelist updated proactively with billing-history and update-details before either was tested, rather than waiting for the same redirect bug found twice already today (Section Q) to surface a third time
DONE — User Management Guide for BSM admin staff

A separate standalone HTML document, built for a non-technical new employee with no programming background, who needs to understand and operate the membership system day to day. Not part of this developer-facing guide — a different document with a different audience.

  1. Plain-language explanation of how memberships work: free vs paid, master/primary vs sub-member, what a seat is
  2. A definitive feature-by-tier comparison table, with every main feature broken into its real sub-features, each carrying its own Free/Paid status — including the two genuine split cases (Wellness Channel: rotating selection on Free vs full permanent access on Paid; AI Cheat Foods: capped at 3 on Free vs unlimited on Paid). Built from three cross-checked sources: the investor/marketing plan, a separate Product Knowledge Reference document, and the onboarder’s own drip-content panels — not invented.
  3. A companion Product Knowledge Reference document holding the deeper “how it works” explanations (the wholefood database, the AI Cheat System’s photo-scoring, the four Neuro States, the Wellness/Busy/Fun/Recovery Day model) as a shared source of truth, written neutrally enough to later feed a Katie AI knowledge base without rewriting the underlying facts
  4. A dedicated section on the membership CRUD (the internal Stripe-driven admin panel) explicitly separating what it can edit (name, description, seats, featured, order) from what it cannot (creating a new plan, changing a price — both of which must happen in Stripe’s own dashboard first, then synced)
  5. Common support scenarios written as real questions (“I invited someone but they say they never got the email”), each with a plain explanation followed by an explicit numbered click-path
  6. One explicit open gap logged honestly rather than guessed at: there is currently no documented way for staff to look up and open a specific customer’s account by name or email. Flagged in three places in the document (contents, a warning box in the relevant section, and the changelog) rather than invented.
DONE — page-protect.php whitelist gap, found via real sub-member testing

Found through real testing, not code review: a sub-member email pointed correctly at /plans-page/, but clicking it sent a free user to /upgrade-page/ instead. Both bsm-plans-page.php and account_page_index.php were deliberately built to handle free users gracefully (each has its own if ($user_status === 'paid') branching with a correct free-user view) — but page-protect.php’s whitelist model intercepts every request before either file ever runs, and neither slug was in its $free_slugs array. Every free user hitting either page was redirected to /upgrade-page/ before the page-level free-user logic ever had a chance to render.

  1. Added plans-page and account to the $free_slugs whitelist in page-protect.php
  2. No change needed to bsm-plans-page.php or account_page_index.php themselves — their free-user views were already correct, they just couldn't be reached
  3. Confirmed live: a real sub-member (William, free tier) clicking his cancellation-notice email link now lands on /plans-page/ showing “Choose your plan” with no current-plan/cancelling block, instead of being redirected to /upgrade-page/
Worth a deliberate sweep of the rest of $free_slugs at some point — this gap existed because the whitelist was last updated before today's plans-page and account-page rebuilds, and the same risk applies to any future page that gets a new free-user branch added to it without a matching whitelist entry.

Second real occurrence, same day

The predicted sweep above turned out to be necessary almost immediately: the “Delete account” button on the account page links to /account-delete-my-account/, which was also missing from $free_slugs, causing the exact same symptom — clicking it redirected to /upgrade-page/ instead of reaching the delete flow. Added account-delete-my-account (the confirmed real slug), cancel-subscription, undo-cancel and manage-sub-members to the whitelist in the same pass, since all four follow the identical pattern and were equally unconfirmed rather than waiting for each to surface as its own bug report. The older delete-account slug referenced in earlier sessions was stale naming and has been removed from the whitelist.

📋 Recommended build order
  1. Step 1 — DONE: Table created with stripe_product_id column included from the start. Verified via SHOW INDEX — PRIMARY on tier_key only, separate non-unique index on stripe_product_id.
  2. Step 2 — DONE: Built bsm-stripe-product-crud.php as a permanent front-end admin panel (upgraded from the originally planned one-time sync script), including the Post/Redirect/Get fix. Four Stripe products live and confirmed correct: Individual (seats=0, featured=true, order=1), Family (seats=4, featured=false, order=2), Instructor (seats=28, featured=false, order=3), Business (seats=100, order=4).
  3. Step 3 — DONE: Admin panel confirmed registered and working — first-time population of bsm_stripe_products completed and verified via phpMyAdmin.
  4. Step 4 — DONE: 6 product/price event handlers added to bsm-stripe-webhook.php and live-tested twice — both the UPDATE path (editing Instructor's seats repeatedly) and the INSERT path (creating Business from scratch) confirmed working end-to-end, verified directly in phpMyAdmin both times.
  5. Step 5 — DONE: bsm-stripe-checkout.php updated to read price ID from the table. Real checkout-through-payment test still outstanding (see Step 9 below).
  6. Step 6 — DONE: bsm-plans-page.php updated to read tiers from the table, all legacy Reactivate/Upgrade-hierarchy wording removed, redundant per-card warning removed. Live-tested repeatedly across real tier changes (Individual↔Family) with all tiers including Business rendering correctly.
  7. Step 7 — DONE: account_page_index.php updated to read tier label and seat limit from the table, three broken/stale links fixed (cancel, undo-cancel, and the danger-row action). Live-tested through real cancel/undo-cancel cycles.
  8. Step 8 — DONE: Built the on-site cancel and undo-cancel flow (bsm-stripe-cancel.php, bsm-stripe-undo-cancel.php) described in Section N, replacing reliance on the Stripe billing portal for this specific action. Fixed two real bugs found along the way: the webhook was not reading cancel_at_period_end at all, and current_period_end was being read from the wrong location in the payload. Live-tested extensively, including across a real tier change from Individual to Family.
  9. Step 9 — DONE: Applied the same changes already proven in Steps 6 and 7 to upgrade-page.php (Section E). This is the genuine free-to-paid entry point — the plans page (Steps 6/7) is for already-paid users changing tier; a brand-new free user's only route into becoming paid is via the upgrade page.
  10. Step 10 — DONE: Ran a full checkout test end-to-end through the actual live upgrade page button for two genuine brand-new free-user signups, confirming Step 5's checkout changes work for a real first-time subscription, not only for tier changes from an already-paid state. Found and fixed a related bug along the way: stripe_next_billing was not being populated at signup (see Section B) — fixed and re-confirmed correct on the second signup.
  11. Step 11 — DONE: Fixed manage-sub-members.php and built the missing invite-acceptance half in onboard-spa.php (Section A2). Proven completely with two real users on a real Family account: one left as a pending invite, one completed onboarding and was confirmed via direct GOD JSON inspection on both sides, then removed and confirmed to retain a normal free account.
  12. Step 12 — DONE: Built a User Management Guide for BSM admin staff — see Section P.
  13. Step 13 — DONE: The master cancellation impact screen and sub-member detachment are both built and proven, though not exactly as originally envisioned here — a single-confirm screen with a real named sub-member list, not a typed-confirmation step. See Part 5 and Section L for the full real detail, including what is still genuinely outstanding (typed confirmation, primary-holder emails).
  14. Step 14 — DONE: Account deletion now cancels any live Stripe subscription immediately in both the self-delete and admin-delete files, and the admin-delete file detaches sub-members. See Part 6 and Section M for full detail, including what is still genuinely outstanding (no email sent to anyone on either path).
  15. Step 15 — DONE: Decision made and built — invoice history and name/email change moved on-site; payment method deliberately stays on the Stripe portal. See Section O.
  16. Step 16 — DONE: T10 full end-to-end test as a brand new user, completed with two real signups (see Part 8 for the corrected checklist and real test detail).
  17. Step 17: Strip the temporary DEBUG: echo statements from bsm-stripe-webhook.php once satisfied no further live debugging is needed. Still genuinely outstanding.
  18. 📝 Step 18: Clean up the six legacy WordPress price options — decision made, not a task: leave them in place rather than delete. See Section J.
  19. Step 19: Delete this appendix from the guide. — superseded by an explicit later decision to keep this appendix permanently in place as a live job list / changelog, revisited and updated as work continues, rather than deleted once the build feels “finished.”