fix: subscription tier sync on admin override (#58) #61
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user