Skip to content
prod e051e98
Browse

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.

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

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.

  1. Pick the account option from the trade-off table.

    OptionAccountSetup costStatement descriptorBest for
    A. Catch-allExisting “general” accountZero — swap .env keys❌ Shows the catch-all namePre-launch, ship today
    B. Rename unusedAn abandoned account you own~5 min✅ New project nameRecycling old accounts
    C. New dedicatedFresh account under your org~15 min✅ Clean from day 1”Serious” product, clean accounting
    D. A → C laterCatch-all now, graduate laterZero nowMixed during bootstrapLaunch fast, defer
    • ✅ An option is chosen with rationale recorded in _CUSTOMIZATIONS.md → ## Stripe account strategy.

Migration cost depends on your billing architecture — check before committing to A.

  1. 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 (Billable trait) → active subscriptions don’t transfer; pick C upfront. Ad-hoc invoices (Froiden/WorkDo — a fresh Stripe Invoice each cycle) → swapping .env keys is enough; A is safe, graduate to C later.

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.

  1. 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.
  2. 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/webhook

This all happens in the Stripe dashboard — account creation, sandbox setup, and copying keys are browser actions a person performs.

  1. 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.
  2. 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_1TLiIg1Uhxevahdt issues sk_test_51TLiIg1Uhxevahdt… / pk_test_51TLiIg1Uhxevahdt… / rk_test_51TLiIg1Uhxevahdt… — matching suffixes = same sandbox.
  3. 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_…, and whsec_… 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:

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)"]
  1. Place each key in its single home.
    • App key (sk_test_*, broad) → admin panel (§5) + server shared/.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) → ~/.zshrc for stripe-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.

  1. Honour the human-gate table for every listed Stripe write.

    OperationSandboxLiveWhy
    refund.createMoney-out — gate always
    subscription.cancel / .updateCustomer-facing billing change
    product.delete / price.deactivateCan break live subscriptions
    webhook_endpoint.deleteBreaks event delivery
    customer.deleteIrreversible; 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, execute before running.

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.

  1. 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), then shred -uz the 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.
  1. 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 lengths
    SELECT
    LENGTH(test_stripe_key) AS pk_len, -- expect 107
    LENGTH(test_stripe_secret) AS sk_len, -- expect 107
    LENGTH(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-check DESCRIBE). Prefer Eloquent (Model::find(1)->test_stripe_secret) when the vendor ships a $fillable model — it throws on unknown attributes; raw SQL does not.
  2. Run a sandbox test payment and confirm the webhook fires.

    • ✅ A test payment/subscription in sandbox mode shows 200 under Stripe dashboard → endpoint → recent deliveries.

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.

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 Billable flow; 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 / packages table.
  • Architecture C — price_data at checkout → no pre-created Prices; the app builds line items at runtime from the package row.
  1. Confirm tier gating matches the schema before pricing anything. The vendor’s packages/plans table constrains which designs you can enforce (module bundles vs. cap columns vs. both vs. neither) — designing “10-unit limit” tiers when there’s no unit_limit column means Phase 8 rework.

    • ✅ The gating model is confirmed from the capabilities doc and matches what the schema supports.
  2. 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 write packages rows (updateOrCreate keyed 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, and packages rows are written via updateOrCreate.
  1. 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.)

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 discoveredreal 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 with 200.
  • 👤 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.