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

SurfaceLocalCloud
Host login (apex)http://show-pony.localhost:4180/loginhttps://show-pony.kumiko.rocks/login
Demo invite (rooftop)http://demo.show-pony.localhost:4180/e/rooftop-launchhttps://demo.show-pony.kumiko.rocks/e/rooftop-launch
Acme invite (offsite)http://acme.show-pony.localhost:4180/e/acme-offsitehttps://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/dehttps://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. The 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 { 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.

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