import { type BillingPlan, isEnterpriseLikePlan } from './billing.js'; /** * Which pricing model an organization is on, persisted on `billing.pricing_model`. * * `seats` (default) is the legacy seat-based model: the Stripe subscription is * anchored on a `builder_seat` line item and AI usage is gated per assigned seat. * `credits` is the PLG model where the org buys credit packs (surfaced as "GAUs") * and deployed apps, and AI usage is metered against the org-level pool with no seats. * * Values match the PostgreSQL `pricing_model_enum`. Existing orgs default to `seats` * so they are grandfathered onto seat-based pricing until explicitly migrated. */ export enum PricingModelEnum { CREDITS = 'credits', SEATS = 'seats' } export type PricingModel = `${PricingModelEnum}`; /** * True only when the org is on the seatless credits (GAU) pricing model. Anything * absent or unrecognized is treated as seat-based — the grandfather-safe default. */ export function isCreditsPricing(pricingModel: PricingModel | undefined | null): boolean { return pricingModel === PricingModelEnum.CREDITS; } /** * True when the org is on seat-based pricing — the inverse of {@link isCreditsPricing}. * Anything absent or unrecognized reads as seat-based, so use this rather than a raw * `=== 'seats'` comparison, which would miss the grandfathered (unset) case. */ export function isSeatsPricing(pricingModel: PricingModel | undefined | null): boolean { return !isCreditsPricing(pricingModel); } /** * True when the org is genuinely on the seatless PLG model — credits pricing * **and** a PLG plan. This, not {@link isCreditsPricing}, is what seat-bearing * UI should branch on. * * `billing.pricing_model` is stamped once, at TRIAL signup, from the * `superblocks.billing.default-pricing-model` flag, and then grandfathered with * no re-evaluation (see `OrganizationRepository`: "Only TRIAL signups reach * here"). It describes the PLG pricing an org signed up under; it says nothing * about a contract negotiated later. * * So a PLG org that converts to an enterprise `standard` contract still carries * `credits` forever — while its contract is explicitly seat-bearing * (`credits_per_seat`, a `builder_seat` entitlement, real assignments). Gating * seat surfaces on the bare stamp hid the AI Builders tile, the seats query and * the whole assign/remove flow from those orgs, leaving no way to manage * licenses the contract actually grants. * * Fails closed to seat-bearing: an absent stamp or an unknown plan reads as * "not seatless", so the surfaces stay visible rather than silently vanishing. * * Because of that asymmetry, do NOT negate this to test "is seat-bearing". A * `false` here means "not provably seatless" — it does not promise the org * holds a `builder_seat` entitlement. A dollar_commit enterprise contract is * enterprise-like with no seats at all, so anything that needs real seats must * check the entitlement (see `seatAssignment.ts`), not `!isSeatlessOrg(...)`. */ export function isSeatlessOrg(pricingModel: PricingModel | undefined | null, plan: BillingPlan | undefined | null): boolean { return isCreditsPricing(pricingModel) && plan != null && !isEnterpriseLikePlan(plan); } const KNOWN_PRICING_MODELS: ReadonlySet = new Set(Object.values(PricingModelEnum)); /** * Coerce the raw value of the `superblocks.billing.default-pricing-model` LaunchDarkly * flag into a valid {@link PricingModel} for stamping onto a new org at creation. (Conversion * does not re-resolve the flag; it carries the existing stamp forward — see * `createBillingHistoryRowForOrg`.) * * Fails **closed to `seats`** for anything unrecognized — an LD typo, a variation the * running code doesn't know yet, or a wrong-typed value — so a misconfigured flag can * never strand a new org on a half-built pricing model. */ export function resolvePricingModel(rawFlagValue: unknown): PricingModel { return typeof rawFlagValue === 'string' && KNOWN_PRICING_MODELS.has(rawFlagValue) ? (rawFlagValue as PricingModel) : PricingModelEnum.SEATS; }