diff --git a/backend/src/features/subscriptions/api/subscriptions.controller.ts b/backend/src/features/subscriptions/api/subscriptions.controller.ts index 7d4e03e..719eba3 100644 --- a/backend/src/features/subscriptions/api/subscriptions.controller.ts +++ b/backend/src/features/subscriptions/api/subscriptions.controller.ts @@ -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 { + 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, + }); + } + } } diff --git a/backend/src/features/subscriptions/api/subscriptions.routes.ts b/backend/src/features/subscriptions/api/subscriptions.routes.ts index 25e4789..cca8439 100644 --- a/backend/src/features/subscriptions/api/subscriptions.routes.ts +++ b/backend/src/features/subscriptions/api/subscriptions.routes.ts @@ -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) + }); }; diff --git a/backend/src/features/subscriptions/domain/subscriptions.service.ts b/backend/src/features/subscriptions/domain/subscriptions.service.ts index 14f26e4..f422dd4 100644 --- a/backend/src/features/subscriptions/domain/subscriptions.service.ts +++ b/backend/src/features/subscriptions/domain/subscriptions.service.ts @@ -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 { + 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 = { + 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 */ diff --git a/backend/src/features/vehicles/domain/vehicles.service.ts b/backend/src/features/vehicles/domain/vehicles.service.ts index 4ab2b6f..e4a7f74 100644 --- a/backend/src/features/vehicles/domain/vehicles.service.ts +++ b/backend/src/features/vehicles/domain/vehicles.service.ts @@ -203,23 +203,85 @@ export class VehiclesService { async getUserVehicles(userId: string): Promise { const cacheKey = `${this.cachePrefix}:user:${userId}`; - + // Check cache const cached = await cacheService.get(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> { + // 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 = { + 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 { const vehicle = await this.repository.findById(id); diff --git a/backend/src/features/vehicles/domain/vehicles.types.ts b/backend/src/features/vehicles/domain/vehicles.types.ts index 024d54c..e1380de 100644 --- a/backend/src/features/vehicles/domain/vehicles.types.ts +++ b/backend/src/features/vehicles/domain/vehicles.types.ts @@ -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; diff --git a/frontend/src/features/subscription/api/subscription.api.ts b/frontend/src/features/subscription/api/subscription.api.ts index 35c7cc4..61df5ed 100644 --- a/frontend/src/features/subscription/api/subscription.api.ts +++ b/frontend/src/features/subscription/api/subscription.api.ts @@ -1,5 +1,5 @@ import { apiClient } from '../../../core/api/client'; -import type { CheckoutRequest, PaymentMethodUpdateRequest } from '../types/subscription.types'; +import type { CheckoutRequest, PaymentMethodUpdateRequest, DowngradeRequest } from '../types/subscription.types'; export const subscriptionApi = { getSubscription: () => apiClient.get('/subscriptions'), @@ -8,4 +8,5 @@ export const subscriptionApi = { reactivate: () => apiClient.post('/subscriptions/reactivate'), updatePaymentMethod: (data: PaymentMethodUpdateRequest) => apiClient.put('/subscriptions/payment-method', data), getInvoices: () => apiClient.get('/subscriptions/invoices'), + downgrade: (data: DowngradeRequest) => apiClient.post('/subscriptions/downgrade', data), }; diff --git a/frontend/src/features/subscription/components/DowngradeFlow.tsx b/frontend/src/features/subscription/components/DowngradeFlow.tsx new file mode 100644 index 0000000..15dc62f --- /dev/null +++ b/frontend/src/features/subscription/components/DowngradeFlow.tsx @@ -0,0 +1,67 @@ +import { useState, useEffect } from 'react'; +import { useVehicles } from '../../vehicles/hooks/useVehicles'; +import { VehicleSelectionDialog } from './VehicleSelectionDialog'; +import type { SubscriptionTier } from '../types/subscription.types'; + +interface DowngradeFlowProps { + targetTier: SubscriptionTier; + onComplete: (vehicleIdsToKeep: string[]) => void; + onCancel: () => void; +} + +const TIER_LIMITS: Record = { + free: 2, + pro: 5, + enterprise: null, // unlimited +}; + +export const DowngradeFlow = ({ + targetTier, + onComplete, + onCancel, +}: DowngradeFlowProps) => { + const { data: vehicles } = useVehicles(); + const [showVehicleSelection, setShowVehicleSelection] = useState(false); + + useEffect(() => { + // Check if vehicle selection is needed + const targetLimit = TIER_LIMITS[targetTier]; + const vehicleCount = vehicles?.length || 0; + + if (targetLimit !== null && vehicleCount > targetLimit) { + // Vehicle count exceeds target tier limit - show selection dialog + setShowVehicleSelection(true); + } else { + // No selection needed - directly downgrade with all vehicles + const allVehicleIds = vehicles?.map((v: any) => v.id) || []; + onComplete(allVehicleIds); + } + }, [vehicles, targetTier, onComplete]); + + const handleVehicleSelectionConfirm = (selectedVehicleIds: string[]) => { + setShowVehicleSelection(false); + onComplete(selectedVehicleIds); + }; + + const handleVehicleSelectionCancel = () => { + setShowVehicleSelection(false); + onCancel(); + }; + + if (!showVehicleSelection) { + return null; + } + + const targetLimit = TIER_LIMITS[targetTier]; + + return ( + + ); +}; diff --git a/frontend/src/features/subscription/components/VehicleSelectionDialog.tsx b/frontend/src/features/subscription/components/VehicleSelectionDialog.tsx new file mode 100644 index 0000000..14ba3cf --- /dev/null +++ b/frontend/src/features/subscription/components/VehicleSelectionDialog.tsx @@ -0,0 +1,135 @@ +import { useState, useEffect } from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + FormGroup, + FormControlLabel, + Checkbox, + Typography, + Alert, + Box, +} from '@mui/material'; +import type { SubscriptionTier } from '../types/subscription.types'; + +interface Vehicle { + id: string; + make?: string; + model?: string; + year?: number; + nickname?: string; +} + +interface VehicleSelectionDialogProps { + open: boolean; + onClose: () => void; + onConfirm: (selectedVehicleIds: string[]) => void; + vehicles: Vehicle[]; + maxSelections: number; + targetTier: SubscriptionTier; +} + +export const VehicleSelectionDialog = ({ + open, + onClose, + onConfirm, + vehicles, + maxSelections, + targetTier, +}: VehicleSelectionDialogProps) => { + const [selectedVehicleIds, setSelectedVehicleIds] = useState([]); + + // Pre-select first N vehicles when dialog opens + useEffect(() => { + if (open && vehicles.length > 0) { + const initialSelection = vehicles.slice(0, maxSelections).map((v) => v.id); + setSelectedVehicleIds(initialSelection); + } + }, [open, vehicles, maxSelections]); + + const handleToggle = (vehicleId: string) => { + setSelectedVehicleIds((prev) => { + if (prev.includes(vehicleId)) { + return prev.filter((id) => id !== vehicleId); + } else { + // Only add if under the limit + if (prev.length < maxSelections) { + return [...prev, vehicleId]; + } + return prev; + } + }); + }; + + const handleConfirm = () => { + onConfirm(selectedVehicleIds); + }; + + const getVehicleLabel = (vehicle: Vehicle): string => { + if (vehicle.nickname) { + return vehicle.nickname; + } + const parts = [vehicle.year, vehicle.make, vehicle.model].filter(Boolean); + return parts.join(' ') || 'Unknown Vehicle'; + }; + + const canConfirm = selectedVehicleIds.length > 0 && selectedVehicleIds.length <= maxSelections; + + return ( + + Select Vehicles to Keep + + + You are downgrading to the {targetTier} tier, which allows {maxSelections} vehicle + {maxSelections > 1 ? 's' : ''}. Select which vehicles you want to keep active. Unselected + vehicles will be hidden but not deleted, and you can unlock them by upgrading later. + + + + + Selected {selectedVehicleIds.length} of {maxSelections} allowed + + + + + {vehicles.map((vehicle) => ( + handleToggle(vehicle.id)} + disabled={ + !selectedVehicleIds.includes(vehicle.id) && + selectedVehicleIds.length >= maxSelections + } + /> + } + label={getVehicleLabel(vehicle)} + /> + ))} + + + {selectedVehicleIds.length === 0 && ( + + You must select at least one vehicle. + + )} + + {selectedVehicleIds.length > maxSelections && ( + + You can only select up to {maxSelections} vehicle{maxSelections > 1 ? 's' : ''}. + + )} + + + + + + + ); +}; diff --git a/frontend/src/features/subscription/hooks/useSubscription.ts b/frontend/src/features/subscription/hooks/useSubscription.ts index 943a500..225ed4a 100644 --- a/frontend/src/features/subscription/hooks/useSubscription.ts +++ b/frontend/src/features/subscription/hooks/useSubscription.ts @@ -74,3 +74,20 @@ export const useInvoices = () => { staleTime: 5 * 60 * 1000, }); }; + +export const useDowngrade = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: subscriptionApi.downgrade, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['subscription'] }); + queryClient.invalidateQueries({ queryKey: ['vehicles'] }); + queryClient.invalidateQueries({ queryKey: ['user-profile'] }); + toast.success('Subscription downgraded successfully'); + }, + onError: (error: unknown) => { + const err = error as { response?: { data?: { message?: string } } }; + toast.error(err.response?.data?.message || 'Downgrade failed'); + }, + }); +}; diff --git a/frontend/src/features/subscription/pages/SubscriptionPage.tsx b/frontend/src/features/subscription/pages/SubscriptionPage.tsx index 3e6242b..788d327 100644 --- a/frontend/src/features/subscription/pages/SubscriptionPage.tsx +++ b/frontend/src/features/subscription/pages/SubscriptionPage.tsx @@ -21,12 +21,14 @@ import { Card } from '../../../shared-minimal/components/Card'; import { TierCard } from '../components/TierCard'; import { PaymentMethodForm } from '../components/PaymentMethodForm'; import { BillingHistory } from '../components/BillingHistory'; +import { DowngradeFlow } from '../components/DowngradeFlow'; import { useSubscription, useCheckout, useCancelSubscription, useReactivateSubscription, useInvoices, + useDowngrade, } from '../hooks/useSubscription'; import { PLANS } from '../constants/plans'; import type { BillingCycle, SubscriptionTier } from '../types/subscription.types'; @@ -37,12 +39,15 @@ export const SubscriptionPage: React.FC = () => { const [billingCycle, setBillingCycle] = useState('monthly'); const [selectedTier, setSelectedTier] = useState(null); const [showPaymentDialog, setShowPaymentDialog] = useState(false); + const [showDowngradeFlow, setShowDowngradeFlow] = useState(false); + const [downgradeTargetTier, setDowngradeTargetTier] = useState(null); const { data: subscriptionData, isLoading: isLoadingSubscription } = useSubscription(); const { data: invoicesData, isLoading: isLoadingInvoices } = useInvoices(); const checkoutMutation = useCheckout(); const cancelMutation = useCancelSubscription(); const reactivateMutation = useReactivateSubscription(); + const downgradeMutation = useDowngrade(); const subscription = subscriptionData?.data; const invoices = invoicesData?.data || []; @@ -53,9 +58,50 @@ export const SubscriptionPage: React.FC = () => { } }; + const getTierRank = (tier: SubscriptionTier): number => { + const ranks = { free: 0, pro: 1, enterprise: 2 }; + return ranks[tier]; + }; + const handleUpgradeClick = (tier: SubscriptionTier) => { - setSelectedTier(tier); - setShowPaymentDialog(true); + const currentTier = subscription?.tier || 'free'; + const isDowngrade = getTierRank(tier) < getTierRank(currentTier); + + if (isDowngrade) { + // Trigger downgrade flow + setDowngradeTargetTier(tier); + setShowDowngradeFlow(true); + } else { + // Trigger upgrade flow (show payment dialog) + setSelectedTier(tier); + setShowPaymentDialog(true); + } + }; + + const handleDowngradeComplete = (vehicleIdsToKeep: string[]) => { + if (!downgradeTargetTier) return; + + downgradeMutation.mutate( + { + targetTier: downgradeTargetTier, + vehicleIdsToKeep, + }, + { + onSuccess: () => { + setShowDowngradeFlow(false); + setDowngradeTargetTier(null); + }, + onError: () => { + setShowDowngradeFlow(false); + setDowngradeTargetTier(null); + }, + } + ); + }; + + const handleDowngradeCancel = () => { + setShowDowngradeFlow(false); + setDowngradeTargetTier(null); }; const handlePaymentSubmit = (paymentMethodId: string) => { @@ -244,6 +290,14 @@ export const SubscriptionPage: React.FC = () => { + + {showDowngradeFlow && downgradeTargetTier && ( + + )} ); }; diff --git a/frontend/src/features/subscription/types/subscription.types.ts b/frontend/src/features/subscription/types/subscription.types.ts index a7d9af3..9f6398d 100644 --- a/frontend/src/features/subscription/types/subscription.types.ts +++ b/frontend/src/features/subscription/types/subscription.types.ts @@ -36,3 +36,8 @@ export interface CheckoutRequest { export interface PaymentMethodUpdateRequest { paymentMethodId: string; } + +export interface DowngradeRequest { + targetTier: SubscriptionTier; + vehicleIdsToKeep: string[]; +}