import { Router } from 'express' import { z } from 'zod' import multer from 'multer' import { prisma } from '../lib/prisma' import { uploadImage } from '../lib/storage' import { requireCompanyAuth } from '../middleware/requireCompanyAuth' import { requireTenant } from '../middleware/requireTenant' import { requireSubscription } from '../middleware/requireSubscription' const router = Router() const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } }) router.use(requireCompanyAuth, requireTenant, requireSubscription) const companySchema = z.object({ name: z.string().min(1).optional(), email: z.string().email().optional(), phone: z.string().optional(), address: z.record(z.unknown()).optional(), }) const brandSchema = z.object({ displayName: z.string().min(1).optional(), tagline: z.string().optional(), primaryColor: z.string().optional(), accentColor: z.string().optional(), publicEmail: z.string().email().optional(), publicPhone: z.string().optional(), publicAddress: z.string().optional(), publicCity: z.string().optional(), publicCountry: z.string().optional(), websiteUrl: z.string().url().optional(), whatsappNumber: z.string().optional(), defaultLocale: z.string().optional(), defaultCurrency: z.enum(['MAD', 'USD', 'EUR']).optional(), amanpayMerchantId: z.string().optional(), amanpaySecretKey: z.string().optional(), paypalEmail: z.string().email().optional(), paypalMerchantId: z.string().optional(), isListedOnMarketplace: z.boolean().optional(), homePageConfig: z.object({ heroTitle: z.union([z.string(), z.null()]).optional(), heroDescription: z.union([z.string(), z.null()]).optional(), viewOffersLabel: z.union([z.string(), z.null()]).optional(), viewPricingLabel: z.union([z.string(), z.null()]).optional(), contactCompanyLabel: z.union([z.string(), z.null()]).optional(), activeOffersTitle: z.union([z.string(), z.null()]).optional(), seeAllOffersLabel: z.union([z.string(), z.null()]).optional(), noActiveOffersLabel: z.union([z.string(), z.null()]).optional(), publishedVehiclesTitle: z.union([z.string(), z.null()]).optional(), viewVehicleLabel: z.union([z.string(), z.null()]).optional(), pricingEyebrow: z.union([z.string(), z.null()]).optional(), pricingTitle: z.union([z.string(), z.null()]).optional(), pricingDescription: z.union([z.string(), z.null()]).optional(), showOffers: z.boolean().optional(), showVehicles: z.boolean().optional(), showPricing: z.boolean().optional(), layout: z.object({ items: z.array(z.object({ id: z.string().min(1), type: z.enum(['hero', 'offers', 'vehicles', 'pricing']), x: z.number().int().min(1), y: z.number().int().min(1), w: z.number().int().min(1), h: z.number().int().min(1), })), }).optional(), }).optional(), menuConfig: z.object({ aboutLabel: z.union([z.string(), z.null()]).optional(), vehiclesLabel: z.union([z.string(), z.null()]).optional(), offersLabel: z.union([z.string(), z.null()]).optional(), pricingLabel: z.union([z.string(), z.null()]).optional(), blogLabel: z.union([z.string(), z.null()]).optional(), contactLabel: z.union([z.string(), z.null()]).optional(), bookCarLabel: z.union([z.string(), z.null()]).optional(), siteNavigationLabel: z.union([z.string(), z.null()]).optional(), showAbout: z.boolean().optional(), showVehicles: z.boolean().optional(), showOffers: z.boolean().optional(), showPricing: z.boolean().optional(), showBlog: z.boolean().optional(), showContact: z.boolean().optional(), pageSections: z.object({ home: z.object({ hero: z.boolean().optional(), offers: z.boolean().optional(), vehicles: z.boolean().optional(), pricing: z.boolean().optional(), }).optional(), about: z.object({ hero: z.boolean().optional(), highlights: z.boolean().optional(), details: z.boolean().optional(), }).optional(), offers: z.object({ header: z.boolean().optional(), grid: z.boolean().optional(), }).optional(), pricing: z.object({ hero: z.boolean().optional(), plans: z.boolean().optional(), payments: z.boolean().optional(), faq: z.boolean().optional(), }).optional(), vehicles: z.object({ header: z.boolean().optional(), filters: z.boolean().optional(), grid: z.boolean().optional(), }).optional(), vehicleDetail: z.object({ gallery: z.boolean().optional(), summary: z.boolean().optional(), }).optional(), blog: z.object({ hero: z.boolean().optional(), posts: z.boolean().optional(), }).optional(), contact: z.object({ content: z.boolean().optional(), }).optional(), booking: z.object({ content: z.boolean().optional(), }).optional(), bookingConfirmation: z.object({ hero: z.boolean().optional(), summary: z.boolean().optional(), actions: z.boolean().optional(), }).optional(), }).optional(), }).optional(), }) const contractSettingsSchema = z.object({ legalName: z.string().optional(), registrationNumber: z.string().optional(), taxId: z.string().optional(), terms: z.string().optional(), fuelPolicy: z.string().optional(), depositPolicy: z.string().optional(), lateFeePolicy: z.string().optional(), damagePolicy: z.string().optional(), contractFooterNote: z.string().optional(), invoiceFooterNote: z.string().optional(), signatureRequired: z.boolean().optional(), showTax: z.boolean().optional(), taxRate: z.number().optional(), taxLabel: z.string().optional(), fuelPolicyType: z.enum(['FULL_TO_FULL', 'FULL_TO_EMPTY', 'SAME_TO_SAME', 'PREPAID', 'FREE']).optional(), fuelPolicyNote: z.string().optional(), fuelChargePerLiter: z.number().int().optional(), fuelShortfallFee: z.number().int().optional(), lateFeePerHour: z.number().int().optional(), additionalDriverCharge: z.enum(['FREE', 'PER_DAY', 'FLAT']).optional(), additionalDriverDailyRate: z.number().int().optional(), additionalDriverFlatRate: z.number().int().optional(), }) const insurancePolicySchema = z.object({ name: z.string().min(1), description: z.string().optional(), type: z.enum(['CDW', 'SCDW', 'THEFT', 'THIRD_PARTY', 'FULL', 'BASIC', 'ROADSIDE', 'PERSONAL', 'CUSTOM']), chargeType: z.enum(['PER_DAY', 'PER_RENTAL', 'PERCENTAGE_OF_RENTAL']), chargeValue: z.number().int().min(0), isRequired: z.boolean().default(false), isActive: z.boolean().default(true), sortOrder: z.number().int().default(0), }) const pricingRuleSchema = z.object({ name: z.string().min(1), type: z.enum(['SURCHARGE', 'DISCOUNT']), condition: z.enum(['AGE_LESS_THAN', 'AGE_GREATER_THAN', 'LICENSE_YEARS_LESS_THAN', 'LICENSE_YEARS_GREATER_THAN']), conditionValue: z.number().int(), adjustmentType: z.enum(['PERCENTAGE', 'FLAT_PER_DAY', 'FLAT_TOTAL']), adjustmentValue: z.number().int(), isActive: z.boolean().default(true), description: z.string().optional(), }) const accountingSettingsSchema = z.object({ reportingPeriod: z.enum(['WEEKLY', 'MONTHLY', 'QUARTERLY', 'ANNUAL']).optional(), fiscalYearStart: z.number().int().min(1).max(12).optional(), currency: z.enum(['MAD', 'USD', 'EUR']).optional(), accountantEmail: z.string().email().optional(), accountantName: z.string().optional(), autoSendReport: z.boolean().optional(), reportFormat: z.enum(['PDF', 'CSV', 'BOTH']).optional(), }) function buildPaymentMethodsEnabled(input: { amanpayMerchantId?: string | null amanpaySecretKey?: string | null paypalEmail?: string | null }) { const methods: Array<'AMANPAY' | 'PAYPAL'> = [] if (input.amanpayMerchantId && input.amanpaySecretKey) methods.push('AMANPAY') if (input.paypalEmail) methods.push('PAYPAL') return methods } router.get('/me', async (req, res, next) => { try { const company = await prisma.company.findUniqueOrThrow({ where: { id: req.companyId }, include: { brand: true, subscription: true, _count: { select: { vehicles: true, customers: true, reservations: true } }, }, }) res.json({ data: company }) } catch (err) { next(err) } }) router.patch('/me', async (req, res, next) => { try { const body = companySchema.parse(req.body) const company = await prisma.company.update({ where: { id: req.companyId }, data: body as any, }) res.json({ data: company }) } catch (err) { next(err) } }) router.get('/me/brand', async (req, res, next) => { try { const brand = await prisma.brandSettings.findUnique({ where: { companyId: req.companyId }, }) res.json({ data: brand }) } catch (err) { next(err) } }) router.patch('/me/brand', async (req, res, next) => { try { const body = brandSchema.parse(req.body) const current = await prisma.brandSettings.findUnique({ where: { companyId: req.companyId } }) const paymentMethodsEnabled = buildPaymentMethodsEnabled({ amanpayMerchantId: body.amanpayMerchantId ?? current?.amanpayMerchantId, amanpaySecretKey: body.amanpaySecretKey ?? current?.amanpaySecretKey, paypalEmail: body.paypalEmail ?? current?.paypalEmail, }) const brand = await prisma.brandSettings.upsert({ where: { companyId: req.companyId }, update: { ...body, paymentMethodsEnabled }, create: { companyId: req.companyId, displayName: body.displayName ?? req.company.name, subdomain: req.company.slug, paymentMethodsEnabled, ...body, }, }) res.json({ data: brand }) } catch (err) { next(err) } }) router.post('/me/brand/logo', upload.single('file'), async (req, res, next) => { try { if (!req.file) { return res.status(400).json({ error: 'missing_file', message: 'A logo file is required', statusCode: 400 }) } const url = await uploadImage(req.file.buffer, `companies/${req.companyId}/brand`, 'logo') const brand = await prisma.brandSettings.upsert({ where: { companyId: req.companyId }, update: { logoUrl: url }, create: { companyId: req.companyId, displayName: req.company.name, subdomain: req.company.slug, logoUrl: url, }, }) res.json({ data: brand }) } catch (err) { next(err) } }) router.post('/me/brand/hero', upload.single('file'), async (req, res, next) => { try { if (!req.file) { return res.status(400).json({ error: 'missing_file', message: 'A hero image file is required', statusCode: 400 }) } const url = await uploadImage(req.file.buffer, `companies/${req.companyId}/brand`, 'hero') const brand = await prisma.brandSettings.upsert({ where: { companyId: req.companyId }, update: { heroImageUrl: url }, create: { companyId: req.companyId, displayName: req.company.name, subdomain: req.company.slug, heroImageUrl: url, }, }) res.json({ data: brand }) } catch (err) { next(err) } }) router.post('/me/brand/subdomain/check', async (req, res, next) => { try { const { subdomain } = z.object({ subdomain: z.string().min(3) }).parse(req.body) const existing = await prisma.brandSettings.findFirst({ where: { subdomain, companyId: { not: req.companyId } }, }) res.json({ data: { available: !existing } }) } catch (err) { next(err) } }) router.post('/me/brand/custom-domain', async (req, res, next) => { try { const { customDomain } = z.object({ customDomain: z.string().min(3) }).parse(req.body) const normalized = customDomain.toLowerCase().trim() const existing = await prisma.brandSettings.findFirst({ where: { customDomain: normalized, companyId: { not: req.companyId } }, }) if (existing) { return res.status(409).json({ error: 'domain_taken', message: 'This custom domain is already in use', statusCode: 409 }) } const brand = await prisma.brandSettings.upsert({ where: { companyId: req.companyId }, update: { customDomain: normalized, customDomainVerified: false, customDomainAddedAt: new Date(), }, create: { companyId: req.companyId, displayName: req.company.name, subdomain: req.company.slug, customDomain: normalized, customDomainVerified: false, customDomainAddedAt: new Date(), }, }) res.json({ data: brand }) } catch (err) { next(err) } }) router.get('/me/brand/custom-domain/status', async (req, res, next) => { try { const brand = await prisma.brandSettings.findUnique({ where: { companyId: req.companyId } }) const customDomain = brand?.customDomain ?? null res.json({ data: { customDomain, verified: brand?.customDomainVerified ?? false, status: customDomain ? (brand?.customDomainVerified ? 'verified' : 'pending_dns') : 'not_configured', dnsTarget: process.env.NEXT_PUBLIC_CUSTOM_DOMAIN_TARGET ?? 'cname.vercel-dns.com', }, }) } catch (err) { next(err) } }) router.delete('/me/brand/custom-domain', async (req, res, next) => { try { const brand = await prisma.brandSettings.updateMany({ where: { companyId: req.companyId }, data: { customDomain: null, customDomainVerified: false, customDomainAddedAt: null }, }) res.json({ data: { success: brand.count > 0 } }) } catch (err) { next(err) } }) router.get('/me/contract-settings', async (req, res, next) => { try { const settings = await prisma.contractSettings.findUnique({ where: { companyId: req.companyId } }) res.json({ data: settings }) } catch (err) { next(err) } }) router.patch('/me/contract-settings', async (req, res, next) => { try { const body = contractSettingsSchema.parse(req.body) const settings = await prisma.contractSettings.upsert({ where: { companyId: req.companyId }, update: body, create: { companyId: req.companyId, ...body }, }) res.json({ data: settings }) } catch (err) { next(err) } }) router.get('/me/insurance-policies', async (req, res, next) => { try { const policies = await prisma.insurancePolicy.findMany({ where: { companyId: req.companyId }, orderBy: [{ isRequired: 'desc' }, { sortOrder: 'asc' }, { name: 'asc' }], }) res.json({ data: policies }) } catch (err) { next(err) } }) router.post('/me/insurance-policies', async (req, res, next) => { try { const body = insurancePolicySchema.parse(req.body) const policy = await prisma.insurancePolicy.create({ data: { companyId: req.companyId, ...body } }) res.status(201).json({ data: policy }) } catch (err) { next(err) } }) router.patch('/me/insurance-policies/:id', async (req, res, next) => { try { const body = insurancePolicySchema.partial().parse(req.body) const policy = await prisma.insurancePolicy.updateMany({ where: { id: req.params.id, companyId: req.companyId }, data: body, }) if (policy.count === 0) return res.status(404).json({ error: 'not_found', message: 'Insurance policy not found', statusCode: 404 }) const updated = await prisma.insurancePolicy.findUniqueOrThrow({ where: { id: req.params.id } }) res.json({ data: updated }) } catch (err) { next(err) } }) router.delete('/me/insurance-policies/:id', async (req, res, next) => { try { const deleted = await prisma.insurancePolicy.deleteMany({ where: { id: req.params.id, companyId: req.companyId } }) if (deleted.count === 0) return res.status(404).json({ error: 'not_found', message: 'Insurance policy not found', statusCode: 404 }) res.json({ data: { success: true } }) } catch (err) { next(err) } }) router.get('/me/pricing-rules', async (req, res, next) => { try { const rules = await prisma.pricingRule.findMany({ where: { companyId: req.companyId }, orderBy: [{ isActive: 'desc' }, { createdAt: 'asc' }], }) res.json({ data: rules }) } catch (err) { next(err) } }) router.post('/me/pricing-rules', async (req, res, next) => { try { const body = pricingRuleSchema.parse(req.body) const rule = await prisma.pricingRule.create({ data: { companyId: req.companyId, ...body } }) res.status(201).json({ data: rule }) } catch (err) { next(err) } }) router.patch('/me/pricing-rules/:id', async (req, res, next) => { try { const body = pricingRuleSchema.partial().parse(req.body) const updated = await prisma.pricingRule.updateMany({ where: { id: req.params.id, companyId: req.companyId }, data: body, }) if (updated.count === 0) return res.status(404).json({ error: 'not_found', message: 'Pricing rule not found', statusCode: 404 }) const rule = await prisma.pricingRule.findUniqueOrThrow({ where: { id: req.params.id } }) res.json({ data: rule }) } catch (err) { next(err) } }) router.delete('/me/pricing-rules/:id', async (req, res, next) => { try { const deleted = await prisma.pricingRule.deleteMany({ where: { id: req.params.id, companyId: req.companyId } }) if (deleted.count === 0) return res.status(404).json({ error: 'not_found', message: 'Pricing rule not found', statusCode: 404 }) res.json({ data: { success: true } }) } catch (err) { next(err) } }) router.get('/me/accounting-settings', async (req, res, next) => { try { const settings = await prisma.accountingSettings.findUnique({ where: { companyId: req.companyId } }) res.json({ data: settings }) } catch (err) { next(err) } }) router.patch('/me/accounting-settings', async (req, res, next) => { try { const body = accountingSettingsSchema.parse(req.body) const settings = await prisma.accountingSettings.upsert({ where: { companyId: req.companyId }, update: body, create: { companyId: req.companyId, ...body }, }) res.json({ data: settings }) } catch (err) { next(err) } }) router.get('/me/api-key', async (req, res, next) => { try { const company = await prisma.company.findUniqueOrThrow({ where: { id: req.companyId }, select: { apiKey: true }, }) res.json({ data: company }) } catch (err) { next(err) } }) router.post('/me/api-key/regenerate', async (req, res, next) => { try { const company = await prisma.company.update({ where: { id: req.companyId }, data: { apiKey: `api_${Math.random().toString(36).slice(2)}${Date.now()}` }, select: { apiKey: true }, }) res.json({ data: company }) } catch (err) { next(err) } }) export default router