Merge pull request 'fix: subscription tier sync on admin override (#58)' (#61) from issue-58-subscription-tier-sync into main
All checks were successful
Deploy to Staging / Build Images (push) Successful in 24s
Deploy to Staging / Deploy to Staging (push) Successful in 29s
Deploy to Staging / Verify Staging (push) Successful in 6s
Deploy to Staging / Notify Staging Ready (push) Successful in 6s
Deploy to Staging / Notify Staging Failure (push) Has been skipped

Reviewed-on: #61
This commit was merged in pull request #61.
This commit is contained in:
2026-01-24 16:55:36 +00:00
4 changed files with 195 additions and 8 deletions

View File

@@ -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';

View File

@@ -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<Subscription | null> {
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<Subscription> {
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 {

View File

@@ -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<Subscription> {
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
*/

View File

@@ -78,13 +78,13 @@ export async function processGracePeriodExpirations(): Promise<GracePeriodResult
await client.query(updateQuery, [subscription.id]);
// Sync tier to user_profiles table
// Sync tier to user_profiles table (user_id is auth0_sub)
const syncQuery = `
UPDATE user_profiles
SET
subscription_tier = 'free',
updated_at = NOW()
WHERE user_id = $1
WHERE auth0_sub = $1
`;
await client.query(syncQuery, [subscription.user_id]);