feat: prompt vehicle selection on login after auto-downgrade (#60) #62
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,7 +28,12 @@ export class VehiclesController {
|
|||||||
async getUserVehicles(request: FastifyRequest, reply: FastifyReply) {
|
async getUserVehicles(request: FastifyRequest, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const userId = (request as any).user.sub;
|
||||||
const vehicles = await this.vehiclesService.getUserVehicles(userId);
|
// Use tier-aware method to filter out locked vehicles after downgrade
|
||||||
|
const vehiclesWithStatus = await this.vehiclesService.getUserVehiclesWithTierStatus(userId);
|
||||||
|
// Only return active vehicles (filter out locked ones)
|
||||||
|
const vehicles = vehiclesWithStatus
|
||||||
|
.filter(v => v.tierStatus === 'active')
|
||||||
|
.map(({ tierStatus, ...vehicle }) => vehicle);
|
||||||
|
|
||||||
return reply.code(200).send(vehicles);
|
return reply.code(200).send(vehicles);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -108,6 +113,20 @@ export class VehiclesController {
|
|||||||
const userId = (request as any).user.sub;
|
const userId = (request as any).user.sub;
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
|
|
||||||
|
// Check tier status - block access to locked vehicles
|
||||||
|
const vehiclesWithStatus = await this.vehiclesService.getUserVehiclesWithTierStatus(userId);
|
||||||
|
const vehicleStatus = vehiclesWithStatus.find(v => v.id === id);
|
||||||
|
if (vehicleStatus && vehicleStatus.tierStatus === 'locked') {
|
||||||
|
return reply.code(403).send({
|
||||||
|
error: 'TIER_REQUIRED',
|
||||||
|
requiredTier: 'pro',
|
||||||
|
feature: 'vehicle.access',
|
||||||
|
featureName: 'Vehicle Access',
|
||||||
|
upgradePrompt: 'Upgrade to Pro to access all your vehicles',
|
||||||
|
message: 'This vehicle is not available on your current subscription tier'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const vehicle = await this.vehiclesService.getVehicle(id, userId);
|
const vehicle = await this.vehiclesService.getVehicle(id, userId);
|
||||||
|
|
||||||
return reply.code(200).send(vehicle);
|
return reply.code(200).send(vehicle);
|
||||||
|
|||||||
@@ -82,6 +82,9 @@ import { CreateVehicleRequest } from './features/vehicles/types/vehicles.types';
|
|||||||
import { MobileSettingsScreen } from './features/settings/mobile/MobileSettingsScreen';
|
import { MobileSettingsScreen } from './features/settings/mobile/MobileSettingsScreen';
|
||||||
import { SecurityMobileScreen } from './features/settings/mobile/SecurityMobileScreen';
|
import { SecurityMobileScreen } from './features/settings/mobile/SecurityMobileScreen';
|
||||||
import { useNavigationStore, useUserStore } from './core/store';
|
import { useNavigationStore, useUserStore } from './core/store';
|
||||||
|
import { useNeedsVehicleSelection, useDowngrade } from './features/subscription/hooks/useSubscription';
|
||||||
|
import { useVehicles } from './features/vehicles/hooks/useVehicles';
|
||||||
|
import { VehicleSelectionDialog } from './features/subscription/components/VehicleSelectionDialog';
|
||||||
import { useDataSync } from './core/hooks/useDataSync';
|
import { useDataSync } from './core/hooks/useDataSync';
|
||||||
import { MobileDebugPanel } from './core/debug/MobileDebugPanel';
|
import { MobileDebugPanel } from './core/debug/MobileDebugPanel';
|
||||||
import { MobileErrorBoundary } from './core/error-boundaries/MobileErrorBoundary';
|
import { MobileErrorBoundary } from './core/error-boundaries/MobileErrorBoundary';
|
||||||
@@ -319,6 +322,25 @@ function App() {
|
|||||||
// Initialize login notifications
|
// Initialize login notifications
|
||||||
useLoginNotifications();
|
useLoginNotifications();
|
||||||
|
|
||||||
|
// Vehicle selection check for auto-downgraded users
|
||||||
|
const needsVehicleSelectionQuery = useNeedsVehicleSelection();
|
||||||
|
const vehiclesQuery = useVehicles();
|
||||||
|
const downgrade = useDowngrade();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Handle vehicle selection confirmation
|
||||||
|
const handleVehicleSelectionConfirm = useCallback((selectedVehicleIds: string[]) => {
|
||||||
|
downgrade.mutate(
|
||||||
|
{ targetTier: 'free', vehicleIdsToKeep: selectedVehicleIds },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
// Invalidate the needs-vehicle-selection query to clear the dialog
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['needs-vehicle-selection'] });
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}, [downgrade, queryClient]);
|
||||||
|
|
||||||
// Enhanced navigation and user state management
|
// Enhanced navigation and user state management
|
||||||
const {
|
const {
|
||||||
activeScreen,
|
activeScreen,
|
||||||
@@ -620,6 +642,28 @@ function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if user needs to make vehicle selection after auto-downgrade
|
||||||
|
// This blocks the app until user selects which vehicles to keep
|
||||||
|
const needsVehicleSelection = needsVehicleSelectionQuery.data?.needsSelection;
|
||||||
|
const vehicleMaxAllowed = needsVehicleSelectionQuery.data?.maxAllowed ?? 2;
|
||||||
|
const vehicleList = vehiclesQuery.data ?? [];
|
||||||
|
|
||||||
|
if (needsVehicleSelection && vehicleList.length > 0) {
|
||||||
|
return (
|
||||||
|
<ThemeProvider>
|
||||||
|
<VehicleSelectionDialog
|
||||||
|
open={true}
|
||||||
|
blocking={true}
|
||||||
|
onClose={() => {}} // No-op - dialog is blocking
|
||||||
|
onConfirm={handleVehicleSelectionConfirm}
|
||||||
|
vehicles={vehicleList}
|
||||||
|
maxSelections={vehicleMaxAllowed}
|
||||||
|
targetTier="free"
|
||||||
|
/>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Mobile app rendering
|
// Mobile app rendering
|
||||||
if (mobileMode) {
|
if (mobileMode) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { apiClient } from '../../../core/api/client';
|
import { apiClient } from '../../../core/api/client';
|
||||||
import type { CheckoutRequest, PaymentMethodUpdateRequest, DowngradeRequest } from '../types/subscription.types';
|
import type { CheckoutRequest, PaymentMethodUpdateRequest, DowngradeRequest, NeedsVehicleSelectionResponse } from '../types/subscription.types';
|
||||||
|
|
||||||
export const subscriptionApi = {
|
export const subscriptionApi = {
|
||||||
getSubscription: () => apiClient.get('/subscriptions'),
|
getSubscription: () => apiClient.get('/subscriptions'),
|
||||||
|
needsVehicleSelection: async (): Promise<NeedsVehicleSelectionResponse> => {
|
||||||
|
const response = await apiClient.get<NeedsVehicleSelectionResponse>('/subscriptions/needs-vehicle-selection');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
checkout: (data: CheckoutRequest) => apiClient.post('/subscriptions/checkout', data),
|
checkout: (data: CheckoutRequest) => apiClient.post('/subscriptions/checkout', data),
|
||||||
cancel: () => apiClient.post('/subscriptions/cancel'),
|
cancel: () => apiClient.post('/subscriptions/cancel'),
|
||||||
reactivate: () => apiClient.post('/subscriptions/reactivate'),
|
reactivate: () => apiClient.post('/subscriptions/reactivate'),
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ interface VehicleSelectionDialogProps {
|
|||||||
vehicles: Vehicle[];
|
vehicles: Vehicle[];
|
||||||
maxSelections: number;
|
maxSelections: number;
|
||||||
targetTier: SubscriptionTier;
|
targetTier: SubscriptionTier;
|
||||||
|
/** When true, dialog cannot be dismissed - user must make a selection */
|
||||||
|
blocking?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const VehicleSelectionDialog = ({
|
export const VehicleSelectionDialog = ({
|
||||||
@@ -38,6 +40,7 @@ export const VehicleSelectionDialog = ({
|
|||||||
vehicles,
|
vehicles,
|
||||||
maxSelections,
|
maxSelections,
|
||||||
targetTier,
|
targetTier,
|
||||||
|
blocking = false,
|
||||||
}: VehicleSelectionDialogProps) => {
|
}: VehicleSelectionDialogProps) => {
|
||||||
const [selectedVehicleIds, setSelectedVehicleIds] = useState<string[]>([]);
|
const [selectedVehicleIds, setSelectedVehicleIds] = useState<string[]>([]);
|
||||||
|
|
||||||
@@ -77,14 +80,42 @@ export const VehicleSelectionDialog = ({
|
|||||||
|
|
||||||
const canConfirm = selectedVehicleIds.length > 0 && selectedVehicleIds.length <= maxSelections;
|
const canConfirm = selectedVehicleIds.length > 0 && selectedVehicleIds.length <= maxSelections;
|
||||||
|
|
||||||
|
// Handle dialog close - prevent if blocking
|
||||||
|
const handleClose = (_event: object, reason: string) => {
|
||||||
|
if (blocking && (reason === 'backdropClick' || reason === 'escapeKeyDown')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
<Dialog
|
||||||
<DialogTitle>Select Vehicles to Keep</DialogTitle>
|
open={open}
|
||||||
|
onClose={handleClose}
|
||||||
|
maxWidth="sm"
|
||||||
|
fullWidth
|
||||||
|
disableEscapeKeyDown={blocking}
|
||||||
|
>
|
||||||
|
<DialogTitle>
|
||||||
|
{blocking ? 'Vehicle Selection Required' : 'Select Vehicles to Keep'}
|
||||||
|
</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
<Alert severity={blocking ? 'info' : 'warning'} sx={{ mb: 2 }}>
|
||||||
|
{blocking ? (
|
||||||
|
<>
|
||||||
|
Your subscription has been downgraded to the {targetTier} tier, which allows{' '}
|
||||||
|
{maxSelections} vehicle{maxSelections > 1 ? 's' : ''}. Please select which vehicles
|
||||||
|
you want to keep active to continue using the app. Unselected vehicles will be hidden
|
||||||
|
but not deleted, and you can unlock them by upgrading later.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
You are downgrading to the {targetTier} tier, which allows {maxSelections} vehicle
|
You are downgrading to the {targetTier} tier, which allows {maxSelections} vehicle
|
||||||
{maxSelections > 1 ? 's' : ''}. Select which vehicles you want to keep active. Unselected
|
{maxSelections > 1 ? 's' : ''}. Select which vehicles you want to keep active.
|
||||||
vehicles will be hidden but not deleted, and you can unlock them by upgrading later.
|
Unselected vehicles will be hidden but not deleted, and you can unlock them by
|
||||||
|
upgrading later.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<Box sx={{ mb: 2 }}>
|
<Box sx={{ mb: 2 }}>
|
||||||
@@ -125,9 +156,9 @@ export const VehicleSelectionDialog = ({
|
|||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={onClose}>Cancel</Button>
|
{!blocking && <Button onClick={onClose}>Cancel</Button>}
|
||||||
<Button onClick={handleConfirm} variant="contained" disabled={!canConfirm}>
|
<Button onClick={handleConfirm} variant="contained" disabled={!canConfirm}>
|
||||||
Confirm Downgrade
|
{blocking ? 'Confirm Selection' : 'Confirm Downgrade'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -19,6 +19,17 @@ export const useSubscription = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useNeedsVehicleSelection = () => {
|
||||||
|
const { isAuthenticated, isLoading } = useAuth0();
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['needs-vehicle-selection'],
|
||||||
|
queryFn: () => subscriptionApi.needsVehicleSelection(),
|
||||||
|
enabled: isAuthenticated && !isLoading,
|
||||||
|
staleTime: 0, // Always fetch fresh on login
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const useCheckout = () => {
|
export const useCheckout = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -41,3 +41,9 @@ export interface DowngradeRequest {
|
|||||||
targetTier: SubscriptionTier;
|
targetTier: SubscriptionTier;
|
||||||
vehicleIdsToKeep: string[];
|
vehicleIdsToKeep: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NeedsVehicleSelectionResponse {
|
||||||
|
needsSelection: boolean;
|
||||||
|
vehicleCount: number;
|
||||||
|
maxAllowed: number;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user