chore: import upstream snapshot with attribution
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) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
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) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import {
|
||||
authMock,
|
||||
authMockFns,
|
||||
createMockRequest,
|
||||
createSession,
|
||||
dbChainMock,
|
||||
dbChainMockFns,
|
||||
resetDbChainMock,
|
||||
} from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@sim/db', () => dbChainMock)
|
||||
vi.mock('@/lib/auth', () => authMock)
|
||||
|
||||
vi.mock('@/lib/billing/plan-helpers', () => ({
|
||||
isOrgPlan: (plan: string) => plan === 'team' || plan === 'enterprise',
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/subscriptions/utils', () => ({
|
||||
ENTITLED_SUBSCRIPTION_STATUSES: ['active', 'past_due'],
|
||||
hasPaidSubscriptionStatus: (status: string) => status === 'active' || status === 'past_due',
|
||||
}))
|
||||
|
||||
import { POST } from '@/app/api/users/me/subscription/[id]/transfer/route'
|
||||
|
||||
function makeRequest(body: unknown, id = 'sub-1') {
|
||||
return POST(
|
||||
createMockRequest(
|
||||
'POST',
|
||||
body,
|
||||
{},
|
||||
`http://localhost/api/users/me/subscription/${id}/transfer`
|
||||
),
|
||||
{ params: Promise.resolve({ id }) }
|
||||
)
|
||||
}
|
||||
|
||||
describe('POST /api/users/me/subscription/[id]/transfer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
authMockFns.mockGetSession.mockResolvedValue(
|
||||
createSession({
|
||||
userId: 'user-1',
|
||||
email: 'owner@example.com',
|
||||
name: 'Owner',
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects transfers for non-organization subscriptions', async () => {
|
||||
dbChainMockFns.for.mockResolvedValueOnce([
|
||||
{ id: 'sub-1', referenceId: 'user-1', plan: 'pro', status: 'active' },
|
||||
])
|
||||
|
||||
const response = await makeRequest({ organizationId: 'org-1' })
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
error: 'Only active Team or Enterprise subscriptions can be transferred to an organization.',
|
||||
})
|
||||
expect(dbChainMockFns.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('transfers an active organization subscription to an admin-owned organization', async () => {
|
||||
dbChainMockFns.for
|
||||
.mockResolvedValueOnce([
|
||||
{ id: 'sub-1', referenceId: 'user-1', plan: 'team', status: 'active' },
|
||||
])
|
||||
.mockResolvedValueOnce([{ id: 'org-1' }])
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([{ role: 'owner' }]).mockResolvedValueOnce([])
|
||||
|
||||
const response = await makeRequest({ organizationId: 'org-1' })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
success: true,
|
||||
message: 'Subscription transferred successfully',
|
||||
})
|
||||
expect(dbChainMockFns.update).toHaveBeenCalled()
|
||||
expect(dbChainMockFns.set).toHaveBeenCalledWith({ referenceId: 'org-1' })
|
||||
})
|
||||
|
||||
it('treats an already-transferred organization subscription as a successful no-op', async () => {
|
||||
dbChainMockFns.for
|
||||
.mockResolvedValueOnce([
|
||||
{ id: 'sub-1', referenceId: 'org-1', plan: 'team', status: 'active' },
|
||||
])
|
||||
.mockResolvedValueOnce([{ id: 'org-1' }])
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([{ role: 'owner' }])
|
||||
|
||||
const response = await makeRequest({ organizationId: 'org-1' })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
success: true,
|
||||
message: 'Subscription already belongs to this organization',
|
||||
})
|
||||
expect(dbChainMockFns.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects the noop probe when the requester is not a member of the target organization', async () => {
|
||||
dbChainMockFns.for
|
||||
.mockResolvedValueOnce([
|
||||
{ id: 'sub-1', referenceId: 'org-1', plan: 'team', status: 'active' },
|
||||
])
|
||||
.mockResolvedValueOnce([{ id: 'org-1' }])
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([])
|
||||
|
||||
const response = await makeRequest({ organizationId: 'org-1' })
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
error: 'Unauthorized - user is not admin of organization',
|
||||
})
|
||||
expect(dbChainMockFns.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects the transfer when the target organization already has an active subscription', async () => {
|
||||
dbChainMockFns.for
|
||||
.mockResolvedValueOnce([
|
||||
{ id: 'sub-1', referenceId: 'user-1', plan: 'team', status: 'active' },
|
||||
])
|
||||
.mockResolvedValueOnce([{ id: 'org-1' }])
|
||||
dbChainMockFns.limit
|
||||
.mockResolvedValueOnce([{ role: 'owner' }])
|
||||
.mockResolvedValueOnce([{ id: 'existing-sub' }])
|
||||
|
||||
const response = await makeRequest({ organizationId: 'org-1' })
|
||||
|
||||
expect(response.status).toBe(409)
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
error: 'Organization already has an active subscription',
|
||||
})
|
||||
expect(dbChainMockFns.update).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,167 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { member, organization, subscription } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { subscriptionTransferContract } from '@/lib/api/contracts/user'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { isOrgPlan } from '@/lib/billing/plan-helpers'
|
||||
import {
|
||||
ENTITLED_SUBSCRIPTION_STATUSES,
|
||||
hasPaidSubscriptionStatus,
|
||||
} from '@/lib/billing/subscriptions/utils'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
|
||||
const logger = createLogger('SubscriptionTransferAPI')
|
||||
|
||||
type TransferOutcome =
|
||||
| { kind: 'error'; status: number; error: string }
|
||||
| { kind: 'noop'; message: string }
|
||||
| { kind: 'success'; message: string }
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
logger.warn('Unauthorized subscription transfer attempt')
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(subscriptionTransferContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const subscriptionId = parsed.data.params.id
|
||||
const { organizationId } = parsed.data.body
|
||||
const userId = session.user.id
|
||||
logger.info('Processing subscription transfer', { subscriptionId, organizationId })
|
||||
|
||||
const outcome = await db.transaction(async (tx): Promise<TransferOutcome> => {
|
||||
const [sub] = await tx
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(eq(subscription.id, subscriptionId))
|
||||
.for('update')
|
||||
|
||||
if (!sub) {
|
||||
return { kind: 'error', status: 404, error: 'Subscription not found' }
|
||||
}
|
||||
|
||||
if (!isOrgPlan(sub.plan) || !hasPaidSubscriptionStatus(sub.status)) {
|
||||
return {
|
||||
kind: 'error',
|
||||
status: 400,
|
||||
error:
|
||||
'Only active Team or Enterprise subscriptions can be transferred to an organization.',
|
||||
}
|
||||
}
|
||||
|
||||
const [org] = await tx
|
||||
.select({ id: organization.id })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.for('update')
|
||||
|
||||
if (!org) {
|
||||
return { kind: 'error', status: 404, error: 'Organization not found' }
|
||||
}
|
||||
|
||||
const [mem] = await tx
|
||||
.select({ role: member.role })
|
||||
.from(member)
|
||||
.where(and(eq(member.userId, userId), eq(member.organizationId, organizationId)))
|
||||
.limit(1)
|
||||
|
||||
if (!mem || (mem.role !== 'owner' && mem.role !== 'admin')) {
|
||||
return {
|
||||
kind: 'error',
|
||||
status: 403,
|
||||
error: 'Unauthorized - user is not admin of organization',
|
||||
}
|
||||
}
|
||||
|
||||
if (sub.referenceId === organizationId) {
|
||||
return { kind: 'noop', message: 'Subscription already belongs to this organization' }
|
||||
}
|
||||
|
||||
if (sub.referenceId !== userId) {
|
||||
return {
|
||||
kind: 'error',
|
||||
status: 403,
|
||||
error: 'Unauthorized - subscription does not belong to user',
|
||||
}
|
||||
}
|
||||
|
||||
const [existingOrgSub] = await tx
|
||||
.select({ id: subscription.id })
|
||||
.from(subscription)
|
||||
.where(
|
||||
and(
|
||||
eq(subscription.referenceId, organizationId),
|
||||
inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingOrgSub) {
|
||||
return {
|
||||
kind: 'error',
|
||||
status: 409,
|
||||
error: 'Organization already has an active subscription',
|
||||
}
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(subscription)
|
||||
.set({ referenceId: organizationId })
|
||||
.where(eq(subscription.id, subscriptionId))
|
||||
|
||||
return { kind: 'success', message: 'Subscription transferred successfully' }
|
||||
})
|
||||
|
||||
if (outcome.kind === 'error') {
|
||||
return NextResponse.json({ error: outcome.error }, { status: outcome.status })
|
||||
}
|
||||
|
||||
if (outcome.kind === 'success') {
|
||||
logger.info('Subscription transfer completed', {
|
||||
subscriptionId,
|
||||
organizationId,
|
||||
userId,
|
||||
})
|
||||
|
||||
recordAudit({
|
||||
actorId: userId,
|
||||
action: AuditAction.SUBSCRIPTION_TRANSFERRED,
|
||||
resourceType: AuditResourceType.SUBSCRIPTION,
|
||||
resourceId: subscriptionId,
|
||||
description: `Subscription transferred to organization ${organizationId}`,
|
||||
metadata: {
|
||||
subscriptionId,
|
||||
organizationId,
|
||||
fromEntity: 'user',
|
||||
toEntity: 'organization',
|
||||
},
|
||||
request,
|
||||
})
|
||||
captureServerEvent(userId, 'subscription_transferred', {
|
||||
subscription_id: subscriptionId,
|
||||
from_entity: 'user',
|
||||
to_entity: 'organization',
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, message: outcome.message })
|
||||
} catch (error) {
|
||||
logger.error('Error transferring subscription', {
|
||||
error: toError(error).message,
|
||||
})
|
||||
return NextResponse.json({ error: 'Failed to transfer subscription' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user