mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(billing): checkout guard, admin panel case (#6641)
* fix(billing): checkout guard, admin panel case * fix(billing): serialize checkout admission * fix(billing): release checkout admission claim
This commit is contained in:
@@ -120,6 +120,22 @@ describe('useUpgradeState', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('shows checkout admission failures through the standard error toast', async () => {
|
||||
mockHandleUpgrade.mockRejectedValueOnce(
|
||||
new Error('Your subscription payment is still processing.')
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
root.render(<Harness />)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await currentState?.doUpgrade('team', 25000)
|
||||
})
|
||||
|
||||
expect(mockToastError).toHaveBeenCalledWith('Your subscription payment is still processing.')
|
||||
})
|
||||
|
||||
it('includes the routed workspace when switching the host billing interval', async () => {
|
||||
await act(async () => {
|
||||
root.render(<Harness />)
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
} from '@/lib/auth/constants'
|
||||
import { getSessionCookieCacheVersion } from '@/lib/auth/security-policy'
|
||||
import { clampExpiryForSession } from '@/lib/auth/session-policy'
|
||||
import { getActiveOrganizationId } from '@/lib/auth/session-response'
|
||||
import { guardSubscriptionPlanWrites } from '@/lib/auth/stripe-adapter-guard'
|
||||
import { sendPlanWelcomeEmail } from '@/lib/billing'
|
||||
import {
|
||||
@@ -45,6 +46,12 @@ import {
|
||||
authorizeSubscriptionReference,
|
||||
isPersonalCheckoutRequest,
|
||||
} from '@/lib/billing/authorization'
|
||||
import {
|
||||
type CheckoutAdmissionClaim,
|
||||
claimCheckoutAdmission,
|
||||
releaseCheckoutAdmission,
|
||||
resolveCheckoutReferenceId,
|
||||
} from '@/lib/billing/checkout-admission'
|
||||
import {
|
||||
getOrganizationIdForSubscriptionReference,
|
||||
syncSubscriptionPlan,
|
||||
@@ -1001,20 +1008,46 @@ export const auth = betterAuth({
|
||||
/**
|
||||
* Personal checkout guard. The Stripe plugin's `authorizeReference`
|
||||
* only runs for organization references (it skips references equal to
|
||||
* the session user), so duplicate-coverage enforcement for personal
|
||||
* checkouts lives here: a member of an org with an entitled paid
|
||||
* subscription must not buy a personal plan on top of it.
|
||||
* the session user), so personal checkout admission lives here. It
|
||||
* prevents both a duplicate checkout while Stripe payment is pending
|
||||
* and a personal plan for someone already covered by an organization.
|
||||
*/
|
||||
if (isBillingEnabled && ctx.path === '/subscription/upgrade') {
|
||||
const session = await getSessionFromCtx(ctx)
|
||||
const sessionUserId = session?.user?.id
|
||||
if (sessionUserId && isPersonalCheckoutRequest(ctx.body ?? {}, sessionUserId)) {
|
||||
await assertPersonalCheckoutAllowed(sessionUserId)
|
||||
if (sessionUserId) {
|
||||
const requestBody = ctx.body ?? {}
|
||||
const referenceId = resolveCheckoutReferenceId(
|
||||
requestBody,
|
||||
sessionUserId,
|
||||
getActiveOrganizationId(session)
|
||||
)
|
||||
if (referenceId) {
|
||||
const checkoutAdmissionClaim = await claimCheckoutAdmission(referenceId)
|
||||
try {
|
||||
if (isPersonalCheckoutRequest(requestBody, sessionUserId)) {
|
||||
await assertPersonalCheckoutAllowed(sessionUserId)
|
||||
}
|
||||
} catch (error) {
|
||||
await releaseCheckoutAdmission(checkoutAdmissionClaim)
|
||||
throw error
|
||||
}
|
||||
return { context: { billingCheckoutAdmissionClaim: checkoutAdmissionClaim } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}),
|
||||
after: createAuthMiddleware(async (ctx) => {
|
||||
if (!isBillingEnabled || ctx.path !== '/subscription/upgrade') return
|
||||
const checkoutContext = ctx as typeof ctx & {
|
||||
billingCheckoutAdmissionClaim?: CheckoutAdmissionClaim
|
||||
}
|
||||
if (checkoutContext.billingCheckoutAdmissionClaim) {
|
||||
await releaseCheckoutAdmission(checkoutContext.billingCheckoutAdmissionClaim)
|
||||
}
|
||||
}),
|
||||
},
|
||||
plugins: [
|
||||
...(env.TURNSTILE_SECRET_KEY
|
||||
|
||||
@@ -22,7 +22,12 @@ function createBaseAdapter() {
|
||||
const asAdapter = (base: ReturnType<typeof createBaseAdapter>) =>
|
||||
guardSubscriptionPlanWrites(base as unknown as Parameters<typeof guardSubscriptionPlanWrites>[0])
|
||||
|
||||
const ORG_ROW = { id: 'sub-1', referenceId: 'org-1', plan: 'team_6000' }
|
||||
const ORG_ROW = {
|
||||
id: 'sub-1',
|
||||
referenceId: 'org-1',
|
||||
plan: 'team_6000',
|
||||
stripeSubscriptionId: 'stripe-sub-1',
|
||||
}
|
||||
const WHERE = [{ field: 'id', value: 'sub-1' }]
|
||||
|
||||
describe('guardSubscriptionPlanWrites', () => {
|
||||
@@ -144,6 +149,87 @@ describe('guardSubscriptionPlanWrites', () => {
|
||||
expect(base.update).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks rebinding a personal subscription to a different Stripe subscription', async () => {
|
||||
const base = createBaseAdapter()
|
||||
const personalPro = {
|
||||
id: 'personal-pro',
|
||||
referenceId: 'user-1',
|
||||
plan: 'pro',
|
||||
stripeSubscriptionId: 'sub_personal_pro',
|
||||
}
|
||||
base.findOne.mockResolvedValueOnce(personalPro)
|
||||
|
||||
const guarded = asAdapter(base)
|
||||
await expect(
|
||||
guarded.update({
|
||||
model: 'subscription',
|
||||
where: [{ field: 'id', value: personalPro.id }] as never,
|
||||
update: {
|
||||
stripeSubscriptionId: 'sub_enterprise',
|
||||
status: 'active',
|
||||
periodEnd: new Date('2026-09-11T18:36:09Z'),
|
||||
billingInterval: 'month',
|
||||
},
|
||||
})
|
||||
).rejects.toThrow(/already bound to Stripe subscription sub_personal_pro/)
|
||||
|
||||
expect(base.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows Stripe state updates when the subscription ID is unchanged', async () => {
|
||||
const base = createBaseAdapter()
|
||||
base.findOne.mockResolvedValueOnce({
|
||||
id: 'personal-pro',
|
||||
referenceId: 'user-1',
|
||||
plan: 'pro',
|
||||
stripeSubscriptionId: 'sub_personal_pro',
|
||||
})
|
||||
|
||||
const guarded = asAdapter(base)
|
||||
await guarded.update({
|
||||
model: 'subscription',
|
||||
where: WHERE as never,
|
||||
update: {
|
||||
stripeSubscriptionId: 'sub_personal_pro',
|
||||
status: 'active',
|
||||
cancelAtPeriodEnd: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(base.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: {
|
||||
stripeSubscriptionId: 'sub_personal_pro',
|
||||
status: 'active',
|
||||
cancelAtPeriodEnd: true,
|
||||
},
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('allows binding an unbound local subscription to Stripe', async () => {
|
||||
const base = createBaseAdapter()
|
||||
base.findOne.mockResolvedValueOnce({
|
||||
id: 'new-subscription',
|
||||
referenceId: 'user-1',
|
||||
plan: 'pro',
|
||||
stripeSubscriptionId: null,
|
||||
})
|
||||
|
||||
const guarded = asAdapter(base)
|
||||
await guarded.update({
|
||||
model: 'subscription',
|
||||
where: WHERE as never,
|
||||
update: { stripeSubscriptionId: 'sub_new', status: 'incomplete' },
|
||||
})
|
||||
|
||||
expect(base.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: { stripeSubscriptionId: 'sub_new', status: 'incomplete' },
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects creating an org-referenced subscription with a non-org plan', async () => {
|
||||
const base = createBaseAdapter()
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'org-1' }])
|
||||
|
||||
@@ -17,11 +17,13 @@ type SubscriptionWriteSurface = Pick<
|
||||
/**
|
||||
* The Better Auth Stripe plugin persists webhook state through the raw
|
||||
* database adapter BEFORE invoking our subscription callbacks — including the
|
||||
* `plan` column resolved from the Stripe price. That makes the adapter the
|
||||
* only in-process seam that can enforce the billing invariant that
|
||||
* organization-referenced subscriptions hold Team/Enterprise plans: by the
|
||||
* time `syncSubscriptionPlan` runs in a callback, the plugin's write has
|
||||
* already landed.
|
||||
* Stripe subscription ID and `plan` column resolved from the Stripe price.
|
||||
* That makes the adapter the only in-process seam that can enforce two billing
|
||||
* invariants before the write lands:
|
||||
*
|
||||
* - an existing subscription cannot be rebound to a different Stripe
|
||||
* subscription merely because both subscriptions share a customer;
|
||||
* - organization-referenced subscriptions hold Team/Enterprise plans.
|
||||
*
|
||||
* Checkout admission blocks user-driven violations; this guard blocks the
|
||||
* remaining vector — an operator swapping an org subscription onto a personal
|
||||
@@ -77,11 +79,31 @@ function guardWriteSurface<TAdapter extends SubscriptionWriteSurface>(
|
||||
return adapter.create(data)
|
||||
},
|
||||
update: async (data) => {
|
||||
if (data.model === 'subscription' && hasNonOrgPlanWrite(data.update)) {
|
||||
if (data.model === 'subscription' && needsSubscriptionRowInspection(data.update)) {
|
||||
const row = await adapter.findOne<SubscriptionRowSlice>({
|
||||
model: 'subscription',
|
||||
where: data.where,
|
||||
})
|
||||
|
||||
if (row && attemptsStripeSubscriptionRebind(row, data.update)) {
|
||||
const rejectedStripeSubscriptionId = (data.update as { stripeSubscriptionId: string })
|
||||
.stripeSubscriptionId
|
||||
logger.error(
|
||||
'Blocked rebinding an existing subscription to a different Stripe subscription',
|
||||
{
|
||||
subscriptionId: row.id,
|
||||
referenceId: row.referenceId,
|
||||
currentStripeSubscriptionId: row.stripeSubscriptionId,
|
||||
rejectedStripeSubscriptionId,
|
||||
}
|
||||
)
|
||||
throw new Error(
|
||||
`Subscription ${row.id} is already bound to Stripe subscription ${row.stripeSubscriptionId}; refusing to bind ${rejectedStripeSubscriptionId}`
|
||||
)
|
||||
}
|
||||
|
||||
if (!hasNonOrgPlanWrite(data.update)) return adapter.update(data)
|
||||
|
||||
const sanitized = await stripPlanWhenOrgReferenced(
|
||||
row ? [row] : [],
|
||||
data.update as Record<string, unknown>
|
||||
@@ -110,6 +132,27 @@ interface SubscriptionRowSlice {
|
||||
id: string
|
||||
referenceId: string
|
||||
plan: string
|
||||
stripeSubscriptionId: string | null
|
||||
}
|
||||
|
||||
function needsSubscriptionRowInspection(update: unknown): boolean {
|
||||
return hasStripeSubscriptionIdWrite(update) || hasNonOrgPlanWrite(update)
|
||||
}
|
||||
|
||||
function hasStripeSubscriptionIdWrite(
|
||||
update: unknown
|
||||
): update is { stripeSubscriptionId: unknown } {
|
||||
return Boolean(update && typeof update === 'object' && 'stripeSubscriptionId' in update)
|
||||
}
|
||||
|
||||
function attemptsStripeSubscriptionRebind(row: SubscriptionRowSlice, update: unknown): boolean {
|
||||
if (!hasStripeSubscriptionIdWrite(update)) return false
|
||||
const incomingStripeSubscriptionId = update.stripeSubscriptionId
|
||||
return (
|
||||
typeof row.stripeSubscriptionId === 'string' &&
|
||||
typeof incomingStripeSubscriptionId === 'string' &&
|
||||
incomingStripeSubscriptionId !== row.stripeSubscriptionId
|
||||
)
|
||||
}
|
||||
|
||||
function hasNonOrgPlanWrite(update: unknown): boolean {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { resetDbChainMock } from '@sim/testing'
|
||||
import { dbChainMockFns, hasMockCondition, resetDbChainMock, schemaMock } from '@sim/testing'
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
@@ -96,6 +96,20 @@ describe('authorizeSubscriptionReference', () => {
|
||||
expect(mockIsOwnerOrAdmin).toHaveBeenCalledWith('owner-1', 'org-1')
|
||||
})
|
||||
|
||||
it('blocks an organization checkout while its bound Stripe subscription is incomplete', async () => {
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([
|
||||
{ id: 'subscription-1', stripeSubscriptionId: 'sub_pending' },
|
||||
])
|
||||
|
||||
await expect(
|
||||
authorizeSubscriptionReference('owner-1', 'org-1', 'upgrade-subscription', 'team_6000')
|
||||
).rejects.toThrow(/subscription payment is still processing/)
|
||||
|
||||
expect(mockHasPaidSubscription).not.toHaveBeenCalled()
|
||||
expect(mockAssertNoUnresolved).not.toHaveBeenCalled()
|
||||
expect(mockIsOwnerOrAdmin).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an organization checkout for a pro plan — org references only hold Team/Enterprise', async () => {
|
||||
await expect(
|
||||
authorizeSubscriptionReference('owner-1', 'org-1', 'upgrade-subscription', 'pro_6000')
|
||||
@@ -148,6 +162,46 @@ describe('assertPersonalCheckoutAllowed', () => {
|
||||
await expect(assertPersonalCheckoutAllowed('user-1')).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps abandoned, unbound checkout placeholders retryable', async () => {
|
||||
await assertPersonalCheckoutAllowed('user-1')
|
||||
|
||||
const predicate = dbChainMockFns.where.mock.calls[0]?.[0]
|
||||
expect(
|
||||
hasMockCondition(
|
||||
predicate,
|
||||
(node) => node.type === 'eq' && node.left === schemaMock.subscription.referenceId
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
hasMockCondition(
|
||||
predicate,
|
||||
(node) =>
|
||||
node.type === 'eq' &&
|
||||
node.left === schemaMock.subscription.status &&
|
||||
node.right === 'incomplete'
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
hasMockCondition(
|
||||
predicate,
|
||||
(node) =>
|
||||
node.type === 'isNotNull' && node.column === schemaMock.subscription.stripeSubscriptionId
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('blocks a personal checkout while its bound Stripe subscription is incomplete', async () => {
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([
|
||||
{ id: 'subscription-1', stripeSubscriptionId: 'sub_pending' },
|
||||
])
|
||||
|
||||
await expect(assertPersonalCheckoutAllowed('user-1')).rejects.toThrow(
|
||||
/subscription payment is still processing/
|
||||
)
|
||||
|
||||
expect(mockGetOrganizationCoverageForMember).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects checkout when an organization subscription already covers the user', async () => {
|
||||
mockGetOrganizationCoverageForMember.mockResolvedValueOnce({
|
||||
status: 'covered',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { db } from '@sim/db'
|
||||
import { subscription } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { APIError } from 'better-auth/api'
|
||||
import { and, eq, isNotNull } from 'drizzle-orm'
|
||||
import { hasPaidSubscription } from '@/lib/billing'
|
||||
import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
|
||||
import { getOrganizationCoverageForMember } from '@/lib/billing/core/subscription'
|
||||
@@ -13,6 +15,44 @@ import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils'
|
||||
|
||||
const logger = createLogger('BillingAuthorization')
|
||||
|
||||
/**
|
||||
* Prevent a billing reference from starting a second Stripe Checkout while a
|
||||
* previously completed Checkout still has a payment in flight.
|
||||
*
|
||||
* Better Auth intentionally reuses an unbound `incomplete` placeholder when a
|
||||
* customer retries an abandoned Checkout. Once Stripe has bound that row to a
|
||||
* subscription, however, reusing it would create another Stripe subscription
|
||||
* and make later webhooks race to own the same local row.
|
||||
*/
|
||||
async function assertNoBoundIncompleteSubscription(referenceId: string): Promise<void> {
|
||||
const [pendingSubscription] = await db
|
||||
.select({
|
||||
id: subscription.id,
|
||||
stripeSubscriptionId: subscription.stripeSubscriptionId,
|
||||
})
|
||||
.from(subscription)
|
||||
.where(
|
||||
and(
|
||||
eq(subscription.referenceId, referenceId),
|
||||
eq(subscription.status, 'incomplete'),
|
||||
isNotNull(subscription.stripeSubscriptionId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!pendingSubscription) return
|
||||
|
||||
logger.warn('Blocking checkout - Stripe subscription payment is still pending', {
|
||||
referenceId,
|
||||
subscriptionId: pendingSubscription.id,
|
||||
stripeSubscriptionId: pendingSubscription.stripeSubscriptionId,
|
||||
})
|
||||
throw new APIError('CONFLICT', {
|
||||
message:
|
||||
'Your subscription payment is still processing. Wait for it to finish before starting another checkout. If this takes longer than expected, contact support.',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a `/subscription/upgrade` request as a personal checkout using
|
||||
* the same reference resolution as the Better Auth Stripe plugin
|
||||
@@ -39,11 +79,9 @@ export function isPersonalCheckoutRequest(
|
||||
/**
|
||||
* 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.
|
||||
* Blocks both a bound, incomplete Stripe subscription and a member already
|
||||
* covered by an entitled organization subscription. 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`
|
||||
@@ -54,6 +92,8 @@ export function isPersonalCheckoutRequest(
|
||||
* rejected rather than risking a duplicate subscription.
|
||||
*/
|
||||
export async function assertPersonalCheckoutAllowed(userId: string): Promise<void> {
|
||||
await assertNoBoundIncompleteSubscription(userId)
|
||||
|
||||
const coverage = await getOrganizationCoverageForMember(userId)
|
||||
|
||||
if (coverage.status === 'covered') {
|
||||
@@ -90,6 +130,8 @@ export async function assertPersonalCheckoutAllowed(userId: string): Promise<voi
|
||||
* reason instead of a generic "Unauthorized":
|
||||
* - Organizations can only check out Team or Enterprise plans — a `pro_*`
|
||||
* plan can never become org-referenced.
|
||||
* - A bound, incomplete Stripe subscription must finish before another
|
||||
* checkout can start.
|
||||
* - Organizations cannot start a checkout while they already have an
|
||||
* active subscription (prevents duplicates).
|
||||
* - Checkout is deferred while an Enterprise issuance is unresolved.
|
||||
@@ -115,6 +157,10 @@ export async function authorizeSubscriptionReference(
|
||||
})
|
||||
}
|
||||
|
||||
if (action === 'upgrade-subscription') {
|
||||
await assertNoBoundIncompleteSubscription(referenceId)
|
||||
}
|
||||
|
||||
if (action === 'upgrade-subscription' && (await hasPaidSubscription(referenceId))) {
|
||||
logger.warn('Blocking checkout - active subscription already exists for organization', {
|
||||
userId,
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockAtomicallyClaim, mockRelease, mockIdempotencyService } = vi.hoisted(() => ({
|
||||
mockAtomicallyClaim: vi.fn(),
|
||||
mockRelease: vi.fn(),
|
||||
mockIdempotencyService: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/idempotency/service', () => ({
|
||||
IdempotencyService: class MockIdempotencyService {
|
||||
constructor(options: unknown) {
|
||||
mockIdempotencyService(options)
|
||||
}
|
||||
|
||||
atomicallyClaim(...args: unknown[]) {
|
||||
return mockAtomicallyClaim(...args)
|
||||
}
|
||||
|
||||
release(...args: unknown[]) {
|
||||
return mockRelease(...args)
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
import {
|
||||
claimCheckoutAdmission,
|
||||
releaseCheckoutAdmission,
|
||||
resolveCheckoutReferenceId,
|
||||
} from '@/lib/billing/checkout-admission'
|
||||
|
||||
describe('checkout admission', () => {
|
||||
beforeEach(() => {
|
||||
mockAtomicallyClaim.mockReset()
|
||||
mockRelease.mockReset()
|
||||
mockRelease.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('uses durable, short-lived database claims', () => {
|
||||
expect(mockIdempotencyService).toHaveBeenCalledWith({
|
||||
namespace: 'billing-checkout-admission',
|
||||
ttlSeconds: 120,
|
||||
inProgressTtlSeconds: 120,
|
||||
retryFailures: true,
|
||||
storeResultBody: false,
|
||||
forceStorage: 'database',
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves explicit, organization, and personal references like Better Auth', () => {
|
||||
expect(
|
||||
resolveCheckoutReferenceId({ referenceId: 'org-explicit' }, 'user-1', 'org-active')
|
||||
).toBe('org-explicit')
|
||||
expect(
|
||||
resolveCheckoutReferenceId({ customerType: 'organization' }, 'user-1', 'org-active')
|
||||
).toBe('org-active')
|
||||
expect(resolveCheckoutReferenceId({}, 'user-1', 'org-active')).toBe('user-1')
|
||||
})
|
||||
|
||||
it('admits only one overlapping checkout for a billing reference', async () => {
|
||||
mockAtomicallyClaim
|
||||
.mockResolvedValueOnce({
|
||||
claimed: true,
|
||||
normalizedKey: 'billing-checkout-admission:stripe:org-1',
|
||||
storageMethod: 'database',
|
||||
claimToken: 'claim-1',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
claimed: false,
|
||||
normalizedKey: 'billing-checkout-admission:stripe:org-1',
|
||||
storageMethod: 'database',
|
||||
existingResult: { status: 'in-progress' },
|
||||
})
|
||||
|
||||
const firstClaim = await claimCheckoutAdmission('org-1')
|
||||
await expect(claimCheckoutAdmission('org-1')).rejects.toThrow(
|
||||
/checkout is already being started/
|
||||
)
|
||||
expect(firstClaim.claimToken).toBe('claim-1')
|
||||
})
|
||||
|
||||
it('releases only the claim owned by this request', async () => {
|
||||
await releaseCheckoutAdmission({
|
||||
normalizedKey: 'billing-checkout-admission:stripe:org-1',
|
||||
storageMethod: 'database',
|
||||
claimToken: 'claim-1',
|
||||
})
|
||||
|
||||
expect(mockRelease).toHaveBeenCalledWith(
|
||||
'billing-checkout-admission:stripe:org-1',
|
||||
'database',
|
||||
'claim-1'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not replace a successful checkout response when release fails', async () => {
|
||||
mockRelease.mockRejectedValueOnce(new Error('database unavailable'))
|
||||
|
||||
await expect(
|
||||
releaseCheckoutAdmission({
|
||||
normalizedKey: 'billing-checkout-admission:stripe:org-1',
|
||||
storageMethod: 'database',
|
||||
claimToken: 'claim-1',
|
||||
})
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { APIError } from 'better-auth/api'
|
||||
import { type AtomicClaimResult, IdempotencyService } from '@/lib/core/idempotency/service'
|
||||
|
||||
const logger = createLogger('BillingCheckoutAdmission')
|
||||
|
||||
const CHECKOUT_ADMISSION_LEASE_SECONDS = 2 * 60
|
||||
|
||||
const checkoutAdmissionLeases = new IdempotencyService({
|
||||
namespace: 'billing-checkout-admission',
|
||||
ttlSeconds: CHECKOUT_ADMISSION_LEASE_SECONDS,
|
||||
inProgressTtlSeconds: CHECKOUT_ADMISSION_LEASE_SECONDS,
|
||||
retryFailures: true,
|
||||
storeResultBody: false,
|
||||
forceStorage: 'database',
|
||||
})
|
||||
|
||||
export interface CheckoutAdmissionClaim {
|
||||
normalizedKey: string
|
||||
storageMethod: AtomicClaimResult['storageMethod']
|
||||
claimToken: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the billing reference using the same precedence as Better Auth's
|
||||
* Stripe `referenceMiddleware`: an explicit reference wins, otherwise an
|
||||
* organization checkout uses the active organization and a personal checkout
|
||||
* uses the session user.
|
||||
*/
|
||||
export function resolveCheckoutReferenceId(
|
||||
body: { referenceId?: unknown; customerType?: unknown },
|
||||
sessionUserId: string,
|
||||
activeOrganizationId: string | null
|
||||
): string | null {
|
||||
if (body.referenceId) {
|
||||
return typeof body.referenceId === 'string' ? body.referenceId : null
|
||||
}
|
||||
if (body.customerType === 'organization') return activeOrganizationId
|
||||
return sessionUserId
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically reserves one in-flight checkout request per billing reference.
|
||||
* The claim spans Better Auth's local-row preparation and Stripe Checkout
|
||||
* creation, closing the window where two requests could both pass a read-only
|
||||
* admission check before either one bound a subscription.
|
||||
*/
|
||||
export async function claimCheckoutAdmission(referenceId: string): Promise<CheckoutAdmissionClaim> {
|
||||
const claim = await checkoutAdmissionLeases.atomicallyClaim('stripe', referenceId)
|
||||
if (!claim.claimed) {
|
||||
logger.warn('Blocking concurrent subscription checkout', { referenceId })
|
||||
throw new APIError('CONFLICT', {
|
||||
message:
|
||||
'A subscription checkout is already being started. Please wait a moment and try again.',
|
||||
})
|
||||
}
|
||||
if (!claim.claimToken) {
|
||||
throw new Error('Checkout admission claim is missing its fencing token')
|
||||
}
|
||||
return {
|
||||
normalizedKey: claim.normalizedKey,
|
||||
storageMethod: claim.storageMethod,
|
||||
claimToken: claim.claimToken,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases a checkout admission claim without masking the endpoint result.
|
||||
* A failed release is bounded by the claim's short lease and remains visible
|
||||
* in logs for operators.
|
||||
*/
|
||||
export async function releaseCheckoutAdmission(claim: CheckoutAdmissionClaim): Promise<void> {
|
||||
try {
|
||||
await checkoutAdmissionLeases.release(
|
||||
claim.normalizedKey,
|
||||
claim.storageMethod,
|
||||
claim.claimToken
|
||||
)
|
||||
} catch (error) {
|
||||
logger.warn('Failed to release checkout admission claim; lease will expire', {
|
||||
normalizedKey: claim.normalizedKey,
|
||||
error: getErrorMessage(error, 'Unknown error'),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,10 @@ const mocks = vi.hoisted(() => ({
|
||||
subscriptionsRetrieve: vi.fn(),
|
||||
patchOutboxEventPayload: vi.fn(),
|
||||
reapplyPaidOrgJoinBillingForExistingMemberTx: vi.fn(),
|
||||
acquireUserBillingIdentityLock: vi.fn(),
|
||||
acquireInvitationMutationLocks: vi.fn(),
|
||||
attachOwnedWorkspacesToOrganizationTx: vi.fn(),
|
||||
invalidateWorkspaceTableLimitsCache: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/audit', () => ({
|
||||
@@ -35,6 +39,10 @@ vi.mock('@/lib/billing/organizations/membership', () => ({
|
||||
reapplyPaidOrgJoinBillingForExistingMemberTx: mocks.reapplyPaidOrgJoinBillingForExistingMemberTx,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/organizations/billing-identity-lock', () => ({
|
||||
acquireUserBillingIdentityLock: mocks.acquireUserBillingIdentityLock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/stripe-client', () => ({
|
||||
requireStripeClient: () => ({
|
||||
subscriptions: { retrieve: mocks.subscriptionsRetrieve },
|
||||
@@ -64,6 +72,10 @@ vi.mock('@/lib/core/outbox/service', () => ({
|
||||
patchOutboxEventPayload: mocks.patchOutboxEventPayload,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/invitations/locks', () => ({
|
||||
acquireInvitationMutationLocks: mocks.acquireInvitationMutationLocks,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/messaging/email/mailer', () => ({
|
||||
sendEmail: vi.fn(),
|
||||
}))
|
||||
@@ -76,6 +88,15 @@ vi.mock('@/lib/posthog/server', () => ({
|
||||
captureServerEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/table/billing', () => ({
|
||||
invalidateWorkspaceTableLimitsCache: mocks.invalidateWorkspaceTableLimitsCache,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/organization-workspaces', () => ({
|
||||
attachOwnedWorkspacesToOrganizationTx: mocks.attachOwnedWorkspacesToOrganizationTx,
|
||||
ownedAttachableWorkspacesWhere: vi.fn(),
|
||||
}))
|
||||
|
||||
import { handleManualEnterpriseSubscription } from '@/lib/billing/webhooks/enterprise'
|
||||
|
||||
const ENTERPRISE_PROVISION_EVENT_TYPE = 'stripe.provision-enterprise'
|
||||
@@ -159,9 +180,18 @@ function eventFor(subscription: Stripe.Subscription): Stripe.Event {
|
||||
|
||||
function queueSuccessfulExistingSubscriptionReconciliation(options: {
|
||||
operation?: ReturnType<typeof operationPayload>
|
||||
workspaceIds?: string[]
|
||||
}) {
|
||||
queueTableRows(schemaMock.organization, [{ creditBalance: '0' }])
|
||||
if (options.operation) {
|
||||
queueTableRows(schemaMock.outboxEvent, [
|
||||
{ eventType: ENTERPRISE_PROVISION_EVENT_TYPE, payload: options.operation },
|
||||
])
|
||||
if (!('applicationResult' in options.operation)) {
|
||||
const workspaceRows = (options.workspaceIds ?? []).map((id) => ({ id }))
|
||||
queueTableRows(schemaMock.workspace, workspaceRows)
|
||||
queueTableRows(schemaMock.workspace, workspaceRows)
|
||||
}
|
||||
queueTableRows(schemaMock.outboxEvent, [
|
||||
{ eventType: ENTERPRISE_PROVISION_EVENT_TYPE, payload: options.operation },
|
||||
])
|
||||
@@ -180,6 +210,12 @@ describe('Enterprise webhook issuance correlation', () => {
|
||||
resetDbChainMock()
|
||||
mocks.patchOutboxEventPayload.mockResolvedValue(true)
|
||||
mocks.reapplyPaidOrgJoinBillingForExistingMemberTx.mockResolvedValue(undefined)
|
||||
mocks.attachOwnedWorkspacesToOrganizationTx.mockResolvedValue({
|
||||
attachedWorkspaceIds: [],
|
||||
addedMemberIds: [],
|
||||
skippedMembers: [],
|
||||
usageLimitUserIds: [],
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
@@ -189,6 +225,13 @@ describe('Enterprise webhook issuance correlation', () => {
|
||||
it('retries when the create webhook races ahead of paused-collection provisioning', async () => {
|
||||
const subscription = stripeSubscription({ operationId: 'operation-1', paused: false })
|
||||
mocks.subscriptionsRetrieve.mockResolvedValue(subscription)
|
||||
queueTableRows(schemaMock.outboxEvent, [
|
||||
{
|
||||
eventType: ENTERPRISE_PROVISION_EVENT_TYPE,
|
||||
payload: operationPayload({ pausePaymentCollection: true }),
|
||||
},
|
||||
])
|
||||
queueTableRows(schemaMock.workspace, [])
|
||||
queueTableRows(schemaMock.organization, [{ creditBalance: '0' }])
|
||||
queueTableRows(schemaMock.outboxEvent, [
|
||||
{
|
||||
@@ -207,6 +250,66 @@ describe('Enterprise webhook issuance correlation', () => {
|
||||
expect(mocks.reapplyPaidOrgJoinBillingForExistingMemberTx).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sweeps the Enterprise owner personal workspaces when issuance is applied', async () => {
|
||||
const subscription = stripeSubscription({ operationId: 'operation-1', paused: false })
|
||||
mocks.subscriptionsRetrieve.mockResolvedValue(subscription)
|
||||
queueSuccessfulExistingSubscriptionReconciliation({
|
||||
operation: operationPayload(),
|
||||
workspaceIds: ['workspace-1', 'workspace-archived'],
|
||||
})
|
||||
mocks.attachOwnedWorkspacesToOrganizationTx.mockResolvedValueOnce({
|
||||
attachedWorkspaceIds: ['workspace-1', 'workspace-archived'],
|
||||
addedMemberIds: [],
|
||||
skippedMembers: [],
|
||||
usageLimitUserIds: [],
|
||||
})
|
||||
|
||||
await expect(
|
||||
handleManualEnterpriseSubscription(eventFor(subscription))
|
||||
).resolves.toBeUndefined()
|
||||
|
||||
expect(mocks.acquireInvitationMutationLocks).toHaveBeenCalledWith(expect.anything(), {
|
||||
invitationIds: [],
|
||||
workspaceIds: ['workspace-1', 'workspace-archived'],
|
||||
})
|
||||
expect(mocks.acquireUserBillingIdentityLock).toHaveBeenCalledWith(expect.anything(), 'owner-1')
|
||||
expect(mocks.attachOwnedWorkspacesToOrganizationTx).toHaveBeenCalledWith(expect.anything(), {
|
||||
ownerUserId: 'owner-1',
|
||||
organizationId: 'org-1',
|
||||
workspaceIds: ['workspace-1', 'workspace-archived'],
|
||||
externalMemberPolicy: 'external-all',
|
||||
ownerMatch: 'owner',
|
||||
includeArchived: true,
|
||||
})
|
||||
expect(mocks.invalidateWorkspaceTableLimitsCache).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.patchOutboxEventPayload).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retries without applying when the Enterprise owner workspace set changes', async () => {
|
||||
const subscription = stripeSubscription({ operationId: 'operation-1', paused: false })
|
||||
mocks.subscriptionsRetrieve.mockResolvedValue(subscription)
|
||||
queueTableRows(schemaMock.outboxEvent, [
|
||||
{ eventType: ENTERPRISE_PROVISION_EVENT_TYPE, payload: operationPayload() },
|
||||
])
|
||||
queueTableRows(schemaMock.workspace, [{ id: 'workspace-1' }])
|
||||
queueTableRows(schemaMock.organization, [{ creditBalance: '0' }])
|
||||
queueTableRows(schemaMock.outboxEvent, [
|
||||
{ eventType: ENTERPRISE_PROVISION_EVENT_TYPE, payload: operationPayload() },
|
||||
])
|
||||
queueTableRows(schemaMock.user, [{ stripeCustomerId: 'cus_1' }])
|
||||
queueTableRows(schemaMock.member, [{ value: 1 }])
|
||||
queueTableRows(schemaMock.subscription, [])
|
||||
queueTableRows(schemaMock.subscription, [{ id: 'local-sub-1', referenceId: 'org-1' }])
|
||||
queueTableRows(schemaMock.workspace, [{ id: 'workspace-1' }, { id: 'workspace-2' }])
|
||||
|
||||
await expect(handleManualEnterpriseSubscription(eventFor(subscription))).rejects.toThrow(
|
||||
'personal workspaces changed during reconciliation'
|
||||
)
|
||||
|
||||
expect(mocks.attachOwnedWorkspacesToOrganizationTx).not.toHaveBeenCalled()
|
||||
expect(mocks.patchOutboxEventPayload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows later Stripe metadata edits after the issuance was already applied', async () => {
|
||||
const subscription = stripeSubscription({ operationId: 'operation-1', paused: false })
|
||||
mocks.subscriptionsRetrieve.mockResolvedValue(subscription)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { member, organization, outboxEvent, subscription, user } from '@sim/db/schema'
|
||||
import { member, organization, outboxEvent, subscription, user, workspace } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, asc, count, eq, inArray, sql } from 'drizzle-orm'
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
enterpriseOperationMatchesStripeSubscription,
|
||||
parseEnterpriseProvisionPayload,
|
||||
} from '@/lib/billing/enterprise-outbox'
|
||||
import { acquireUserBillingIdentityLock } from '@/lib/billing/organizations/billing-identity-lock'
|
||||
import {
|
||||
acquireOrganizationMutationLock,
|
||||
reapplyPaidOrgJoinBillingForExistingMemberTx,
|
||||
@@ -26,13 +27,67 @@ import {
|
||||
} from '@/lib/billing/webhooks/enterprise-reconciliation-lease'
|
||||
import { stripeWebhookIdempotency } from '@/lib/billing/webhooks/idempotency'
|
||||
import { patchOutboxEventPayload } from '@/lib/core/outbox/service'
|
||||
import { acquireInvitationMutationLocks } from '@/lib/invitations/locks'
|
||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
import { getFromEmailAddress } from '@/lib/messaging/email/utils'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { invalidateWorkspaceTableLimitsCache } from '@/lib/table/billing'
|
||||
import {
|
||||
attachOwnedWorkspacesToOrganizationTx,
|
||||
ownedAttachableWorkspacesWhere,
|
||||
} from '@/lib/workspaces/organization-workspaces'
|
||||
import { parseEnterpriseSubscriptionMetadata } from '../types'
|
||||
|
||||
const logger = createLogger('BillingEnterprise')
|
||||
|
||||
interface EnterpriseWorkspaceSweepPlan {
|
||||
operationId: string
|
||||
ownerUserId: string
|
||||
workspaceIds: string[]
|
||||
}
|
||||
|
||||
function sameOrderedIds(left: string[], right: string[]): boolean {
|
||||
return left.length === right.length && left.every((id, index) => id === right[index])
|
||||
}
|
||||
|
||||
async function planEnterpriseOwnerWorkspaceSweep(
|
||||
metadata: Stripe.Metadata,
|
||||
organizationId: string
|
||||
): Promise<EnterpriseWorkspaceSweepPlan | null> {
|
||||
const operationId = metadata.enterpriseOperationId
|
||||
if (!operationId) return null
|
||||
|
||||
const [operationRow] = await db
|
||||
.select({ eventType: outboxEvent.eventType, payload: outboxEvent.payload })
|
||||
.from(outboxEvent)
|
||||
.where(eq(outboxEvent.id, operationId))
|
||||
.limit(1)
|
||||
const payload = operationRow ? parseEnterpriseProvisionPayload(operationRow.payload) : null
|
||||
if (
|
||||
operationRow?.eventType !== ENTERPRISE_PROVISION_EVENT_TYPE ||
|
||||
!payload ||
|
||||
payload.applicationResult ||
|
||||
payload.request.organizationId !== organizationId
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const workspaceIds = (
|
||||
await db
|
||||
.select({ id: workspace.id })
|
||||
.from(workspace)
|
||||
.where(
|
||||
ownedAttachableWorkspacesWhere({
|
||||
userId: payload.request.ownerUserId,
|
||||
includeArchived: true,
|
||||
})
|
||||
)
|
||||
.orderBy(workspace.id)
|
||||
).map((row) => row.id)
|
||||
|
||||
return { operationId, ownerUserId: payload.request.ownerUserId, workspaceIds }
|
||||
}
|
||||
|
||||
export async function handleManualEnterpriseSubscription(event: Stripe.Event) {
|
||||
return stripeWebhookIdempotency.executeWithIdempotency(
|
||||
'manual-enterprise-subscription',
|
||||
@@ -115,6 +170,7 @@ async function reconcileManualEnterpriseSubscription(
|
||||
}
|
||||
|
||||
const { seats, monthlyPrice } = enterpriseMetadata
|
||||
const workspaceSweepPlan = await planEnterpriseOwnerWorkspaceSweep(metadata, referenceId)
|
||||
|
||||
// Get the first subscription item which contains the period information
|
||||
const referenceItem = stripeSubscription.items?.data?.[0]
|
||||
@@ -148,6 +204,12 @@ async function reconcileManualEnterpriseSubscription(
|
||||
}
|
||||
|
||||
const coreResult = await db.transaction(async (tx) => {
|
||||
if (workspaceSweepPlan && workspaceSweepPlan.workspaceIds.length > 0) {
|
||||
await acquireInvitationMutationLocks(tx, {
|
||||
invitationIds: [],
|
||||
workspaceIds: workspaceSweepPlan.workspaceIds,
|
||||
})
|
||||
}
|
||||
await acquireOrganizationMutationLock(tx, referenceId)
|
||||
await tx.execute(
|
||||
sql`select pg_advisory_xact_lock(hashtextextended(${`stripe-subscription:${stripeSubscription.id}`}, 0))`
|
||||
@@ -273,7 +335,7 @@ async function reconcileManualEnterpriseSubscription(
|
||||
|
||||
if (existing && existing.referenceId !== referenceId) {
|
||||
throw new Error(
|
||||
`Stripe subscription ${stripeSubscription.id} is already bound to organization ${existing.referenceId}`
|
||||
`Stripe subscription ${stripeSubscription.id} is already bound to reference ${existing.referenceId}, not organization ${referenceId}`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -315,6 +377,46 @@ async function reconcileManualEnterpriseSubscription(
|
||||
})
|
||||
.where(eq(organization.id, referenceId))
|
||||
|
||||
let attachedWorkspaceIds: string[] = []
|
||||
if (operationNewlyApplied && correlatedOperation) {
|
||||
if (
|
||||
!workspaceSweepPlan ||
|
||||
workspaceSweepPlan.operationId !== operationId ||
|
||||
workspaceSweepPlan.ownerUserId !== correlatedOperation.request.ownerUserId
|
||||
) {
|
||||
throw new Error('Unable to establish the Enterprise owner workspace sweep')
|
||||
}
|
||||
|
||||
await acquireUserBillingIdentityLock(tx, workspaceSweepPlan.ownerUserId)
|
||||
const currentWorkspaceIds = (
|
||||
await tx
|
||||
.select({ id: workspace.id })
|
||||
.from(workspace)
|
||||
.where(
|
||||
ownedAttachableWorkspacesWhere({
|
||||
userId: workspaceSweepPlan.ownerUserId,
|
||||
includeArchived: true,
|
||||
})
|
||||
)
|
||||
.orderBy(workspace.id)
|
||||
).map((row) => row.id)
|
||||
if (!sameOrderedIds(workspaceSweepPlan.workspaceIds, currentWorkspaceIds)) {
|
||||
throw new Error(
|
||||
'Enterprise owner personal workspaces changed during reconciliation; retry the webhook'
|
||||
)
|
||||
}
|
||||
|
||||
const attached = await attachOwnedWorkspacesToOrganizationTx(tx, {
|
||||
ownerUserId: workspaceSweepPlan.ownerUserId,
|
||||
organizationId: referenceId,
|
||||
workspaceIds: currentWorkspaceIds,
|
||||
externalMemberPolicy: 'external-all',
|
||||
ownerMatch: 'owner',
|
||||
includeArchived: true,
|
||||
})
|
||||
attachedWorkspaceIds = attached.attachedWorkspaceIds
|
||||
}
|
||||
|
||||
// The organization lock is held across the census and all member billing
|
||||
// transitions. Add/remove/accept paths take the same lock, so a departing
|
||||
// member cannot be re-paused after their removal restores personal Pro.
|
||||
@@ -346,6 +448,7 @@ async function reconcileManualEnterpriseSubscription(
|
||||
operationNewlyApplied,
|
||||
hasCorrelatedOperation: Boolean(correlatedOperation),
|
||||
subscriptionNewlyInserted: !existing,
|
||||
attachedWorkspaceIds,
|
||||
...creditLimits,
|
||||
}
|
||||
})
|
||||
@@ -357,10 +460,14 @@ async function reconcileManualEnterpriseSubscription(
|
||||
operationNewlyApplied,
|
||||
hasCorrelatedOperation,
|
||||
subscriptionNewlyInserted,
|
||||
attachedWorkspaceIds,
|
||||
configuredUsageLimitCredits,
|
||||
prepaidCredits,
|
||||
effectiveUsageLimitCredits,
|
||||
} = coreResult
|
||||
for (const workspaceId of attachedWorkspaceIds) {
|
||||
invalidateWorkspaceTableLimitsCache(workspaceId)
|
||||
}
|
||||
const shouldAnnounce = hasCorrelatedOperation ? operationNewlyApplied : subscriptionNewlyInserted
|
||||
|
||||
logger.info('[subscription.created] Upserted enterprise subscription', {
|
||||
@@ -372,6 +479,7 @@ async function reconcileManualEnterpriseSubscription(
|
||||
effectiveUsageLimitCredits,
|
||||
prepaidCredits,
|
||||
seats,
|
||||
attachedWorkspaceCount: attachedWorkspaceIds.length,
|
||||
note: 'Seats from metadata, Stripe quantity set to 1',
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user