Plans & Stripe checkout
Chapter 14 covered PII and legal text. This chapter wires paid plans the way a real SaaS does: tier caps in the app, Stripe for checkout, and a sysadmin surface to flip billing live.
User story: Mira outgrows Free
Section titled “User story: Mira outgrows Free”Mira’s on Free (1 event, 50 RSVPs). The rooftop launch fills the list; she creates a second event and hits the cap. The host UI shows usage on Plan & billing and offers Starter or Pro, using the same numbers as the pricing page from chapter 12.
Locally you can mount Stripe test keys and complete checkout. Prod demo
(show-pony.kumiko.rocks) stays read-only for writes. You still see tier,
usage, and the billing screen; checkout needs a local install or
billingLive=false until a sysadmin enables it.
On the cloud demo the host tenant stays on Free (one event cap). The boot
seed creates Rooftop Launch on demo and Acme Offsite on the separate
acme tenant (one event each). Grant a paid tier via Platform → tier admin +
DB reset if you need multiple events on a single tenant (chapter 11).
| Surface | Local | Cloud |
|---|---|---|
| Host login (apex) | http://show-pony.localhost:4180/login | https://show-pony.kumiko.rocks/login |
| Demo invite (rooftop) | http://demo.show-pony.localhost:4180/e/rooftop-launch | https://demo.show-pony.kumiko.rocks/e/rooftop-launch |
| Acme invite (offsite) | http://acme.show-pony.localhost:4180/e/acme-offsite | https://acme.show-pony.kumiko.rocks/e/acme-offsite |
| Marketing (EN) | http://show-pony.localhost:4180/ | https://show-pony.kumiko.rocks/ |
| Marketing (DE) | http://show-pony.localhost:4180/de | https://show-pony.kumiko.rocks/de |
What gets mounted
Section titled “What gets mounted”| Piece | Role |
|---|---|
tier-engine + SHOWPONY_TIER_MAP | free / starter / pro / studio caps (events + RSVPs) |
cap-counter + cap-guard.ts | Block the (N+1)th event or RSVP with upgrade_required |
billing-foundation + subscription-stripe | Checkout session + webhook → read_subscriptions |
| Webhook route | Sync subscription tier into read_tier_assignments |
| Host Plan & billing screen | Usage, plan cards, Stripe redirect |
| Platform workspace | Tier admin + Stripe settings (config:nav:audience-system) |
Tier numbers live in one place. The marketing preview and runtime caps both read
src/marketing/pricing.ts / tier-map.ts.
Run-config (excerpt)
Section titled “Run-config (excerpt)”// Single source of truth for show-pony feature composition.// Both bin/server.ts (dev) and the kumiko-schema CLI build on this,// so the runtime registry and generated schema never drift apart.//// HAS_AUTH=true → composeFeatures automatically pulls in the bundled auth chain// (config/user/tenant/auth-email-password/secrets).
import { createAdminShellFeature } from "@cosmicdrift/kumiko-bundled-features/admin-shell";import { createAuditFeature } from "@cosmicdrift/kumiko-bundled-features/audit";import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";import { billingFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";import { createComplianceProfilesFeature } from "@cosmicdrift/kumiko-bundled-features/compliance-profiles";import { createCryptoShreddingFeature } from "@cosmicdrift/kumiko-bundled-features/crypto-shredding";import { createJobsFeature } from "@cosmicdrift/kumiko-bundled-features/jobs";import { mailFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/mail-foundation";import { mailTransportInMemoryFeature } from "@cosmicdrift/kumiko-bundled-features/mail-transport-inmemory";import { createManagedPagesFeature } from "@cosmicdrift/kumiko-bundled-features/managed-pages";import { createRateLimitingFeature } from "@cosmicdrift/kumiko-bundled-features/rate-limiting";import { createSecretsFeature } from "@cosmicdrift/kumiko-bundled-features/secrets";import { createSessionsFeature } from "@cosmicdrift/kumiko-bundled-features/sessions";import { createTemplateResolverFeature } from "@cosmicdrift/kumiko-bundled-features/template-resolver";import { createTenantLifecycleFeature } from "@cosmicdrift/kumiko-bundled-features/tenant-lifecycle";import { createTierEngineFeature } from "@cosmicdrift/kumiko-bundled-features/tier-engine";import { composePagesStack } from "@cosmicdrift/kumiko-dev-server/compose-stacks";import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";import { localeDe } from "@cosmicdrift/kumiko-locale-de";import { appShellFeature } from "./features/app-shell/feature";import { showPonyFeature } from "./features/show-pony/feature";import { DEFAULT_TIER, SHOWPONY_TIER_MAP } from "./features/show-pony/tier-map";import { renderLegalLayout } from "./legal-layout";import { createShowPonyTenantRoutingFeature, resolveSubdomainPageTenant } from "./tenant-routing";
/** Overview screens + nav only — app-shell owns workspaces host/platform. */const adminShellFeature = createAdminShellFeature({ registerWorkspaces: false, includeTierAdmin: true,});
export type AppFeaturesRouting = { readonly baseDomain: string;};
/** $BASE_DOMAIN or show-pony.localhost — the one place that default lives. */export function resolveBaseDomainFromEnv(): string { return process.env["BASE_DOMAIN"] ?? "show-pony.localhost";}
export function buildAppFeatures(routing: AppFeaturesRouting): FeatureDefinition[] { return [ localeDe(), authFoundationFeature, createSessionsFeature(), createShowPonyTenantRoutingFeature({ baseDomain: routing.baseDomain }), createTemplateResolverFeature(), ...composePagesStack({ wrapLayout: renderLegalLayout }), createManagedPagesFeature({ resolveApexTenant: resolveSubdomainPageTenant, allowCustomCss: false, }), mailFoundationFeature, mailTransportInMemoryFeature, createRateLimitingFeature(), createAuditFeature(), createJobsFeature(), createTierEngineFeature({ defaultTier: DEFAULT_TIER, tierMap: SHOWPONY_TIER_MAP }), createComplianceProfilesFeature(), createTenantLifecycleFeature(), billingFoundationFeature, createCryptoShreddingFeature(), createSecretsFeature(), adminShellFeature, appShellFeature, showPonyFeature, ];}
export const HAS_AUTH = true;subscription-stripe mounts in bin/main.ts / bin/server.ts only when
STRIPE_PRICE_STARTER and STRIPE_PRICE_PRO are set (see bin/stripe-billing-env.ts).
API key + webhook secret can come from env (Pulumi) or sysadmin Stripe settings;
billing live is a runtime toggle. Keep it off on the public demo until you mean it.
Host billing screen
Section titled “Host billing screen”Custom React screen for the Stripe redirect flow, following the PublicStatus / Money-Horse pattern:
// showpony:query:billing-info : tier + subscription + whether checkout is enabled// showpony:query:usage : event/RSVP counts vs caps// billing-foundation:write:create-checkout-session → redirect to StripeNav: Account → Plan & billing in the Events workspace.
Cap enforcement
Section titled “Cap enforcement”Event create wraps withStockCap on event:create. Anonymous rsvp:submit
checks guest cap before insert. Guests see a friendly “list full” message, not
an upgrade CTA.
Infra (optional)
Section titled “Infra (optional)”When Pulumi stack config includes showPonyStripePriceStarter + showPonyStripePricePro,
createKumikoApp injects STRIPE_* env vars. Webhook URL:
https://show-pony.kumiko.rocks/webhooks/subscription/stripe
Register that endpoint in the Stripe dashboard (test mode for the learning sample).
Try it locally
Section titled “Try it locally”export STRIPE_API_KEY=sk_test_…export STRIPE_WEBHOOK_SECRET=whsec_…export STRIPE_PRICE_STARTER=price_…export STRIPE_PRICE_PRO=price_…bun devSign in as host → Plan & billing. As sysadmin → Platform → Stripe settings → set keys if not in env → toggle billing live → retry upgrade from the host workspace.
Back to the tutorial index: Show Pony.