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 | Plan catalog, checkout / plan-switch / portal handlers, webhook → read_subscriptions |
| Webhook route | Sync subscription tier into read_tier_assignments |
| Host Plan & billing screen | billing dashboard: plan cards (billingPlansPanel()) + usage panel |
| Platform workspace | Tier admin + Stripe settings (config:nav:audience-system) |
Runtime caps and the billing catalog read src/features/show-pony/tier-map.ts
(capsForTier). The marketing page keeps its own display numbers in
src/marketing/pricing.ts, so change both when a tier changes.
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 { createAuthFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";import { createChannelEmailFeature, createInMemoryTransport,} from "@cosmicdrift/kumiko-bundled-features/channel-email";import { createComplianceProfilesFeature } from "@cosmicdrift/kumiko-bundled-features/compliance-profiles";import { createDeliveryFeature } from "@cosmicdrift/kumiko-bundled-features/delivery";import { createHostRoutingFeature, type HostRoutingOptions,} from "@cosmicdrift/kumiko-bundled-features/host-routing";import { createJobsFeature } from "@cosmicdrift/kumiko-bundled-features/jobs";import { createLegalPagesFeature } from "@cosmicdrift/kumiko-bundled-features/legal-pages";import { createManagedPagesFeature } from "@cosmicdrift/kumiko-bundled-features/managed-pages";import { createMetricsFeature, createSystemMetricsFeature, DEFAULT_METRICS,} from "@cosmicdrift/kumiko-bundled-features/metrics";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 { composeRendererStack, composeSecurityStack,} 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 { createShowPonyBillingFoundationFeature } from "./features/show-pony/billing/plan-catalog";import { showPonyFeature } from "./features/show-pony/feature";import { DEFAULT_TIER, SHOWPONY_TIER_MAP } from "./features/show-pony/tier-map";import { showPonyLegalLayout } from "./marketing/layouts/legal";import { resolveSubdomainPageTenant } from "./tenant-routing";
/** Overview screens + nav only — app-shell owns workspaces host/platform. */const adminShellFeature = createAdminShellFeature({ registerWorkspaces: false, includeTierAdmin: true, metrics: DEFAULT_METRICS,});
export type AppFeaturesRouting = { readonly baseDomain: string; /** Absolute origin the billing-plans catalog redirects back to after * checkout/portal — defaults to `https://${baseDomain}` when omitted. */ readonly appBaseUrl?: string;};
/** $BASE_DOMAIN or show-pony.localhost — the one place that default lives. */export function resolveBaseDomainFromEnv( env: Record<string, string | undefined> = process.env,): string { return env["BASE_DOMAIN"] ?? "show-pony.localhost";}
/** The host decides the tenant: apex = host surface, `<key>.<baseDomain>` = guest pages. * No apexAnonymousTenantId, so anonymous writes on the apex keep answering tenant_required. */export function showPonyHostRoutingOptions(baseDomain: string): HostRoutingOptions { return { tenantBy: "host", baseDomain, apex: { file: "admin.html" }, tenant: { file: "index.html" }, unknownTenant: { file: "index.html" }, };}
export function buildAppFeatures(routing: AppFeaturesRouting): FeatureDefinition[] { return [ localeDe(), createAuthFoundationFeature(), ...composeSecurityStack(), createHostRoutingFeature(showPonyHostRoutingOptions(routing.baseDomain)), ...composeRendererStack(), createLegalPagesFeature({ wrapLayout: showPonyLegalLayout, terms: true }), createManagedPagesFeature({ resolveApexTenant: resolveSubdomainPageTenant, allowCustomCss: false, }), createDeliveryFeature(), // Guests are addressed by the email they typed, so no user lookup exists. // The in-memory transport keeps the demo behaviour: no RSVP mail leaves the process. createChannelEmailFeature({ transport: createInMemoryTransport(), resolveEmail: async () => null, }), createJobsFeature(), createTierEngineFeature({ defaultTier: DEFAULT_TIER, tierMap: SHOWPONY_TIER_MAP }), createComplianceProfilesFeature(), createTenantLifecycleFeature(), createShowPonyBillingFoundationFeature(routing.appBaseUrl ?? `https://${routing.baseDomain}`), createSecretsFeature(), createMetricsFeature({ metrics: DEFAULT_METRICS }), createSystemMetricsFeature({ metrics: DEFAULT_METRICS }), adminShellFeature, appShellFeature, showPonyFeature, ];}
export const HAS_AUTH = true;subscription-stripe mounts in bin/main.ts / bin/server.ts as soon as at least
one of STRIPE_PRICE_STARTER / STRIPE_PRICE_PRO is set (priceToTierFromEnv in
src/features/show-pony/tier-map.ts, hasConfiguredPrices in bin/server.ts). Without any price, billing-plans resolves
enabled: false and checkout / switch / portal reject with feature_disabled.
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”No custom checkout code. billing-foundation derives the plans query, the
checkout / switch / portal handlers and a plan-cards panel from one catalog:
// The show-pony billing-plans catalog — the single wiring point for// billing-foundation's catalog-derived query/screen/panel. Shared by// run-config.ts, the webhook test and the billing-plans integration tests// so they never drift onto separate catalog instances.
import { type BillingPlanBenefit, type BillingPlanCatalog, createBillingFoundationFeature,} from "@cosmicdrift/kumiko-bundled-features/billing-foundation";import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";import { capsForTier } from "../tier-map";import { resolveTier } from "../tier-resolver";
export type PaidTier = "starter" | "pro";
export const PAID_TIERS: readonly PaidTier[] = ["starter", "pro"];
export const BILLING_PAGE_PATH = "/host/billing";
export function eventsBenefit(count: number): BillingPlanBenefit { return Number.isFinite(count) ? { labelKey: "showpony:billing.benefit.events", params: { count } } : { labelKey: "showpony:billing.benefit.eventsUnlimited" };}
export function guestsBenefit(count: number): BillingPlanBenefit { return Number.isFinite(count) ? { labelKey: "showpony:billing.benefit.guests", params: { count } } : { labelKey: "showpony:billing.benefit.guestsUnlimited" };}
export const showPonyBillingCatalog: BillingPlanCatalog<PaidTier> = { plans: PAID_TIERS, tierLabelKey: (tier) => `showpony:billing.tier.${tier}`, benefits: (tier) => { const caps = capsForTier(tier); return [eventsBenefit(caps.maxEvents), guestsBenefit(caps.maxGuests)]; }, resolveCurrentTier: (db) => resolveTier(db), viewRoles: ["Admin"], purchaseRoles: ["Admin"], successPath: BILLING_PAGE_PATH, cancelPath: BILLING_PAGE_PATH, returnPath: BILLING_PAGE_PATH,};
export function createShowPonyBillingFoundationFeature(baseUrl: string): FeatureDefinition { return createBillingFoundationFeature({ baseUrl, catalog: showPonyBillingCatalog });}The billing dashboard screen combines billingPlansPanel() with the custom
ShowPonyUsagePanel (event / RSVP usage against caps, upgrade hint):
import { billingPlansPanel } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";import { access, type FeatureRegistrar } from "@cosmicdrift/kumiko-framework/engine";import type { DashboardScreenDefinition, EntityEditScreenDefinition, EntityListScreenDefinition,} from "@cosmicdrift/kumiko-framework/ui-types";
export const eventListScreen: EntityListScreenDefinition = { id: "event-list", type: "entityList", entity: "event", columns: ["title", "slug", "startsAt", "location", { field: "guestLimit", hideOnNarrow: true }], pageSize: 25, defaultSort: { field: "title", dir: "asc" }, rowActions: [ { kind: "navigate", id: "edit", label: "kumiko.actions.edit", screen: "event-edit" }, ],};
export const eventEditScreen: EntityEditScreenDefinition = { id: "event-edit", type: "entityEdit", entity: "event", recordTitleField: "title", access: { roles: access.admin }, layout: { sections: [ { title: "showpony:section.event-basics", columns: 2, fields: [{ field: "title", span: 2 }, "slug", "startsAt", "location", "guestLimit"], }, { title: "showpony:section.event-details", columns: 1, fields: ["description"] }, ], },};
export const rsvpListScreen: EntityListScreenDefinition = { id: "rsvp-list", type: "entityList", entity: "rsvp", columns: ["name", "status", "plusN", "email"], pageSize: 50, defaultSort: { field: "status", dir: "asc" }, access: { roles: access.admin },};
import { inviteBrandingScreen } from "../screens/invite-branding-screen";
export const billingScreen: DashboardScreenDefinition = { id: "billing", type: "dashboard", panels: [ billingPlansPanel(), { kind: "custom", id: "usage", component: { react: { __component: "ShowPonyUsagePanel" } } }, ], access: { roles: ["Admin"] },};
export function registerShowPonyScreens(r: FeatureRegistrar): void { r.screen(eventListScreen); r.screen(eventEditScreen); r.screen(rsvpListScreen); r.screen(inviteBrandingScreen); r.screen(billingScreen);}The panel reads billing-foundation:query:billing-plans and starts Stripe via
billing-foundation:write:start-plan-checkout, switch-plan and
create-portal-session. Nav: 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.
Deployment configuration (optional)
Section titled “Deployment configuration (optional)”When the deployment config sets the Starter and Pro Stripe price IDs, it injects
STRIPE_* env vars into the app. 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.