Skip to content

Assign a tier to a tenant manually (without billing)

When you onboard a partner, run a beta cohort, or fix a botched payment, you need to grant a tenant a tier without a billing purchase going through. The tier-engine feature ships a SystemAdmin-only handler and a built-in admin screen for exactly that.

  • A SystemAdmin sitting in their own tenant can assign a tier to any tenant — cross-tenant write, no role-impersonation needed.
  • The grant is stamped source: "manual" so the next time your Stripe → tier sync runs, it leaves the operator-set tier alone.
  • The resolver cache that gates r.toggleable() features is updated synchronously in the same request, so a toggleable feature unlocked by the tier is reachable immediately — no cache flush, no restart.
  • An app simply mounts the built-in admin screen; no custom UI required.

From the SystemAdmin’s perspective:

  1. Open the app’s sysadmin workspace.
  2. Pick Tier admin from the nav (or open it directly via the qualified screen ref tier-engine:screen:tier-admin).
  3. Pick the target tenant and the tier name from the dropdown — the options come from your app’s TierMap, not from a hard-coded list.
  4. Save. The grant is live before the next request.

A real app configures the tier-engine with its own TierMap. The map defines which tier names exist and what each tier unlocks. Mount the admin screen via r.nav so it appears in the SystemAdmin workspace.

This is the whole composition — no extra handlers, no custom screen, no per-app set-tenant-tier endpoint. The bundled feature owns all of it:

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,
});

Surface the admin screen from your app shell:

// In an app feature that owns the sysadmin navigation:
r.nav({
workspace: "sysadmin",
screen: "tier-engine:screen:tier-admin",
title: "Tier admin",
});

The screen ref must be qualified (tier-engine:screen:tier-admin) because the screen is owned by the bundled feature, not by the app feature that mounts it.

The detail that makes this correct: set-tenant-tier writes through the event-store executor directly, which bypasses the postSave entity hook the resolver normally uses to invalidate its cache. Without an explicit sync 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 the handler for exactly this reason. The recipe’s integration test exercises the round trip — grant, then read-back via get-tenant-tier, then list options via tier-options — end-to-end against the real dispatcher.

  • Recipe: tier-admin sample — runnable example with integration tests covering grant + idempotent re-grant + role-fail-closed.
  • Reference: tier-engine — full handler/query inventory plus the resolver-extension setup.