From 684615a8a2a051306ba5a3e7715ac91026f425e8 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sat, 24 Jan 2026 11:25:57 -0600 Subject: [PATCH] feat: add needs-vehicle-selection endpoint (refs #60) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../api/subscriptions.controller.ts | 22 +++++++ .../subscriptions/api/subscriptions.routes.ts | 6 ++ .../domain/subscriptions.service.ts | 63 +++++++++++++++++++ .../domain/subscriptions.types.ts | 7 +++ 4 files changed, 98 insertions(+) diff --git a/backend/src/features/subscriptions/api/subscriptions.controller.ts b/backend/src/features/subscriptions/api/subscriptions.controller.ts index 719eba3..36616c1 100644 --- a/backend/src/features/subscriptions/api/subscriptions.controller.ts +++ b/backend/src/features/subscriptions/api/subscriptions.controller.ts @@ -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 { + 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 */ diff --git a/backend/src/features/subscriptions/api/subscriptions.routes.ts b/backend/src/features/subscriptions/api/subscriptions.routes.ts index cca8439..7ef9595 100644 --- a/backend/src/features/subscriptions/api/subscriptions.routes.ts +++ b/backend/src/features/subscriptions/api/subscriptions.routes.ts @@ -19,6 +19,12 @@ export const subscriptionsRoutes: FastifyPluginAsync = async ( 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 fastify.post('/subscriptions/checkout', { preHandler: [fastify.authenticate], diff --git a/backend/src/features/subscriptions/domain/subscriptions.service.ts b/backend/src/features/subscriptions/domain/subscriptions.service.ts index e760f0f..4a2665e 100644 --- a/backend/src/features/subscriptions/domain/subscriptions.service.ts +++ b/backend/src/features/subscriptions/domain/subscriptions.service.ts @@ -15,7 +15,9 @@ import { BillingCycle, SubscriptionStatus, UpdateSubscriptionData, + NeedsVehicleSelectionResponse, } from './subscriptions.types'; +import { VehiclesRepository } from '../../vehicles/data/vehicles.repository'; interface StripeWebhookEvent { id: string; @@ -27,6 +29,7 @@ interface StripeWebhookEvent { export class SubscriptionsService { private userProfileRepository: UserProfileRepository; + private vehiclesRepository: VehiclesRepository; constructor( private repository: SubscriptionsRepository, @@ -34,6 +37,7 @@ export class SubscriptionsService { pool: 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 { + 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) */ diff --git a/backend/src/features/subscriptions/domain/subscriptions.types.ts b/backend/src/features/subscriptions/domain/subscriptions.types.ts index 2bcab80..1d6ca83 100644 --- a/backend/src/features/subscriptions/domain/subscriptions.types.ts +++ b/backend/src/features/subscriptions/domain/subscriptions.types.ts @@ -131,3 +131,10 @@ export interface UpdateSubscriptionData { export interface UpdateDonationData { status?: DonationStatus; } + +// Needs vehicle selection check response +export interface NeedsVehicleSelectionResponse { + needsSelection: boolean; + vehicleCount: number; + maxAllowed: number; +}