Skip to content

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.

A tiny newsletter app with two tiers:

TierNewsletters per monthSoft warning atHard block at
free1011 (110%)12 (120%)
pro100110 (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.

┌──────────────────────────────────────────────────────────────┐
│ 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() │
└──────────────────────────────────────────────────────────────┘

The most thorough doc for the demo is the integration test itself:

Terminal window
bun test

src/__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.

Terminal window
bun kumiko dev # Postgres + Redis
bun install
cd samples/apps/cap-billing-demo
bun dev # → http://localhost:4290
LoginValue
URLhttp://localhost:4290
Email[email protected]
Passwordchangeme
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.

The sample is intentionally minimal. For a production app, swap:

Demo componentProduction replacement
mail-transport-inmemorymail-transport-smtp (BYOK) or a custom plugin
Hardcoded DEMO_TIER_MAPstays — 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 domainyour 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.

  • src/feature.ts — the wrapped send handler. Here you see how withCapEnforcement turns a normal handler into a cap-aware one
  • src/tier-map.ts — DEMO_TIER_MAP + tier-name whitelist
  • src/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)

The sample is meant as a doc test. Concrete weaknesses we see here:

  • Notifier address hardcoded. buildSoftHitNotifier in feature.ts sends to admin@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-event directly; in production an app mounts createSubscriptionStripeFeature(...) or createSubscriptionMollieFeature(...) and Mollie/Stripe webhooks hit /api/subscription/webhook/:providerName.

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