feat: add needs-vehicle-selection endpoint (refs #60)

- Add GET /api/subscriptions/needs-vehicle-selection endpoint
- Returns { needsSelection, vehicleCount, maxAllowed }
- Checks: free tier, >2 vehicles, no existing selections

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Eric Gullickson
2026-01-24 11:25:57 -06:00
parent 7c39d2f042
commit 684615a8a2
4 changed files with 98 additions and 0 deletions

View File

@@ -49,6 +49,28 @@ export class SubscriptionsController {
} }
} }
/**
* GET /api/subscriptions/needs-vehicle-selection - Check if user needs vehicle selection
*/
async checkNeedsVehicleSelection(request: FastifyRequest, reply: FastifyReply): Promise<void> {
try {
const userId = (request as any).user.sub;
const result = await this.service.checkNeedsVehicleSelection(userId);
reply.status(200).send(result);
} catch (error: any) {
logger.error('Failed to check needs vehicle selection', {
userId: (request as any).user?.sub,
error: error.message,
});
reply.status(500).send({
error: 'Failed to check needs vehicle selection',
message: error.message,
});
}
}
/** /**
* POST /api/subscriptions/checkout - Create Stripe checkout session * POST /api/subscriptions/checkout - Create Stripe checkout session
*/ */

View File

@@ -19,6 +19,12 @@ export const subscriptionsRoutes: FastifyPluginAsync = async (
handler: subscriptionsController.getSubscription.bind(subscriptionsController) handler: subscriptionsController.getSubscription.bind(subscriptionsController)
}); });
// GET /api/subscriptions/needs-vehicle-selection - Check if user needs vehicle selection
fastify.get('/subscriptions/needs-vehicle-selection', {
preHandler: [fastify.authenticate],
handler: subscriptionsController.checkNeedsVehicleSelection.bind(subscriptionsController)
});
// POST /api/subscriptions/checkout - Create Stripe checkout session // POST /api/subscriptions/checkout - Create Stripe checkout session
fastify.post('/subscriptions/checkout', { fastify.post('/subscriptions/checkout', {
preHandler: [fastify.authenticate], preHandler: [fastify.authenticate],

View File

@@ -15,7 +15,9 @@ import {
BillingCycle, BillingCycle,
SubscriptionStatus, SubscriptionStatus,
UpdateSubscriptionData, UpdateSubscriptionData,
NeedsVehicleSelectionResponse,
} from './subscriptions.types'; } from './subscriptions.types';
import { VehiclesRepository } from '../../vehicles/data/vehicles.repository';
interface StripeWebhookEvent { interface StripeWebhookEvent {
id: string; id: string;
@@ -27,6 +29,7 @@ interface StripeWebhookEvent {
export class SubscriptionsService { export class SubscriptionsService {
private userProfileRepository: UserProfileRepository; private userProfileRepository: UserProfileRepository;
private vehiclesRepository: VehiclesRepository;
constructor( constructor(
private repository: SubscriptionsRepository, private repository: SubscriptionsRepository,
@@ -34,6 +37,7 @@ export class SubscriptionsService {
pool: Pool pool: Pool
) { ) {
this.userProfileRepository = new UserProfileRepository(pool); this.userProfileRepository = new UserProfileRepository(pool);
this.vehiclesRepository = new VehiclesRepository(pool);
} }
/** /**
@@ -57,6 +61,65 @@ export class SubscriptionsService {
} }
} }
/**
* Check if user needs to make a vehicle selection after auto-downgrade
* Returns true if: user is on free tier, has >2 vehicles, and hasn't made selections
*/
async checkNeedsVehicleSelection(userId: string): Promise<NeedsVehicleSelectionResponse> {
const FREE_TIER_VEHICLE_LIMIT = 2;
try {
// Get current subscription
const subscription = await this.repository.findByUserId(userId);
// No subscription or not on free tier = no selection needed
if (!subscription || subscription.tier !== 'free') {
return {
needsSelection: false,
vehicleCount: 0,
maxAllowed: FREE_TIER_VEHICLE_LIMIT,
};
}
// Count user's active vehicles
const vehicleCount = await this.vehiclesRepository.countByUserId(userId);
// If within limit, no selection needed
if (vehicleCount <= FREE_TIER_VEHICLE_LIMIT) {
return {
needsSelection: false,
vehicleCount,
maxAllowed: FREE_TIER_VEHICLE_LIMIT,
};
}
// Check if user already has vehicle selections
const existingSelections = await this.repository.findVehicleSelectionsByUserId(userId);
// If user already made selections, no selection needed
if (existingSelections.length > 0) {
return {
needsSelection: false,
vehicleCount,
maxAllowed: FREE_TIER_VEHICLE_LIMIT,
};
}
// User is on free tier, has >2 vehicles, and hasn't made selections
return {
needsSelection: true,
vehicleCount,
maxAllowed: FREE_TIER_VEHICLE_LIMIT,
};
} catch (error: any) {
logger.error('Failed to check needs vehicle selection', {
userId,
error: error.message,
});
throw error;
}
}
/** /**
* Create new subscription (Stripe customer + initial free tier record) * Create new subscription (Stripe customer + initial free tier record)
*/ */

View File

@@ -131,3 +131,10 @@ export interface UpdateSubscriptionData {
export interface UpdateDonationData { export interface UpdateDonationData {
status?: DonationStatus; status?: DonationStatus;
} }
// Needs vehicle selection check response
export interface NeedsVehicleSelectionResponse {
needsSelection: boolean;
vehicleCount: number;
maxAllowed: number;
}