feat: add vehicle selection and downgrade flow - M5 (refs #55)

This commit is contained in:
Eric Gullickson
2026-01-18 16:44:45 -06:00
parent 94d1c677bc
commit 6c1a100eb9
11 changed files with 509 additions and 7 deletions

View File

@@ -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,
});
}
}
}

View File

@@ -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)
});
};

View File

@@ -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
*/

View File

@@ -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);

View File

@@ -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;

View File

@@ -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),
};

View File

@@ -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<SubscriptionTier, number | null> = {
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 (
<VehicleSelectionDialog
open={showVehicleSelection}
onClose={handleVehicleSelectionCancel}
onConfirm={handleVehicleSelectionConfirm}
vehicles={vehicles || []}
maxSelections={targetLimit || 0}
targetTier={targetTier}
/>
);
};

View File

@@ -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<string[]>([]);
// 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 (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>Select Vehicles to Keep</DialogTitle>
<DialogContent>
<Alert severity="warning" sx={{ mb: 2 }}>
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.
</Alert>
<Box sx={{ mb: 2 }}>
<Typography variant="body2" color="text.secondary">
Selected {selectedVehicleIds.length} of {maxSelections} allowed
</Typography>
</Box>
<FormGroup>
{vehicles.map((vehicle) => (
<FormControlLabel
key={vehicle.id}
control={
<Checkbox
checked={selectedVehicleIds.includes(vehicle.id)}
onChange={() => handleToggle(vehicle.id)}
disabled={
!selectedVehicleIds.includes(vehicle.id) &&
selectedVehicleIds.length >= maxSelections
}
/>
}
label={getVehicleLabel(vehicle)}
/>
))}
</FormGroup>
{selectedVehicleIds.length === 0 && (
<Alert severity="error" sx={{ mt: 2 }}>
You must select at least one vehicle.
</Alert>
)}
{selectedVehicleIds.length > maxSelections && (
<Alert severity="error" sx={{ mt: 2 }}>
You can only select up to {maxSelections} vehicle{maxSelections > 1 ? 's' : ''}.
</Alert>
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button onClick={handleConfirm} variant="contained" disabled={!canConfirm}>
Confirm Downgrade
</Button>
</DialogActions>
</Dialog>
);
};

View File

@@ -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');
},
});
};

View File

@@ -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<BillingCycle>('monthly');
const [selectedTier, setSelectedTier] = useState<SubscriptionTier | null>(null);
const [showPaymentDialog, setShowPaymentDialog] = useState(false);
const [showDowngradeFlow, setShowDowngradeFlow] = useState(false);
const [downgradeTargetTier, setDowngradeTargetTier] = useState<SubscriptionTier | null>(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 = () => {
</Button>
</DialogActions>
</Dialog>
{showDowngradeFlow && downgradeTargetTier && (
<DowngradeFlow
targetTier={downgradeTargetTier}
onComplete={handleDowngradeComplete}
onCancel={handleDowngradeCancel}
/>
)}
</Box>
);
};

View File

@@ -36,3 +36,8 @@ export interface CheckoutRequest {
export interface PaymentMethodUpdateRequest {
paymentMethodId: string;
}
export interface DowngradeRequest {
targetTier: SubscriptionTier;
vehicleIdsToKeep: string[];
}