5 · Payments & plans
Objective — stand up the gateway and plans without the two expensive mistakes: never assume the webhook URL or event list (vendors ship custom controllers) and never invent pricing tiers (that’s a market decision) — most work is in the Stripe dashboard, a few values land in the panel.
Background
Section titled “Background”This is the heaviest page in the phase and the most expensive to get wrong. Most of the work happens in the Stripe dashboard (external), then a few values land in the admin panel. Two rules dominate everything below: never assume the webhook URL or event list (CodeCanyon apps ship custom controllers), and never invent pricing tiers (that’s a market decision, not a config choice).
1. Choose a Stripe account strategy
Section titled “1. Choose a Stripe account strategy”Before creating any account or webhook, decide which Stripe account this app lives under. The right choice is cheap now and expensive to change after customers subscribe — a product decision, not a config one.
-
Pick the account option from the trade-off table.
Option Account Setup cost Statement descriptor Best for A. Catch-all Existing “general” account Zero — swap .envkeys❌ Shows the catch-all name Pre-launch, ship today B. Rename unused An abandoned account you own ~5 min ✅ New project name Recycling old accounts C. New dedicated Fresh account under your org ~15 min ✅ Clean from day 1 ”Serious” product, clean accounting D. A → C later Catch-all now, graduate later Zero now Mixed during bootstrap Launch fast, defer - ✅ An option is chosen with rationale recorded in
_CUSTOMIZATIONS.md → ## Stripe account strategy.
- ✅ An option is chosen with rationale recorded in
Migration cost depends on your billing architecture — check before committing to A.
-
Detect the billing architecture to know A’s migration cost.
Terminal window # Cashier? (persistent Subscription objects — migration is HIGH cost)grep -rn "Billable\|->newSubscription\|->subscriptions()" app/Models/ app/Http/ 2>/dev/null | head# Custom webhook controller? (ad-hoc invoices — migration is NEAR-ZERO cost)find app/ -iname "*Stripe*Controller*" -o -iname "*BillingWebhook*" 2>/dev/null# Expected: Cashier usage (Billable/newSubscription) OR a custom Stripe controller- ✅ Architecture is known: Cashier (
Billabletrait) → active subscriptions don’t transfer; pick C upfront. Ad-hoc invoices (Froiden/WorkDo — a fresh Stripe Invoice each cycle) → swapping.envkeys is enough; A is safe, graduate to C later.
- ✅ Architecture is known: Cashier (
What never migrates freely even for ad-hoc apps: historical invoices + dispute history (stay in the old account), the statement descriptor on past charges, and tax filings / 1099-K (mid-year splits = two 1099-Ks). Record your choice, rationale, and migration trigger in _CUSTOMIZATIONS.md → ## Stripe account strategy.
2. Discover the REAL webhook URL and event list
Section titled “2. Discover the REAL webhook URL and event list”Find the actual URL in three places, in order: the admin panel Payment Gateway tab (Froiden / WorkDo / MagicAI show a read-only “Webhook URL” field — copy it verbatim, hash included), the routes, then the controller.
-
Search routes for the real Stripe POST endpoint.
Terminal window grep -rnE "stripe|webhook" routes/ | grep -viE "^\s*//|#"find app/ -iname "*Stripe*Controller*" -o -iname "*Webhook*Controller*"# Expected: the Route::post(...) Stripe POSTs to + the controller file path- ✅ The actual webhook URL is found (panel field, route, or controller); the hash, if any, is noted in
_CUSTOMIZATIONS.md.
- ✅ The actual webhook URL is found (panel field, route, or controller); the hash, if any, is noted in
-
Extract the exact event list the controller handles — do not pad with Cashier’s defaults (unhandled events count against Stripe’s per-endpoint limit and add noise).
Terminal window grep -rnE "'(checkout|customer|invoice|payment_intent|charge|subscription|payout)\." \app/Http/Controllers/ --include="*StripeWebhookController*.php" 2>/dev/null# Froiden family typically: invoice.payment_succeeded / invoice.payment_failed /# payment_intent.succeeded / payment_intent.payment_failed# Expected: the exact event strings the controller's switch/match handles- ✅ The exact handled-event list is captured — no padding with unhandled defaults.
Real URLs observed in the wild: https://[DOMAIN]/webhook/billing-verify-webhook/{32-char hash} https://[DOMAIN]/webhooks/stripe https://[DOMAIN]/api/stripe/webhook3. Create the account, sandbox, and keys
Section titled “3. Create the account, sandbox, and keys”This all happens in the Stripe dashboard — account creation, sandbox setup, and copying keys are browser actions a person performs.
-
Set up the account per the §1 decision. A → sign into the general account; B → Settings → Business details, update DBA/descriptor; C → create under your org (shared legal entity auto-fills KYC; skip Stripe’s “products” phase — the app creates products via API).
- ✅ The account matches the §1 strategy.
-
Create a Sandbox named “[PROJECT] Staging” (and ideally a separate “[PROJECT] Development”), then confirm you’re in the right one via the key suffix.
- ✅ The sandbox is confirmed via the account ID encoded in the key suffix: a sandbox
acct_1TLiIg1Uhxevahdtissuessk_test_51TLiIg1Uhxevahdt…/pk_test_51TLiIg1Uhxevahdt…/rk_test_51TLiIg1Uhxevahdt…— matching suffixes = same sandbox.
- ✅ The sandbox is confirmed via the account ID encoded in the key suffix: a sandbox
-
Grab the keys and create the webhook endpoint. Copy the Publishable key (
pk_test_…) and Secret key (sk_test_…, one chance to view), create the endpoint (Developers → Webhooks → Add endpoint) with the §2 URL and event list, reveal and copy the signing secret (whsec_…), then set the statement descriptor (Settings → Business details → ASCII uppercase, 5–22 chars, ≥5 letters).- ✅
pk_test_…,sk_test_…, andwhsec_…are copied; the endpoint uses the §2 URL + events; the descriptor is set (it inherits sandbox→live and is hard to change later).
- ✅
One sandbox quirk catches people verifying the account ID with an MCP call:
4. Separate the three actor keys
Section titled “4. Separate the three actor keys”Separate Stripe keys by actor, not by “restricted vs unrestricted.” Each key lives in exactly one place and never cross-pollinates.
flowchart TD SB["Stripe sandbox"] SB --> APP["APP key (sk_test_*)<br/>broad scope by design"] SB --> AG["AGENT key (rk_test_agent_*)<br/>narrow scope"] SB --> DEV["DEV key (rk_test_dev_*)<br/>narrow scope"] APP --> APPL["Admin panel + server shared/.env<br/>(never in shell, never in MCP)"] AG --> AGL["Claude Code MCP config<br/>(never in app .env)"] DEV --> DEVL["~/.zshrc / stripe-cli<br/>(never in app .env)"]- Place each key in its single home.
-
App key (
sk_test_*, broad) → admin panel (§5) + servershared/.env. Broad because the app makes whatever Stripe calls the vendor coded; a missing scope = runtime error blocking real checkouts. Never export it to shell or MCP. -
Agent key (
rk_test_agent_*, narrow) → Claude Code’s Stripe MCP config. Preset: Write on Products, Prices, Coupons, Promotion Codes, Webhook Endpoints; Read on the rest; subscriptions Read-only (human-gated). -
Developer key (
rk_test_dev_*, narrow) →~/.zshrcforstripe-cli/curl. Same scope as agent, optionally Write on Customers/PaymentIntents for manual testing. -
✅ Each key sits in exactly one place; none cross-pollinate. (Skip the agent/dev keys if you only need the app key for §5 and won’t use Stripe MCP/CLI this phase — they block nothing.)
-
These Stripe writes pause for explicit confirmation regardless of key scope — a person confirms each before it runs.
-
Honour the human-gate table for every listed Stripe write.
Operation Sandbox Live Why refund.create✅ ✅ Money-out — gate always subscription.cancel/.update✅ ✅ Customer-facing billing change product.delete/price.deactivate✅ ✅ Can break live subscriptions webhook_endpoint.delete✅ ✅ Breaks event delivery customer.delete✅ ✅ Irreversible; breaks invoice lookups Bulk ops (>5 writes in a loop) ✅ ✅ Blast radius — show the count first *.create(product/price/webhook/coupon)⬜ ✅ Sandbox creates are cheap to reverse Gate protocol: agent prints the full payload, says “Confirm with ‘yes, execute’ or I’ll abort,” waits with no timeout, runs only on
yes, execute.- ✅ Every gated write paused for an explicit
yes, executebefore running.
- ✅ Every gated write paused for an explicit
5. Configure the gateway in the admin panel
Section titled “5. Configure the gateway in the admin panel”Admin → Config → Finance (or Settings → Payments). Entering the keys is a panel action; verifying they persisted is an agent DB check.
-
Enter the keys in the Finance tab, enabling the gateways you want and disabling the rest. Transport the values without leaking them to chat/tool logs (temp file
chmod 600, or 1Password), thenshred -uzthe temp file after saving. If there’s no Finance tab, gateway setup is a code task — defer to the payments phase.- ✅ Keys saved in the panel; temp files shredded; secrets never hit chat/tool logs.
-
Enumerate the real column names, then check lengths against the expected values.
-- Step 1 — enumerate the REAL column names (don't guess)DESCRIBE payment_settings; -- or settings / admin_settings / superadmin_payment_gateways-- Step 2 — SELECT only columns that exist; check lengthsSELECTLENGTH(test_stripe_key) AS pk_len, -- expect 107LENGTH(test_stripe_secret) AS sk_len, -- expect 107LENGTH(stripe_test_webhook_key) AS whsec_len -- expect 38 (word order may differ!)FROM payment_settings WHERE id = 1;-- Expected: pk_len 107, sk_len 107, whsec_len 38- ✅ Lengths read 107 / 107 / 38. If a length is
NULL/0: either the value didn’t save (re-enter in the panel) or you queried a non-existent column (re-checkDESCRIBE). Prefer Eloquent (Model::find(1)->test_stripe_secret) when the vendor ships a$fillablemodel — it throws on unknown attributes; raw SQL does not.
- ✅ Lengths read 107 / 107 / 38. If a length is
-
Run a sandbox test payment and confirm the webhook fires.
- ✅ A test payment/subscription in sandbox mode shows
200under Stripe dashboard → endpoint → recent deliveries.
- ✅ A test payment/subscription in sandbox mode shows
If the save form maps camelCase→snake_case, find the mapping with grep -rn "stripe_test_webhook_key\|testStripeWebhookKey" app/Livewire/ app/Http/ and record it in _CUSTOMIZATIONS.md.
6. Set up subscription plans
Section titled “6. Set up subscription plans”The plan tiers and prices are a 👤 decision; once approved, the seeding is agent work. Match your execution to the vendor’s billing architecture (confirmed in the capabilities doc):
- Architecture A — Cashier → plans map to Stripe Products/Prices via the
Billableflow; seed per the framework’s plan setup. - Architecture B — paste-price-ID → create Stripe Products + recurring Prices, then paste each
price_*ID into the admin panel /packagestable. - Architecture C — price_data at checkout → no pre-created Prices; the app builds line items at runtime from the package row.
-
Confirm tier gating matches the schema before pricing anything. The vendor’s
packages/planstable constrains which designs you can enforce (module bundles vs. cap columns vs. both vs. neither) — designing “10-unit limit” tiers when there’s nounit_limitcolumn means Phase 8 rework.- ✅ The gating model is confirmed from the capabilities doc and matches what the schema supports.
-
For B/C, run the canonical flow — create 1 Product per paid tier + 2 recurring Prices (monthly + yearly) via Stripe MCP (human-gated write), capture the
price_*IDs, then writepackagesrows (updateOrCreatekeyed on name;modules()->sync()for the pivot). Run DB writes on staging via tinker, ideally inside a server-side script that reads the secret from the DB so the key never leaves the server.- ✅ Products + recurring Prices exist,
price_*IDs are captured, andpackagesrows are written viaupdateOrCreate.
- ✅ Products + recurring Prices exist,
-
Verify the plans render and checkout opens with a recurring price (Playwright
/superadmin/packages).- ✅ Correct row count, module counts, monthly+yearly prices, and Stripe IDs populated (no “Invalid Stripe plan”); the public pricing page → Subscribe opens Stripe Checkout with the correct recurring price (cancel without paying). (“Price must be recurring for mode=subscription” = a one-time price; recreate with
recurring.interval.)
- ✅ Correct row count, module counts, monthly+yearly prices, and Stripe IDs populated (no “Invalid Stripe plan”); the public pricing page → Subscribe opens Stripe Checkout with the correct recurring price (cancel without paying). (“Price must be recurring for mode=subscription” = a one-time price; recreate with
Checklist
Section titled “Checklist”Do not mark this step done until every box below is checked.
- 👤 Strategy chosen — Stripe account strategy (A/B/C/D) with rationale + migration trigger recorded in
_CUSTOMIZATIONS.md. - 🤖 Webhook discovered — real webhook URL + exact event list discovered from the app (not assumed); endpoint created;
whsec_…copied. - 👤 Keys grabbed — sandbox confirmed via key-suffix match; statement descriptor set; app keys copied.
- 🔀 Three-actor model honoured — app key in panel/server only; agent key in MCP; dev key in shell; human-gate understood.
- 🔀 Keys verified — saved in the admin panel; columns verified via
DESCRIBE-first with expected lengths (107/107/38); test payment fires the webhook with200. - 👤 Plans from research — created only from approved market research, or explicitly marked
[!] BLOCKED; tier gating matches schema; checkout opens with a recurring price; gotchas logged.