77import { Router , Request } from 'express' ;
88import { getBillingService , getAllPlans , getPlan , comparePlans } from '../billing/index.js' ;
99import type { SubscriptionTier } from '../billing/types.js' ;
10- import { getConfig } from '../config.js' ;
11- import { db } from '../db/index.js' ;
10+ import { getConfig , isAdminUser } from '../config.js' ;
11+ import { db , type PlanType } from '../db/index.js' ;
1212import { requireAuth } from './auth.js' ;
13+ import { getProvisioner , RESOURCE_TIERS } from '../provisioner/index.js' ;
14+ import { getResourceTierForPlan } from '../services/planLimits.js' ;
1315import type Stripe from 'stripe' ;
1416
1517export const billingRouter = Router ( ) ;
1618
19+ /**
20+ * Resize user's workspaces to match their new plan tier
21+ * Called after plan upgrade/downgrade to adjust compute resources
22+ *
23+ * Strategy:
24+ * - Stopped workspaces: Resize immediately (no disruption)
25+ * - Running workspaces: Save config for next restart (no agent disruption)
26+ *
27+ * User can manually restart to get new resources immediately, or wait for
28+ * natural restart (auto-stop idle, manual restart, etc.)
29+ */
30+ async function resizeWorkspacesForPlan ( userId : string , newPlan : PlanType ) : Promise < void > {
31+ try {
32+ const workspaces = await db . workspaces . findByUserId ( userId ) ;
33+ if ( workspaces . length === 0 ) return ;
34+
35+ const provisioner = getProvisioner ( ) ;
36+ const targetTierName = getResourceTierForPlan ( newPlan ) ;
37+ const targetTier = RESOURCE_TIERS [ targetTierName ] ;
38+
39+ console . log ( `[billing] Upgrading ${ workspaces . length } workspace(s) for user ${ userId . substring ( 0 , 8 ) } to ${ targetTierName } ` ) ;
40+
41+ for ( const workspace of workspaces ) {
42+ if ( workspace . status !== 'running' && workspace . status !== 'stopped' ) {
43+ console . log ( `[billing] Skipping workspace ${ workspace . id . substring ( 0 , 8 ) } (status: ${ workspace . status } )` ) ;
44+ continue ;
45+ }
46+
47+ try {
48+ // For running workspaces: don't restart, apply on next restart
49+ // This prevents disrupting active agents
50+ const skipRestart = workspace . status === 'running' ;
51+
52+ await provisioner . resize ( workspace . id , targetTier , skipRestart ) ;
53+
54+ if ( skipRestart ) {
55+ console . log ( `[billing] Queued resize for workspace ${ workspace . id . substring ( 0 , 8 ) } to ${ targetTierName } (will apply on next restart)` ) ;
56+ // TODO: Store pending upgrade in workspace metadata so we can show in UI
57+ } else {
58+ console . log ( `[billing] Resized workspace ${ workspace . id . substring ( 0 , 8 ) } to ${ targetTierName } ` ) ;
59+ }
60+ } catch ( error ) {
61+ console . error ( `[billing] Failed to resize workspace ${ workspace . id } :` , error ) ;
62+ // Continue with other workspaces even if one fails
63+ }
64+ }
65+ } catch ( error ) {
66+ console . error ( '[billing] Failed to resize workspaces:' , error ) ;
67+ }
68+ }
69+
1770/**
1871 * GET /api/billing/plans
1972 * Get all available billing plans
@@ -85,7 +138,6 @@ billingRouter.get('/compare', (req, res) => {
85138 */
86139billingRouter . get ( '/subscription' , requireAuth , async ( req , res ) => {
87140 const userId = req . session . userId ! ;
88- const billing = getBillingService ( ) ;
89141
90142 try {
91143 // Fetch user from database
@@ -94,6 +146,28 @@ billingRouter.get('/subscription', requireAuth, async (req, res) => {
94146 return res . status ( 404 ) . json ( { error : 'User not found' } ) ;
95147 }
96148
149+ // Admin users have special status - show their current plan without Stripe
150+ if ( isAdminUser ( user . githubUsername ) ) {
151+ return res . json ( {
152+ tier : user . plan || 'enterprise' ,
153+ subscription : null ,
154+ customer : null ,
155+ isAdmin : true ,
156+ } ) ;
157+ }
158+
159+ // If user doesn't have a Stripe customer ID and is on free tier, skip Stripe calls entirely
160+ // This prevents hanging on Stripe API calls for users who have never paid
161+ if ( ! user . stripeCustomerId && user . plan === 'free' ) {
162+ return res . json ( {
163+ tier : 'free' ,
164+ subscription : null ,
165+ customer : null ,
166+ } ) ;
167+ }
168+
169+ const billing = getBillingService ( ) ;
170+
97171 // Get or create Stripe customer
98172 const customerId = user . stripeCustomerId ||
99173 await billing . getOrCreateCustomer ( user . id , user . email || '' , user . githubUsername ) ;
@@ -150,7 +224,6 @@ billingRouter.post('/checkout', requireAuth, async (req, res) => {
150224 return ;
151225 }
152226
153- const billing = getBillingService ( ) ;
154227 const config = getConfig ( ) ;
155228
156229 try {
@@ -160,6 +233,26 @@ billingRouter.post('/checkout', requireAuth, async (req, res) => {
160233 return res . status ( 404 ) . json ( { error : 'User not found' } ) ;
161234 }
162235
236+ // Admin users get free upgrades - skip Stripe entirely
237+ if ( isAdminUser ( user . githubUsername ) ) {
238+ // Update user plan directly
239+ await db . users . update ( userId , { plan : tier } ) ;
240+ console . log ( `[billing] Admin user ${ user . githubUsername } upgraded to ${ tier } (free)` ) ;
241+
242+ // Resize workspaces to match new plan (async)
243+ resizeWorkspacesForPlan ( userId , tier as PlanType ) . catch ( ( err ) => {
244+ console . error ( `[billing] Failed to resize workspaces for admin ${ user . githubUsername } :` , err ) ;
245+ } ) ;
246+
247+ // Return a fake session that redirects to success
248+ return res . json ( {
249+ sessionId : 'admin-upgrade' ,
250+ checkoutUrl : `${ config . publicUrl } /billing/success?admin=true` ,
251+ } ) ;
252+ }
253+
254+ const billing = getBillingService ( ) ;
255+
163256 // Get or create customer
164257 const customerId = user . stripeCustomerId ||
165258 await billing . getOrCreateCustomer ( user . id , user . email || '' , user . githubUsername ) ;
@@ -370,9 +463,9 @@ billingRouter.get('/invoices', requireAuth, async (req, res) => {
370463 return res . status ( 404 ) . json ( { error : 'User not found' } ) ;
371464 }
372465
466+ // No Stripe customer = no invoices, skip Stripe call entirely
373467 if ( ! user . stripeCustomerId ) {
374- res . json ( { invoices : [ ] } ) ;
375- return ;
468+ return res . json ( { invoices : [ ] } ) ;
376469 }
377470
378471 const billing = getBillingService ( ) ;
@@ -466,11 +559,16 @@ billingRouter.post(
466559 // Extract subscription tier and update user's plan
467560 if ( billingEvent . userId ) {
468561 const subscription = billingEvent . data as unknown as Stripe . Subscription ;
469- const tier = billing . getTierFromSubscription ( subscription ) ;
562+ const tier = billing . getTierFromSubscription ( subscription ) as PlanType ;
470563
471564 // Update user's plan in database
472565 await db . users . update ( billingEvent . userId , { plan : tier } ) ;
473566 console . log ( `Updated user ${ billingEvent . userId } plan to: ${ tier } ` ) ;
567+
568+ // Resize workspaces to match new plan (async, don't block webhook)
569+ resizeWorkspacesForPlan ( billingEvent . userId , tier ) . catch ( ( err ) => {
570+ console . error ( `Failed to resize workspaces for user ${ billingEvent . userId } :` , err ) ;
571+ } ) ;
474572 } else {
475573 console . warn ( 'Subscription event received without userId:' , billingEvent . id ) ;
476574 }
@@ -482,6 +580,11 @@ billingRouter.post(
482580 if ( billingEvent . userId ) {
483581 await db . users . update ( billingEvent . userId , { plan : 'free' } ) ;
484582 console . log ( `User ${ billingEvent . userId } subscription canceled, reset to free plan` ) ;
583+
584+ // Resize workspaces down to free tier (async)
585+ resizeWorkspacesForPlan ( billingEvent . userId , 'free' ) . catch ( ( err ) => {
586+ console . error ( `Failed to resize workspaces for user ${ billingEvent . userId } :` , err ) ;
587+ } ) ;
485588 }
486589 break ;
487590 }
0 commit comments