Cap-Billing Demo
Sample app showing how a Kumiko app wires tier-engine + cap-counter + mail-foundation plugin API together to enforce per-tenant caps on newsletter sends — including a soft-hit notification and a hard block.
The sample doubles as living documentation for the pattern: read the code top-to-bottom and you’ve understood the cap engine.
What the demo does
Section titled “What the demo does”A tiny newsletter app with two tiers:
| Tier | Newsletters per month | Soft warning at | Hard block at |
|---|---|---|---|
| free | 10 | 11 (110%) | 12 (120%) |
| pro | 100 | 110 (110%) | 120 (120%) |
Mails land in an in-memory transport (mail-transport-inmemory).
There’s no real SMTP server — perfect for the demo, no Mailpit/Mailcrab
needed. The inbox is read via a helper function.
Architecture in 4 layers
Section titled “Architecture in 4 layers”┌──────────────────────────────────────────────────────────────┐│ src/feature.ts ││ newsletter:write:send (cap-aware) ││ ├── inner handler: createTransportForTenant + .send() ││ └── wrapper: withCapEnforcement ││ ├── pre: enforceCapAndMaybeNotify (tier-conditional) ││ │ └── notifier: sends warning mail to admin ││ └── post: incrementCap (+1) │└──────────────────────────────────────────────────────────────┘┌──────────────────────────────────────────────────────────────┐│ src/tier-map.ts ││ DEMO_TIER_MAP: Record<TierName, {features, caps}> ││ resolveTier(ctx) → 1. subscription row (provider webhook) ││ 2. config "newsletter:config:tier" ││ 3. default "free" │└──────────────────────────────────────────────────────────────┘┌──────────────────────────────────────────────────────────────┐│ src/run-config.ts ││ APP_FEATURES = [secrets, cap-counter, mail-foundation, ││ mail-transport-inmemory, ││ billing-foundation, newsletter] │└──────────────────────────────────────────────────────────────┘┌──────────────────────────────────────────────────────────────┐│ bundled-features (no code in the app) ││ tier-engine: composeApp + TierMap type ││ cap-counter: enforceCap + withCapEnforcement + counter ES ││ mail-foundation: plugin API for transports ││ mail-transport-inmemory: per-tenant in-memory inbox ││ billing-foundation: provider plugin host (Stripe/ ││ Mollie). Demo mounts no ││ providers — tests call process- ││ event directly; your own app adds ││ createSubscriptionStripeFeature() │└──────────────────────────────────────────────────────────────┘Demo story as a test
Section titled “Demo story as a test”The most thorough doc for the demo is the integration test itself:
bun testsrc/__tests__/cap-billing-demo.integration.test.ts boots the full
dispatcher + DB and proves step by step:
- 10 newsletters sent without warning
- 12th newsletter triggers the soft-hit notification once
- 13th newsletter is hard-blocked (CapExceededError)
- Pro tenant unaffected by the free tenant’s cap
- Mid-period tier change: free→pro upgrade keeps the counter intact + immediately uses the higher cap (= the real Stripe-webhook path). pro→free downgrade blocks immediately when the counter is above the new hard limit.
Read the test file top-to-bottom — it’s written as a living doc.
Run locally
Section titled “Run locally”bun kumiko dev # Postgres + Redisbun installcd samples/apps/cap-billing-demobun dev # → http://localhost:4290| Login | Value |
|---|---|
| URL | http://localhost:4290 |
[email protected] | |
| Password | changeme |
| Tenant | ”Cap-Billing-Demo” |
In the browser, use the Designer/Admin UI to set the config key
newsletter:config:tier to "free" or "pro" and trigger the
newsletter:write:send handler. The “sent” mails land in
getInbox(tenantId) from
@cosmicdrift/kumiko-bundled-features/mail-transport-inmemory —
there’s no HTTP endpoint for it because the sample shows the
architecture, not an inbox UI.
If you want clickable: write a small r.queryHandler("inbox:list")
that returns getInbox(ctx.user.tenantId). ~20 LOC, deliberately
omitted to keep the focus on cap+tier.
How do I port this to a real app?
Section titled “How do I port this to a real app?”The sample is intentionally minimal. For a production app, swap:
| Demo component | Production replacement |
|---|---|
mail-transport-inmemory | mail-transport-smtp (BYOK) or a custom plugin |
Hardcoded DEMO_TIER_MAP | stays — tier definitions are static, the subscription row only writes the tier key |
| Tier switch via webhook (test-only) | mount a real plugin: createSubscriptionStripeFeature(...) and/or createSubscriptionMollieFeature(...) in the run config |
| 2 tiers (free/pro) | any number, see samples/apps/platform/src/tier-map.ts for a 4-tier example |
| Newsletter domain | your own feature with withCapEnforcement(handler, capResolver) |
The plugin API switch between demo and production is a single
config value: mail-foundation:config:provider flips from
"inmemory" to "smtp", no code refactor.
Key files
Section titled “Key files”src/feature.ts— the wrapped send handler. Here you see howwithCapEnforcementturns a normal handler into a cap-aware onesrc/tier-map.ts— DEMO_TIER_MAP + tier-name whitelistsrc/run-config.ts— feature composition (which bundled-features the demo mounts)src/__tests__/cap-billing-demo.integration.test.ts— the played-out story (10/11/12/13 newsletters, soft+hard transitions, tenant isolation)
Questions / weaknesses this demo exposes
Section titled “Questions / weaknesses this demo exposes”The sample is meant as a doc test. Concrete weaknesses we see here:
- Notifier address hardcoded.
buildSoftHitNotifierinfeature.tssends toadmin@tenant-${id.slice(-4)}.demo. A real app would query tenant config or the users table. - Tier lookup per send call.
resolveTier(ctx)runs a DB query on the subscription row on every send — for busy tenants caching would be sensible. The demo skips it because it distracts from the cap pattern. - No provider mount in the demo itself. The tests call
billing-foundation:write:process-eventdirectly; in production an app mountscreateSubscriptionStripeFeature(...)orcreateSubscriptionMollieFeature(...)and Mollie/Stripe webhooks hit/api/subscription/webhook/:providerName.
Source code
Section titled “Source code”The feature entry point — embedded straight from the source file, so the code here is exactly what runs. Multi-file samples keep their remaining files next to it on GitHub (link below):
// kumiko-feature-version: 1//// newsletter — Demo-Feature, das tier-engine + cap-counter + die// mail-foundation Plugin-API zusammenbringt.//// **Was passiert beim send-Handler:**// 1. Pre-Call (via withCapEnforcement-Wrapper):// a) Tenant-Tier aus ctx.config lesen// b) Tier → cap-limit aus DEMO_TIER_MAP mappen// c) enforceCapAndMaybeNotify ruft enforceCap → bei// soft-hit-crossing den Notifier (sendet Warning-Mail über// dieselbe mail-foundation, an den Admin) UND dispatched// mark-soft-warned// 2. Inner Handler: createTransportForTenant → transport.send// 3. Post-Success: increment-cap-Counter um 1//// **Beobachtbar im InMemory-Inbox** (mail-transport-inmemory):// - Die "echten" Newsletter-Mails (an event.payload.to)// - Die Soft-Hit-Warning-Mails (an [email protected], beim ersten// überschreiten)//// **Tier-Switching:** primary-source ist die `subscription`-row aus// billing-foundation (= produktiver Pfad: Stripe/Mollie webhook// schreibt → tier ändert sich). Fallback ist der config-key// "newsletter:config:tier" — den nutzt die Demo-README für manuelles// Switchen ohne Provider, plus tier-engine-only-Tests behalten so ihren// existing flow.
import { BILLING_FOUNDATION_FEATURE, getSubscriptionForTenant, SubscriptionStatuses,} from "@cosmicdrift/kumiko-bundled-features/billing-foundation";import { currentCalendarMonthStartIso, type SoftHitNotifier, withCapEnforcement,} from "@cosmicdrift/kumiko-bundled-features/cap-counter";import type { EmailMessage } from "@cosmicdrift/kumiko-bundled-features/channel-email";import { createTransportForTenant, mailFoundationFeature,} from "@cosmicdrift/kumiko-bundled-features/mail-foundation";import { access, createTenantConfig, defineFeature, type HandlerContext, type WriteHandlerDef,} from "@cosmicdrift/kumiko-framework/engine";import { z } from "zod";import { DEMO_TIER_MAP, TIER_NAMES, type TierName } from "./tier-map";
const FEATURE_NAME = "newsletter";const NEWSLETTER_CAP = "newsletters-per-month";
// =============================================================================// Inner send-handler// =============================================================================
const sendSchema = z.object({ to: z.string().email(), subject: z.string().min(1), html: z.string().min(1),});
const innerSendHandler: WriteHandlerDef = { name: "send", schema: sendSchema, access: { roles: ["TenantAdmin", "SystemAdmin"] }, handler: async (event, ctx) => { const payload = event.payload as z.infer<typeof sendSchema>; const transport = await createTransportForTenant( ctx, event.user.tenantId, "newsletter:write:send", ); await transport.send({ to: payload.to, subject: payload.subject, html: payload.html, }); return { isSuccess: true as const, data: { sent: true } }; },};
// =============================================================================// Tier-Lookup + Cap-Resolver// =============================================================================
/** * Tenant-Tier auflösen. * * **Reihenfolge:** * 1. subscription-row aus billing-foundation (= produktiver * Pfad). Wenn die row existiert, trumpft sie den config-fallback — * auch im canceled-Status (= Tenant fällt auf free zurück, NICHT * auf einen verwaisten config="pro"-override). * 2. config-key "newsletter:config:tier" (= Demo-README-Pfad ohne * Provider; auch der tier-engine-only-Pfad der ursprünglichen * Tests bleibt so grün). * 3. Default "free". * * Whitelist-Filter via TIER_NAMES verhindert Tippos ("Pro" / "Premium"), * die sonst silent zu free fallen würden. */function isValidTierName(value: string): value is TierName { return (TIER_NAMES as readonly string[]).includes(value);}
async function resolveTier(ctx: HandlerContext): Promise<TierName> { const tenantId = ctx.user?.tenantId; if (tenantId) { const sub = await getSubscriptionForTenant(ctx, tenantId); if (sub) { // Subscription existiert → trumpft config. Bei active+valid-tier // → der subscription-tier; sonst free (canceled/past_due/etc). if (sub.status === SubscriptionStatuses.active && isValidTierName(sub.tier)) { return sub.tier; } return "free"; } }
const raw = (await ctx.config?.("newsletter:config:tier")) as string | undefined; if (raw && isValidTierName(raw)) { return raw; } return "free";}
/** Notifier-Factory: bauen einen SoftHitNotifier der die Warnung über * dieselbe mail-foundation an den Tenant-Admin schickt. */function buildSoftHitNotifier(ctx: HandlerContext): SoftHitNotifier { return async (info) => { const transport = await createTransportForTenant( ctx, info.tenantId, "newsletter:soft-hit-notifier", ); const message: EmailMessage = { to: `admin@tenant-${info.tenantId.slice(-4)}.demo`, subject: `[Cap Warning] '${info.capName}' bei ${info.value}/${info.limit}`, html: `<p>Hallo Admin,</p>` + `<p>der Cap <strong>${info.capName}</strong> für deinen Tenant ist bei <strong>${info.value}</strong> von ${info.limit} angekommen.</p>` + `<p>Du bist im soft-Bereich (110% des Limits). Hard-Block kommt bei 120%. Upgrade auf einen höheren Tier oder warte auf den nächsten Monatsreset.</p>`, }; await transport.send(message); };}
// =============================================================================// Wrapped send-handler (cap-aware)// =============================================================================
const wrappedSendHandler = withCapEnforcement(innerSendHandler, async (_event, ctx) => { const tier = await resolveTier(ctx); // resolveTier returns one of TIER_NAMES; DEMO_TIER_MAP has an entry // for each. tsc's `noUncheckedIndexedAccess` doesn't carry that // narrowing through a Record-lookup — extract via const + non-null // assert (= contract: tier is always a valid key here, see TIER_NAMES // whitelist in resolveTier). const tierEntry = DEMO_TIER_MAP[tier]; if (!tierEntry) { throw new Error(`newsletter: tier "${tier}" not in DEMO_TIER_MAP — TIER_NAMES drift?`); } const limit = tierEntry.caps.newslettersPerMonth; return { capName: NEWSLETTER_CAP, periodStartIso: currentCalendarMonthStartIso(), limit, profile: "burstable", notify: buildSoftHitNotifier(ctx), };});
// =============================================================================// Feature-definition// =============================================================================
export const newsletterFeature = defineFeature(FEATURE_NAME, (r) => { // Hard-deps: mail-foundation für Plugin-API + config für tier-Wahl + // cap-counter (transitiv via withCapEnforcement, aber explicit ist // klarer für boot-Validator-Errors). r.requires("config"); r.requires("cap-counter"); r.requires(mailFoundationFeature.name); r.requires(BILLING_FOUNDATION_FEATURE);
// Tier-config-key. Tenant-Admin setzt's; default "free". r.config({ keys: { tier: createTenantConfig("select", { default: "free", options: TIER_NAMES, write: access.roles("TenantAdmin", "SystemAdmin"), read: access.roles("TenantAdmin", "SystemAdmin", "User"), }), }, });
r.writeHandler(wrappedSendHandler);});
/** QN für den send-Handler — exportiert damit Tests + Clients ohne * Hand-Ableitung zugreifen. */export const NEWSLETTER_SEND_QN = "newsletter:write:send";export const NEWSLETTER_TIER_CONFIG_KEY = "newsletter:config:tier";📄 On GitHub: samples/apps/cap-billing-demo/src/feature.ts