feat: Add tier-based vehicle limit enforcement (refs #23)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 4m37s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 38s
Deploy to Staging / Verify Staging (pull_request) Successful in 6s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 6s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped

Backend:
- Add VEHICLE_LIMITS configuration to feature-tiers.ts
- Add getVehicleLimit, canAddVehicle helper functions
- Implement transaction-based limit check with FOR UPDATE locking
- Add VehicleLimitExceededError and 403 TIER_REQUIRED response
- Add countByUserId to VehiclesRepository
- Add comprehensive tests for all limit logic

Frontend:
- Add getResourceLimit, isAtResourceLimit to useTierAccess hook
- Create VehicleLimitDialog component with mobile/desktop modes
- Add useVehicleLimitCheck shared hook for limit state
- Update VehiclesPage with limit checks and lock icon
- Update VehiclesMobileScreen with limit checks
- Add tests for VehicleLimitDialog

Implements vehicle limits per tier (Free: 2, Pro: 5, Enterprise: unlimited)
with race condition prevention and consistent UX across mobile/desktop.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Eric Gullickson
2026-01-11 16:36:53 -06:00
parent dff743ca36
commit 20189a1d37
15 changed files with 1179 additions and 48 deletions

View File

@@ -76,3 +76,75 @@ export function getFeatureConfig(featureKey: string): FeatureConfig | undefined
export function getAllFeatureConfigs(): Record<string, FeatureConfig> {
return { ...FEATURE_TIERS };
}
// Vehicle limits per tier
// null indicates unlimited (enterprise tier)
export const VEHICLE_LIMITS: Record<SubscriptionTier, number | null> = {
free: 2,
pro: 5,
enterprise: null,
} as const;
/**
* Vehicle limits vary by subscription tier and must be queryable
* at runtime for both backend enforcement and frontend UI state.
*
* @param tier - User's subscription tier
* @returns Maximum vehicles allowed, or null for unlimited (enterprise tier)
*/
export function getVehicleLimit(tier: SubscriptionTier): number | null {
return VEHICLE_LIMITS[tier] ?? null;
}
/**
* Check if a user can add another vehicle based on their tier and current count.
*
* @param tier - User's subscription tier
* @param currentCount - Number of vehicles user currently has
* @returns true if user can add another vehicle, false if at/over limit
*/
export function canAddVehicle(tier: SubscriptionTier, currentCount: number): boolean {
const limit = getVehicleLimit(tier);
// null limit means unlimited (enterprise)
if (limit === null) {
return true;
}
return currentCount < limit;
}
/**
* Vehicle limit configuration with upgrade prompt.
* Structure supports additional resource types in the future.
*/
export interface VehicleLimitConfig {
limit: number | null;
tier: SubscriptionTier;
upgradePrompt: string;
}
/**
* Get vehicle limit configuration with upgrade prompt for a tier.
*
* @param tier - User's subscription tier
* @returns Configuration with limit and upgrade prompt
*/
export function getVehicleLimitConfig(tier: SubscriptionTier): VehicleLimitConfig {
const limit = getVehicleLimit(tier);
const defaultPrompt = 'Upgrade to access additional vehicles.';
let upgradePrompt: string;
if (tier === 'free') {
upgradePrompt = 'Free tier is limited to 2 vehicles. Upgrade to Pro for up to 5 vehicles, or Enterprise for unlimited.';
} else if (tier === 'pro') {
upgradePrompt = 'Pro tier is limited to 5 vehicles. Upgrade to Enterprise for unlimited vehicles.';
} else {
upgradePrompt = defaultPrompt;
}
return {
limit,
tier,
upgradePrompt,
};
}

View File

@@ -1,11 +1,15 @@
import {
TIER_LEVELS,
FEATURE_TIERS,
VEHICLE_LIMITS,
getTierLevel,
canAccessFeature,
getRequiredTier,
getFeatureConfig,
getAllFeatureConfigs,
getVehicleLimit,
canAddVehicle,
getVehicleLimitConfig,
} from '../feature-tiers';
describe('feature-tiers', () => {
@@ -101,4 +105,97 @@ describe('feature-tiers', () => {
expect(FEATURE_TIERS['test' as keyof typeof FEATURE_TIERS]).toBeUndefined();
});
});
describe('VEHICLE_LIMITS', () => {
it('defines correct limits for each tier', () => {
expect(VEHICLE_LIMITS.free).toBe(2);
expect(VEHICLE_LIMITS.pro).toBe(5);
expect(VEHICLE_LIMITS.enterprise).toBeNull();
});
});
describe('getVehicleLimit', () => {
it('returns 2 for free tier', () => {
expect(getVehicleLimit('free')).toBe(2);
});
it('returns 5 for pro tier', () => {
expect(getVehicleLimit('pro')).toBe(5);
});
it('returns null for enterprise tier (unlimited)', () => {
expect(getVehicleLimit('enterprise')).toBeNull();
});
});
describe('canAddVehicle', () => {
describe('free tier (limit 2)', () => {
it('returns true when below limit', () => {
expect(canAddVehicle('free', 0)).toBe(true);
expect(canAddVehicle('free', 1)).toBe(true);
});
it('returns false when at limit', () => {
expect(canAddVehicle('free', 2)).toBe(false);
});
it('returns false when over limit', () => {
expect(canAddVehicle('free', 3)).toBe(false);
});
});
describe('pro tier (limit 5)', () => {
it('returns true when below limit', () => {
expect(canAddVehicle('pro', 0)).toBe(true);
expect(canAddVehicle('pro', 4)).toBe(true);
});
it('returns false when at limit', () => {
expect(canAddVehicle('pro', 5)).toBe(false);
});
it('returns false when over limit', () => {
expect(canAddVehicle('pro', 6)).toBe(false);
});
});
describe('enterprise tier (unlimited)', () => {
it('always returns true regardless of count', () => {
expect(canAddVehicle('enterprise', 0)).toBe(true);
expect(canAddVehicle('enterprise', 100)).toBe(true);
expect(canAddVehicle('enterprise', 999999)).toBe(true);
});
});
});
describe('getVehicleLimitConfig', () => {
it('returns correct config for free tier', () => {
const config = getVehicleLimitConfig('free');
expect(config.limit).toBe(2);
expect(config.tier).toBe('free');
expect(config.upgradePrompt).toContain('Free tier is limited to 2 vehicles');
expect(config.upgradePrompt).toContain('Pro');
expect(config.upgradePrompt).toContain('Enterprise');
});
it('returns correct config for pro tier', () => {
const config = getVehicleLimitConfig('pro');
expect(config.limit).toBe(5);
expect(config.tier).toBe('pro');
expect(config.upgradePrompt).toContain('Pro tier is limited to 5 vehicles');
expect(config.upgradePrompt).toContain('Enterprise');
});
it('returns correct config for enterprise tier', () => {
const config = getVehicleLimitConfig('enterprise');
expect(config.limit).toBeNull();
expect(config.tier).toBe('enterprise');
expect(config.upgradePrompt).toBeTruthy();
});
it('provides default upgradePrompt fallback', () => {
const config = getVehicleLimitConfig('enterprise');
expect(config.upgradePrompt).toBe('Upgrade to access additional vehicles.');
});
});
});

View File

@@ -4,7 +4,7 @@
*/
import { FastifyRequest, FastifyReply } from 'fastify';
import { VehiclesService } from '../domain/vehicles.service';
import { VehiclesService, VehicleLimitExceededError } from '../domain/vehicles.service';
import { VehiclesRepository } from '../data/vehicles.repository';
import { pool } from '../../../core/config/database';
import { logger } from '../../../core/logging/logger';
@@ -21,7 +21,7 @@ export class VehiclesController {
constructor() {
const repository = new VehiclesRepository(pool);
this.vehiclesService = new VehiclesService(repository);
this.vehiclesService = new VehiclesService(repository, pool);
this.nhtsaClient = new NHTSAClient(pool);
}
@@ -62,25 +62,40 @@ export class VehiclesController {
const userId = (request as any).user.sub;
const vehicle = await this.vehiclesService.createVehicle(request.body, userId);
return reply.code(201).send(vehicle);
} catch (error: any) {
logger.error('Error creating vehicle', { error, userId: (request as any).user?.sub });
if (error instanceof VehicleLimitExceededError) {
return reply.code(403).send({
error: 'TIER_REQUIRED',
requiredTier: error.tier === 'free' ? 'pro' : 'enterprise',
currentTier: error.tier,
feature: 'vehicle.addBeyondLimit',
featureName: 'Additional Vehicles',
upgradePrompt: error.upgradePrompt,
context: {
limit: error.limit,
count: error.currentCount
}
});
}
if (error.message === 'Invalid VIN format') {
return reply.code(400).send({
error: 'Bad Request',
message: error.message
});
}
if (error.message === 'Vehicle with this VIN already exists') {
return reply.code(400).send({
error: 'Bad Request',
message: error.message
});
}
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to create vehicle'

View File

@@ -47,11 +47,21 @@ export class VehiclesRepository {
WHERE user_id = $1 AND is_active = true
ORDER BY created_at DESC
`;
const result = await this.pool.query(query, [userId]);
return result.rows.map(row => this.mapRow(row));
}
async countByUserId(userId: string): Promise<number> {
const query = `
SELECT COUNT(*) as count FROM vehicles
WHERE user_id = $1 AND is_active = true
`;
const result = await this.pool.query(query, [userId]);
return parseInt(result.rows[0].count, 10);
}
async findById(id: string): Promise<Vehicle | null> {
const query = 'SELECT * FROM vehicles WHERE id = $1';
const result = await this.pool.query(query, [id]);

View File

@@ -3,6 +3,7 @@
* @ai-context Handles VIN decoding, caching, and business rules
*/
import { Pool } from 'pg';
import { VehiclesRepository } from '../data/vehicles.repository';
import {
Vehicle,
@@ -21,13 +22,33 @@ import { normalizeMakeName, normalizeModelName } from './name-normalizer';
import { getVehicleDataService, getPool } from '../../platform';
import { auditLogService } from '../../audit-log';
import { NHTSAClient, NHTSADecodeResponse, DecodedVehicleData, MatchedField } from '../external/nhtsa';
import { canAddVehicle, getVehicleLimitConfig } from '../../../core/config/feature-tiers';
import { UserProfileRepository } from '../../user-profile/data/user-profile.repository';
import { SubscriptionTier } from '../../user-profile/domain/user-profile.types';
export class VehicleLimitExceededError extends Error {
constructor(
public tier: SubscriptionTier,
public currentCount: number,
public limit: number,
public upgradePrompt: string
) {
super('Vehicle limit exceeded');
this.name = 'VehicleLimitExceededError';
}
}
export class VehiclesService {
private readonly cachePrefix = 'vehicles';
private readonly listCacheTTL = 300; // 5 minutes
private userProfileRepository: UserProfileRepository;
constructor(private repository: VehiclesRepository) {
constructor(
private repository: VehiclesRepository,
private pool: Pool
) {
// VIN decode service is now provided by platform feature
this.userProfileRepository = new UserProfileRepository(pool);
}
async createVehicle(data: CreateVehicleRequest, userId: string): Promise<VehicleResponse> {
@@ -52,29 +73,123 @@ export class VehiclesService {
}
}
// Create vehicle with user-provided data
const vehicle = await this.repository.create({
...data,
userId,
make: data.make ? normalizeMakeName(data.make) : undefined,
model: data.model ? normalizeModelName(data.model) : undefined,
});
// Get user's tier for limit enforcement
const userProfile = await this.userProfileRepository.getByAuth0Sub(userId);
if (!userProfile) {
throw new Error('User profile not found');
}
const userTier = userProfile.subscriptionTier;
// Invalidate user's vehicle list cache
await this.invalidateUserCache(userId);
// Tier limit enforcement with transaction + FOR UPDATE locking to prevent race condition
const client = await this.pool.connect();
try {
await client.query('BEGIN');
// Log vehicle creation to unified audit log
const vehicleDesc = [vehicle.year, vehicle.make, vehicle.model].filter(Boolean).join(' ');
await auditLogService.info(
'vehicle',
userId,
`Vehicle created: ${vehicleDesc || vehicle.id}`,
'vehicle',
vehicle.id,
{ vin: vehicle.vin, make: vehicle.make, model: vehicle.model, year: vehicle.year }
).catch(err => logger.error('Failed to log vehicle create audit event', { error: err }));
// Lock user's vehicle rows and get count
const countResult = await client.query(
'SELECT COUNT(*) as count FROM vehicles WHERE user_id = $1 AND is_active = true FOR UPDATE',
[userId]
);
const currentCount = parseInt(countResult.rows[0].count, 10);
return this.toResponse(vehicle);
// Check if user can add another vehicle
if (!canAddVehicle(userTier, currentCount)) {
await client.query('ROLLBACK');
const limitConfig = getVehicleLimitConfig(userTier);
throw new VehicleLimitExceededError(
userTier,
currentCount,
limitConfig.limit!,
limitConfig.upgradePrompt
);
}
// Create vehicle with user-provided data (within transaction)
const query = `
INSERT INTO vehicles (
user_id, vin, make, model, year,
engine, transmission, trim_level, drive_type, fuel_type,
nickname, color, license_plate, odometer_reading
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING *
`;
const values = [
userId,
(data.vin && data.vin.trim().length > 0) ? data.vin.trim() : null,
data.make ? normalizeMakeName(data.make) : null,
data.model ? normalizeModelName(data.model) : null,
data.year,
data.engine,
data.transmission,
data.trimLevel,
data.driveType,
data.fuelType,
data.nickname,
data.color,
data.licensePlate,
data.odometerReading || 0
];
const result = await client.query(query, values);
await client.query('COMMIT');
const vehicle = this.mapVehicleRow(result.rows[0]);
// Invalidate user's vehicle list cache
await this.invalidateUserCache(userId);
// Log vehicle creation to unified audit log
const vehicleDesc = [vehicle.year, vehicle.make, vehicle.model].filter(Boolean).join(' ');
await auditLogService.info(
'vehicle',
userId,
`Vehicle created: ${vehicleDesc || vehicle.id}`,
'vehicle',
vehicle.id,
{ vin: vehicle.vin, make: vehicle.make, model: vehicle.model, year: vehicle.year }
).catch(err => logger.error('Failed to log vehicle create audit event', { error: err }));
return this.toResponse(vehicle);
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
/**
* Map database row to Vehicle domain object
*/
private mapVehicleRow(row: any): Vehicle {
return {
id: row.id,
userId: row.user_id,
vin: row.vin,
make: row.make,
model: row.model,
year: row.year,
engine: row.engine,
transmission: row.transmission,
trimLevel: row.trim_level,
driveType: row.drive_type,
fuelType: row.fuel_type,
nickname: row.nickname,
color: row.color,
licensePlate: row.license_plate,
odometerReading: row.odometer_reading,
isActive: row.is_active,
deletedAt: row.deleted_at,
createdAt: row.created_at,
updatedAt: row.updated_at,
imageStorageBucket: row.image_storage_bucket,
imageStorageKey: row.image_storage_key,
imageFileName: row.image_file_name,
imageContentType: row.image_content_type,
imageFileSize: row.image_file_size,
};
}
async getUserVehicles(userId: string): Promise<VehicleResponse[]> {

View File

@@ -4,7 +4,6 @@
*/
import request from 'supertest';
import { app } from '../../../../app';
import pool from '../../../../core/config/database';
import { cacheService } from '../../../../core/config/redis';
import { readFileSync } from 'fs';
@@ -13,15 +12,19 @@ import fastifyPlugin from 'fastify-plugin';
// Mock auth plugin to bypass JWT validation in tests
jest.mock('../../../../core/plugins/auth.plugin', () => {
const fp = require('fastify-plugin');
return {
default: fastifyPlugin(async function(fastify) {
fastify.decorate('authenticate', async function(request, _reply) {
default: fp(async function(fastify: any) {
fastify.decorate('authenticate', async function(request: any, _reply: any) {
request.user = { sub: 'test-user-123' };
});
}, { name: 'auth-plugin' })
};
});
// Import app after mocking
import app from '../../../../app';
describe('Vehicles Integration Tests', () => {
beforeAll(async () => {
@@ -263,7 +266,7 @@ describe('Vehicles Integration Tests', () => {
it('should return 404 for non-existent vehicle', async () => {
const fakeId = '550e8400-e29b-41d4-a716-446655440000';
const response = await request(app)
.delete(`/api/vehicles/${fakeId}`)
.expect(404);
@@ -271,4 +274,183 @@ describe('Vehicles Integration Tests', () => {
expect(response.body.error).toBe('Vehicle not found');
});
});
describe('Vehicle Limit Enforcement', () => {
beforeEach(async () => {
// Ensure user_profiles table exists and has test user
await pool.query(`
CREATE TABLE IF NOT EXISTS user_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
auth0_sub VARCHAR(255) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL,
display_name VARCHAR(255),
subscription_tier VARCHAR(50) NOT NULL DEFAULT 'free',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
// Create or update test user with free tier
await pool.query(`
INSERT INTO user_profiles (auth0_sub, email, subscription_tier)
VALUES ($1, $2, $3)
ON CONFLICT (auth0_sub) DO UPDATE SET subscription_tier = EXCLUDED.subscription_tier
`, ['test-user-123', 'test@example.com', 'free']);
});
it('should enforce free tier limit (2 vehicles)', async () => {
// Create 2 vehicles (at limit)
await pool.query(`
INSERT INTO vehicles (user_id, year, make, model, license_plate)
VALUES
($1, 2020, 'Honda', 'Civic', 'ABC123'),
($1, 2021, 'Toyota', 'Camry', 'XYZ789')
`, ['test-user-123']);
// Attempt to create a 3rd vehicle
const response = await request(app)
.post('/api/vehicles')
.send({
year: 2022,
make: 'Ford',
model: 'F-150',
licensePlate: 'TEST123'
})
.expect(403);
expect(response.body).toMatchObject({
error: 'TIER_REQUIRED',
currentTier: 'free',
requiredTier: 'pro',
feature: 'vehicle.addBeyondLimit',
context: {
limit: 2,
count: 2
}
});
expect(response.body.upgradePrompt).toContain('Free tier is limited to 2 vehicles');
});
it('should allow free tier user to add vehicle when below limit', async () => {
// Create 1 vehicle (below limit)
await pool.query(`
INSERT INTO vehicles (user_id, year, make, model, license_plate)
VALUES ($1, 2020, 'Honda', 'Civic', 'ABC123')
`, ['test-user-123']);
// Should be able to add 2nd vehicle
const response = await request(app)
.post('/api/vehicles')
.send({
year: 2021,
make: 'Toyota',
model: 'Camry',
licensePlate: 'XYZ789'
})
.expect(201);
expect(response.body).toMatchObject({
year: 2021,
make: 'Toyota',
model: 'Camry'
});
});
it('should enforce pro tier limit (5 vehicles)', async () => {
// Update user to pro tier
await pool.query(`
UPDATE user_profiles SET subscription_tier = 'pro' WHERE auth0_sub = $1
`, ['test-user-123']);
// Create 5 vehicles (at limit)
for (let i = 1; i <= 5; i++) {
await pool.query(`
INSERT INTO vehicles (user_id, year, make, model, license_plate)
VALUES ($1, 2020, 'Honda', 'Civic', $2)
`, ['test-user-123', `PLATE${i}`]);
}
// Attempt to create a 6th vehicle
const response = await request(app)
.post('/api/vehicles')
.send({
year: 2022,
make: 'Ford',
model: 'F-150',
licensePlate: 'TEST123'
})
.expect(403);
expect(response.body).toMatchObject({
error: 'TIER_REQUIRED',
currentTier: 'pro',
requiredTier: 'enterprise',
context: {
limit: 5,
count: 5
}
});
});
it('should allow enterprise tier unlimited vehicles', async () => {
// Update user to enterprise tier
await pool.query(`
UPDATE user_profiles SET subscription_tier = 'enterprise' WHERE auth0_sub = $1
`, ['test-user-123']);
// Create 10 vehicles (well beyond free/pro limits)
for (let i = 1; i <= 10; i++) {
await pool.query(`
INSERT INTO vehicles (user_id, year, make, model, license_plate)
VALUES ($1, 2020, 'Honda', 'Civic', $2)
`, ['test-user-123', `PLATE${i}`]);
}
// Should still be able to add another vehicle
const response = await request(app)
.post('/api/vehicles')
.send({
year: 2022,
make: 'Ford',
model: 'F-150',
licensePlate: 'ENTERPRISE1'
})
.expect(201);
expect(response.body).toMatchObject({
year: 2022,
make: 'Ford',
model: 'F-150'
});
});
it('should only count active vehicles towards limit', async () => {
// Create 1 active vehicle and 1 deleted vehicle
await pool.query(`
INSERT INTO vehicles (user_id, year, make, model, license_plate)
VALUES ($1, 2020, 'Honda', 'Civic', 'ABC123')
`, ['test-user-123']);
await pool.query(`
INSERT INTO vehicles (user_id, year, make, model, license_plate, is_active, deleted_at)
VALUES ($1, 2019, 'Toyota', 'Corolla', 'OLD123', false, NOW())
`, ['test-user-123']);
// Should be able to add 2nd active vehicle (deleted one doesn't count)
const response = await request(app)
.post('/api/vehicles')
.send({
year: 2021,
make: 'Toyota',
model: 'Camry',
licensePlate: 'XYZ789'
})
.expect(201);
expect(response.body).toMatchObject({
year: 2021,
make: 'Toyota'
});
});
});
});

View File

@@ -0,0 +1,66 @@
/**
* @ai-summary Unit tests for VehiclesRepository
* @ai-context Tests repository data access methods with mocked database
*/
import { Pool } from 'pg';
import { VehiclesRepository } from '../../data/vehicles.repository';
describe('VehiclesRepository', () => {
let pool: Pool;
let repository: VehiclesRepository;
beforeEach(() => {
pool = {
query: jest.fn(),
} as any;
repository = new VehiclesRepository(pool);
});
describe('countByUserId', () => {
it('returns accurate count of active vehicles', async () => {
const mockResult = {
rows: [{ count: '3' }],
};
(pool.query as jest.Mock).mockResolvedValue(mockResult);
const count = await repository.countByUserId('user-123');
expect(count).toBe(3);
expect(pool.query).toHaveBeenCalledWith(
expect.stringContaining('SELECT COUNT(*) as count FROM vehicles'),
['user-123']
);
expect(pool.query).toHaveBeenCalledWith(
expect.stringContaining('is_active = true'),
['user-123']
);
});
it('returns 0 for user with no vehicles', async () => {
const mockResult = {
rows: [{ count: '0' }],
};
(pool.query as jest.Mock).mockResolvedValue(mockResult);
const count = await repository.countByUserId('user-empty');
expect(count).toBe(0);
});
it('only counts active vehicles (excludes deleted)', async () => {
const mockResult = {
rows: [{ count: '2' }],
};
(pool.query as jest.Mock).mockResolvedValue(mockResult);
const count = await repository.countByUserId('user-with-deleted');
expect(count).toBe(2);
expect(pool.query).toHaveBeenCalledWith(
expect.stringContaining('is_active = true'),
['user-with-deleted']
);
});
});
});

View File

@@ -53,10 +53,19 @@ describe('VehiclesService', () => {
findByUserAndVIN: jest.fn(),
update: jest.fn(),
softDelete: jest.fn(),
countByUserId: jest.fn(),
} as any;
const mockPool = {
query: jest.fn(),
connect: jest.fn().mockResolvedValue({
query: jest.fn(),
release: jest.fn(),
}),
} as any;
mockRepository.mockImplementation(() => repositoryInstance);
service = new VehiclesService(repositoryInstance);
service = new VehiclesService(repositoryInstance, mockPool);
});
describe('dropdown data integration', () => {