6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (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 / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
150 lines
6.0 KiB
TypeScript
150 lines
6.0 KiB
TypeScript
import { db } from '@sim/db'
|
|
import { createLogger } from '@sim/logger'
|
|
import { APIError } from 'better-auth/api'
|
|
import { hasPaidSubscription } from '@/lib/billing'
|
|
import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
|
|
import { getOrganizationCoverageForMember } from '@/lib/billing/core/subscription'
|
|
import {
|
|
assertNoUnresolvedEnterpriseIssuance,
|
|
EnterpriseIssuanceInProgressError,
|
|
} from '@/lib/billing/enterprise-outbox'
|
|
import { isOrgPlan } from '@/lib/billing/plan-helpers'
|
|
import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils'
|
|
|
|
const logger = createLogger('BillingAuthorization')
|
|
|
|
/**
|
|
* Classify a `/subscription/upgrade` request as a personal checkout using
|
|
* the same reference resolution as the Better Auth Stripe plugin
|
|
* (`@better-auth/stripe` 1.6.23, `referenceMiddleware` — classification
|
|
* unchanged from 1.6.13; 1.6.23 only moved the resolved `referenceId` into
|
|
* the middleware return value): an explicit `referenceId` defines the
|
|
* reference (personal iff it is the session user); without one, the
|
|
* reference defaults to the user unless `customerType: 'organization'`
|
|
* selects the session's active organization.
|
|
*
|
|
* This mirror exists because the plugin does not expose its resolution and
|
|
* skips `authorizeReference` for personal references entirely. Re-verify
|
|
* against the plugin's `referenceMiddleware`/`getReferenceId` when
|
|
* upgrading better-auth.
|
|
*/
|
|
export function isPersonalCheckoutRequest(
|
|
body: { referenceId?: unknown; customerType?: unknown },
|
|
sessionUserId: string
|
|
): boolean {
|
|
if (body.referenceId) return body.referenceId === sessionUserId
|
|
return body.customerType !== 'organization'
|
|
}
|
|
|
|
/**
|
|
* Guard for personal (user-referenced) checkouts on `/subscription/upgrade`.
|
|
*
|
|
* A member of an organization with an entitled paid subscription is already
|
|
* covered by that org — their usage pools to it and personal Pro
|
|
* subscriptions are paused on join — so a personal checkout would bill the
|
|
* same human twice. Throws {@link APIError} with a user-facing message; the
|
|
* checkout UI surfaces it as-is.
|
|
*
|
|
* Called from the Better Auth `hooks.before` middleware, NOT from
|
|
* `authorizeReference`: the Stripe plugin skips `authorizeReference`
|
|
* entirely when the reference is the session user, so this is the only
|
|
* enforcement point that personal checkouts actually pass through.
|
|
*
|
|
* Fails closed: when coverage cannot be determined, the checkout is
|
|
* rejected rather than risking a duplicate subscription.
|
|
*/
|
|
export async function assertPersonalCheckoutAllowed(userId: string): Promise<void> {
|
|
const coverage = await getOrganizationCoverageForMember(userId)
|
|
|
|
if (coverage.status === 'covered') {
|
|
logger.warn(
|
|
'Blocking personal checkout - user is already covered by an organization subscription',
|
|
{ userId, organizationId: coverage.organizationId }
|
|
)
|
|
throw new APIError('FORBIDDEN', {
|
|
message:
|
|
"You're already covered by your organization's plan, so a personal plan would bill you twice. Manage your plan from the organization's billing settings.",
|
|
})
|
|
}
|
|
|
|
if (coverage.status === 'unknown') {
|
|
logger.error(
|
|
'Blocking personal checkout - could not verify organization coverage; failing closed',
|
|
{ userId }
|
|
)
|
|
throw new APIError('SERVICE_UNAVAILABLE', {
|
|
message: 'We could not verify your billing status. Please try again in a moment.',
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a user is authorized to manage billing for a given reference ID.
|
|
* Only invoked for organization references — the Stripe plugin skips this
|
|
* callback when the reference is the session user itself (personal
|
|
* checkouts are guarded by {@link assertPersonalCheckoutAllowed} in the
|
|
* `hooks.before` middleware instead).
|
|
*
|
|
* For `upgrade-subscription` (checkout) this enforces, each thrown as an
|
|
* {@link APIError} with a descriptive message so callers see the real
|
|
* reason instead of a generic "Unauthorized":
|
|
* - Organizations can only check out Team or Enterprise plans — a `pro_*`
|
|
* plan can never become org-referenced.
|
|
* - Organizations cannot start a checkout while they already have an
|
|
* active subscription (prevents duplicates).
|
|
* - Checkout is deferred while an Enterprise issuance is unresolved.
|
|
*/
|
|
export async function authorizeSubscriptionReference(
|
|
userId: string,
|
|
referenceId: string,
|
|
action?: string,
|
|
plan?: string
|
|
): Promise<boolean> {
|
|
if (!isOrgScopedSubscription({ referenceId }, userId)) {
|
|
return true
|
|
}
|
|
|
|
if (action === 'upgrade-subscription' && !isOrgPlan(plan)) {
|
|
logger.warn('Blocking checkout - organizations can only subscribe to Team or Enterprise', {
|
|
userId,
|
|
referenceId,
|
|
plan: plan ?? null,
|
|
})
|
|
throw new APIError('FORBIDDEN', {
|
|
message: 'Organizations can only subscribe to Team or Enterprise plans.',
|
|
})
|
|
}
|
|
|
|
if (action === 'upgrade-subscription' && (await hasPaidSubscription(referenceId))) {
|
|
logger.warn('Blocking checkout - active subscription already exists for organization', {
|
|
userId,
|
|
referenceId,
|
|
})
|
|
throw new APIError('CONFLICT', {
|
|
message:
|
|
'This organization already has an active subscription. Manage it from the billing settings.',
|
|
})
|
|
}
|
|
|
|
if (action === 'upgrade-subscription') {
|
|
try {
|
|
// This is an early checkout admission check, not the transactional
|
|
// fence: canonical Sim-side entitlement conversion paths re-check under
|
|
// the organization mutation lock before changing local billing state.
|
|
await assertNoUnresolvedEnterpriseIssuance(db, referenceId)
|
|
} catch (error) {
|
|
if (!(error instanceof EnterpriseIssuanceInProgressError)) throw error
|
|
logger.warn('Blocking checkout - Enterprise issuance is unfinished for organization', {
|
|
userId,
|
|
referenceId,
|
|
})
|
|
throw new APIError('CONFLICT', {
|
|
message:
|
|
'This organization has an Enterprise plan setup in progress. Please try again in a few minutes or contact support.',
|
|
})
|
|
}
|
|
}
|
|
|
|
return isOrganizationOwnerOrAdmin(userId, referenceId)
|
|
}
|