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 { UserProfileService } from '../../user-profile/domain/user-profile.service';
|
||||||
import { UserProfileRepository } from '../../user-profile/data/user-profile.repository';
|
import { UserProfileRepository } from '../../user-profile/data/user-profile.repository';
|
||||||
import { AdminRepository } from '../data/admin.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 { pool } from '../../../core/config/database';
|
||||||
import { logger } from '../../../core/logging/logger';
|
import { logger } from '../../../core/logging/logger';
|
||||||
import {
|
import {
|
||||||
@@ -28,15 +31,22 @@ import { AdminService } from '../domain/admin.service';
|
|||||||
export class UsersController {
|
export class UsersController {
|
||||||
private userProfileService: UserProfileService;
|
private userProfileService: UserProfileService;
|
||||||
private adminService: AdminService;
|
private adminService: AdminService;
|
||||||
|
private subscriptionsService: SubscriptionsService;
|
||||||
private userProfileRepository: UserProfileRepository;
|
private userProfileRepository: UserProfileRepository;
|
||||||
|
private adminRepository: AdminRepository;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.userProfileRepository = new UserProfileRepository(pool);
|
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 = new UserProfileService(this.userProfileRepository);
|
||||||
this.userProfileService.setAdminRepository(adminRepository);
|
this.userProfileService.setAdminRepository(this.adminRepository);
|
||||||
this.adminService = new AdminService(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
|
* 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(
|
async updateTier(
|
||||||
request: FastifyRequest<{ Params: UserAuth0SubInput; Body: UpdateTierInput }>,
|
request: FastifyRequest<{ Params: UserAuth0SubInput; Body: UpdateTierInput }>,
|
||||||
@@ -261,12 +273,39 @@ export class UsersController {
|
|||||||
const { auth0Sub } = paramsResult.data;
|
const { auth0Sub } = paramsResult.data;
|
||||||
const { subscriptionTier } = bodyResult.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,
|
auth0Sub,
|
||||||
subscriptionTier,
|
'user_profile',
|
||||||
actorId
|
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);
|
return reply.code(200).send(updatedUser);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
SubscriptionEvent,
|
SubscriptionEvent,
|
||||||
Donation,
|
Donation,
|
||||||
TierVehicleSelection,
|
TierVehicleSelection,
|
||||||
|
SubscriptionTier,
|
||||||
CreateSubscriptionRequest,
|
CreateSubscriptionRequest,
|
||||||
UpdateSubscriptionData,
|
UpdateSubscriptionData,
|
||||||
CreateSubscriptionEventRequest,
|
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 Mapping Methods ==========
|
||||||
|
|
||||||
private mapSubscriptionRow(row: any): Subscription {
|
private mapSubscriptionRow(row: any): Subscription {
|
||||||
|
|||||||
@@ -728,6 +728,67 @@ export class SubscriptionsService {
|
|||||||
return 'free';
|
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
|
* Get invoices for a user's subscription
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -78,13 +78,13 @@ export async function processGracePeriodExpirations(): Promise<GracePeriodResult
|
|||||||
|
|
||||||
await client.query(updateQuery, [subscription.id]);
|
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 = `
|
const syncQuery = `
|
||||||
UPDATE user_profiles
|
UPDATE user_profiles
|
||||||
SET
|
SET
|
||||||
subscription_tier = 'free',
|
subscription_tier = 'free',
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE user_id = $1
|
WHERE auth0_sub = $1
|
||||||
`;
|
`;
|
||||||
|
|
||||||
await client.query(syncQuery, [subscription.user_id]);
|
await client.query(syncQuery, [subscription.user_id]);
|
||||||
|
|||||||
Reference in New Issue
Block a user