feat: add vehicle selection and downgrade flow - M5 (refs #55)
This commit is contained in:
@@ -246,4 +246,65 @@ export class SubscriptionsController {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/subscriptions/downgrade - Downgrade subscription with vehicle selection
|
||||
*/
|
||||
async downgrade(
|
||||
request: FastifyRequest<{
|
||||
Body: {
|
||||
targetTier: 'free' | 'pro';
|
||||
vehicleIdsToKeep: string[];
|
||||
};
|
||||
}>,
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const { targetTier, vehicleIdsToKeep } = request.body;
|
||||
|
||||
// Validate inputs
|
||||
if (!targetTier || !vehicleIdsToKeep) {
|
||||
reply.status(400).send({
|
||||
error: 'Missing required fields',
|
||||
message: 'targetTier and vehicleIdsToKeep are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!['free', 'pro'].includes(targetTier)) {
|
||||
reply.status(400).send({
|
||||
error: 'Invalid tier',
|
||||
message: 'targetTier must be "free" or "pro"',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(vehicleIdsToKeep)) {
|
||||
reply.status(400).send({
|
||||
error: 'Invalid vehicle selection',
|
||||
message: 'vehicleIdsToKeep must be an array',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Downgrade subscription
|
||||
const updatedSubscription = await this.service.downgradeSubscription(
|
||||
userId,
|
||||
targetTier,
|
||||
vehicleIdsToKeep
|
||||
);
|
||||
|
||||
reply.status(200).send(updatedSubscription);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to downgrade subscription', {
|
||||
userId: (request as any).user?.sub,
|
||||
error: error.message,
|
||||
});
|
||||
reply.status(500).send({
|
||||
error: 'Failed to downgrade subscription',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,4 +48,10 @@ export const subscriptionsRoutes: FastifyPluginAsync = async (
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: subscriptionsController.getInvoices.bind(subscriptionsController)
|
||||
});
|
||||
|
||||
// POST /api/subscriptions/downgrade - Downgrade subscription with vehicle selection
|
||||
fastify.post('/subscriptions/downgrade', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: subscriptionsController.downgrade.bind(subscriptionsController)
|
||||
});
|
||||
};
|
||||
|
||||
@@ -271,6 +271,95 @@ export class SubscriptionsService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Downgrade subscription to a lower tier with vehicle selection
|
||||
*/
|
||||
async downgradeSubscription(
|
||||
userId: string,
|
||||
targetTier: SubscriptionTier,
|
||||
vehicleIdsToKeep: string[]
|
||||
): Promise<Subscription> {
|
||||
try {
|
||||
logger.info('Downgrading subscription', { userId, targetTier, vehicleCount: vehicleIdsToKeep.length });
|
||||
|
||||
// Get current subscription
|
||||
const currentSubscription = await this.repository.findByUserId(userId);
|
||||
if (!currentSubscription) {
|
||||
throw new Error('No subscription found for user');
|
||||
}
|
||||
|
||||
// Define tier limits
|
||||
const tierLimits: Record<SubscriptionTier, number | null> = {
|
||||
free: 2,
|
||||
pro: 5,
|
||||
enterprise: null, // unlimited
|
||||
};
|
||||
|
||||
const targetLimit = tierLimits[targetTier];
|
||||
|
||||
// Validate vehicle selection count
|
||||
if (targetLimit !== null && vehicleIdsToKeep.length > targetLimit) {
|
||||
throw new Error(`Vehicle selection exceeds tier limit. ${targetTier} tier allows ${targetLimit} vehicles, but ${vehicleIdsToKeep.length} were selected.`);
|
||||
}
|
||||
|
||||
// Cancel current Stripe subscription if exists (downgrading from paid tier)
|
||||
if (currentSubscription.stripeSubscriptionId) {
|
||||
await this.stripeClient.cancelSubscription(
|
||||
currentSubscription.stripeSubscriptionId,
|
||||
false // Cancel immediately, not at period end
|
||||
);
|
||||
}
|
||||
|
||||
// Clear previous vehicle selections
|
||||
await this.repository.deleteVehicleSelectionsByUserId(userId);
|
||||
|
||||
// Save new vehicle selections
|
||||
for (const vehicleId of vehicleIdsToKeep) {
|
||||
await this.repository.createVehicleSelection({
|
||||
userId,
|
||||
vehicleId,
|
||||
});
|
||||
}
|
||||
|
||||
// Update subscription tier
|
||||
const updateData: UpdateSubscriptionData = {
|
||||
tier: targetTier,
|
||||
status: 'active',
|
||||
stripeSubscriptionId: undefined,
|
||||
billingCycle: undefined,
|
||||
cancelAtPeriodEnd: false,
|
||||
};
|
||||
|
||||
const updatedSubscription = await this.repository.update(
|
||||
currentSubscription.id,
|
||||
updateData
|
||||
);
|
||||
|
||||
if (!updatedSubscription) {
|
||||
throw new Error('Failed to update subscription');
|
||||
}
|
||||
|
||||
// Sync tier to user profile
|
||||
await this.syncTierToUserProfile(userId, targetTier);
|
||||
|
||||
logger.info('Subscription downgraded', {
|
||||
subscriptionId: updatedSubscription.id,
|
||||
userId,
|
||||
targetTier,
|
||||
vehicleCount: vehicleIdsToKeep.length,
|
||||
});
|
||||
|
||||
return updatedSubscription;
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to downgrade subscription', {
|
||||
userId,
|
||||
targetTier,
|
||||
error: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming Stripe webhook event
|
||||
*/
|
||||
|
||||
@@ -203,23 +203,85 @@ export class VehiclesService {
|
||||
|
||||
async getUserVehicles(userId: string): Promise<VehicleResponse[]> {
|
||||
const cacheKey = `${this.cachePrefix}:user:${userId}`;
|
||||
|
||||
|
||||
// Check cache
|
||||
const cached = await cacheService.get<VehicleResponse[]>(cacheKey);
|
||||
if (cached) {
|
||||
logger.debug('Vehicle list cache hit', { userId });
|
||||
return cached;
|
||||
}
|
||||
|
||||
|
||||
// Get from database
|
||||
const vehicles = await this.repository.findByUserId(userId);
|
||||
const response = vehicles.map((v: Vehicle) => this.toResponse(v));
|
||||
|
||||
|
||||
// Cache result
|
||||
await cacheService.set(cacheKey, response, this.listCacheTTL);
|
||||
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user vehicles with tier-gated status
|
||||
* Returns vehicles with tierStatus: 'active' | 'locked'
|
||||
*/
|
||||
async getUserVehiclesWithTierStatus(userId: string): Promise<Array<VehicleResponse & { tierStatus: 'active' | 'locked' }>> {
|
||||
// Get user's subscription tier
|
||||
const userProfile = await this.userProfileRepository.getByAuth0Sub(userId);
|
||||
if (!userProfile) {
|
||||
throw new Error('User profile not found');
|
||||
}
|
||||
const userTier = userProfile.subscriptionTier;
|
||||
|
||||
// Get all vehicles
|
||||
const vehicles = await this.repository.findByUserId(userId);
|
||||
|
||||
// Define tier limits
|
||||
const tierLimits: Record<SubscriptionTier, number | null> = {
|
||||
free: 2,
|
||||
pro: 5,
|
||||
enterprise: null, // unlimited
|
||||
};
|
||||
|
||||
const tierLimit = tierLimits[userTier];
|
||||
|
||||
// If tier has unlimited vehicles, all are active
|
||||
if (tierLimit === null) {
|
||||
return vehicles.map((v: Vehicle) => ({
|
||||
...this.toResponse(v),
|
||||
tierStatus: 'active' as const,
|
||||
}));
|
||||
}
|
||||
|
||||
// If vehicle count is within tier limit, all are active
|
||||
if (vehicles.length <= tierLimit) {
|
||||
return vehicles.map((v: Vehicle) => ({
|
||||
...this.toResponse(v),
|
||||
tierStatus: 'active' as const,
|
||||
}));
|
||||
}
|
||||
|
||||
// Vehicle count exceeds tier limit - check for tier_vehicle_selections
|
||||
// Get vehicle selections from subscriptions repository
|
||||
const { SubscriptionsRepository } = await import('../../subscriptions/data/subscriptions.repository');
|
||||
const subscriptionsRepo = new SubscriptionsRepository(this.pool);
|
||||
const selections = await subscriptionsRepo.findVehicleSelectionsByUserId(userId);
|
||||
const selectedVehicleIds = new Set(selections.map(s => s.vehicleId));
|
||||
|
||||
// If no selections exist, return all as active (selections only exist after downgrade)
|
||||
if (selections.length === 0) {
|
||||
return vehicles.map((v: Vehicle) => ({
|
||||
...this.toResponse(v),
|
||||
tierStatus: 'active' as const,
|
||||
}));
|
||||
}
|
||||
|
||||
// Mark vehicles as active or locked based on selections
|
||||
return vehicles.map((v: Vehicle) => ({
|
||||
...this.toResponse(v),
|
||||
tierStatus: selectedVehicleIds.has(v.id) ? ('active' as const) : ('locked' as const),
|
||||
}));
|
||||
}
|
||||
|
||||
async getVehicle(id: string, userId: string): Promise<VehicleResponse> {
|
||||
const vehicle = await this.repository.findById(id);
|
||||
|
||||
@@ -195,6 +195,11 @@ export interface VehicleParams {
|
||||
id: string;
|
||||
}
|
||||
|
||||
// Vehicle with tier status (for tier-gated access)
|
||||
export interface VehicleWithTierStatus extends Vehicle {
|
||||
tierStatus: 'active' | 'locked';
|
||||
}
|
||||
|
||||
// TCO (Total Cost of Ownership) response
|
||||
export interface TCOResponse {
|
||||
vehicleId: string;
|
||||
|
||||
Reference in New Issue
Block a user