@@ -8,9 +8,9 @@ import * as schema from '@sim/db/schema'
88import { createLogger } from '@sim/logger'
99import { toError } from '@sim/utils/errors'
1010import { generateId } from '@sim/utils/id'
11- import { betterAuth , type User } from 'better-auth'
11+ import { type BetterAuthOptions , betterAuth , type User } from 'better-auth'
1212import { drizzleAdapter } from 'better-auth/adapters/drizzle'
13- import { APIError , createAuthMiddleware } from 'better-auth/api'
13+ import { APIError , createAuthMiddleware , getSessionFromCtx } from 'better-auth/api'
1414import { nextCookies } from 'better-auth/next-js'
1515import {
1616 admin ,
@@ -34,8 +34,13 @@ import {
3434import { getAccessControlConfig , isEmailBlockedByAccessControl } from '@/lib/auth/access-control'
3535import { createAnonymousSession , ensureAnonymousUserExists } from '@/lib/auth/anonymous'
3636import { getRequestedSignInProviderId , isSignInProviderAllowed } from '@/lib/auth/constants'
37+ import { guardSubscriptionPlanWrites } from '@/lib/auth/stripe-adapter-guard'
3738import { sendPlanWelcomeEmail } from '@/lib/billing'
38- import { authorizeSubscriptionReference } from '@/lib/billing/authorization'
39+ import {
40+ assertPersonalCheckoutAllowed ,
41+ authorizeSubscriptionReference ,
42+ isPersonalCheckoutRequest ,
43+ } from '@/lib/billing/authorization'
3944import {
4045 getOrganizationIdForSubscriptionReference ,
4146 syncSubscriptionPlan ,
@@ -46,7 +51,8 @@ import {
4651 ensureOrganizationForTeamSubscription ,
4752 syncSubscriptionUsageLimits ,
4853} from '@/lib/billing/organization'
49- import { isTeam } from '@/lib/billing/plan-helpers'
54+ import { pauseProSubscriptionForOrgCoverage } from '@/lib/billing/organizations/membership'
55+ import { isPro , isTeam } from '@/lib/billing/plan-helpers'
5056import { getPlans , resolvePlanFromStripeSubscription } from '@/lib/billing/plans'
5157import { syncSeatsFromStripeQuantity } from '@/lib/billing/validation/seat-management'
5258import { handleAbandonedCheckout } from '@/lib/billing/webhooks/checkout'
@@ -58,7 +64,6 @@ import {
5864 handleInvoicePaymentSucceeded ,
5965} from '@/lib/billing/webhooks/invoices'
6066import {
61- handleOrganizationPlanDowngrade ,
6267 handleSubscriptionCreated ,
6368 handleSubscriptionDeleted ,
6469} from '@/lib/billing/webhooks/subscription'
@@ -193,10 +198,13 @@ export const auth = betterAuth({
193198 ...( env . NEXT_PUBLIC_SOCKET_URL ? [ env . NEXT_PUBLIC_SOCKET_URL ] : [ ] ) ,
194199 ...additionalTrustedOrigins ,
195200 ] . filter ( Boolean ) ,
196- database : drizzleAdapter ( db , {
197- provider : 'pg' ,
198- schema,
199- } ) ,
201+ database : ( options : BetterAuthOptions ) =>
202+ guardSubscriptionPlanWrites (
203+ drizzleAdapter ( db , {
204+ provider : 'pg' ,
205+ schema,
206+ } ) ( options )
207+ ) ,
200208 session : {
201209 cookieCache : {
202210 enabled : true ,
@@ -903,6 +911,21 @@ export const auth = betterAuth({
903911 }
904912 }
905913
914+ /**
915+ * Personal checkout guard. The Stripe plugin's `authorizeReference`
916+ * only runs for organization references (it skips references equal to
917+ * the session user), so duplicate-coverage enforcement for personal
918+ * checkouts lives here: a member of an org with an entitled paid
919+ * subscription must not buy a personal plan on top of it.
920+ */
921+ if ( isBillingEnabled && ctx . path === '/subscription/upgrade' ) {
922+ const session = await getSessionFromCtx ( ctx )
923+ const sessionUserId = session ?. user ?. id
924+ if ( sessionUserId && isPersonalCheckoutRequest ( ctx . body ?? { } , sessionUserId ) ) {
925+ await assertPersonalCheckoutAllowed ( sessionUserId )
926+ }
927+ }
928+
906929 return
907930 } ) ,
908931 } ,
@@ -3140,8 +3163,21 @@ export const auth = betterAuth({
31403163 subscription : {
31413164 enabled : true ,
31423165 plans : getPlans ( ) ,
3143- authorizeReference : async ( { user, referenceId, action } ) => {
3144- return await authorizeSubscriptionReference ( user . id , referenceId , action )
3166+ authorizeReference : async ( { user, referenceId, action } , ctx ) => {
3167+ const body : unknown = ctx ?. body
3168+ const requestedPlan =
3169+ typeof body === 'object' &&
3170+ body !== null &&
3171+ 'plan' in body &&
3172+ typeof body . plan === 'string'
3173+ ? body . plan
3174+ : undefined
3175+ return await authorizeSubscriptionReference (
3176+ user . id ,
3177+ referenceId ,
3178+ action ,
3179+ requestedPlan
3180+ )
31453181 } ,
31463182 getCheckoutSessionParams : async ( ) => ( {
31473183 params : { allow_promotion_codes : true } ,
@@ -3175,11 +3211,16 @@ export const auth = betterAuth({
31753211 )
31763212 }
31773213
3178- await syncSubscriptionPlan ( subscription . id , subscription . plan , planFromStripe )
3214+ const syncedPlan = await syncSubscriptionPlan (
3215+ subscription . id ,
3216+ subscription . plan ,
3217+ planFromStripe ,
3218+ subscription . referenceId
3219+ )
31793220
31803221 const subscriptionForOrg = {
31813222 ...subscription ,
3182- plan : planFromStripe ?? subscription . plan ,
3223+ plan : syncedPlan ?? subscription . plan ,
31833224 enterpriseOperationId : stripeSubscription . metadata ?. enterpriseOperationId ?? null ,
31843225 }
31853226
@@ -3202,7 +3243,29 @@ export const auth = betterAuth({
32023243 throw orgError
32033244 }
32043245
3205- await handleSubscriptionCreated ( resolvedSubscription , event . id )
3246+ /**
3247+ * Transactional fence behind the personal-checkout admission
3248+ * guard: if the user joined a paid organization while their
3249+ * checkout was in flight, pause the fresh personal Pro at
3250+ * period end (same state a paid-org joiner's personal Pro
3251+ * enters; restored automatically if they leave the org).
3252+ *
3253+ * Runs BEFORE the free→paid transition handling: a personal
3254+ * subscription born covered is not a free→paid transition —
3255+ * the org plan keeps governing the user — so the usage reset
3256+ * (which would wipe org-attributed current-period usage) and
3257+ * its instrumentation must not run. Gated on `covered`, not
3258+ * `paused`, so event retries decide identically even when the
3259+ * join path already paused the subscription.
3260+ */
3261+ const coveredByOrganization = isPro ( resolvedSubscription . plan )
3262+ ? ( await pauseProSubscriptionForOrgCoverage ( resolvedSubscription . referenceId ) )
3263+ . covered
3264+ : false
3265+
3266+ if ( ! coveredByOrganization ) {
3267+ await handleSubscriptionCreated ( resolvedSubscription , event . id )
3268+ }
32063269
32073270 await syncSubscriptionUsageLimits ( resolvedSubscription )
32083271
@@ -3238,8 +3301,6 @@ export const auth = betterAuth({
32383301 const isUpgradeToTeam =
32393302 isTeamPlan && ! isTeam ( subscription . plan ) && referenceOrganizationId == null
32403303
3241- const effectivePlanForTeamFeatures = planFromStripe ?? subscription . plan
3242-
32433304 logger . info ( '[onSubscriptionUpdate] Subscription updated' , {
32443305 subscriptionId : subscription . id ,
32453306 status : subscription . status ,
@@ -3258,11 +3319,24 @@ export const auth = betterAuth({
32583319 )
32593320 }
32603321
3261- await syncSubscriptionPlan ( subscription . id , subscription . plan , planFromStripe )
3322+ const syncedPlan = await syncSubscriptionPlan (
3323+ subscription . id ,
3324+ subscription . plan ,
3325+ planFromStripe ,
3326+ subscription . referenceId
3327+ )
3328+
3329+ /**
3330+ * All downstream processing keys off the plan the DB actually
3331+ * holds after the sync — a plan write refused by the org/plan
3332+ * invariant must not leak the rejected Stripe plan into org
3333+ * resolution, seat sync, or usage limits.
3334+ */
3335+ const effectivePlanForTeamFeatures = syncedPlan ?? subscription . plan
32623336
32633337 const subscriptionForOrg = {
32643338 ...subscription ,
3265- plan : planFromStripe ?? subscription . plan ,
3339+ plan : effectivePlanForTeamFeatures ,
32663340 enterpriseOperationId : stripeSubscription . metadata ?. enterpriseOperationId ?? null ,
32673341 }
32683342
@@ -3333,16 +3407,6 @@ export const auth = betterAuth({
33333407 error,
33343408 } )
33353409 }
3336- } else {
3337- await handleOrganizationPlanDowngrade (
3338- {
3339- id : resolvedSubscription . id ,
3340- plan : effectivePlanForTeamFeatures ?? null ,
3341- referenceId : resolvedSubscription . referenceId ,
3342- status : resolvedSubscription . status ?? null ,
3343- } ,
3344- event . id
3345- )
33463410 }
33473411
33483412 await writeBillingInterval ( resolvedSubscription . id , isAnnual ? 'year' : 'month' )
0 commit comments