stripe-live-price-rotation
TL;DR
Wrong skill? If the founder wants to temporarily flip a price to €0.00 for an end-to-end checkout walkthrough (no real money moves, revert in 90s), use
stripe-e2e-test-price-swapinstead. This skill is for permanent pricing changes.
Stripe prices are immutable. To "change a price" you create a NEW price object on the same product and archive (active=false) the old one. Old subscribers stay on the old price; new Checkout sessions can only use active prices. The hard parts are not Stripe — they're (a) finding every place the price lives in your codebase, (b) the legacy env-var name your old code still reads, and (c) verifying the new JS bundle on the CDN actually has the new copy.
When to use
- Adam: "set scan free, apply fixes for $7, ward mode for $29/mo"
- Adam: "drop the price on X to Y"
- A pricing experiment requires a fast rollback path (you can flip
active=true/falseon archived prices to revert)
Required context before starting
- Stripe secret key (sk_live_… or sk_test_…) — usually in product .env on the host
- The product IDs (
prod_…) for each tier — Stripe dashboard orGET /v1/products - Whether the host runs the service as system systemd (
sudo systemctl) or user systemd (systemctl --user) — check first, don't assume - Branch name + worktree path of the frontend repo
- What env-var names the backend reads — grep for both modern (
PRICE_X) and legacy (X_PRICE_ID) forms
The 8-step procedure
1. List current state (sanity check)
ssh $HOST 'set -a; source /path/to/.env; set +a;
curl -s -u "$STRIPE_SECRET_KEY:" "https://api.stripe.com/v1/products?limit=20" | \
python3 -c "import json,sys; d=json.load(sys.stdin); [print(p[\"id\"],p[\"name\"]) for p in d[\"data\"]]"
'
For each product you're touching, list its prices:
curl -s -u "$STRIPE_SECRET_KEY:" "https://api.stripe.com/v1/prices?product=prod_X&limit=10" | \
python3 -c "import json,sys; d=json.load(sys.stdin); [print(pr['id'],pr.get('unit_amount'),pr['currency'],pr.get('recurring',{}).get('interval','one-time'),'active='+str(pr['active'])) for pr in d['data']]"
Capture the OLD price IDs — you'll archive them in step 3.
2. Create new prices
One-time:
curl -s -u "$STRIPE_SECRET_KEY:" https://api.stripe.com/v1/prices \
-d "product=prod_X" \
-d "unit_amount=700" \
-d "currency=usd" \
-d "nickname=Rescue \$7 (2026-04-28)"
Recurring monthly:
curl -s -u "$STRIPE_SECRET_KEY:" https://api.stripe.com/v1/prices \
-d "product=prod_Y" \
-d "unit_amount=2900" \
-d "currency=usd" \
-d "recurring[interval]=month" \
-d "nickname=Ward \$29/mo (2026-04-28)"
Capture the new price_… IDs. Use a dated nickname so future-you can identify them in the dashboard.
3. Archive old prices
curl -s -u "$STRIPE_SECRET_KEY:" "https://api.stripe.com/v1/prices/$OLD" -d active=false
⚠️ Stripe prices cannot be deleted. Archived = no new Checkout sessions can use them. Existing subscriptions on that price keep billing at the old rate until manually migrated. If you have live subscribers, plan the migration separately (Stripe Subscriptions API update to swap to new price; usually proration applies).
4. Update backend env
Edit the .env on the production host (NOT the worktree — production is the source of truth for env vars):
sed -i "s|^PRICE_RESCUE=.*|PRICE_RESCUE=price_NEW|" /path/to/.env
sed -i "s|^PRICE_WARD=.*|PRICE_WARD=price_NEW|" /path/to/.env
The legacy alias trap. If the codebase has both PRICE_RESCUE (modern) AND RESCUE_PRICE_ID (older naming from earlier sprints), you MUST update both — checkout-routes.js may still read the old one. Grep for it:
ssh $HOST 'grep -rE "process\.env\.[A-Z_]*PRICE[A-Z_]*" /path/to/app/src/'
5. Patch frontend copy
Pricing copy lives in MANY places. Don't trust just PricingCards.jsx. Hunt all of:
src/components/PricingCards.jsx— tierpricefield,period,tagline, sub-copy, footer notesrc/components/LandingPage.jsx(or hero component) — hero CTA buttons, hero sub-paragraphsrc/pages/Pricing.jsx(or/pricingroute) — page h1 sub-copysrc/components/ReportPage.jsx(or post-checkout component) — inline upsell CTAsindex.html—<meta name="description">and OG tags (SEO impact)- Tests:
src/components/__tests__/PricingCards.test.jsx— hardcoded snapshot strings
Fast grep:
cd $FRONTEND_DIR && grep -rnE "\\\$7|\\\$29|\\\$79|\\\$N|specific-old-words" \
--include="*.jsx" --include="*.tsx" --include="*.html" --include="*.md" src/ index.html
6. Build + deploy
cd $FRONTEND_DIR && npm run build
# Verify no errors and the bundle changed:
ls dist/assets/PricingCards*.js # new hash should differ from current prod
rsync -avz --delete dist/ $HOST:/path/to/panel-dist/
7. Restart backend (system OR user systemd)
Detect first, don't assume:
ssh $HOST 'sudo systemctl list-units --type=service --all --no-pager 2>/dev/null | grep -iE "<service>"'
If found there → it's system-level:
ssh $HOST "sudo systemctl restart $SERVICE; sleep 2; sudo systemctl status $SERVICE --no-pager | head -15"
If not found → check user systemd (requires lingering or active session):
ssh $HOST "loginctl list-users; sudo -u $USER XDG_RUNTIME_DIR=/run/user/\$(id -u $USER) systemctl --user list-units --type=service"
8. Smoke test (JS bundle, not just HTML)
Pricing copy compiles into hashed JS chunks. The HTML may be stale-cached even when the bundle hash changed. Verify both:
# 1. Healthz
curl -s -o /dev/null -w "HTTP %{http_code}\n" https://$DOMAIN/api/healthz
# 2. Landing HTML has new strings
curl -s https://$DOMAIN/ | grep -oE "(NEW_PRICE|new copy)" | sort -u
# 3. Pricing page returns 200
curl -s -o /dev/null -w "HTTP %{http_code}\n" https://$DOMAIN/pricing
# 4. CRITICAL: the JS chunk has new prices, no old leftovers
NEW_HASH=$(curl -s https://$DOMAIN/ | grep -oE 'PricingCards-[A-Za-z0-9_-]+\.js' | head -1)
curl -s "https://$DOMAIN/assets/$NEW_HASH" | grep -oE "(\\\$NEW|\\\$OLD)" | sort -u
# Should show NEW values only. ANY $OLD leftover means a file you forgot to patch.
9. Commit + document
cd $FRONTEND_DIR
git add -A src/
git commit -m "feat(pricing): <new structure>"
git push --set-upstream origin <branch>
Obsidian status doc:
projects/<product>/<date>-pricing-reset.mdwith: directive quote, new vs old table, archived price IDs, files changed, commit hash, smoke-test results, what's intentionally NOT done (e.g. marketing-site copy in a separate repo)- Append to
log.md - Update
index.mdto link the new doc, mark older pricing-research notes as superseded
Discord post to home channel: directive received, what shipped, what's left from prior sprint status (don't conflate the pricing change with unrelated open items, but DO surface them so Adam doesn't have to re-read three docs).
Pitfalls (validated)
The "Pricing.jsx accidentally double-wrapped" — when patching with
x_patchand the surrounding context is generic styling, the diff can land inside an existing<p>tag. After patching, re-read each file you touched and confirm structure. If you nest a<p>inside a<p>, React will hydrate-error in production. Verify bynpm run buildAND by viewing the page after deploy.The legacy env-var alias — Adam's products have accumulated env naming history.
PRICE_RESCUE(sprint 1.5+) coexists withRESCUE_PRICE_ID(earlier code). Setting only one breaks the other code path silently. Alwaysgrepthe codebase forprocess.env.*PRICE*before deciding which vars to update.systemctl --userfails on services that turned out to be system-level. rescue-medic specifically: SOUL/sprint docs may say "user systemd-managed" but the unit is in/etc/systemd/system/. Checksudo systemctl list-unitsfirst.JS bundle hash changes on rebuild — old bundle stays cached on the CDN/CF edge briefly. The MD5/hash-named bundle in the new HTML reference is the truth. Curl the HTML, extract the hash, then curl that exact bundle. Don't trust browser-side smoke testing without a hard refresh.
Archived ≠ removed. If you screwed up and need to revert, you can re-activate the archived price (
active=true) — but if you've already deleted env references and the codebase doesn't know the old ID, you have to either restore the old ID or re-create yet another new one. Save the OLD price IDs in your status doc for at least a sprint.Subscription products have a migration cost. Setting
active=falseon a Ward $79/mo doesn't affect existing $79/mo subscribers — they keep paying $79 forever until manually migrated. If migration is intended, do it deliberately via the Stripe Subscriptions API (POST /v1/subscriptions/<id>withitems[0][price]=$NEW_IDandproration_behavior=create_prorations). For rescue.wisechef.ai 2026-04-28 there were zero existing Ward subscribers so this was a non-issue — capture the count before assuming.payment-guard.jsdev-mode bypass. Some backends have a hardcodedamount: 700literal in dev/bypass paths (rescue-medic does atpayment-guard.js:109). If you flip Rescue to a different price, that literal is now lying in dev mode. Either update it or accept that dev-mode amount no longer reflects production — but DECIDE, don't ignore.
Smoke-test minimal checklist (post-deploy)
-
/api/healthz200 -
/200, contains expected new copy -
/pricing200 - New JS bundle hash present in HTML (not the old one)
- New JS bundle contains only new prices (no $OLD leftovers)
- Service active (
systemctl status) AND has been up since AFTER the env edit - Backend logs show no errors on first checkout attempt (tail journal for 30s)
- (Optional) Run a real Stripe Checkout in test mode to verify the price renders correctly in the Stripe-hosted page
Reference: validated 2026-04-28 on rescue.wisechef.ai
- Adam directive at 06:50 → live at 06:55 (8 min E2E)
- Stripe products kept (
prod_UPmpM16Na320EQScan,prod_UPmpflAs0uKmafRescue,prod_UPmptqbjkwX5LkWard,prod_UPmpnRsNUVobvFICU) - 3 prices archived (Scan $7, Rescue $29, Ward $79/mo); 2 created (Rescue $7 =
price_1TR3a7Egmqt5xoaLGVKQ04IP, Ward $29/mo =price_1TR3a7Egmqt5xoaLiWc8fn32) - Scan tier: kept the product, marked
PRICE_SCAN=free(app layer never reads it for charging — gated only byprofessional_capacityboolean) - 4 frontend files patched, 1 build, 1 rsync, 1 service restart, 4 smoke checks, 1 commit, 1 status doc, 1 Discord post
- Status doc:
projects/wisechef/rescue/2026-04-28-pricing-reset.md - Commit:
b6f7f48onagent/tori/rescue-sprint1-frontend