stack/packages/stack-shared/src/plans.ts
Bilal Godil d4f6f58735 feat(hexclave): PR 2 — visible rebrand to Hexclave
Rebased onto dev after PR 1475 (cl/hexclave-pr1) was squash-merged.
Squashes the original 46-commit branch (including PR1-duplicate commits
that arrived via cherry-picks/merges) into a single commit containing
only PR2's net delta over dev.

Original PR 1481 head: 94872de407873a1cabd4085deb21b69afe8d7699
(kept locally at backup/cl-romantic-mendel-5a2c25-pre-rebase)
2026-05-23 17:35:08 -07:00

108 lines
3.1 KiB
TypeScript

/**
* Plan configuration for Hexclave pricing tiers.
*
* This file defines the limits for each plan and the item IDs used to track them.
* Import these constants in seed.ts and backend code for limit enforcement.
*/
export const UNLIMITED = 1_000_000_000;
/**
* Item IDs used across the codebase for tracking plan limits.
*/
export const ITEM_IDS = {
seats: "dashboard_admins",
authUsers: "auth_users",
emailsPerMonth: "emails_per_month",
analyticsTimeoutSeconds: "analytics_timeout_seconds",
analyticsEvents: "analytics_events",
sessionReplays: "session_replays",
onboardingCall: "onboarding_call",
} as const;
export type ItemId = typeof ITEM_IDS[keyof typeof ITEM_IDS];
/**
* The offerings/limits included in a plan.
*/
export type PlanProductOfferings = {
seats: number,
authUsers: number,
emailsPerMonth: number,
analyticsTimeoutSeconds: number,
analyticsEvents: number,
sessionReplays: number,
};
/**
* Plan limits by plan ID.
*/
export const PLAN_LIMITS: {
free: PlanProductOfferings,
team: PlanProductOfferings,
growth: PlanProductOfferings,
} = {
free: {
seats: 1,
authUsers: 10_000,
emailsPerMonth: 1_000,
analyticsTimeoutSeconds: 10,
analyticsEvents: 100_000,
sessionReplays: 2_500,
},
team: {
seats: 4,
authUsers: 50_000,
emailsPerMonth: 25_000,
analyticsTimeoutSeconds: 60,
analyticsEvents: 500_000,
sessionReplays: 2_500,
},
growth: {
seats: 4,
authUsers: UNLIMITED,
emailsPerMonth: 25_000,
analyticsTimeoutSeconds: 300,
analyticsEvents: 1_000_000,
sessionReplays: 2_500,
},
};
export type PlanId = keyof typeof PLAN_LIMITS;
/**
* Base plan IDs ordered from highest to lowest tier. Use this (instead of
* string literals) whenever code needs to pick a customer's "current" plan
* from their product list, so the choice stays in sync with `PLAN_LIMITS`.
*/
export const BASE_PLAN_IDS_BY_TIER = ["growth", "team", "free"] as const satisfies readonly PlanId[];
/**
* Minimal shape of a product entry as it comes out of `team.useProducts()` /
* `customer.useProducts()` on both the SDK and dashboard sides. Structural so
* we don't pull SDK types into `stack-shared`.
*/
type PlanResolutionProduct = { id: string | null, type?: string };
/**
* Picks the customer's highest-tier active base plan (growth → team → free),
* falling back to `"free"` if none of the known plans appear as a
* subscription. Single source of truth for plan gating in the dashboard —
* do not reintroduce ad-hoc `p.id === "team" || p.id === "growth"` checks.
*/
export function resolvePlanId(products: ReadonlyArray<PlanResolutionProduct>): PlanId {
const activeSubscriptionPlanIds = new Set(
products.filter(p => p.type === "subscription" && p.id != null).map(p => p.id),
);
return BASE_PLAN_IDS_BY_TIER.find(id => activeSubscriptionPlanIds.has(id)) ?? "free";
}
/**
* Convenience predicate for "is this customer on a paid plan?". Anything
* above free counts, so new paid tiers added to `PLAN_LIMITS` are picked up
* automatically.
*/
export function isPaidPlan(products: ReadonlyArray<PlanResolutionProduct>): boolean {
return resolvePlanId(products) !== "free";
}