Files
simstudioai--sim/apps/sim/lib/billing/subscriptions/utils.ts
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

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 }
}