Files
carmanagement/apps/api/src/modules/companies/settingsEntitlements.ts
T
root 7ff2dbb139
Build & Deploy / Build & Push Docker Image (push) Successful in 2m55s
Test / Type Check (all packages) (push) Successful in 54s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Failing after 48s
Test / Homepage Unit Tests (push) Successful in 43s
Test / Storefront Unit Tests (push) Failing after 40s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Failing after 42s
Test / API Integration Tests (push) Successful in 1m0s
chore: wire Carplace into dev and production stacks
2026-07-02 18:15:42 -04:00

228 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.carplace_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'
| 'carplace'
| '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.' },
carplace: { label: 'Branding and Carplace', description: 'Control Carplace listing, logo, colors, and 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.' },
carplace: { 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: 'إدارة بيانات الشركة العامة والإعدادات الافتراضية.' },
carplace: { 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: 'carplace', sectionKey: 'carplace', 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.carplace_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.carplace_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()
}
}