Skip to content

Tier Admin

Manually assign a pricing tier to a tenant — without a billing purchase and without writing the projection by hand. The recipe shows the SystemAdmin-only operator flow plus the subtlety that makes it correct: the grant updates the in-memory resolver cache synchronously, so toggleable features unlock in the same request.

  • tier-engine:write:set-tenant-tier — a SystemAdmin assigns a tier to any tenant, cross-tenant. Stamps source: "manual" so a future Stripe → tier sync won’t overwrite the grant.
  • tier-engine:query:get-tenant-tier — SystemAdmin reads back which tier a tenant is on plus its source ("manual" vs "billing").
  • tier-engine:query:tier-options — lists the configured tier names so the admin UI doesn’t have to hard-code them.
  • Cache-sync invariant — the manual grant updates the resolver cache the same request, not just the projection. A r.toggleable() feature that depends on the granted tier is reachable immediately after the set call, before any cache refresh, replay, or restart.

set-tenant-tier writes through the event-store executor directly. That path bypasses the postSave entity-hook the resolver normally uses to invalidate the cache after a tier-assignment change. Without an explicit cache update the grant would persist (next request would see it) but this request still sees the old tier — surprising for an operator who just clicked “set tier to pro”.

The feature wires onAssigned into createSetTenantTierWrite for exactly this reason; the integration test below exercises the full flow end-to-end so the invariant has runnable evidence.

Terminal window
bun test src/__tests__/feature.integration.test.ts

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

// Tier Admin Sample
//
// Shows the SystemAdmin-only operator flow: assigning a tier to *any*
// tenant without a billing purchase. The app side is intentionally tiny —
// the recipe is the bundled tier-engine itself, configured with the app's
// own TierMap. The integration test exercises the cross-tenant grant
// (set-tenant-tier), the read-back (get-tenant-tier returning
// source:"manual"), and the option-list (tier-options) end-to-end.
import {
createTierEngineFeature,
type TierMap,
} from "@cosmicdrift/kumiko-bundled-features/tier-engine";
// --- App caps ---
//
// Each app picks its own cap dimensions. Here a tiny example: how many
// notes a tenant may keep. The TierMap is generic in this cap-shape so
// downstream code stays type-safe end-to-end.
export type AppCaps = { readonly maxNotes: number };
// --- Tier map ---
//
// "free" + "pro" — the operator picks one of these names when granting
// a tier manually. `features` is empty in this sample because the recipe
// focuses on the grant flow; a real app would list the toggleable
// feature ids that the tier unlocks.
export const appTierMap: TierMap<AppCaps> = {
free: { features: [], caps: { maxNotes: 5 } },
pro: { features: [], caps: { maxNotes: 100 } },
};
// --- Configured tier-engine ---
//
// `defaultTier: "free"` means every new tenant starts on free via the
// `inTransaction` entity hook the tier-engine registers — no app code
// needed. `tierMap` makes `tier-options` return ["free", "pro"] so the
// tier-admin screen can populate its picker without hard-coding.
export const tierEngineForApp = createTierEngineFeature<AppCaps>({
defaultTier: "free",
tierMap: appTierMap,
});

📄 On GitHub: samples/recipes/tier-admin/src/feature.ts