d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
195 lines
6.1 KiB
TypeScript
195 lines
6.1 KiB
TypeScript
import {
|
|
DEFAULT_ENTERPRISE_TIER_COST_LIMIT,
|
|
DEFAULT_FREE_CREDITS,
|
|
DEFAULT_PRO_TIER_COST_LIMIT,
|
|
DEFAULT_TEAM_TIER_COST_LIMIT,
|
|
} from '@/lib/billing/constants'
|
|
import { CREDIT_MULTIPLIER } from '@/lib/billing/credits/conversion'
|
|
import {
|
|
getPlanTierCredits,
|
|
isEnterprise,
|
|
isFree,
|
|
isOrgPlan,
|
|
isPro,
|
|
isTeam,
|
|
} from '@/lib/billing/plan-helpers'
|
|
import { parseEnterpriseSubscriptionMetadata } from '@/lib/billing/types'
|
|
import { env, envNumber } from '@/lib/core/config/env'
|
|
|
|
export const ENTITLED_SUBSCRIPTION_STATUSES = ['active', 'past_due'] as const
|
|
|
|
export const USABLE_SUBSCRIPTION_STATUSES = ['active'] as const
|
|
|
|
/**
|
|
* Returns true when a subscription should still count as a paid plan entitlement.
|
|
*/
|
|
export function hasPaidSubscriptionStatus(status: string | null | undefined): boolean {
|
|
return ENTITLED_SUBSCRIPTION_STATUSES.includes(
|
|
status as (typeof ENTITLED_SUBSCRIPTION_STATUSES)[number]
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Returns true when a subscription status is usable for product access.
|
|
*/
|
|
export function hasUsableSubscriptionStatus(status: string | null | undefined): boolean {
|
|
return USABLE_SUBSCRIPTION_STATUSES.includes(
|
|
status as (typeof USABLE_SUBSCRIPTION_STATUSES)[number]
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Returns true when a subscription is usable for product access.
|
|
*/
|
|
export function hasUsableSubscriptionAccess(
|
|
status: string | null | undefined,
|
|
billingBlocked: boolean | null | undefined
|
|
): boolean {
|
|
return hasUsableSubscriptionStatus(status) && !billingBlocked
|
|
}
|
|
|
|
/**
|
|
* Get the free tier limit from env or fallback to default
|
|
*/
|
|
export function getFreeTierLimit(): number {
|
|
return envNumber(env.FREE_TIER_COST_LIMIT, DEFAULT_FREE_CREDITS)
|
|
}
|
|
|
|
/**
|
|
* Get the pro tier limit from env or fallback to default
|
|
*/
|
|
export function getProTierLimit(): number {
|
|
return envNumber(env.PRO_TIER_COST_LIMIT, DEFAULT_PRO_TIER_COST_LIMIT)
|
|
}
|
|
|
|
/**
|
|
* Get the team tier limit per seat from env or fallback to default
|
|
*/
|
|
export function getTeamTierLimitPerSeat(): number {
|
|
return envNumber(env.TEAM_TIER_COST_LIMIT, DEFAULT_TEAM_TIER_COST_LIMIT)
|
|
}
|
|
|
|
/**
|
|
* Get the enterprise tier limit per seat from env or fallback to default
|
|
*/
|
|
export function getEnterpriseTierLimitPerSeat(): number {
|
|
return envNumber(env.ENTERPRISE_TIER_COST_LIMIT, DEFAULT_ENTERPRISE_TIER_COST_LIMIT)
|
|
}
|
|
|
|
export function checkEnterprisePlan(subscription: any): boolean {
|
|
return isEnterprise(subscription?.plan) && hasPaidSubscriptionStatus(subscription?.status)
|
|
}
|
|
|
|
export function getEffectiveSeats(subscription: any): number {
|
|
if (!subscription) {
|
|
return 0
|
|
}
|
|
|
|
if (isEnterprise(subscription.plan)) {
|
|
const metadata = parseEnterpriseSubscriptionMetadata(subscription.metadata)
|
|
if (metadata) {
|
|
return metadata.seats
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// Mirrors the Stripe subscription's `quantity`. For personal Pro this
|
|
// is null in practice, so `?? 0` returns 0. For Team, fall back to 1
|
|
// while the seat value has not yet been synced from Stripe so invite
|
|
// and seat-validation flows don't transiently read as zero seats.
|
|
if (isTeam(subscription.plan)) {
|
|
return subscription.seats ?? (hasPaidSubscriptionStatus(subscription.status) ? 1 : 0)
|
|
}
|
|
|
|
if (isPro(subscription.plan)) {
|
|
return subscription.seats ?? 0
|
|
}
|
|
|
|
return 0
|
|
}
|
|
|
|
export function checkProPlan(subscription: any): boolean {
|
|
return isPro(subscription?.plan) && hasPaidSubscriptionStatus(subscription?.status)
|
|
}
|
|
|
|
export function checkTeamPlan(subscription: any): boolean {
|
|
return isTeam(subscription?.plan) && hasPaidSubscriptionStatus(subscription?.status)
|
|
}
|
|
|
|
/**
|
|
* True when the subscription's `referenceId` is an org (i.e. not the
|
|
* caller's own `userId`). Prefer this over plan-name checks for scope
|
|
* decisions — a `pro_*` sub attached to an org is org-scoped even though
|
|
* `isTeam` / `isOrgPlan` return false.
|
|
*/
|
|
export function isOrgScopedSubscription(
|
|
subscription: { referenceId?: string | null } | null | undefined,
|
|
userId: string
|
|
): boolean {
|
|
if (!subscription?.referenceId) return false
|
|
return subscription.referenceId !== userId
|
|
}
|
|
|
|
/**
|
|
* Get the minimum usage limit for an individual user (used for validation).
|
|
*
|
|
* Callers should only invoke this for **personally-scoped** subscriptions —
|
|
* any org-scoped subscription (team, enterprise, or `pro_*` attached to an
|
|
* organization) uses the organization-level limit instead. Callers are
|
|
* responsible for gating with `isOrgScopedSubscription` before calling.
|
|
*
|
|
* @param subscription The subscription object
|
|
* @returns The per-user minimum limit in dollars
|
|
*/
|
|
export function getPerUserMinimumLimit(subscription: any): number {
|
|
if (!subscription || !hasPaidSubscriptionStatus(subscription.status)) {
|
|
return getFreeTierLimit()
|
|
}
|
|
|
|
if (isPro(subscription.plan)) {
|
|
const tierCredits = getPlanTierCredits(subscription.plan)
|
|
if (tierCredits > 0) return tierCredits / CREDIT_MULTIPLIER
|
|
return getProTierLimit()
|
|
}
|
|
|
|
if (isOrgPlan(subscription.plan)) {
|
|
return 0
|
|
}
|
|
|
|
return getFreeTierLimit()
|
|
}
|
|
|
|
/**
|
|
* Check if a user can edit their usage limits based on their subscription
|
|
* Free and Enterprise plans cannot edit limits
|
|
* Pro and Team plans can increase their limits
|
|
* @param subscription The subscription object
|
|
* @returns Whether the user can edit their usage limits
|
|
*/
|
|
export function canEditUsageLimit(subscription: any): boolean {
|
|
if (!subscription || !hasUsableSubscriptionStatus(subscription.status)) {
|
|
return false // Free plan users cannot edit limits
|
|
}
|
|
|
|
// Only Pro and Team plans can edit limits
|
|
// Enterprise has fixed limits that match their monthly cost
|
|
return isPro(subscription.plan) || isTeam(subscription.plan)
|
|
}
|
|
|
|
/**
|
|
* Get pricing info for a plan. Supports both legacy names (`'pro'`, `'team'`)
|
|
* and new credit-tier names (`'pro_4000'`, `'team_8000'`).
|
|
*/
|
|
export function getPlanPricing(plan: string): { basePrice: number } {
|
|
if (isFree(plan)) return { basePrice: 0 }
|
|
if (isEnterprise(plan)) return { basePrice: getEnterpriseTierLimitPerSeat() }
|
|
|
|
if (isPro(plan) || isTeam(plan)) {
|
|
const tierCredits = getPlanTierCredits(plan)
|
|
if (tierCredits > 0) return { basePrice: tierCredits / CREDIT_MULTIPLIER }
|
|
return { basePrice: isPro(plan) ? getProTierLimit() : getTeamTierLimitPerSeat() }
|
|
}
|
|
|
|
return { basePrice: 0 }
|
|
}
|