Skip to content

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.

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 — 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
PieceRole
tier-engine + SHOWPONY_TIER_MAPfree / starter / pro / studio caps (events + RSVPs)
cap-counter + cap-guard.tsBlock the (N+1)th event or RSVP with upgrade_required
billing-foundation + subscription-stripeCheckout session + webhook → read_subscriptions
Webhook routeSync subscription tier into read_tier_assignments
Host Plan & billing screenUsage, plan cards, Stripe redirect
Platform workspaceTier admin + Stripe settings (config:nav:audience-system)

Tier numbers live in one place — marketing preview and runtime caps both read src/marketing/pricing.ts / tier-map.ts.

// 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 { billingFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
import { capCounterFeature } from "@cosmicdrift/kumiko-bundled-features/cap-counter";
import { createComplianceProfilesFeature } from "@cosmicdrift/kumiko-bundled-features/compliance-profiles";
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 { 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 { 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 { resolveSubdomainPageTenant } from "./tenant-routing";
/** Overview screens + nav only — app-shell owns workspaces host/platform. */
const adminShellFeature = createAdminShellFeature({
registerWorkspaces: false,
includeTierAdmin: true,
});
export const APP_FEATURES = [
...composePagesStack({ wrapLayout: renderLegalLayout }),
createManagedPagesFeature({
resolveApexTenant: resolveSubdomainPageTenant,
allowCustomCss: false,
}),
mailFoundationFeature,
mailTransportInMemoryFeature,
createRateLimitingFeature(),
createAuditFeature(),
createJobsFeature(),
createTierEngineFeature({ defaultTier: DEFAULT_TIER, tierMap: SHOWPONY_TIER_MAP }),
createComplianceProfilesFeature(),
createTenantLifecycleFeature(),
billingFoundationFeature,
createSecretsFeature(),
capCounterFeature,
adminShellFeature,
appShellFeature,
showPonyFeature,
] as const;
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.

Custom React screen (Stripe redirect flow) — pattern copied from PublicStatus / Money-Horse:

// 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 Stripe

Nav: Account → Plan & billing in the Events workspace.

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.

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

Terminal window
export STRIPE_API_KEY=sk_test_…
export STRIPE_WEBHOOK_SECRET=whsec_…
export STRIPE_PRICE_STARTER=price_…
export STRIPE_PRICE_PRO=price_…
bun dev

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