chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
canEditUsageLimit,
|
||||
checkEnterprisePlan,
|
||||
checkProPlan,
|
||||
checkTeamPlan,
|
||||
getEffectiveSeats,
|
||||
hasPaidSubscriptionStatus,
|
||||
hasUsableSubscriptionAccess,
|
||||
hasUsableSubscriptionStatus,
|
||||
} from '@/lib/billing/subscriptions/utils'
|
||||
|
||||
describe('billing subscription status helpers', () => {
|
||||
it('treats past_due as paid entitlement but not usable access', () => {
|
||||
expect(hasPaidSubscriptionStatus('active')).toBe(true)
|
||||
expect(hasPaidSubscriptionStatus('past_due')).toBe(true)
|
||||
expect(hasPaidSubscriptionStatus('canceled')).toBe(false)
|
||||
|
||||
expect(hasUsableSubscriptionStatus('active')).toBe(true)
|
||||
expect(hasUsableSubscriptionStatus('past_due')).toBe(false)
|
||||
expect(hasUsableSubscriptionStatus('incomplete')).toBe(false)
|
||||
|
||||
expect(hasUsableSubscriptionAccess('active', false)).toBe(true)
|
||||
expect(hasUsableSubscriptionAccess('active', true)).toBe(false)
|
||||
expect(hasUsableSubscriptionAccess('past_due', false)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps paid plan checks true for past_due subscriptions', () => {
|
||||
expect(checkProPlan({ plan: 'pro_4000', status: 'past_due' })).toBe(true)
|
||||
expect(checkTeamPlan({ plan: 'team_8000', status: 'past_due' })).toBe(true)
|
||||
expect(checkEnterprisePlan({ plan: 'enterprise', status: 'past_due' })).toBe(true)
|
||||
})
|
||||
|
||||
it('only allows usage limit editing for active usable subscriptions', () => {
|
||||
expect(canEditUsageLimit({ plan: 'pro_4000', status: 'active' })).toBe(true)
|
||||
expect(canEditUsageLimit({ plan: 'team_8000', status: 'active' })).toBe(true)
|
||||
|
||||
expect(canEditUsageLimit({ plan: 'pro_4000', status: 'past_due' })).toBe(false)
|
||||
expect(canEditUsageLimit({ plan: 'team_8000', status: 'past_due' })).toBe(false)
|
||||
expect(canEditUsageLimit({ plan: 'enterprise', status: 'active' })).toBe(false)
|
||||
expect(canEditUsageLimit({ plan: 'free', status: 'active' })).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to one seat for entitled team subscriptions before seats sync completes', () => {
|
||||
expect(getEffectiveSeats({ plan: 'team_8000', status: 'active', seats: null })).toBe(1)
|
||||
expect(getEffectiveSeats({ plan: 'team_8000', status: 'past_due', seats: undefined })).toBe(1)
|
||||
expect(getEffectiveSeats({ plan: 'team_8000', status: 'canceled', seats: null })).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,194 @@
|
||||
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user