feat: add vehicle selection and downgrade flow - M5 (refs #55)
This commit is contained in:
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,4 +48,10 @@ export const subscriptionsRoutes: FastifyPluginAsync = async (
|
|||||||
preHandler: [fastify.authenticate],
|
preHandler: [fastify.authenticate],
|
||||||
handler: subscriptionsController.getInvoices.bind(subscriptionsController)
|
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)
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
* Handle incoming Stripe webhook event
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -221,6 +221,68 @@ export class VehiclesService {
|
|||||||
return response;
|
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> {
|
async getVehicle(id: string, userId: string): Promise<VehicleResponse> {
|
||||||
const vehicle = await this.repository.findById(id);
|
const vehicle = await this.repository.findById(id);
|
||||||
|
|
||||||
|
|||||||
@@ -195,6 +195,11 @@ export interface VehicleParams {
|
|||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Vehicle with tier status (for tier-gated access)
|
||||||
|
export interface VehicleWithTierStatus extends Vehicle {
|
||||||
|
tierStatus: 'active' | 'locked';
|
||||||
|
}
|
||||||
|
|
||||||
// TCO (Total Cost of Ownership) response
|
// TCO (Total Cost of Ownership) response
|
||||||
export interface TCOResponse {
|
export interface TCOResponse {
|
||||||
vehicleId: string;
|
vehicleId: string;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { apiClient } from '../../../core/api/client';
|
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 = {
|
export const subscriptionApi = {
|
||||||
getSubscription: () => apiClient.get('/subscriptions'),
|
getSubscription: () => apiClient.get('/subscriptions'),
|
||||||
@@ -8,4 +8,5 @@ export const subscriptionApi = {
|
|||||||
reactivate: () => apiClient.post('/subscriptions/reactivate'),
|
reactivate: () => apiClient.post('/subscriptions/reactivate'),
|
||||||
updatePaymentMethod: (data: PaymentMethodUpdateRequest) => apiClient.put('/subscriptions/payment-method', data),
|
updatePaymentMethod: (data: PaymentMethodUpdateRequest) => apiClient.put('/subscriptions/payment-method', data),
|
||||||
getInvoices: () => apiClient.get('/subscriptions/invoices'),
|
getInvoices: () => apiClient.get('/subscriptions/invoices'),
|
||||||
|
downgrade: (data: DowngradeRequest) => apiClient.post('/subscriptions/downgrade', data),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -74,3 +74,20 @@ export const useInvoices = () => {
|
|||||||
staleTime: 5 * 60 * 1000,
|
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');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -21,12 +21,14 @@ import { Card } from '../../../shared-minimal/components/Card';
|
|||||||
import { TierCard } from '../components/TierCard';
|
import { TierCard } from '../components/TierCard';
|
||||||
import { PaymentMethodForm } from '../components/PaymentMethodForm';
|
import { PaymentMethodForm } from '../components/PaymentMethodForm';
|
||||||
import { BillingHistory } from '../components/BillingHistory';
|
import { BillingHistory } from '../components/BillingHistory';
|
||||||
|
import { DowngradeFlow } from '../components/DowngradeFlow';
|
||||||
import {
|
import {
|
||||||
useSubscription,
|
useSubscription,
|
||||||
useCheckout,
|
useCheckout,
|
||||||
useCancelSubscription,
|
useCancelSubscription,
|
||||||
useReactivateSubscription,
|
useReactivateSubscription,
|
||||||
useInvoices,
|
useInvoices,
|
||||||
|
useDowngrade,
|
||||||
} from '../hooks/useSubscription';
|
} from '../hooks/useSubscription';
|
||||||
import { PLANS } from '../constants/plans';
|
import { PLANS } from '../constants/plans';
|
||||||
import type { BillingCycle, SubscriptionTier } from '../types/subscription.types';
|
import type { BillingCycle, SubscriptionTier } from '../types/subscription.types';
|
||||||
@@ -37,12 +39,15 @@ export const SubscriptionPage: React.FC = () => {
|
|||||||
const [billingCycle, setBillingCycle] = useState<BillingCycle>('monthly');
|
const [billingCycle, setBillingCycle] = useState<BillingCycle>('monthly');
|
||||||
const [selectedTier, setSelectedTier] = useState<SubscriptionTier | null>(null);
|
const [selectedTier, setSelectedTier] = useState<SubscriptionTier | null>(null);
|
||||||
const [showPaymentDialog, setShowPaymentDialog] = useState(false);
|
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: subscriptionData, isLoading: isLoadingSubscription } = useSubscription();
|
||||||
const { data: invoicesData, isLoading: isLoadingInvoices } = useInvoices();
|
const { data: invoicesData, isLoading: isLoadingInvoices } = useInvoices();
|
||||||
const checkoutMutation = useCheckout();
|
const checkoutMutation = useCheckout();
|
||||||
const cancelMutation = useCancelSubscription();
|
const cancelMutation = useCancelSubscription();
|
||||||
const reactivateMutation = useReactivateSubscription();
|
const reactivateMutation = useReactivateSubscription();
|
||||||
|
const downgradeMutation = useDowngrade();
|
||||||
|
|
||||||
const subscription = subscriptionData?.data;
|
const subscription = subscriptionData?.data;
|
||||||
const invoices = invoicesData?.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) => {
|
const handleUpgradeClick = (tier: SubscriptionTier) => {
|
||||||
|
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);
|
setSelectedTier(tier);
|
||||||
setShowPaymentDialog(true);
|
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) => {
|
const handlePaymentSubmit = (paymentMethodId: string) => {
|
||||||
@@ -244,6 +290,14 @@ export const SubscriptionPage: React.FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{showDowngradeFlow && downgradeTargetTier && (
|
||||||
|
<DowngradeFlow
|
||||||
|
targetTier={downgradeTargetTier}
|
||||||
|
onComplete={handleDowngradeComplete}
|
||||||
|
onCancel={handleDowngradeCancel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -36,3 +36,8 @@ export interface CheckoutRequest {
|
|||||||
export interface PaymentMethodUpdateRequest {
|
export interface PaymentMethodUpdateRequest {
|
||||||
paymentMethodId: string;
|
paymentMethodId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DowngradeRequest {
|
||||||
|
targetTier: SubscriptionTier;
|
||||||
|
vehicleIdsToKeep: string[];
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user