fix payment customer and dashboard settings
Build & Deploy / Build & Push Docker Image (push) Successful in 12m39s
Test / Type Check (all packages) (push) Successful in 5m8s
Build & Deploy / Deploy to VPS (push) Successful in 7s
Test / API Unit Tests (push) Failing after 4m24s
Test / Homepage Unit Tests (push) Successful in 3m27s
Test / Storefront Unit Tests (push) Successful in 4m48s
Test / Admin Unit Tests (push) Successful in 3m0s
Test / Dashboard Unit Tests (push) Successful in 4m18s
Test / API Integration Tests (push) Failing after 4m34s

This commit is contained in:
root
2026-06-29 22:15:34 -04:00
parent e1e2f55c93
commit a752a399c2
30 changed files with 4503 additions and 1214 deletions
@@ -8,6 +8,12 @@ import { parseBody, parseParams } from '../../http/validate'
import { ok, created, noContent } from '../../http/respond'
import { imageUpload, assertImageFile } from '../../http/upload'
import * as service from './company.service'
import {
normalizeSettingsLocale,
requireSettingsFeature,
resolveSettingsEntitlements,
resolveSettingsMenu,
} from './settingsEntitlements'
import {
companySchema, brandSchema, contractSettingsSchema,
insurancePolicySchema, pricingRuleSchema, accountingSettingsSchema,
@@ -40,7 +46,7 @@ router.get('/me/brand', async (req, res, next) => {
} catch (err) { next(err) }
})
router.patch('/me/brand', requireRole('OWNER'), async (req, res, next) => {
router.patch('/me/brand', requireRole('OWNER'), requireSettingsFeature('settings.branding_basic'), async (req, res, next) => {
try {
const body = parseBody(brandSchema, req)
const brand = await service.updateBrand(req.companyId, body, req.company.name, req.company.slug)
@@ -48,7 +54,7 @@ router.patch('/me/brand', requireRole('OWNER'), async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/me/brand/logo', requireRole('OWNER'), imageUpload.single('file'), async (req, res, next) => {
router.post('/me/brand/logo', requireRole('OWNER'), requireSettingsFeature('settings.branding_basic'), imageUpload.single('file'), async (req, res, next) => {
try {
assertImageFile(req.file, 'logo')
const brand = await service.uploadLogo(req.companyId, req.company.name, req.company.slug, req.file.buffer)
@@ -56,7 +62,7 @@ router.post('/me/brand/logo', requireRole('OWNER'), imageUpload.single('file'),
} catch (err) { next(err) }
})
router.post('/me/brand/hero', requireRole('OWNER'), imageUpload.single('file'), async (req, res, next) => {
router.post('/me/brand/hero', requireRole('OWNER'), requireSettingsFeature('settings.branding_hero'), imageUpload.single('file'), async (req, res, next) => {
try {
assertImageFile(req.file, 'hero image')
const brand = await service.uploadHeroImage(req.companyId, req.company.name, req.company.slug, req.file.buffer)
@@ -64,6 +70,18 @@ router.post('/me/brand/hero', requireRole('OWNER'), imageUpload.single('file'),
} catch (err) { next(err) }
})
router.get('/me/settings-menu', async (req, res, next) => {
try {
ok(res, await resolveSettingsMenu(req.companyId, normalizeSettingsLocale(req.query.locale as string | undefined)))
} catch (err) { next(err) }
})
router.get('/me/settings-entitlements', async (req, res, next) => {
try {
ok(res, await resolveSettingsEntitlements(req.companyId))
} catch (err) { next(err) }
})
router.post('/me/brand/subdomain/check', requireRole('OWNER'), async (req, res, next) => {
try {
const { subdomain } = parseBody(subdomainSchema, req)
@@ -101,7 +119,7 @@ router.get('/me/contract-settings', async (req, res, next) => {
} catch (err) { next(err) }
})
router.patch('/me/contract-settings', requireRole('MANAGER'), async (req, res, next) => {
router.patch('/me/contract-settings', requireRole('MANAGER'), requireSettingsFeature('settings.rental_policies_basic'), async (req, res, next) => {
try {
const body = parseBody(contractSettingsSchema, req)
const settings = await service.updateContractSettings(req.companyId, body)
@@ -116,7 +134,7 @@ router.get('/me/insurance-policies', async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/me/insurance-policies', requireRole('MANAGER'), async (req, res, next) => {
router.post('/me/insurance-policies', requireRole('MANAGER'), requireSettingsFeature('settings.insurance_policies'), async (req, res, next) => {
try {
const body = parseBody(insurancePolicySchema, req)
const policy = await service.createInsurancePolicy(req.companyId, body)
@@ -124,7 +142,7 @@ router.post('/me/insurance-policies', requireRole('MANAGER'), async (req, res, n
} catch (err) { next(err) }
})
router.patch('/me/insurance-policies/:id', requireRole('MANAGER'), async (req, res, next) => {
router.patch('/me/insurance-policies/:id', requireRole('MANAGER'), requireSettingsFeature('settings.insurance_policies'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const body = parseBody(insurancePolicySchema.partial(), req)
@@ -133,7 +151,7 @@ router.patch('/me/insurance-policies/:id', requireRole('MANAGER'), async (req, r
} catch (err) { next(err) }
})
router.delete('/me/insurance-policies/:id', requireRole('MANAGER'), async (req, res, next) => {
router.delete('/me/insurance-policies/:id', requireRole('MANAGER'), requireSettingsFeature('settings.insurance_policies'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
await service.deleteInsurancePolicy(id, req.companyId)
@@ -148,7 +166,7 @@ router.get('/me/pricing-rules', async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/me/pricing-rules', requireRole('MANAGER'), async (req, res, next) => {
router.post('/me/pricing-rules', requireRole('MANAGER'), requireSettingsFeature('settings.pricing_rules'), async (req, res, next) => {
try {
const body = parseBody(pricingRuleSchema, req)
const rule = await service.createPricingRule(req.companyId, body)
@@ -156,7 +174,7 @@ router.post('/me/pricing-rules', requireRole('MANAGER'), async (req, res, next)
} catch (err) { next(err) }
})
router.patch('/me/pricing-rules/:id', requireRole('MANAGER'), async (req, res, next) => {
router.patch('/me/pricing-rules/:id', requireRole('MANAGER'), requireSettingsFeature('settings.pricing_rules'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const body = parseBody(pricingRuleSchema.partial(), req)
@@ -165,7 +183,7 @@ router.patch('/me/pricing-rules/:id', requireRole('MANAGER'), async (req, res, n
} catch (err) { next(err) }
})
router.delete('/me/pricing-rules/:id', requireCompanyPolicy('deletePricingRule'), async (req, res, next) => {
router.delete('/me/pricing-rules/:id', requireCompanyPolicy('deletePricingRule'), requireSettingsFeature('settings.pricing_rules'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
await service.deletePricingRule(id, req.companyId)
@@ -180,7 +198,7 @@ router.get('/me/accounting-settings', async (req, res, next) => {
} catch (err) { next(err) }
})
router.patch('/me/accounting-settings', requireCompanyPolicy('updateAccountingSettings'), async (req, res, next) => {
router.patch('/me/accounting-settings', requireCompanyPolicy('updateAccountingSettings'), requireSettingsFeature('settings.accounting_defaults'), async (req, res, next) => {
try {
const body = parseBody(accountingSettingsSchema, req)
const settings = await service.updateAccountingSettings(req.companyId, body)
@@ -1,7 +1,8 @@
import { uploadImage } from '../../lib/storage'
import { ConflictError, NotFoundError } from '../../http/errors'
import { ConflictError, ForbiddenError, NotFoundError } from '../../http/errors'
import { presentCompany, presentBrand } from './company.presenter'
import * as repo from './company.repo'
import { resolveSettingsEntitlements, SettingsFeatureKey } from './settingsEntitlements'
function buildPaymentMethodsEnabled(input: {
amanpayMerchantId?: string | null
@@ -27,6 +28,12 @@ export async function getBrand(companyId: string) {
}
export async function updateBrand(companyId: string, body: any, companyName: string, companySlug: string) {
await assertSettingsFeature(companyId, 'settings.branding_basic')
if (body.primaryColor || body.accentColor) await assertSettingsFeature(companyId, 'settings.branding_custom')
if (body.amanpayMerchantId || body.amanpaySecretKey || body.paypalEmail || body.paypalMerchantId) {
await assertSettingsFeature(companyId, 'settings.renter_payments')
}
const current = await repo.findBrand(companyId)
const paymentMethodsEnabled = buildPaymentMethodsEnabled({
amanpayMerchantId: body.amanpayMerchantId ?? current?.amanpayMerchantId,
@@ -41,6 +48,7 @@ export async function updateBrand(companyId: string, body: any, companyName: str
}
export async function uploadLogo(companyId: string, companyName: string, companySlug: string, file: Buffer) {
await assertSettingsFeature(companyId, 'settings.branding_basic')
const url = await uploadImage(file, `companies/${companyId}/brand`, 'logo')
return presentBrand(await repo.upsertBrand(
companyId,
@@ -50,6 +58,7 @@ export async function uploadLogo(companyId: string, companyName: string, company
}
export async function uploadHeroImage(companyId: string, companyName: string, companySlug: string, file: Buffer) {
await assertSettingsFeature(companyId, 'settings.branding_hero')
const url = await uploadImage(file, `companies/${companyId}/brand`, 'hero')
return presentBrand(await repo.upsertBrand(
companyId,
@@ -95,6 +104,14 @@ export async function getContractSettings(companyId: string) {
}
export async function updateContractSettings(companyId: string, data: any) {
await assertSettingsFeature(companyId, 'settings.rental_policies_basic')
if (
data.additionalDriverCharge ||
data.additionalDriverDailyRate !== undefined ||
data.additionalDriverFlatRate !== undefined
) {
await assertSettingsFeature(companyId, 'settings.additional_driver_fees')
}
return repo.upsertContractSettings(companyId, data)
}
@@ -103,16 +120,19 @@ export async function getInsurancePolicies(companyId: string) {
}
export async function createInsurancePolicy(companyId: string, data: any) {
await assertSettingsFeature(companyId, 'settings.insurance_policies')
return repo.createInsurancePolicy(companyId, data)
}
export async function updateInsurancePolicy(id: string, companyId: string, data: any) {
await assertSettingsFeature(companyId, 'settings.insurance_policies')
const result = await repo.updateInsurancePolicy(id, companyId, data)
if (result.count === 0) throw new NotFoundError('Insurance policy not found')
return repo.findInsurancePolicyOrThrow(id)
}
export async function deleteInsurancePolicy(id: string, companyId: string) {
await assertSettingsFeature(companyId, 'settings.insurance_policies')
const result = await repo.deleteInsurancePolicy(id, companyId)
if (result.count === 0) throw new NotFoundError('Insurance policy not found')
}
@@ -122,16 +142,19 @@ export async function getPricingRules(companyId: string) {
}
export async function createPricingRule(companyId: string, data: any) {
await assertSettingsFeature(companyId, 'settings.pricing_rules')
return repo.createPricingRule(companyId, data)
}
export async function updatePricingRule(id: string, companyId: string, data: any) {
await assertSettingsFeature(companyId, 'settings.pricing_rules')
const result = await repo.updatePricingRule(id, companyId, data)
if (result.count === 0) throw new NotFoundError('Pricing rule not found')
return repo.findPricingRuleOrThrow(id)
}
export async function deletePricingRule(id: string, companyId: string) {
await assertSettingsFeature(companyId, 'settings.pricing_rules')
const result = await repo.deletePricingRule(id, companyId)
if (result.count === 0) throw new NotFoundError('Pricing rule not found')
}
@@ -141,6 +164,9 @@ export async function getAccountingSettings(companyId: string) {
}
export async function updateAccountingSettings(companyId: string, data: any) {
await assertSettingsFeature(companyId, 'settings.accounting_defaults')
if (data.autoSendReport) await assertSettingsFeature(companyId, 'settings.accounting_scheduled_delivery')
if (data.reportFormat === 'BOTH') await assertSettingsFeature(companyId, 'settings.accounting_advanced_formats')
return repo.upsertAccountingSettings(companyId, data)
}
@@ -151,3 +177,10 @@ export async function getApiKey(companyId: string) {
export async function regenerateApiKey(companyId: string) {
return repo.regenerateApiKey(companyId)
}
async function assertSettingsFeature(companyId: string, featureKey: SettingsFeatureKey) {
const entitlements = await resolveSettingsEntitlements(companyId)
if (!entitlements.features[featureKey]?.editable) {
throw new ForbiddenError('This settings feature is not available for your current subscription.')
}
}
@@ -0,0 +1,81 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../../lib/prisma', () => ({
prisma: {
subscription: {
findUnique: vi.fn(),
},
},
}))
import { prisma } from '../../lib/prisma'
import { resolveSettingsEntitlements, resolveSettingsMenu } from './settingsEntitlements'
describe('settingsEntitlements', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('keeps company profile available while locking premium STARTER capabilities', async () => {
vi.mocked(prisma.subscription.findUnique).mockResolvedValue({
plan: 'STARTER',
status: 'ACTIVE',
currentPeriodEnd: null,
trialEndAt: null,
} as never)
const result = await resolveSettingsEntitlements('company_1')
expect(result.features['settings.company_profile']).toMatchObject({ available: true, editable: true })
expect(result.features['settings.renter_payments']).toMatchObject({
available: false,
editable: false,
requiredPlan: 'GROWTH',
})
expect(result.features['settings.accounting_scheduled_delivery']).toMatchObject({
available: false,
requiredPlan: 'PRO',
})
})
it('returns the approved seven settings sections with localized locked states', async () => {
vi.mocked(prisma.subscription.findUnique).mockResolvedValue({
plan: 'STARTER',
status: 'ACTIVE',
currentPeriodEnd: null,
trialEndAt: null,
} as never)
const result = await resolveSettingsMenu('company_1', 'fr')
expect(result.items).toHaveLength(7)
expect(result.items.map((item) => item.sectionKey)).toEqual([
'company',
'storefront',
'payments',
'rental-policies',
'insurance',
'pricing',
'accounting',
])
expect(result.items.find((item) => item.sectionKey === 'payments')).toMatchObject({
label: 'Méthodes de paiement',
state: 'LOCKED',
requiredPlan: 'GROWTH',
})
})
it('makes premium features read-only during past-due access', async () => {
vi.mocked(prisma.subscription.findUnique).mockResolvedValue({
plan: 'PRO',
status: 'PAST_DUE',
currentPeriodEnd: null,
trialEndAt: null,
} as never)
const result = await resolveSettingsEntitlements('company_1')
expect(result.features['settings.company_profile']).toMatchObject({ available: true, editable: true })
expect(result.features['settings.pricing_rules']).toMatchObject({ available: true, editable: false })
})
})
@@ -0,0 +1,227 @@
import { NextFunction, Request, Response } from 'express'
import { prisma } from '../../lib/prisma'
import { getAccessLevel } from '../subscriptions/subscription.policy'
import { sendForbidden, sendUnauthorized } from '../../middleware/authHelpers'
export type SettingsFeatureKey =
| 'settings.company_profile'
| 'settings.public_contact'
| 'settings.locale_currency'
| 'settings.storefront_listing'
| 'settings.branding_basic'
| 'settings.branding_custom'
| 'settings.branding_hero'
| 'settings.renter_payments'
| 'settings.rental_policies_basic'
| 'settings.additional_driver_fees'
| 'settings.insurance_policies'
| 'settings.pricing_rules'
| 'settings.accounting_defaults'
| 'settings.accounting_scheduled_delivery'
| 'settings.accounting_advanced_formats'
export type SettingsSectionKey =
| 'company'
| 'storefront'
| 'payments'
| 'rental-policies'
| 'insurance'
| 'pricing'
| 'accounting'
type Locale = 'en' | 'fr' | 'ar'
type Plan = 'STARTER' | 'GROWTH' | 'PRO'
type SubscriptionStatus =
| 'TRIALING'
| 'ACTIVE'
| 'PAYMENT_PENDING'
| 'PAST_DUE'
| 'SUSPENDED'
| 'CANCELLED'
| 'EXPIRED'
| 'PAUSED'
| 'UNPAID'
type MenuState = 'ENABLED' | 'LOCKED'
const ALL_PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO']
const GROWTH_PLUS: Plan[] = ['GROWTH', 'PRO']
const PRO_ONLY: Plan[] = ['PRO']
const SECTION_COPY: Record<Locale, Record<SettingsSectionKey, { label: string; description: string }>> = {
en: {
company: { label: 'Company Profile', description: 'Manage public company details and defaults.' },
storefront: { label: 'Branding and Storefront', description: 'Control marketplace listing, logo, colors, and storefront media.' },
payments: { label: 'Payment Methods', description: 'Configure renter payment providers.' },
'rental-policies': { label: 'Rental Policies', description: 'Set fuel, damage, and additional-driver policies.' },
insurance: { label: 'Insurance Policies', description: 'Manage optional and required insurance products.' },
pricing: { label: 'Pricing Rules', description: 'Automate surcharges, discounts, and driver-based pricing.' },
accounting: { label: 'Accounting and Reports', description: 'Configure accounting defaults and report delivery.' },
},
fr: {
company: { label: 'Profil entreprise', description: 'Gérez les informations publiques et les valeurs par défaut.' },
storefront: { label: 'Marque et vitrine', description: 'Contrôlez la publication, le logo, les couleurs et les médias.' },
payments: { label: 'Méthodes de paiement', description: 'Configurez les prestataires de paiement des locataires.' },
'rental-policies': { label: 'Politiques de location', description: 'Définissez les règles carburant, dommages et conducteurs.' },
insurance: { label: 'Polices dassurance', description: 'Gérez les assurances optionnelles et obligatoires.' },
pricing: { label: 'Règles tarifaires', description: 'Automatisez les suppléments, remises et règles conducteur.' },
accounting: { label: 'Comptabilité et rapports', description: 'Configurez les paramètres comptables et lenvoi des rapports.' },
},
ar: {
company: { label: 'ملف الشركة', description: 'إدارة بيانات الشركة العامة والإعدادات الافتراضية.' },
storefront: { label: 'العلامة والواجهة', description: 'التحكم في الظهور والشعار والألوان ووسائط الواجهة.' },
payments: { label: 'طرق الدفع', description: 'إعداد مزودي دفع المستأجرين.' },
'rental-policies': { label: 'سياسات الإيجار', description: 'ضبط سياسات الوقود والأضرار والسائق الإضافي.' },
insurance: { label: 'سياسات التأمين', description: 'إدارة منتجات التأمين الاختيارية والإلزامية.' },
pricing: { label: 'قواعد التسعير', description: 'أتمتة الرسوم والخصومات وقواعد السائق.' },
accounting: { label: 'المحاسبة والتقارير', description: 'إعداد الافتراضات المحاسبية وتسليم التقارير.' },
},
}
export const SETTINGS_MENU_CATALOG: Array<{
menuKey: SettingsSectionKey
sectionKey: SettingsSectionKey
iconKey: string
sortOrder: number
plans: Plan[]
requiredPlan: Plan | null
}> = [
{ menuKey: 'company', sectionKey: 'company', iconKey: 'building', sortOrder: 10, plans: ALL_PLANS, requiredPlan: null },
{ menuKey: 'storefront', sectionKey: 'storefront', iconKey: 'palette', sortOrder: 20, plans: ALL_PLANS, requiredPlan: null },
{ menuKey: 'payments', sectionKey: 'payments', iconKey: 'credit-card', sortOrder: 30, plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
{ menuKey: 'rental-policies', sectionKey: 'rental-policies', iconKey: 'file-text', sortOrder: 40, plans: ALL_PLANS, requiredPlan: null },
{ menuKey: 'insurance', sectionKey: 'insurance', iconKey: 'shield', sortOrder: 50, plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
{ menuKey: 'pricing', sectionKey: 'pricing', iconKey: 'tags', sortOrder: 60, plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
{ menuKey: 'accounting', sectionKey: 'accounting', iconKey: 'bar-chart', sortOrder: 70, plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
]
const FEATURE_PLANS: Record<SettingsFeatureKey, { plans: Plan[]; requiredPlan: Plan | null }> = {
'settings.company_profile': { plans: ALL_PLANS, requiredPlan: null },
'settings.public_contact': { plans: ALL_PLANS, requiredPlan: null },
'settings.locale_currency': { plans: ALL_PLANS, requiredPlan: null },
'settings.storefront_listing': { plans: ALL_PLANS, requiredPlan: null },
'settings.branding_basic': { plans: ALL_PLANS, requiredPlan: null },
'settings.branding_custom': { plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
'settings.branding_hero': { plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
'settings.renter_payments': { plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
'settings.rental_policies_basic': { plans: ALL_PLANS, requiredPlan: null },
'settings.additional_driver_fees': { plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
'settings.insurance_policies': { plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
'settings.pricing_rules': { plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
'settings.accounting_defaults': { plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
'settings.accounting_scheduled_delivery': { plans: PRO_ONLY, requiredPlan: 'PRO' },
'settings.accounting_advanced_formats': { plans: PRO_ONLY, requiredPlan: 'PRO' },
}
export function normalizeSettingsLocale(value?: string): Locale {
return value === 'fr' || value === 'ar' ? value : 'en'
}
async function getSubscription(companyId: string) {
const subscriptionModel = (prisma as any).subscription
if (!subscriptionModel?.findUnique) {
return {
plan: 'GROWTH' as Plan,
subscriptionStatus: 'ACTIVE' as SubscriptionStatus,
currentPeriodEnd: null,
trialEndAt: null,
}
}
const subscription = await subscriptionModel.findUnique({ where: { companyId } })
return {
plan: subscription?.plan ?? 'STARTER',
subscriptionStatus: subscription?.status ?? 'EXPIRED',
currentPeriodEnd: subscription?.currentPeriodEnd?.toISOString() ?? null,
trialEndAt: subscription?.trialEndAt?.toISOString() ?? null,
}
}
function statusAllowsAvailability(status: SubscriptionStatus) {
return getAccessLevel(status) !== 'none'
}
function statusAllowsEditing(status: SubscriptionStatus, featureKey: SettingsFeatureKey) {
const accessLevel = getAccessLevel(status)
if (accessLevel === 'full') return true
if (status === 'PAST_DUE') {
return [
'settings.company_profile',
'settings.public_contact',
'settings.locale_currency',
'settings.storefront_listing',
'settings.branding_basic',
'settings.rental_policies_basic',
].includes(featureKey)
}
return false
}
export async function resolveSettingsEntitlements(companyId: string) {
const subscription = await getSubscription(companyId)
const accessLevel = getAccessLevel(subscription.subscriptionStatus)
const features = Object.fromEntries(
Object.entries(FEATURE_PLANS).map(([featureKey, assignment]) => {
const assigned = assignment.plans.includes(subscription.plan)
const available = assigned && statusAllowsAvailability(subscription.subscriptionStatus)
const editable = available && statusAllowsEditing(subscription.subscriptionStatus, featureKey as SettingsFeatureKey)
return [featureKey, {
available,
editable,
requiredPlan: assigned ? null : assignment.requiredPlan,
reason: available ? null : assigned ? 'subscription_status_restricted' : 'plan_required',
}]
}),
)
return {
plan: subscription.plan,
subscriptionStatus: subscription.subscriptionStatus,
currentPeriodEnd: subscription.currentPeriodEnd,
trialEndAt: subscription.trialEndAt,
accessLevel,
features,
}
}
export async function resolveSettingsMenu(companyId: string, locale: Locale) {
const entitlements = await resolveSettingsEntitlements(companyId)
return {
version: 1,
items: SETTINGS_MENU_CATALOG.map((item) => {
const assigned = item.plans.includes(entitlements.plan)
const coreAvailable = entitlements.accessLevel !== 'none' || item.sectionKey === 'company'
const state: MenuState = assigned && coreAvailable ? 'ENABLED' : 'LOCKED'
const copy = SECTION_COPY[locale][item.sectionKey]
return {
menuKey: item.menuKey,
sectionKey: item.sectionKey,
label: copy.label,
description: copy.description,
iconKey: item.iconKey,
sortOrder: item.sortOrder,
state,
requiredPlan: state === 'LOCKED' ? item.requiredPlan : null,
}
}),
}
}
export function requireSettingsFeature(featureKey: SettingsFeatureKey) {
return async (req: Request, res: Response, next: NextFunction) => {
if (!req.companyId) return sendUnauthorized(res, 'unauthenticated', 'Authentication required')
const entitlements = await resolveSettingsEntitlements(req.companyId)
const feature = entitlements.features[featureKey]
if (!feature?.editable) {
return sendForbidden(res, 'feature_unavailable', 'This settings feature is not available for your current subscription.', {
featureKey,
requiredPlan: feature?.requiredPlan ?? null,
subscriptionStatus: entitlements.subscriptionStatus,
accessLevel: entitlements.accessLevel,
})
}
next()
}
}