From 2c0cbd5bf72e67ff4b48d880dce19f419660ff2b Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Mon, 19 Jan 2026 09:03:50 -0600 Subject: [PATCH 1/2] fix: sync subscription tier on admin override (refs #58) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add adminOverrideTier() method to SubscriptionsService that atomically updates both subscriptions.tier and user_profiles.subscription_tier using database transactions. Changes: - SubscriptionsRepository: Add updateTierByUserId() and createForAdminOverride() methods with transaction support - SubscriptionsService: Add adminOverrideTier() method with transaction wrapping for atomic dual-table updates - UsersController: Replace userProfileService.updateSubscriptionTier() with subscriptionsService.adminOverrideTier() This ensures admin tier changes properly sync to both database tables, fixing the Settings page "Current Plan" display mismatch. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../features/admin/api/users.controller.ts | 51 +++++++++-- .../data/subscriptions.repository.ts | 87 +++++++++++++++++++ .../domain/subscriptions.service.ts | 61 +++++++++++++ 3 files changed, 193 insertions(+), 6 deletions(-) diff --git a/backend/src/features/admin/api/users.controller.ts b/backend/src/features/admin/api/users.controller.ts index 525d737..80af1aa 100644 --- a/backend/src/features/admin/api/users.controller.ts +++ b/backend/src/features/admin/api/users.controller.ts @@ -7,6 +7,9 @@ import { FastifyRequest, FastifyReply } from 'fastify'; import { UserProfileService } from '../../user-profile/domain/user-profile.service'; import { UserProfileRepository } from '../../user-profile/data/user-profile.repository'; import { AdminRepository } from '../data/admin.repository'; +import { SubscriptionsService } from '../../subscriptions/domain/subscriptions.service'; +import { SubscriptionsRepository } from '../../subscriptions/data/subscriptions.repository'; +import { StripeClient } from '../../subscriptions/external/stripe/stripe.client'; import { pool } from '../../../core/config/database'; import { logger } from '../../../core/logging/logger'; import { @@ -28,15 +31,22 @@ import { AdminService } from '../domain/admin.service'; export class UsersController { private userProfileService: UserProfileService; private adminService: AdminService; + private subscriptionsService: SubscriptionsService; private userProfileRepository: UserProfileRepository; + private adminRepository: AdminRepository; constructor() { this.userProfileRepository = new UserProfileRepository(pool); - const adminRepository = new AdminRepository(pool); + this.adminRepository = new AdminRepository(pool); + const subscriptionsRepository = new SubscriptionsRepository(pool); + const stripeClient = new StripeClient(); this.userProfileService = new UserProfileService(this.userProfileRepository); - this.userProfileService.setAdminRepository(adminRepository); - this.adminService = new AdminService(adminRepository); + this.userProfileService.setAdminRepository(this.adminRepository); + this.adminService = new AdminService(this.adminRepository); + // Admin feature depends on Subscriptions for tier management + // This is intentional - admin has oversight capabilities + this.subscriptionsService = new SubscriptionsService(subscriptionsRepository, stripeClient, pool); } /** @@ -226,6 +236,8 @@ export class UsersController { /** * PATCH /api/admin/users/:auth0Sub/tier - Update subscription tier + * Uses subscriptionsService.adminOverrideTier() to sync both subscriptions.tier + * and user_profiles.subscription_tier atomically */ async updateTier( request: FastifyRequest<{ Params: UserAuth0SubInput; Body: UpdateTierInput }>, @@ -261,12 +273,39 @@ export class UsersController { const { auth0Sub } = paramsResult.data; const { subscriptionTier } = bodyResult.data; - const updatedUser = await this.userProfileService.updateSubscriptionTier( + // Verify user exists before attempting tier change + const currentUser = await this.userProfileService.getUserDetails(auth0Sub); + if (!currentUser) { + return reply.code(404).send({ + error: 'Not found', + message: 'User not found', + }); + } + + const previousTier = currentUser.subscriptionTier; + + // Use subscriptionsService to update both tables atomically + await this.subscriptionsService.adminOverrideTier(auth0Sub, subscriptionTier); + + // Log audit action + await this.adminRepository.logAuditAction( + actorId, + 'UPDATE_TIER', auth0Sub, - subscriptionTier, - actorId + 'user_profile', + currentUser.id, + { previousTier, newTier: subscriptionTier } ); + logger.info('User subscription tier updated via admin', { + auth0Sub, + previousTier, + newTier: subscriptionTier, + actorAuth0Sub: actorId, + }); + + // Return updated user profile + const updatedUser = await this.userProfileService.getUserDetails(auth0Sub); return reply.code(200).send(updatedUser); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; diff --git a/backend/src/features/subscriptions/data/subscriptions.repository.ts b/backend/src/features/subscriptions/data/subscriptions.repository.ts index 8a49de5..747820e 100644 --- a/backend/src/features/subscriptions/data/subscriptions.repository.ts +++ b/backend/src/features/subscriptions/data/subscriptions.repository.ts @@ -10,6 +10,7 @@ import { SubscriptionEvent, Donation, TierVehicleSelection, + SubscriptionTier, CreateSubscriptionRequest, UpdateSubscriptionData, CreateSubscriptionEventRequest, @@ -526,6 +527,92 @@ export class SubscriptionsRepository { } } + // ========== Transaction Support ========== + + /** + * Update subscription tier by user ID (supports transactions) + * Used by adminOverrideTier for atomic dual-table updates + */ + async updateTierByUserId( + userId: string, + tier: SubscriptionTier, + client?: any + ): Promise { + const queryClient = client || this.pool; + const query = ` + UPDATE subscriptions + SET tier = $1 + WHERE user_id = $2 + RETURNING * + `; + + try { + const result = await queryClient.query(query, [tier, userId]); + + if (result.rows.length === 0) { + return null; + } + + logger.info('Subscription tier updated by user ID', { userId, tier }); + return this.mapSubscriptionRow(result.rows[0]); + } catch (error: any) { + logger.error('Failed to update subscription tier by user ID', { + userId, + tier, + error: error.message, + }); + throw error; + } + } + + /** + * Create subscription for admin override (when user has no subscription record) + * Uses provided client for transaction support + */ + async createForAdminOverride( + userId: string, + tier: SubscriptionTier, + client?: any + ): Promise { + const queryClient = client || this.pool; + // Generate a placeholder Stripe customer ID since admin override bypasses Stripe + const placeholderCustomerId = `admin_override_${userId}_${Date.now()}`; + + const query = ` + INSERT INTO subscriptions ( + user_id, stripe_customer_id, tier, billing_cycle, status + ) + VALUES ($1, $2, $3, 'monthly', 'active') + RETURNING * + `; + + const values = [userId, placeholderCustomerId, tier]; + + try { + const result = await queryClient.query(query, values); + logger.info('Subscription created via admin override', { + subscriptionId: result.rows[0].id, + userId, + tier, + }); + return this.mapSubscriptionRow(result.rows[0]); + } catch (error: any) { + logger.error('Failed to create subscription for admin override', { + userId, + tier, + error: error.message, + }); + throw error; + } + } + + /** + * Get pool for transaction management + */ + getPool(): Pool { + return this.pool; + } + // ========== Private Mapping Methods ========== private mapSubscriptionRow(row: any): Subscription { diff --git a/backend/src/features/subscriptions/domain/subscriptions.service.ts b/backend/src/features/subscriptions/domain/subscriptions.service.ts index 4f4be22..e760f0f 100644 --- a/backend/src/features/subscriptions/domain/subscriptions.service.ts +++ b/backend/src/features/subscriptions/domain/subscriptions.service.ts @@ -728,6 +728,67 @@ export class SubscriptionsService { return 'free'; } + /** + * Admin override of subscription tier (bypasses Stripe) + * Updates both subscriptions.tier and user_profiles.subscription_tier atomically + * Creates subscription record if user doesn't have one + * + * Note: Admin feature depends on Subscriptions for tier management + * This is intentional - admin has oversight capabilities + */ + async adminOverrideTier(userId: string, newTier: SubscriptionTier): Promise { + const pool = this.repository.getPool(); + const client = await pool.connect(); + + try { + await client.query('BEGIN'); + + logger.info('Admin overriding subscription tier', { userId, newTier }); + + // Check if user has a subscription record + let subscription = await this.repository.findByUserId(userId); + + if (!subscription) { + // Create subscription record for user (admin override bypasses Stripe) + logger.info('Creating subscription record for admin override', { userId, newTier }); + subscription = await this.repository.createForAdminOverride(userId, newTier, client); + } else { + // Update existing subscription tier + const updated = await this.repository.updateTierByUserId(userId, newTier, client); + if (!updated) { + throw new Error('Failed to update subscription tier'); + } + subscription = updated; + } + + // Sync tier to user_profiles table (within same transaction) + await client.query( + 'UPDATE user_profiles SET subscription_tier = $1 WHERE auth0_sub = $2', + [newTier, userId] + ); + + await client.query('COMMIT'); + + logger.info('Admin subscription tier override complete', { + userId, + newTier, + subscriptionId: subscription.id, + }); + + return subscription; + } catch (error: any) { + await client.query('ROLLBACK'); + logger.error('Failed to admin override subscription tier', { + userId, + newTier, + error: error.message, + }); + throw error; + } finally { + client.release(); + } + } + /** * Get invoices for a user's subscription */ -- 2.49.1 From 8c86d8d492953dd972278dda0780f795bc702274 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Mon, 19 Jan 2026 09:53:45 -0600 Subject: [PATCH 2/2] fix: correct user_profiles column name in grace-period job (refs #58) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The grace-period job was using 'user_id' to query user_profiles table, but the correct column name is 'auth0_sub'. This would cause the tier sync to fail during grace period auto-downgrade. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- backend/src/features/subscriptions/jobs/grace-period.job.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/features/subscriptions/jobs/grace-period.job.ts b/backend/src/features/subscriptions/jobs/grace-period.job.ts index 8e8553b..cd8117c 100644 --- a/backend/src/features/subscriptions/jobs/grace-period.job.ts +++ b/backend/src/features/subscriptions/jobs/grace-period.job.ts @@ -78,13 +78,13 @@ export async function processGracePeriodExpirations(): Promise