diff --git a/apps/api/src/middleware/requireSubscription.test.ts b/apps/api/src/middleware/requireSubscription.test.ts index 6aa555b..3dcc8f3 100644 --- a/apps/api/src/middleware/requireSubscription.test.ts +++ b/apps/api/src/middleware/requireSubscription.test.ts @@ -1,6 +1,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { NextFunction, Request, Response } from 'express' +vi.mock('../lib/prisma', () => ({ + prisma: { + subscription: { + findUnique: vi.fn(), + }, + }, +})) + +import { prisma } from '../lib/prisma' import { requireSubscription } from './requireSubscription' function responseStub() { @@ -12,27 +21,28 @@ function responseStub() { describe('requireSubscription middleware', () => { beforeEach(() => { + vi.clearAllMocks() process.env.NEXT_PUBLIC_DASHBOARD_URL = 'https://dashboard.example.test' }) - it('requires tenant/company context first', () => { + it('requires tenant/company context first', async () => { const req = {} as Request const res = responseStub() const next = vi.fn() as NextFunction - requireSubscription(req, res, next) + await requireSubscription(req, res, next) expect(res.status).toHaveBeenCalledWith(401) expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'No company context', statusCode: 401 }) expect(next).not.toHaveBeenCalled() }) - it('blocks suspended companies with a subscription recovery URL', () => { + it('blocks suspended companies with a subscription recovery URL', async () => { const req = { company: { status: 'SUSPENDED' } } as Request const res = responseStub() const next = vi.fn() as NextFunction - requireSubscription(req, res, next) + await requireSubscription(req, res, next) expect(res.status).toHaveBeenCalledWith(402) expect(res.json).toHaveBeenCalledWith({ @@ -42,14 +52,15 @@ describe('requireSubscription middleware', () => { billingUrl: 'https://dashboard.example.test/subscription', }) expect(next).not.toHaveBeenCalled() + expect(prisma.subscription.findUnique).not.toHaveBeenCalled() }) - it('blocks pending companies with setup guidance', () => { + it('blocks pending companies with setup guidance', async () => { const req = { company: { status: 'PENDING' } } as Request const res = responseStub() const next = vi.fn() as NextFunction - requireSubscription(req, res, next) + await requireSubscription(req, res, next) expect(res.status).toHaveBeenCalledWith(402) expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ @@ -59,12 +70,39 @@ describe('requireSubscription middleware', () => { expect(next).not.toHaveBeenCalled() }) - it('allows active companies through', () => { - const req = { company: { status: 'ACTIVE' } } as Request + it('blocks expired trial subscriptions with a subscription recovery URL', async () => { + vi.mocked(prisma.subscription.findUnique).mockResolvedValue({ status: 'EXPIRED' } as never) + + const req = { company: { id: 'company_1', status: 'TRIALING' } } as Request const res = responseStub() const next = vi.fn() as NextFunction - requireSubscription(req, res, next) + await requireSubscription(req, res, next) + + expect(prisma.subscription.findUnique).toHaveBeenCalledWith({ + where: { companyId: 'company_1' }, + select: { status: true }, + }) + expect(res.status).toHaveBeenCalledWith(402) + expect(res.json).toHaveBeenCalledWith({ + error: 'subscription_required', + message: 'Your subscription has ended. Please reactivate to continue.', + statusCode: 402, + billingUrl: 'https://dashboard.example.test/subscription', + subscriptionStatus: 'EXPIRED', + accessLevel: 'none', + }) + expect(next).not.toHaveBeenCalled() + }) + + it('allows active companies with active subscriptions through', async () => { + vi.mocked(prisma.subscription.findUnique).mockResolvedValue({ status: 'ACTIVE' } as never) + + const req = { company: { id: 'company_1', status: 'ACTIVE' } } as Request + const res = responseStub() + const next = vi.fn() as NextFunction + + await requireSubscription(req, res, next) expect(next).toHaveBeenCalledTimes(1) expect(res.status).not.toHaveBeenCalled() diff --git a/apps/api/src/middleware/requireSubscription.ts b/apps/api/src/middleware/requireSubscription.ts index 9f4439c..f2a85e2 100644 --- a/apps/api/src/middleware/requireSubscription.ts +++ b/apps/api/src/middleware/requireSubscription.ts @@ -1,4 +1,6 @@ import { Request, Response, NextFunction } from 'express' +import { prisma } from '../lib/prisma' +import { getAccessLevel, hasAnyAccess } from '../modules/subscriptions/subscription.policy' import { sendUnauthorized, sendPaymentRequired } from './authHelpers' const BLOCKED_STATUSES = ['SUSPENDED', 'PENDING'] @@ -8,9 +10,9 @@ const BLOCKED_STATUSES = ['SUSPENDED', 'PENDING'] * Must be applied after `requireTenant`. * * Guarantees on success: - * req.company.status is not SUSPENDED or PENDING + * req.company.status is not SUSPENDED or PENDING, and subscription access is not none */ -export function requireSubscription(req: Request, res: Response, next: NextFunction) { +export async function requireSubscription(req: Request, res: Response, next: NextFunction) { const company = req.company if (!company) return sendUnauthorized(res, 'unauthenticated', 'No company context') @@ -25,5 +27,24 @@ export function requireSubscription(req: Request, res: Response, next: NextFunct ) } + const subscription = await prisma.subscription.findUnique({ + where: { companyId: company.id }, + select: { status: true }, + }) + const subscriptionStatus = subscription?.status ?? 'EXPIRED' + + if (!hasAnyAccess(subscriptionStatus)) { + return sendPaymentRequired( + res, + 'subscription_required', + 'Your subscription has ended. Please reactivate to continue.', + { + billingUrl: `${process.env.NEXT_PUBLIC_DASHBOARD_URL}/subscription`, + subscriptionStatus, + accessLevel: getAccessLevel(subscriptionStatus), + }, + ) + } + next() } diff --git a/apps/api/src/modules/menu/menu.service.test.ts b/apps/api/src/modules/menu/menu.service.test.ts index f726d4c..ca9086b 100644 --- a/apps/api/src/modules/menu/menu.service.test.ts +++ b/apps/api/src/modules/menu/menu.service.test.ts @@ -268,4 +268,45 @@ describe('menu.service', () => { 'settings', ]) }) + + it('returns no feature menu for expired trial subscriptions', async () => { + vi.mocked(prisma.employee.findUniqueOrThrow).mockResolvedValue({ + id: 'employee_1', + role: 'OWNER', + isActive: true, + companyId: 'company_1', + } as never) + + vi.mocked(prisma.company.findUniqueOrThrow).mockResolvedValue({ + id: 'company_1', + name: 'Atlas Cars', + status: 'TRIALING', + subscription: { plan: 'STARTER', status: 'EXPIRED' }, + } as never) + + vi.mocked(prisma.menuItem.findMany).mockResolvedValue([ + { + id: 'item_dashboard', + systemKey: 'dashboard', + label: 'Dashboard', + itemType: 'INTERNAL_PAGE', + routeOrUrl: '/', + icon: 'LayoutDashboard', + parentId: null, + openInNewTab: false, + isRequired: true, + isActive: true, + displayOrder: 10, + roleVisibilities: [{ role: 'OWNER' }], + subscriptionAssignments: [{ plan: 'STARTER', displayOrder: 10, isActive: true }], + companyAssignments: [], + }, + ] as never) + + const result = await getEmployeeMenu('employee_1') + + expect(result.subscriptionStatus).toBe('EXPIRED') + expect(result.subscriptionAccessLevel).toBe('none') + expect(result.items).toEqual([]) + }) }) diff --git a/apps/api/src/modules/menu/menu.service.ts b/apps/api/src/modules/menu/menu.service.ts index 57a7e0f..cad7450 100644 --- a/apps/api/src/modules/menu/menu.service.ts +++ b/apps/api/src/modules/menu/menu.service.ts @@ -1,5 +1,6 @@ import { AppError } from '../../http/errors' import { prisma } from '../../lib/prisma' +import { getAccessLevel, hasFullAccess, type AccessLevel } from '../subscriptions/subscription.policy' type EmployeeRole = 'OWNER' | 'MANAGER' | 'AGENT' type Plan = 'STARTER' | 'GROWTH' | 'PRO' @@ -466,23 +467,27 @@ async function getMenuEvaluationContext(companyId: string, role: EmployeeRole, e }) const currentPlan = company.subscription?.plan ?? null - const currentPlanName = currentPlan as Plan | null + const subscriptionStatus = company.subscription?.status ?? 'EXPIRED' + const subscriptionAccessLevel = getAccessLevel(subscriptionStatus) + const subscriptionHasFullAccess = hasFullAccess(subscriptionStatus) + const currentPlanName = subscriptionHasFullAccess ? currentPlan as Plan | null : null const evaluated: EvaluatedMenuItem[] = (menuItems as any[]).map((item: any) => { const defaultsToAllSubscriptions = item.subscriptionAssignments.length === 0 const subscriptionAssignment = item.subscriptionAssignments.find( - (assignment: any) => assignment.isActive && (!currentPlan || assignment.plan === currentPlan), + (assignment: any) => assignment.isActive && (!currentPlanName || assignment.plan === currentPlanName), ) const companyAssignment = item.companyAssignments.find((assignment: any) => assignment.isActive) const roleAllowed = item.roleVisibilities.length === 0 || item.roleVisibilities.some((visibility: any) => visibility.role === role) - const subscriptionEntitled = Boolean(subscriptionAssignment || (defaultsToAllSubscriptions && currentPlan)) - const entitled = Boolean(subscriptionEntitled || companyAssignment) + const subscriptionEntitled = Boolean(subscriptionHasFullAccess && (subscriptionAssignment || (defaultsToAllSubscriptions && currentPlanName))) + const entitled = Boolean(subscriptionHasFullAccess && (subscriptionEntitled || companyAssignment)) const visible = Boolean(item.isActive && entitled && roleAllowed && employeeIsActive) const displayOrder = companyAssignment?.displayOrder ?? subscriptionAssignment?.displayOrder ?? item.displayOrder const reasons: string[] = [] if (!item.isActive) reasons.push('Hidden because the menu item is inactive.') if (!employeeIsActive) reasons.push('Hidden because the user account is inactive.') + if (!subscriptionHasFullAccess) reasons.push(`Hidden because subscription status ${subscriptionStatus} does not allow dashboard feature access.`) if (!entitled) reasons.push(describeEntitlement(currentPlanName, false, false, false)) if (entitled) { reasons.push( @@ -518,6 +523,8 @@ async function getMenuEvaluationContext(companyId: string, role: EmployeeRole, e company, role, currentPlan: currentPlanName, + subscriptionStatus, + subscriptionAccessLevel: subscriptionAccessLevel as AccessLevel, items: sortByDisplayOrder(evaluated), } } @@ -578,6 +585,8 @@ export async function previewCompanyMenu(input: MenuPreviewInput) { company: context.company, role: context.role, subscriptionPlan: context.currentPlan, + subscriptionStatus: context.subscriptionStatus, + subscriptionAccessLevel: context.subscriptionAccessLevel, items: context.items, finalMenu: buildMenuTree( context.items @@ -615,6 +624,8 @@ export async function getEmployeeMenu(employeeId: string) { companyId: employee.companyId, role: employee.role, subscriptionPlan: context.currentPlan, + subscriptionStatus: context.subscriptionStatus, + subscriptionAccessLevel: context.subscriptionAccessLevel, items: buildMenuTree( visibleItems.map((item) => ({ id: item.id, diff --git a/apps/dashboard/src/components/layout/Sidebar.boundary.test.ts b/apps/dashboard/src/components/layout/Sidebar.boundary.test.ts index 9df6b4b..11915c1 100644 --- a/apps/dashboard/src/components/layout/Sidebar.boundary.test.ts +++ b/apps/dashboard/src/components/layout/Sidebar.boundary.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { APPROVED_BASELINE_MENU_KEYS, hasRenderableMenuItems } from './Sidebar' +import { APPROVED_BASELINE_MENU_KEYS, hasRenderableMenuItems, shouldUseGeneratedMenu } from './Sidebar' describe('Sidebar menu rendering helpers', () => { it('keeps the safe fallback menu to the approved seven baseline items in order', () => { @@ -17,6 +17,9 @@ describe('Sidebar menu rendering helpers', () => { it('treats an empty employee menu as non-renderable so fallback navigation remains available', () => { expect(hasRenderableMenuItems(null)).toBe(false) expect(hasRenderableMenuItems([])).toBe(false) + expect(shouldUseGeneratedMenu('loaded', [])).toBe(false) + expect(shouldUseGeneratedMenu('loaded', [], 'full')).toBe(false) + expect(shouldUseGeneratedMenu('loaded', [], 'none')).toBe(true) }) it('ignores structural-only menu responses without clickable entries', () => { @@ -49,12 +52,12 @@ describe('Sidebar menu rendering helpers', () => { }) it('detects nested clickable menu entries', () => { - expect(hasRenderableMenuItems([ + const menuItems = [ { id: 'parent', systemKey: null, label: 'Operations', - itemType: 'PARENT_MENU', + itemType: 'PARENT_MENU' as const, routeOrUrl: null, icon: null, parentId: null, @@ -65,7 +68,7 @@ describe('Sidebar menu rendering helpers', () => { id: 'fleet', systemKey: 'fleet', label: 'Fleet', - itemType: 'INTERNAL_PAGE', + itemType: 'INTERNAL_PAGE' as const, routeOrUrl: '/fleet', icon: 'Car', parentId: 'parent', @@ -75,6 +78,10 @@ describe('Sidebar menu rendering helpers', () => { }, ], }, - ])).toBe(true) + ] + + expect(hasRenderableMenuItems(menuItems)).toBe(true) + expect(shouldUseGeneratedMenu('loaded', menuItems)).toBe(true) + expect(shouldUseGeneratedMenu('failed', menuItems)).toBe(false) }) }) diff --git a/apps/dashboard/src/components/layout/Sidebar.tsx b/apps/dashboard/src/components/layout/Sidebar.tsx index 42db1b0..5a328ae 100644 --- a/apps/dashboard/src/components/layout/Sidebar.tsx +++ b/apps/dashboard/src/components/layout/Sidebar.tsx @@ -68,6 +68,8 @@ interface GeneratedMenuItem { children: GeneratedMenuItem[] } +type SubscriptionAccessLevel = 'full' | 'limited' | 'read_only' | 'none' + function computeInitials(name: string): string { const parts = name.trim().split(/\s+/).filter(Boolean) if (parts.length === 0) return 'U' @@ -136,6 +138,16 @@ export function hasRenderableMenuItems(items: GeneratedMenuItem[] | null): boole }) } +export function shouldUseGeneratedMenu( + menuLoadState: 'loading' | 'loaded' | 'failed', + items: GeneratedMenuItem[] | null, + subscriptionAccessLevel: SubscriptionAccessLevel | null = null, +): boolean { + if (menuLoadState !== 'loaded') return false + if (subscriptionAccessLevel === 'none') return true + return hasRenderableMenuItems(items) +} + function notifyParent(message: Record) { if (typeof window === 'undefined' || window.parent === window) return window.parent.postMessage(message, '*') @@ -154,6 +166,7 @@ export default function Sidebar() { }) const [role, setRole] = useState('AGENT') const [menuItems, setMenuItems] = useState(null) + const [menuAccessLevel, setMenuAccessLevel] = useState(null) const [menuLoadState, setMenuLoadState] = useState<'loading' | 'loaded' | 'failed'>('loading') const [open, setOpen] = useState(false) const [mounted, setMounted] = useState(false) @@ -221,7 +234,7 @@ export default function Sidebar() { Promise.all([ apiFetch<{ employee: EmployeeProfile }>('/auth/employee/me'), - apiFetch<{ items: GeneratedMenuItem[] }>('/auth/employee/menu').catch(() => null), + apiFetch<{ items: GeneratedMenuItem[]; subscriptionAccessLevel?: SubscriptionAccessLevel }>('/auth/employee/menu').catch(() => null), ]) .then(([{ employee }, menu]) => { if (cancelled) return @@ -230,8 +243,10 @@ export default function Sidebar() { if (employee.role) setRole(employee.role) if (menu) { setMenuItems(menu.items) + setMenuAccessLevel(menu.subscriptionAccessLevel ?? null) setMenuLoadState('loaded') } else { + setMenuAccessLevel(null) setMenuLoadState('failed') } if (employee.preferredLanguage === 'en' || employee.preferredLanguage === 'fr' || employee.preferredLanguage === 'ar') { @@ -247,6 +262,7 @@ export default function Sidebar() { .catch(() => { if (cancelled) return if (!cached) setUser(toSidebarUser({}, dict.workspaceUser, dict.localAuth)) + setMenuAccessLevel(null) setMenuLoadState('failed') }) @@ -283,7 +299,7 @@ export default function Sidebar() { children: [], })) - const useGeneratedMenu = menuLoadState === 'loaded' + const useGeneratedMenu = shouldUseGeneratedMenu(menuLoadState, menuItems, menuAccessLevel) const ownerSystemMenuItems = OWNER_SYSTEM_NAV_ITEMS .filter((item) => !mounted || hasMinRole(role, item.minRole)) .map((item) => ({