fix bug 16 and 17
Build & Push / Pipeline Tests (push) Successful in 1m53s
Test / Type Check (all packages) (push) Successful in 56s
Build & Push / Build & Push Docker Image (push) Failing after 4m53s
Test / API Unit Tests (push) Successful in 1m15s
Test / Homepage Unit Tests (push) Successful in 46s
Test / Carplace Unit Tests (push) Successful in 41s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 42s
Test / API Integration Tests (push) Successful in 1m6s

This commit is contained in:
root
2026-08-04 00:52:58 -04:00
parent 626da23c2e
commit 149806a773
72 changed files with 834 additions and 174 deletions
+2 -2
View File
@@ -492,7 +492,7 @@ export function listPlanFeatures() {
})
}
export function createPlanFeature(data: { plan: 'STARTER' | 'GROWTH' | 'PRO'; label: string; sortOrder?: number }) {
export function createPlanFeature(data: { plan: 'STARTER' | 'GROWTH' | 'PRO' | 'ENTERPRISE'; label: string; sortOrder?: number }) {
return prisma.planFeature.create({
data: {
plan: data.plan,
@@ -502,7 +502,7 @@ export function createPlanFeature(data: { plan: 'STARTER' | 'GROWTH' | 'PRO'; la
})
}
export function updatePlanFeature(id: string, data: Partial<{ plan: 'STARTER' | 'GROWTH' | 'PRO'; label: string; sortOrder: number }>) {
export function updatePlanFeature(id: string, data: Partial<{ plan: 'STARTER' | 'GROWTH' | 'PRO' | 'ENTERPRISE'; label: string; sortOrder: number }>) {
return prisma.planFeature.update({
where: { id },
data,
+5 -5
View File
@@ -150,7 +150,7 @@ export const adminCompanyUpdateSchema = z.object({
}).optional(),
}).optional(),
subscription: z.object({
plan: z.enum(['STARTER', 'GROWTH', 'PRO']).optional(),
plan: z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE']).optional(),
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']).optional(),
status: z.enum(['TRIALING', 'ACTIVE', 'PAST_DUE', 'CANCELLED', 'UNPAID']).optional(),
currency: z.literal('MAD').optional(),
@@ -316,14 +316,14 @@ export const homepageUpdateSchema = z.object({
export const pricingUpdateSchema = z.object({
entries: z.array(z.object({
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
plan: z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE']),
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
amount: z.number().int().positive(),
})).min(1),
})
const planFeatureSchema = z.object({
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
plan: z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE']),
label: z.string().min(1).max(120),
sortOrder: z.number().int().min(0).default(0),
})
@@ -336,7 +336,7 @@ export const promotionCreateSchema = z.object({
description: z.string().max(500).optional(),
discountType: z.enum(['PERCENTAGE', 'FIXED']),
discountValue: z.number().int().positive(),
plans: z.array(z.enum(['STARTER', 'GROWTH', 'PRO'])),
plans: z.array(z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE'])),
periods: z.array(z.enum(['MONTHLY', 'ANNUAL'])),
maxUses: z.number().int().positive().nullable().optional(),
validFrom: z.string().datetime(),
@@ -350,7 +350,7 @@ export const planFeatureIdParamSchema = z.object({ featureId: z.string().min(1)
export const promotionIdParamSchema = z.object({ promotionId: z.string().min(1) })
const employeeRoleSchema = z.enum(['OWNER', 'MANAGER', 'AGENT'])
const planSchema = z.enum(['STARTER', 'GROWTH', 'PRO'])
const planSchema = z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE'])
const menuItemTypeSchema = z.enum(['INTERNAL_PAGE', 'EXTERNAL_LINK', 'PARENT_MENU', 'SECTION_LABEL', 'DIVIDER'])
export const menuItemSchema = z.object({
@@ -11,6 +11,7 @@ export const accountStartSchema = z.object({
email: z.string().email(),
password: z.string().min(8).max(128),
preferredLanguage: z.enum(['en', 'fr', 'ar']).default('en'),
subscriptionPlan: z.enum(['STARTER', 'GROWTH', 'PRO']).optional(),
})
export const companyProfileSchema = z.object({
@@ -57,6 +58,6 @@ export const paymentSetupSchema = z.object({
})
export const planSchema = z.object({
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
plan: z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE']),
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
})
@@ -63,7 +63,7 @@ export async function startAccount(body: AccountStartInput) {
await tx.subscription.create({
data: {
companyId: company.id,
plan: 'STARTER',
plan: body.subscriptionPlan ?? 'STARTER',
billingPeriod: 'MONTHLY',
currency: 'MAD',
status: 'TRIALING',
@@ -32,7 +32,7 @@ type CompanySignupInput = {
responsibleEmail: string
currency: 'MAD'
registrationNumber: string
plan: 'STARTER' | 'GROWTH' | 'PRO'
plan: 'STARTER' | 'GROWTH' | 'PRO' | 'ENTERPRISE'
billingPeriod: 'MONTHLY' | 'ANNUAL'
preferredLanguage: 'en' | 'fr' | 'ar'
firstName: string
@@ -31,7 +31,7 @@ export const companySignupSchema = z.object({
responsiblePhone: z.string().min(1).max(80),
responsibleEmail: z.string().email(),
preferredLanguage: z.enum(['en', 'fr', 'ar']).default('en'),
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
plan: z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE']),
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
currency: z.literal('MAD'),
paymentProvider: z.enum(['AMANPAY', 'PAYPAL']),
+18 -2
View File
@@ -1,3 +1,4 @@
import { accountStartSchema } from './auth.account.schemas'
import { describe, expect, it } from 'vitest'
import { companySignupSchema } from './auth.company.schemas'
import { employeeForgotPasswordSchema, employeeLanguageSchema, employeeLoginSchema, employeeResetPasswordSchema } from './auth.employee.schemas'
@@ -36,15 +37,30 @@ describe('auth schemas', () => {
paymentProvider: 'AMANPAY',
} as const
it('defaults company signup language and rejects unsupported commercial choices', () => {
it('defaults company signup language and validates commercial choices', () => {
const parsed = companySignupSchema.parse(validCompanySignup)
expect(parsed.preferredLanguage).toBe('en')
expect(companySignupSchema.safeParse({ ...validCompanySignup, plan: 'ENTERPRISE' }).success).toBe(false)
expect(companySignupSchema.safeParse({ ...validCompanySignup, plan: 'ENTERPRISE' }).success).toBe(true)
expect(companySignupSchema.safeParse({ ...validCompanySignup, currency: 'EUR' }).success).toBe(false)
expect(companySignupSchema.safeParse({ ...validCompanySignup, paymentProvider: 'STRIPE' }).success).toBe(false)
})
it('accepts optional subscription plan for minimal account start', () => {
const parsed = accountStartSchema.parse({
firstName: 'Aya',
lastName: 'Benali',
companyName: 'Atlas Cars',
email: 'owner@example.test',
password: 'safe-password',
preferredLanguage: 'fr',
subscriptionPlan: 'PRO',
})
expect(parsed.subscriptionPlan).toBe('PRO')
expect(accountStartSchema.safeParse({ ...parsed, subscriptionPlan: 'TEAM' }).success).toBe(false)
})
it('normalizes employee auth fields and rejects weak reset payloads', () => {
expect(employeeLoginSchema.parse({ email: 'Agent@Example.COM', password: 'password' })).toEqual({
email: 'agent@example.com',
@@ -30,7 +30,7 @@ export type SettingsSectionKey =
| 'accounting'
type Locale = 'en' | 'fr' | 'ar'
type Plan = 'STARTER' | 'GROWTH' | 'PRO'
type Plan = 'STARTER' | 'GROWTH' | 'PRO' | 'ENTERPRISE'
type SubscriptionStatus =
| 'TRIALING'
| 'ACTIVE'
@@ -44,9 +44,9 @@ type SubscriptionStatus =
type MenuState = 'ENABLED' | 'LOCKED'
const ALL_PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO']
const GROWTH_PLUS: Plan[] = ['GROWTH', 'PRO']
const PRO_ONLY: Plan[] = ['PRO']
const ALL_PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE']
const GROWTH_PLUS: Plan[] = ['GROWTH', 'PRO', 'ENTERPRISE']
const PRO_ONLY: Plan[] = ['PRO', 'ENTERPRISE']
const SECTION_COPY: Record<Locale, Record<SettingsSectionKey, { label: string; description: string }>> = {
en: {
+1 -1
View File
@@ -3,7 +3,7 @@ import { prisma } from '../../lib/prisma'
import { getAccessLevel, hasFullAccess, type AccessLevel } from '../subscriptions/subscription.policy'
type EmployeeRole = 'OWNER' | 'MANAGER' | 'AGENT'
type Plan = 'STARTER' | 'GROWTH' | 'PRO'
type Plan = 'STARTER' | 'GROWTH' | 'PRO' | 'ENTERPRISE'
type MenuItemType = 'INTERNAL_PAGE' | 'EXTERNAL_LINK' | 'PARENT_MENU' | 'SECTION_LABEL' | 'DIVIDER'
type AdminActor = {
@@ -1,6 +1,6 @@
import { z } from 'zod'
const planEnum = z.enum(['STARTER', 'GROWTH', 'PRO'])
const planEnum = z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE'])
const billingPeriodEnum = z.enum(['MONTHLY', 'ANNUAL'])
const providerEnum = z.enum(['STRIPE'])
const currencyEnum = z.enum(['MAD', 'EUR', 'USD'])
@@ -259,7 +259,7 @@ export async function handleStripeWebhook(event: any, rawBody: string | Buffer =
// ─── Checkout ─────────────────────────────────────────────────
export async function checkout(companyId: string, body: {
plan: 'STARTER' | 'GROWTH' | 'PRO'; billingPeriod: 'MONTHLY' | 'ANNUAL'
plan: 'STARTER' | 'GROWTH' | 'PRO' | 'ENTERPRISE'; billingPeriod: 'MONTHLY' | 'ANNUAL'
currency: 'MAD'; provider: 'STRIPE'
successUrl: string; failureUrl: string
}) {
@@ -367,7 +367,7 @@ export async function resume(companyId: string) {
// ─── Reactivation ────────────────────────────────────────────
export async function reactivate(companyId: string, body: {
plan: 'STARTER' | 'GROWTH' | 'PRO'; billingPeriod: 'MONTHLY' | 'ANNUAL'
plan: 'STARTER' | 'GROWTH' | 'PRO' | 'ENTERPRISE'; billingPeriod: 'MONTHLY' | 'ANNUAL'
currency: 'MAD'; provider: 'STRIPE'
successUrl: string; failureUrl: string
}) {
@@ -1,5 +1,25 @@
import { prisma } from '../../lib/prisma'
export async function getCompanySubscription(companyId: string) {
return prisma.subscription.findUnique({ where: { companyId } })
}
export async function findPlanFeatures(plan: string) {
return prisma.planFeature.findMany({
where: { plan: plan as any },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
})
}
export async function countActiveFleetVehicles(companyId: string) {
return prisma.vehicle.count({
where: {
companyId,
status: { not: 'OUT_OF_SERVICE' },
},
})
}
export async function findMany(where: any, skip: number, take: number) {
return Promise.all([
prisma.vehicle.findMany({ where, skip, take, orderBy: { createdAt: 'desc' } }),
@@ -4,6 +4,9 @@ vi.mock('../../lib/prisma', () => ({ prisma: {} }))
vi.mock('../../lib/storage', () => ({ uploadImage: vi.fn().mockResolvedValue('https://cdn.example.test/photo.jpg') }))
vi.mock('./vehicle.repo', () => ({
create: vi.fn(),
getCompanySubscription: vi.fn(),
findPlanFeatures: vi.fn(),
countActiveFleetVehicles: vi.fn(),
findFirst: vi.fn(),
updateById: vi.fn(),
findById: vi.fn(),
@@ -35,6 +38,11 @@ const vehicle = {
}
beforeEach(() => vi.clearAllMocks())
beforeEach(() => {
vi.mocked(repo.getCompanySubscription).mockResolvedValue({ plan: 'STARTER' } as any)
vi.mocked(repo.findPlanFeatures).mockResolvedValue([{ label: 'Up to 25 vehicles' }] as any)
vi.mocked(repo.countActiveFleetVehicles).mockResolvedValue(0)
})
describe('vehicle.service edge behavior', () => {
it('deduplicates and trims location settings when creating vehicles', async () => {
@@ -1,3 +1,4 @@
import { PLAN_FEATURES } from '@rentaldrivego/types'
import { uploadImage } from '../../lib/storage'
import { AppError, NotFoundError, ValidationError } from '../../http/errors'
import { presentVehicle, presentVehicleList } from './vehicle.presenter'
@@ -349,6 +350,13 @@ const VEHICLE_STATUSES = ['AVAILABLE', 'RESERVED', 'READY', 'RENTED', 'RETURNED'
const VEHICLE_CATEGORIES = ['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'] as const
const VEHICLE_TRANSMISSIONS = ['AUTOMATIC', 'MANUAL'] as const
const VEHICLE_FUEL_TYPES = ['GASOLINE', 'DIESEL', 'ELECTRIC', 'HYBRID'] as const
const ACTIVE_FLEET_STATUSES = new Set(VEHICLE_STATUSES.filter((status) => status !== 'OUT_OF_SERVICE'))
const FALLBACK_VEHICLE_LIMITS: Record<string, number | null> = {
STARTER: 25,
GROWTH: 75,
PRO: 150,
ENTERPRISE: null,
}
function listTextVariants(value: string) {
const lower = value.toLowerCase()
@@ -357,6 +365,47 @@ function listTextVariants(value: string) {
return Array.from(new Set([value, lower, upper, title]))
}
function parseVehicleLimit(labels: string[]) {
for (const label of labels) {
if (!/\bvehicles?\b/i.test(label)) continue
if (/\bunlimited\b/i.test(label)) return null
const openEndedMatch = label.match(/\b(\d+)\s*\+/)
if (openEndedMatch) return null
const match = label.match(/\b(\d+)\b/)
if (match) return Number(match[1])
}
return undefined
}
async function getVehicleLimitForCompany(companyId: string) {
const subscription = await repo.getCompanySubscription(companyId)
const plan = subscription?.plan
if (!plan) {
throw new ValidationError('A subscription plan is required before adding vehicles')
}
const persistedFeatures = await repo.findPlanFeatures(plan)
const persistedLimit = parseVehicleLimit(persistedFeatures.map((feature: any) => feature.label))
if (persistedLimit !== undefined) return persistedLimit
const fallbackLimit = parseVehicleLimit(PLAN_FEATURES[plan] ?? [])
if (fallbackLimit !== undefined) return fallbackLimit
return FALLBACK_VEHICLE_LIMITS[plan] ?? null
}
async function assertCanAddActiveFleetVehicle(companyId: string) {
const limit = await getVehicleLimitForCompany(companyId)
if (limit == null) return
const activeCount = await repo.countActiveFleetVehicles(companyId)
if (activeCount >= limit) {
throw new ValidationError(`Your subscription plan allows up to ${limit} active vehicles. Upgrade your plan or retire a vehicle before adding another one.`)
}
}
export async function listVehicles(companyId: string, query: { status?: string; category?: string; published?: string; search?: string; page?: number; pageSize?: number }) {
const page = query.page ?? 1
const pageSize = query.pageSize ?? 20
@@ -413,9 +462,15 @@ export async function getVehicle(id: string, companyId: string) {
}
export async function createVehicle(data: any, companyId: string) {
const patch = applyLocationSettings(data)
const status = patch.status ?? 'AVAILABLE'
if (ACTIVE_FLEET_STATUSES.has(status as any)) {
await assertCanAddActiveFleetVehicle(companyId)
}
return presentVehicle(await repo.create({
dailyRate: 0,
...applyLocationSettings(data),
...patch,
companyId,
}))
}
@@ -424,11 +479,14 @@ const PUBLISHED_STATUSES = new Set(['AVAILABLE', 'RESERVED', 'READY', 'RENTED'])
export async function updateVehicle(id: string, companyId: string, data: any) {
const patch = applyLocationSettings(data)
if (patch.status) {
if (typeof patch.status === 'string') {
patch.isPublished = PUBLISHED_STATUSES.has(patch.status)
}
const existing = await repo.findFirst(id, companyId)
if (!existing) throw new NotFoundError('Vehicle not found')
if (existing.status === 'OUT_OF_SERVICE' && typeof patch.status === 'string' && ACTIVE_FLEET_STATUSES.has(patch.status as any)) {
await assertCanAddActiveFleetVehicle(companyId)
}
const updated = await repo.updateById(id, patch)
if (patch.dailyRate !== undefined && typeof patch.dailyRate === 'number') {
await syncPricingBaseRateFromVehicle(id, existing.dailyRate, patch.dailyRate)
@@ -439,6 +497,9 @@ export async function updateVehicle(id: string, companyId: string, data: any) {
export async function setStatus(id: string, companyId: string, status: string) {
const existing = await repo.findFirst(id, companyId)
if (!existing) throw new NotFoundError('Vehicle not found')
if (existing.status === 'OUT_OF_SERVICE' && ACTIVE_FLEET_STATUSES.has(status as any)) {
await assertCanAddActiveFleetVehicle(companyId)
}
const isPublished = PUBLISHED_STATUSES.has(status)
return presentVehicle(await repo.updateById(id, { status, isPublished }))
}
@@ -25,6 +25,9 @@ const mockVehicle = {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(repo.getCompanySubscription).mockResolvedValue({ plan: 'STARTER' } as any)
vi.mocked(repo.findPlanFeatures).mockResolvedValue([{ label: 'Up to 25 vehicles' }] as any)
vi.mocked(repo.countActiveFleetVehicles).mockResolvedValue(0)
})
describe('vehicle.service', () => {
@@ -91,6 +94,7 @@ describe('vehicle.service', () => {
it('creates a vehicle with companyId', async () => {
vi.mocked(repo.create).mockResolvedValue(mockVehicle as any)
const result = await service.createVehicle({ make: 'Toyota', model: 'Camry', year: 2022, licensePlate: 'ABC-123', dailyRate: 500 }, 'comp_1')
expect(repo.countActiveFleetVehicles).toHaveBeenCalledWith('comp_1')
expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({ companyId: 'comp_1' }))
expect(result).toEqual(mockVehicle)
})
@@ -100,6 +104,57 @@ describe('vehicle.service', () => {
await service.createVehicle({ make: 'Toyota', model: 'Camry', year: 2022, licensePlate: 'ABC-123' }, 'comp_1')
expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({ companyId: 'comp_1', dailyRate: 0 }))
})
it('rejects active vehicle creation when the subscription vehicle limit is reached', async () => {
vi.mocked(repo.countActiveFleetVehicles).mockResolvedValue(25)
await expect(service.createVehicle({
make: 'Toyota',
model: 'Camry',
year: 2022,
licensePlate: 'ABC-123',
}, 'comp_1')).rejects.toThrow('allows up to 25 active vehicles')
expect(repo.create).not.toHaveBeenCalled()
})
it('uses the numeric vehicle limit from persisted plan features', async () => {
vi.mocked(repo.findPlanFeatures).mockResolvedValue([{ label: 'Up to 3 vehicles' }] as any)
vi.mocked(repo.countActiveFleetVehicles).mockResolvedValue(3)
await expect(service.createVehicle({
make: 'Toyota',
model: 'Camry',
year: 2022,
licensePlate: 'ABC-123',
}, 'comp_1')).rejects.toThrow('allows up to 3 active vehicles')
})
it('enforces the PRO 150 vehicle limit', async () => {
vi.mocked(repo.getCompanySubscription).mockResolvedValue({ plan: 'PRO' } as any)
vi.mocked(repo.findPlanFeatures).mockResolvedValue([{ label: 'Up to 150 vehicles' }] as any)
vi.mocked(repo.countActiveFleetVehicles).mockResolvedValue(150)
await expect(service.createVehicle({
make: 'Toyota',
model: 'Camry',
year: 2022,
licensePlate: 'ABC-123',
}, 'comp_1')).rejects.toThrow('allows up to 150 active vehicles')
expect(repo.create).not.toHaveBeenCalled()
})
it('allows open-ended enterprise vehicle plans', async () => {
vi.mocked(repo.getCompanySubscription).mockResolvedValue({ plan: 'ENTERPRISE' } as any)
vi.mocked(repo.findPlanFeatures).mockResolvedValue([{ label: '150+ vehicles' }] as any)
vi.mocked(repo.create).mockResolvedValue(mockVehicle as any)
await service.createVehicle({ make: 'Toyota', model: 'Camry', year: 2022, licensePlate: 'ABC-123' }, 'comp_1')
expect(repo.countActiveFleetVehicles).not.toHaveBeenCalled()
expect(repo.create).toHaveBeenCalled()
})
})
describe('updateVehicle', () => {
@@ -121,6 +176,15 @@ describe('vehicle.service', () => {
await service.updateVehicle('veh_1', 'comp_1', { status: 'AVAILABLE' })
expect(repo.updateById).toHaveBeenCalledWith('veh_1', expect.objectContaining({ isPublished: true }))
})
it('rejects reactivating an out-of-service vehicle when the fleet limit is reached', async () => {
vi.mocked(repo.findFirst).mockResolvedValue({ ...mockVehicle, status: 'OUT_OF_SERVICE' } as any)
vi.mocked(repo.countActiveFleetVehicles).mockResolvedValue(25)
await expect(service.updateVehicle('veh_1', 'comp_1', { status: 'AVAILABLE' })).rejects.toThrow('allows up to 25 active vehicles')
expect(repo.updateById).not.toHaveBeenCalled()
})
})
describe('uploadPhotos', () => {