refractor code,
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
export function presentCompany(company: any) {
|
||||
return company
|
||||
}
|
||||
|
||||
export function presentBrand(brand: any) {
|
||||
return brand
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { prisma } from '../../lib/prisma'
|
||||
|
||||
export async function findCompany(companyId: string) {
|
||||
return prisma.company.findUniqueOrThrow({
|
||||
where: { id: companyId },
|
||||
include: {
|
||||
brand: true,
|
||||
subscription: true,
|
||||
_count: { select: { vehicles: true, customers: true, reservations: true } },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateCompany(companyId: string, data: any) {
|
||||
return prisma.company.update({ where: { id: companyId }, data })
|
||||
}
|
||||
|
||||
export async function findBrand(companyId: string) {
|
||||
return prisma.brandSettings.findUnique({ where: { companyId } })
|
||||
}
|
||||
|
||||
export async function upsertBrand(companyId: string, updateData: any, createData: any) {
|
||||
return prisma.brandSettings.upsert({
|
||||
where: { companyId },
|
||||
update: updateData,
|
||||
create: { companyId, ...createData },
|
||||
})
|
||||
}
|
||||
|
||||
export async function findContractSettings(companyId: string) {
|
||||
return prisma.contractSettings.findUnique({ where: { companyId } })
|
||||
}
|
||||
|
||||
export async function upsertContractSettings(companyId: string, data: any) {
|
||||
return prisma.contractSettings.upsert({
|
||||
where: { companyId },
|
||||
update: data,
|
||||
create: { companyId, ...data },
|
||||
})
|
||||
}
|
||||
|
||||
export async function findInsurancePolicies(companyId: string) {
|
||||
return prisma.insurancePolicy.findMany({
|
||||
where: { companyId },
|
||||
orderBy: [{ isRequired: 'desc' }, { sortOrder: 'asc' }, { name: 'asc' }],
|
||||
})
|
||||
}
|
||||
|
||||
export async function createInsurancePolicy(companyId: string, data: any) {
|
||||
return prisma.insurancePolicy.create({ data: { companyId, ...data } })
|
||||
}
|
||||
|
||||
export async function updateInsurancePolicy(id: string, companyId: string, data: any) {
|
||||
return prisma.insurancePolicy.updateMany({ where: { id, companyId }, data })
|
||||
}
|
||||
|
||||
export async function findInsurancePolicyOrThrow(id: string) {
|
||||
return prisma.insurancePolicy.findUniqueOrThrow({ where: { id } })
|
||||
}
|
||||
|
||||
export async function deleteInsurancePolicy(id: string, companyId: string) {
|
||||
return prisma.insurancePolicy.deleteMany({ where: { id, companyId } })
|
||||
}
|
||||
|
||||
export async function findPricingRules(companyId: string) {
|
||||
return prisma.pricingRule.findMany({
|
||||
where: { companyId },
|
||||
orderBy: [{ isActive: 'desc' }, { createdAt: 'asc' }],
|
||||
})
|
||||
}
|
||||
|
||||
export async function createPricingRule(companyId: string, data: any) {
|
||||
return prisma.pricingRule.create({ data: { companyId, ...data } })
|
||||
}
|
||||
|
||||
export async function updatePricingRule(id: string, companyId: string, data: any) {
|
||||
return prisma.pricingRule.updateMany({ where: { id, companyId }, data })
|
||||
}
|
||||
|
||||
export async function findPricingRuleOrThrow(id: string) {
|
||||
return prisma.pricingRule.findUniqueOrThrow({ where: { id } })
|
||||
}
|
||||
|
||||
export async function deletePricingRule(id: string, companyId: string) {
|
||||
return prisma.pricingRule.deleteMany({ where: { id, companyId } })
|
||||
}
|
||||
|
||||
export async function findAccountingSettings(companyId: string) {
|
||||
return prisma.accountingSettings.findUnique({ where: { companyId } })
|
||||
}
|
||||
|
||||
export async function upsertAccountingSettings(companyId: string, data: any) {
|
||||
return prisma.accountingSettings.upsert({
|
||||
where: { companyId },
|
||||
update: data,
|
||||
create: { companyId, ...data },
|
||||
})
|
||||
}
|
||||
|
||||
export async function findApiKey(companyId: string) {
|
||||
return prisma.company.findUniqueOrThrow({ where: { id: companyId }, select: { apiKey: true } })
|
||||
}
|
||||
|
||||
export async function regenerateApiKey(companyId: string) {
|
||||
return prisma.company.update({
|
||||
where: { id: companyId },
|
||||
data: { apiKey: `api_${Math.random().toString(36).slice(2)}${Date.now()}` },
|
||||
select: { apiKey: true },
|
||||
})
|
||||
}
|
||||
|
||||
export async function findBrandBySubdomain(subdomain: string, excludeCompanyId: string) {
|
||||
return prisma.brandSettings.findFirst({ where: { subdomain, companyId: { not: excludeCompanyId } } })
|
||||
}
|
||||
|
||||
export async function findBrandByCustomDomain(customDomain: string, excludeCompanyId: string) {
|
||||
return prisma.brandSettings.findFirst({ where: { customDomain, companyId: { not: excludeCompanyId } } })
|
||||
}
|
||||
|
||||
export async function clearCustomDomain(companyId: string) {
|
||||
return prisma.brandSettings.updateMany({
|
||||
where: { companyId },
|
||||
data: { customDomain: null, customDomainVerified: false, customDomainAddedAt: null },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { Router } from 'express'
|
||||
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
|
||||
import { requireTenant } from '../../middleware/requireTenant'
|
||||
import { requireSubscription } from '../../middleware/requireSubscription'
|
||||
import { requireRole } from '../../middleware/requireRole'
|
||||
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 {
|
||||
companySchema, brandSchema, contractSettingsSchema,
|
||||
insurancePolicySchema, pricingRuleSchema, accountingSettingsSchema,
|
||||
subdomainSchema, customDomainSchema, idParamSchema,
|
||||
} from './company.schemas'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.use(requireCompanyAuth, requireTenant, requireSubscription)
|
||||
|
||||
router.get('/me', async (req, res, next) => {
|
||||
try {
|
||||
const company = await service.getCompany(req.companyId)
|
||||
ok(res, company)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/me', requireRole('OWNER'), async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(companySchema, req)
|
||||
const company = await service.updateCompany(req.companyId, body)
|
||||
ok(res, company)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/me/brand', async (req, res, next) => {
|
||||
try {
|
||||
const brand = await service.getBrand(req.companyId)
|
||||
ok(res, brand)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/me/brand', requireRole('OWNER'), async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(brandSchema, req)
|
||||
const brand = await service.updateBrand(req.companyId, body, req.company.name, req.company.slug)
|
||||
ok(res, brand)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/me/brand/logo', requireRole('OWNER'), 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)
|
||||
ok(res, brand)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/me/brand/hero', requireRole('OWNER'), 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)
|
||||
ok(res, brand)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/me/brand/subdomain/check', requireRole('OWNER'), async (req, res, next) => {
|
||||
try {
|
||||
const { subdomain } = parseBody(subdomainSchema, req)
|
||||
const result = await service.checkSubdomainAvailability(subdomain, req.companyId)
|
||||
ok(res, result)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/me/brand/custom-domain', requireRole('OWNER'), async (req, res, next) => {
|
||||
try {
|
||||
const { customDomain } = parseBody(customDomainSchema, req)
|
||||
const brand = await service.setCustomDomain(req.companyId, req.company.name, req.company.slug, customDomain)
|
||||
ok(res, brand)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/me/brand/custom-domain/status', async (req, res, next) => {
|
||||
try {
|
||||
const status = await service.getCustomDomainStatus(req.companyId)
|
||||
ok(res, status)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.delete('/me/brand/custom-domain', requireRole('OWNER'), async (req, res, next) => {
|
||||
try {
|
||||
const result = await service.removeCustomDomain(req.companyId)
|
||||
ok(res, result)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/me/contract-settings', async (req, res, next) => {
|
||||
try {
|
||||
const settings = await service.getContractSettings(req.companyId)
|
||||
ok(res, settings)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/me/contract-settings', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(contractSettingsSchema, req)
|
||||
const settings = await service.updateContractSettings(req.companyId, body)
|
||||
ok(res, settings)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/me/insurance-policies', async (req, res, next) => {
|
||||
try {
|
||||
const policies = await service.getInsurancePolicies(req.companyId)
|
||||
ok(res, policies)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/me/insurance-policies', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(insurancePolicySchema, req)
|
||||
const policy = await service.createInsurancePolicy(req.companyId, body)
|
||||
created(res, policy)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/me/insurance-policies/:id', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const body = parseBody(insurancePolicySchema.partial(), req)
|
||||
const policy = await service.updateInsurancePolicy(id, req.companyId, body)
|
||||
ok(res, policy)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.delete('/me/insurance-policies/:id', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
await service.deleteInsurancePolicy(id, req.companyId)
|
||||
ok(res, { success: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/me/pricing-rules', async (req, res, next) => {
|
||||
try {
|
||||
const rules = await service.getPricingRules(req.companyId)
|
||||
ok(res, rules)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/me/pricing-rules', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(pricingRuleSchema, req)
|
||||
const rule = await service.createPricingRule(req.companyId, body)
|
||||
created(res, rule)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/me/pricing-rules/:id', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const body = parseBody(pricingRuleSchema.partial(), req)
|
||||
const rule = await service.updatePricingRule(id, req.companyId, body)
|
||||
ok(res, rule)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.delete('/me/pricing-rules/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
await service.deletePricingRule(id, req.companyId)
|
||||
ok(res, { success: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/me/accounting-settings', async (req, res, next) => {
|
||||
try {
|
||||
const settings = await service.getAccountingSettings(req.companyId)
|
||||
ok(res, settings)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/me/accounting-settings', async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(accountingSettingsSchema, req)
|
||||
const settings = await service.updateAccountingSettings(req.companyId, body)
|
||||
ok(res, settings)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/me/api-key', async (req, res, next) => {
|
||||
try {
|
||||
const company = await service.getApiKey(req.companyId)
|
||||
ok(res, company)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/me/api-key/regenerate', async (req, res, next) => {
|
||||
try {
|
||||
const company = await service.regenerateApiKey(req.companyId)
|
||||
ok(res, company)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,158 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export 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(),
|
||||
})
|
||||
|
||||
export 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(),
|
||||
})
|
||||
|
||||
export 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(),
|
||||
})
|
||||
|
||||
export 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),
|
||||
})
|
||||
|
||||
export 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(),
|
||||
})
|
||||
|
||||
export 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(),
|
||||
})
|
||||
|
||||
export const subdomainSchema = z.object({ subdomain: z.string().min(3) })
|
||||
export const customDomainSchema = z.object({ customDomain: z.string().min(3) })
|
||||
export const idParamSchema = z.object({ id: z.string() })
|
||||
@@ -0,0 +1,153 @@
|
||||
import { uploadImage } from '../../lib/storage'
|
||||
import { ConflictError, NotFoundError } from '../../http/errors'
|
||||
import { presentCompany, presentBrand } from './company.presenter'
|
||||
import * as repo from './company.repo'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export async function getCompany(companyId: string) {
|
||||
return presentCompany(await repo.findCompany(companyId))
|
||||
}
|
||||
|
||||
export async function updateCompany(companyId: string, data: any) {
|
||||
return presentCompany(await repo.updateCompany(companyId, data))
|
||||
}
|
||||
|
||||
export async function getBrand(companyId: string) {
|
||||
return presentBrand(await repo.findBrand(companyId))
|
||||
}
|
||||
|
||||
export async function updateBrand(companyId: string, body: any, companyName: string, companySlug: string) {
|
||||
const current = await repo.findBrand(companyId)
|
||||
const paymentMethodsEnabled = buildPaymentMethodsEnabled({
|
||||
amanpayMerchantId: body.amanpayMerchantId ?? current?.amanpayMerchantId,
|
||||
amanpaySecretKey: body.amanpaySecretKey ?? current?.amanpaySecretKey,
|
||||
paypalEmail: body.paypalEmail ?? current?.paypalEmail,
|
||||
})
|
||||
return presentBrand(await repo.upsertBrand(
|
||||
companyId,
|
||||
{ ...body, paymentMethodsEnabled },
|
||||
{ displayName: body.displayName ?? companyName, subdomain: companySlug, paymentMethodsEnabled, ...body },
|
||||
))
|
||||
}
|
||||
|
||||
export async function uploadLogo(companyId: string, companyName: string, companySlug: string, file: Buffer) {
|
||||
const url = await uploadImage(file, `companies/${companyId}/brand`, 'logo')
|
||||
return presentBrand(await repo.upsertBrand(
|
||||
companyId,
|
||||
{ logoUrl: url },
|
||||
{ displayName: companyName, subdomain: companySlug, logoUrl: url },
|
||||
))
|
||||
}
|
||||
|
||||
export async function uploadHeroImage(companyId: string, companyName: string, companySlug: string, file: Buffer) {
|
||||
const url = await uploadImage(file, `companies/${companyId}/brand`, 'hero')
|
||||
return presentBrand(await repo.upsertBrand(
|
||||
companyId,
|
||||
{ heroImageUrl: url },
|
||||
{ displayName: companyName, subdomain: companySlug, heroImageUrl: url },
|
||||
))
|
||||
}
|
||||
|
||||
export async function checkSubdomainAvailability(subdomain: string, companyId: string) {
|
||||
const existing = await repo.findBrandBySubdomain(subdomain, companyId)
|
||||
return { available: !existing }
|
||||
}
|
||||
|
||||
export async function setCustomDomain(companyId: string, companyName: string, companySlug: string, customDomain: string) {
|
||||
const normalized = customDomain.toLowerCase().trim()
|
||||
const existing = await repo.findBrandByCustomDomain(normalized, companyId)
|
||||
if (existing) throw new ConflictError('This custom domain is already in use')
|
||||
return repo.upsertBrand(
|
||||
companyId,
|
||||
{ customDomain: normalized, customDomainVerified: false, customDomainAddedAt: new Date() },
|
||||
{ displayName: companyName, subdomain: companySlug, customDomain: normalized, customDomainVerified: false, customDomainAddedAt: new Date() },
|
||||
)
|
||||
}
|
||||
|
||||
export async function getCustomDomainStatus(companyId: string) {
|
||||
const brand = await repo.findBrand(companyId)
|
||||
const customDomain = brand?.customDomain ?? null
|
||||
return {
|
||||
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',
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeCustomDomain(companyId: string) {
|
||||
const result = await repo.clearCustomDomain(companyId)
|
||||
return { success: result.count > 0 }
|
||||
}
|
||||
|
||||
export async function getContractSettings(companyId: string) {
|
||||
return repo.findContractSettings(companyId)
|
||||
}
|
||||
|
||||
export async function updateContractSettings(companyId: string, data: any) {
|
||||
return repo.upsertContractSettings(companyId, data)
|
||||
}
|
||||
|
||||
export async function getInsurancePolicies(companyId: string) {
|
||||
return repo.findInsurancePolicies(companyId)
|
||||
}
|
||||
|
||||
export async function createInsurancePolicy(companyId: string, data: any) {
|
||||
return repo.createInsurancePolicy(companyId, data)
|
||||
}
|
||||
|
||||
export async function updateInsurancePolicy(id: string, companyId: string, data: any) {
|
||||
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) {
|
||||
const result = await repo.deleteInsurancePolicy(id, companyId)
|
||||
if (result.count === 0) throw new NotFoundError('Insurance policy not found')
|
||||
}
|
||||
|
||||
export async function getPricingRules(companyId: string) {
|
||||
return repo.findPricingRules(companyId)
|
||||
}
|
||||
|
||||
export async function createPricingRule(companyId: string, data: any) {
|
||||
return repo.createPricingRule(companyId, data)
|
||||
}
|
||||
|
||||
export async function updatePricingRule(id: string, companyId: string, data: any) {
|
||||
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) {
|
||||
const result = await repo.deletePricingRule(id, companyId)
|
||||
if (result.count === 0) throw new NotFoundError('Pricing rule not found')
|
||||
}
|
||||
|
||||
export async function getAccountingSettings(companyId: string) {
|
||||
return repo.findAccountingSettings(companyId)
|
||||
}
|
||||
|
||||
export async function updateAccountingSettings(companyId: string, data: any) {
|
||||
return repo.upsertAccountingSettings(companyId, data)
|
||||
}
|
||||
|
||||
export async function getApiKey(companyId: string) {
|
||||
return repo.findApiKey(companyId)
|
||||
}
|
||||
|
||||
export async function regenerateApiKey(companyId: string) {
|
||||
return repo.regenerateApiKey(companyId)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import * as repo from './company.repo'
|
||||
import * as service from './company.service'
|
||||
|
||||
vi.mock('./company.repo')
|
||||
vi.mock('../../lib/storage', () => ({
|
||||
uploadImage: vi.fn().mockResolvedValue('http://localhost:4000/storage/companies/comp_1/brand/logo.jpg'),
|
||||
}))
|
||||
|
||||
const mockCompany = {
|
||||
id: 'comp_1',
|
||||
name: 'Test Rentals',
|
||||
slug: 'test-rentals',
|
||||
email: 'info@test.com',
|
||||
brand: null,
|
||||
subscription: null,
|
||||
_count: { vehicles: 5, customers: 10, reservations: 20 },
|
||||
}
|
||||
|
||||
const mockBrand = {
|
||||
id: 'brand_1',
|
||||
companyId: 'comp_1',
|
||||
displayName: 'Test Rentals',
|
||||
subdomain: 'test-rentals',
|
||||
logoUrl: null,
|
||||
heroImageUrl: null,
|
||||
customDomain: null,
|
||||
customDomainVerified: false,
|
||||
amanpayMerchantId: null,
|
||||
amanpaySecretKey: null,
|
||||
paypalEmail: null,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('company.service', () => {
|
||||
describe('getCompany', () => {
|
||||
it('returns company with brand and subscription', async () => {
|
||||
vi.mocked(repo.findCompany).mockResolvedValue(mockCompany as any)
|
||||
const result = await service.getCompany('comp_1')
|
||||
expect(result).toEqual(mockCompany)
|
||||
expect(repo.findCompany).toHaveBeenCalledWith('comp_1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateCompany', () => {
|
||||
it('updates company profile', async () => {
|
||||
vi.mocked(repo.updateCompany).mockResolvedValue({ ...mockCompany, name: 'Updated Rentals' } as any)
|
||||
const result = await service.updateCompany('comp_1', { name: 'Updated Rentals' })
|
||||
expect(repo.updateCompany).toHaveBeenCalledWith('comp_1', { name: 'Updated Rentals' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('uploadLogo', () => {
|
||||
it('uploads logo and upserts brand', async () => {
|
||||
vi.mocked(repo.upsertBrand).mockResolvedValue({ ...mockBrand, logoUrl: 'http://localhost:4000/storage/companies/comp_1/brand/logo.jpg' } as any)
|
||||
const result = await service.uploadLogo('comp_1', 'Test Rentals', 'test-rentals', Buffer.from(''))
|
||||
expect(repo.upsertBrand).toHaveBeenCalledWith(
|
||||
'comp_1',
|
||||
expect.objectContaining({ logoUrl: 'http://localhost:4000/storage/companies/comp_1/brand/logo.jpg' }),
|
||||
expect.objectContaining({ logoUrl: 'http://localhost:4000/storage/companies/comp_1/brand/logo.jpg' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('uploadHeroImage', () => {
|
||||
it('uploads hero image and upserts brand', async () => {
|
||||
vi.mocked(repo.upsertBrand).mockResolvedValue({ ...mockBrand, heroImageUrl: 'http://localhost:4000/storage/companies/comp_1/brand/logo.jpg' } as any)
|
||||
await service.uploadHeroImage('comp_1', 'Test Rentals', 'test-rentals', Buffer.from(''))
|
||||
expect(repo.upsertBrand).toHaveBeenCalledWith(
|
||||
'comp_1',
|
||||
expect.objectContaining({ heroImageUrl: expect.any(String) }),
|
||||
expect.objectContaining({ heroImageUrl: expect.any(String) }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkSubdomainAvailability', () => {
|
||||
it('returns available=true when subdomain is free', async () => {
|
||||
vi.mocked(repo.findBrandBySubdomain).mockResolvedValue(null)
|
||||
const result = await service.checkSubdomainAvailability('my-rental', 'comp_1')
|
||||
expect(result.available).toBe(true)
|
||||
})
|
||||
|
||||
it('returns available=false when subdomain is taken', async () => {
|
||||
vi.mocked(repo.findBrandBySubdomain).mockResolvedValue(mockBrand as any)
|
||||
const result = await service.checkSubdomainAvailability('taken-rental', 'comp_1')
|
||||
expect(result.available).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setCustomDomain', () => {
|
||||
it('throws ConflictError when custom domain is already in use', async () => {
|
||||
vi.mocked(repo.findBrandByCustomDomain).mockResolvedValue(mockBrand as any)
|
||||
await expect(service.setCustomDomain('comp_1', 'Test Rentals', 'test-rentals', 'taken.com')).rejects.toThrow('This custom domain is already in use')
|
||||
})
|
||||
|
||||
it('sets custom domain when available', async () => {
|
||||
vi.mocked(repo.findBrandByCustomDomain).mockResolvedValue(null)
|
||||
vi.mocked(repo.upsertBrand).mockResolvedValue({ ...mockBrand, customDomain: 'my-rental.com' } as any)
|
||||
await service.setCustomDomain('comp_1', 'Test Rentals', 'test-rentals', 'My-Rental.com')
|
||||
expect(repo.upsertBrand).toHaveBeenCalledWith(
|
||||
'comp_1',
|
||||
expect.objectContaining({ customDomain: 'my-rental.com' }),
|
||||
expect.objectContaining({ customDomain: 'my-rental.com' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateInsurancePolicy', () => {
|
||||
it('throws NotFoundError when policy does not belong to company', async () => {
|
||||
vi.mocked(repo.updateInsurancePolicy).mockResolvedValue({ count: 0 } as any)
|
||||
await expect(service.updateInsurancePolicy('pol_1', 'comp_1', { name: 'CDW' })).rejects.toThrow('Insurance policy not found')
|
||||
})
|
||||
})
|
||||
|
||||
describe('deletePricingRule', () => {
|
||||
it('throws NotFoundError when rule does not belong to company', async () => {
|
||||
vi.mocked(repo.deletePricingRule).mockResolvedValue({ count: 0 } as any)
|
||||
await expect(service.deletePricingRule('rule_1', 'comp_1')).rejects.toThrow('Pricing rule not found')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user