The plan catalog in Stripe — operators
Who runs it: whoever deploys, and whoever changes a price. php artisan ew:billing-sync.
What they came to do: make Stripe's Products and Prices match what PlanCatalog says EntryWick sells, so a
checkout has something to charge.
True when they leave: every plan that is sold for money has a Stripe Product and a Price per billing mode, their
ids are on plans.stripe, and running the command again would change nothing.
What it does
For each plan in plans, in the Stripe mode the configured STRIPE_SECRET belongs to:
| Plan | Product | Prices |
|---|---|---|
| Free | — | — (never charged) |
| Starter, Growth, Scale | starter, growth, scale |
{plan}_event (one-time), {plan}_annual (yearly) |
| Enterprise | — | — (quoted, invoiced by hand — PRICING §10) |
--dry-run prints what would change and sends nothing.
Decisions already made
- Idempotent by construction, not by remembering. The Product's id is the plan code and each Price carries a
lookup key, so a second run finds them instead of making a second set. Nothing depends on
plans.stripebeing right, which is why a restored database or a hand-edited row cannot produce duplicates. - A price change makes a new Price. Stripe amounts are immutable. The new Price takes the lookup key
(
transfer_lookup_key) and the old one is archived, not deleted: a tenant already subscribed keeps the price they bought, which is how PRICING §10's grandfathering works. New checkouts get the new one. - Test and live are separate catalogs. The mode is recorded on
plans.stripe.livemode, andPlan::stripePriceId()returns null when it does not match the key the box now holds — so a mis-keyed box refuses to start a checkout instead of sending Stripe an id from the other mode and failing at the payer's browser. - No Stripe secret is not an error. The command says so and succeeds, so a deploy to a box without keys stays green; the purchase paths refuse on their own with the same message.
- It never deletes anything at Stripe. Every correction is a create-and-archive, because money objects that something might still reference are not ours to remove.
Running it
- Staging: the deploy runs it after
PlatformSeeder, with the test-mode keys written from theSTAGING_STRIPE_*repository secrets (docs/NEEDS_FROM_OWNER.md, week 16). - After changing a price in
PlanCatalog(a pricing decision — the doc changes in the same PR): re-seed the plans (php artisan db:seed --class=Database\\Seeders\\PlatformSeeder) and runew:billing-sync. - Locally: it needs a
sk_test_key in.env; without one it is a no-op.
Open questions
- One currency (
EW_BILLING_CURRENCY, USD). A non-US organization pays in dollars until someone asks otherwise — and the first ask means a price per currency, not a conversion. - Nothing prunes a Product for a plan that stops being sold;
is_active = falseon our side leaves Stripe's object in place. Deliberate for now (archiving a product people are subscribed to is its own decision), but unowned.