fixing platform admin
This commit is contained in:
@@ -9,6 +9,11 @@ import { requireAdminAuth, requireAdminRole } from '../middleware/requireAdminAu
|
||||
|
||||
const router = Router()
|
||||
|
||||
const permissionSchema = z.object({
|
||||
resource: z.enum(['COMPANIES', 'COMPANY_EMPLOYEES', 'SUBSCRIPTIONS', 'PAYMENTS', 'OFFERS', 'RENTERS', 'ADMIN_USERS', 'AUDIT_LOGS', 'PLATFORM_METRICS']),
|
||||
actions: z.array(z.enum(['READ', 'CREATE', 'UPDATE', 'DELETE', 'SUSPEND', 'IMPERSONATE'])).min(1),
|
||||
})
|
||||
|
||||
function signAdminToken(adminId: string) {
|
||||
return jwt.sign({ sub: adminId, type: 'admin' }, process.env.JWT_SECRET!, { expiresIn: '8h' })
|
||||
}
|
||||
@@ -17,7 +22,7 @@ function signAdminToken(adminId: string) {
|
||||
|
||||
router.post('/auth/login', async (req, res, next) => {
|
||||
try {
|
||||
const { email, password, totpCode } = z.object({ email: z.string().email(), password: z.string(), totpCode: z.string().optional() }).parse(req.body)
|
||||
const { email, password, totpCode } = z.object({ email: z.string().email().max(255).trim().toLowerCase(), password: z.string().max(128), totpCode: z.string().length(6).optional() }).parse(req.body)
|
||||
const admin = await prisma.adminUser.findUnique({ where: { email } })
|
||||
if (!admin || !admin.isActive) return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid email or password', statusCode: 401 })
|
||||
|
||||
@@ -176,11 +181,12 @@ router.get('/metrics', requireAdminAuth, requireAdminRole('FINANCE'), async (req
|
||||
|
||||
router.get('/audit-logs', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
|
||||
try {
|
||||
const { adminId, action, companyId, page = '1', pageSize = '50' } = req.query as Record<string, string>
|
||||
const { adminId, action, companyId, entityId, page = '1', pageSize = '50' } = req.query as Record<string, string>
|
||||
const where: any = {}
|
||||
if (adminId) where.adminUserId = adminId
|
||||
if (action) where.action = { contains: action }
|
||||
if (companyId) where.companyId = companyId
|
||||
if (entityId) where.resourceId = entityId
|
||||
const [logs, total] = await Promise.all([
|
||||
prisma.auditLog.findMany({ where, include: { adminUser: { select: { firstName: true, lastName: true, email: true } } }, skip: (parseInt(page) - 1) * parseInt(pageSize), take: parseInt(pageSize), orderBy: { createdAt: 'desc' } }),
|
||||
prisma.auditLog.count({ where }),
|
||||
@@ -193,16 +199,48 @@ router.get('/audit-logs', requireAdminAuth, requireAdminRole('ADMIN'), async (re
|
||||
|
||||
router.get('/admins', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => {
|
||||
try {
|
||||
const admins = await prisma.adminUser.findMany({ select: { id: true, email: true, firstName: true, lastName: true, role: true, isActive: true, totpEnabled: true, lastLoginAt: true, createdAt: true } })
|
||||
const admins = await prisma.adminUser.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
totpEnabled: true,
|
||||
lastLoginAt: true,
|
||||
createdAt: true,
|
||||
permissions: true,
|
||||
},
|
||||
})
|
||||
res.json({ data: admins })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/admins', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => {
|
||||
try {
|
||||
const body = z.object({ email: z.string().email(), firstName: z.string(), lastName: z.string(), role: z.enum(['ADMIN','SUPPORT','FINANCE','VIEWER']), password: z.string().min(8) }).parse(req.body)
|
||||
const body = z.object({
|
||||
email: z.string().email(),
|
||||
firstName: z.string(),
|
||||
lastName: z.string(),
|
||||
role: z.enum(['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER']),
|
||||
password: z.string().min(8),
|
||||
permissions: z.array(permissionSchema).optional(),
|
||||
}).parse(req.body)
|
||||
const passwordHash = await bcrypt.hash(body.password, 12)
|
||||
const admin = await prisma.adminUser.create({ data: { ...body, passwordHash, password: undefined } as any })
|
||||
const admin = await prisma.adminUser.create({
|
||||
data: {
|
||||
email: body.email,
|
||||
firstName: body.firstName,
|
||||
lastName: body.lastName,
|
||||
role: body.role,
|
||||
passwordHash,
|
||||
permissions: body.permissions ? {
|
||||
create: body.permissions,
|
||||
} : undefined,
|
||||
},
|
||||
include: { permissions: true },
|
||||
})
|
||||
const { passwordHash: _, ...safe } = admin
|
||||
res.status(201).json({ data: safe })
|
||||
} catch (err) { next(err) }
|
||||
@@ -210,10 +248,32 @@ router.post('/admins', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async
|
||||
|
||||
router.patch('/admins/:id/role', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => {
|
||||
try {
|
||||
const { role } = z.object({ role: z.enum(['ADMIN','SUPPORT','FINANCE','VIEWER']) }).parse(req.body)
|
||||
const { role } = z.object({ role: z.enum(['SUPER_ADMIN', 'ADMIN','SUPPORT','FINANCE','VIEWER']) }).parse(req.body)
|
||||
await prisma.adminUser.update({ where: { id: req.params.id }, data: { role } })
|
||||
res.json({ data: { success: true } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/admins/:id/permissions', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => {
|
||||
try {
|
||||
const { permissions } = z.object({ permissions: z.array(permissionSchema) }).parse(req.body)
|
||||
await prisma.adminUser.findUniqueOrThrow({ where: { id: req.params.id } })
|
||||
await prisma.$transaction([
|
||||
prisma.adminPermission.deleteMany({ where: { adminUserId: req.params.id } }),
|
||||
prisma.adminPermission.createMany({
|
||||
data: permissions.map((permission) => ({
|
||||
adminUserId: req.params.id,
|
||||
resource: permission.resource,
|
||||
actions: permission.actions,
|
||||
})),
|
||||
}),
|
||||
])
|
||||
const admin = await prisma.adminUser.findUniqueOrThrow({
|
||||
where: { id: req.params.id },
|
||||
include: { permissions: true },
|
||||
})
|
||||
res.json({ data: admin })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -8,6 +8,29 @@ import { generateFinancialReport, toCsv } from '../services/financialReportServi
|
||||
const router = Router()
|
||||
router.use(requireCompanyAuth, requireTenant, requireSubscription)
|
||||
|
||||
function getRangeFromPeriod(period: string) {
|
||||
const now = new Date()
|
||||
|
||||
switch (period) {
|
||||
case 'WEEKLY': {
|
||||
const start = new Date(now)
|
||||
start.setDate(now.getDate() - 7)
|
||||
return { from: start, to: now }
|
||||
}
|
||||
case 'QUARTERLY': {
|
||||
const start = new Date(now.getFullYear(), now.getMonth() - 2, 1)
|
||||
return { from: start, to: now }
|
||||
}
|
||||
case 'ANNUAL': {
|
||||
const start = new Date(now.getFullYear(), 0, 1)
|
||||
return { from: start, to: now }
|
||||
}
|
||||
case 'MONTHLY':
|
||||
default:
|
||||
return { from: new Date(now.getFullYear(), now.getMonth(), 1), to: now }
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/summary', async (req, res, next) => {
|
||||
try {
|
||||
const { period = '30d' } = req.query as Record<string, string>
|
||||
@@ -25,6 +48,108 @@ router.get('/summary', async (req, res, next) => {
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/dashboard', async (req, res, next) => {
|
||||
try {
|
||||
const now = new Date()
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||
const prevMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1)
|
||||
const prevMonthEnd = new Date(monthStart.getTime() - 1)
|
||||
|
||||
const [
|
||||
totalBookings,
|
||||
previousBookings,
|
||||
activeVehicles,
|
||||
previousActiveVehicles,
|
||||
totalCustomers,
|
||||
previousCustomers,
|
||||
monthlyRevenueAgg,
|
||||
previousRevenueAgg,
|
||||
recentReservations,
|
||||
sourceGroups,
|
||||
subscription,
|
||||
] = await Promise.all([
|
||||
prisma.reservation.count({ where: { companyId: req.companyId, createdAt: { gte: monthStart } } }),
|
||||
prisma.reservation.count({ where: { companyId: req.companyId, createdAt: { gte: prevMonthStart, lte: prevMonthEnd } } }),
|
||||
prisma.vehicle.count({ where: { companyId: req.companyId, status: { in: ['AVAILABLE', 'RENTED'] } } }),
|
||||
prisma.vehicle.count({ where: { companyId: req.companyId, createdAt: { lte: prevMonthEnd }, status: { in: ['AVAILABLE', 'RENTED'] } } }),
|
||||
prisma.customer.count({ where: { companyId: req.companyId } }),
|
||||
prisma.customer.count({ where: { companyId: req.companyId, createdAt: { lte: prevMonthEnd } } }),
|
||||
prisma.reservation.aggregate({ where: { companyId: req.companyId, status: { in: ['CONFIRMED', 'ACTIVE', 'COMPLETED'] }, createdAt: { gte: monthStart } }, _sum: { totalAmount: true } }),
|
||||
prisma.reservation.aggregate({ where: { companyId: req.companyId, status: { in: ['CONFIRMED', 'ACTIVE', 'COMPLETED'] }, createdAt: { gte: prevMonthStart, lte: prevMonthEnd } }, _sum: { totalAmount: true } }),
|
||||
prisma.reservation.findMany({
|
||||
where: { companyId: req.companyId },
|
||||
include: { customer: true, vehicle: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 8,
|
||||
}),
|
||||
prisma.reservation.groupBy({
|
||||
by: ['source'],
|
||||
where: { companyId: req.companyId },
|
||||
_count: { id: true },
|
||||
_sum: { totalAmount: true },
|
||||
}),
|
||||
prisma.subscription.findUnique({ where: { companyId: req.companyId } }),
|
||||
])
|
||||
|
||||
const pct = (current: number, previous: number) => {
|
||||
if (previous === 0) return current > 0 ? 100 : 0
|
||||
return Math.round(((current - previous) / previous) * 100)
|
||||
}
|
||||
|
||||
const typedRecentReservations = recentReservations as Array<{
|
||||
id: string
|
||||
contractNumber: string | null
|
||||
startDate: Date
|
||||
endDate: Date
|
||||
status: string
|
||||
totalAmount: number
|
||||
customer: { firstName: string; lastName: string }
|
||||
vehicle: { make: string; model: string }
|
||||
}>
|
||||
|
||||
const typedSourceGroups = sourceGroups as Array<{
|
||||
source: string
|
||||
_count: { id: number }
|
||||
_sum: { totalAmount: number | null }
|
||||
}>
|
||||
|
||||
res.json({
|
||||
data: {
|
||||
kpis: {
|
||||
totalBookings,
|
||||
activeVehicles,
|
||||
monthlyRevenue: monthlyRevenueAgg._sum.totalAmount ?? 0,
|
||||
totalCustomers,
|
||||
bookingsChange: pct(totalBookings, previousBookings),
|
||||
vehiclesChange: pct(activeVehicles, previousActiveVehicles),
|
||||
revenueChange: pct(monthlyRevenueAgg._sum.totalAmount ?? 0, previousRevenueAgg._sum.totalAmount ?? 0),
|
||||
customersChange: pct(totalCustomers, previousCustomers),
|
||||
},
|
||||
recentReservations: typedRecentReservations.map((reservation) => ({
|
||||
id: reservation.id,
|
||||
bookingRef: reservation.contractNumber ?? reservation.id.slice(-8).toUpperCase(),
|
||||
customerName: `${reservation.customer.firstName} ${reservation.customer.lastName}`,
|
||||
vehicleName: `${reservation.vehicle.make} ${reservation.vehicle.model}`,
|
||||
startDate: reservation.startDate,
|
||||
endDate: reservation.endDate,
|
||||
status: reservation.status,
|
||||
totalAmount: reservation.totalAmount,
|
||||
})),
|
||||
sourceBreakdown: typedSourceGroups.map((group) => ({
|
||||
source: group.source,
|
||||
count: group._count.id,
|
||||
revenue: group._sum.totalAmount ?? 0,
|
||||
})),
|
||||
subscription: subscription ? {
|
||||
status: subscription.status,
|
||||
planName: subscription.plan,
|
||||
trialEndsAt: subscription.trialEndAt,
|
||||
} : null,
|
||||
},
|
||||
})
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/sources', async (req, res, next) => {
|
||||
try {
|
||||
const sources = await prisma.reservation.groupBy({ by: ['source'], where: { companyId: req.companyId }, _count: { id: true } })
|
||||
@@ -34,14 +159,17 @@ router.get('/sources', async (req, res, next) => {
|
||||
|
||||
router.get('/report', async (req, res, next) => {
|
||||
try {
|
||||
const { from, to, format = 'JSON' } = req.query as Record<string, string>
|
||||
if (!from || !to) return res.status(400).json({ error: 'missing_params', message: 'from and to date params required', statusCode: 400 })
|
||||
const { from, to, format = 'JSON', period } = req.query as Record<string, string>
|
||||
const accountingSettings = await prisma.accountingSettings.findUnique({ where: { companyId: req.companyId } })
|
||||
const derivedRange = getRangeFromPeriod(period || accountingSettings?.reportingPeriod || 'MONTHLY')
|
||||
const startDate = from ? new Date(from) : derivedRange.from
|
||||
const endDate = to ? new Date(to) : derivedRange.to
|
||||
|
||||
const report = await generateFinancialReport(req.companyId, new Date(from), new Date(to))
|
||||
const report = await generateFinancialReport(req.companyId, startDate, endDate)
|
||||
|
||||
if (format === 'CSV') {
|
||||
res.setHeader('Content-Type', 'text/csv')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="report-${from}-${to}.csv"`)
|
||||
res.setHeader('Content-Disposition', `attachment; filename="report-${startDate.toISOString().slice(0, 10)}-${endDate.toISOString().slice(0, 10)}.csv"`)
|
||||
return res.send(toCsv(report.rows))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
import { Router } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { clerkClient } from '@clerk/clerk-sdk-node'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { sendNotification } from '../services/notificationService'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const signupSchema = z.object({
|
||||
firstName: z.string().min(1).max(80),
|
||||
lastName: z.string().min(1).max(80),
|
||||
email: z.string().email(),
|
||||
companyName: z.string().min(2).max(120),
|
||||
companyPhone: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
|
||||
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
|
||||
currency: z.enum(['MAD', 'USD', 'EUR']),
|
||||
paymentProvider: z.enum(['AMANPAY', 'PAYPAL']),
|
||||
})
|
||||
|
||||
const ownerWorkspaceSchema = z.object({
|
||||
companyName: z.string().min(2).max(120),
|
||||
companyPhone: z.string().nullish(),
|
||||
country: z.string().nullish(),
|
||||
city: z.string().nullish(),
|
||||
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
|
||||
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
|
||||
currency: z.enum(['MAD', 'USD', 'EUR']),
|
||||
paymentProvider: z.enum(['AMANPAY', 'PAYPAL']),
|
||||
})
|
||||
|
||||
const completeSignupMetadataSchema = ownerWorkspaceSchema.extend({
|
||||
signupFlow: z.literal('company-owner'),
|
||||
})
|
||||
|
||||
const verifySchema = z.object({
|
||||
email: z.string().email(),
|
||||
})
|
||||
|
||||
function slugifyCompanyName(value: string) {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 50) || 'company'
|
||||
}
|
||||
|
||||
async function generateUniqueSlug(baseName: string) {
|
||||
const base = slugifyCompanyName(baseName)
|
||||
|
||||
for (let attempt = 0; attempt < 25; attempt += 1) {
|
||||
const slug = attempt === 0 ? base : `${base}-${attempt + 1}`
|
||||
const existing = await prisma.company.findUnique({ where: { slug } })
|
||||
if (!existing) return slug
|
||||
}
|
||||
|
||||
return `${base}-${Date.now().toString(36)}`
|
||||
}
|
||||
|
||||
function addDays(date: Date, days: number) {
|
||||
return new Date(date.getTime() + days * 24 * 60 * 60 * 1000)
|
||||
}
|
||||
|
||||
function buildSignupConfirmationEmail(opts: {
|
||||
firstName: string
|
||||
companyName: string
|
||||
plan: 'STARTER' | 'GROWTH' | 'PRO'
|
||||
billingPeriod: 'MONTHLY' | 'ANNUAL'
|
||||
currency: 'MAD' | 'USD' | 'EUR'
|
||||
paymentProvider?: 'AMANPAY' | 'PAYPAL'
|
||||
trialEndAt?: Date
|
||||
isResend?: boolean
|
||||
usesInvitation?: boolean
|
||||
}) {
|
||||
const greetingName = opts.firstName.trim() || 'there'
|
||||
const lines = [
|
||||
`Hi ${greetingName},`,
|
||||
'',
|
||||
opts.isResend
|
||||
? `We sent a fresh owner invitation for ${opts.companyName}.`
|
||||
: `Your RentalDriveGo workspace for ${opts.companyName} has been created successfully.`,
|
||||
`Plan: ${opts.plan} (${opts.billingPeriod.toLowerCase()})`,
|
||||
`Currency: ${opts.currency}`,
|
||||
]
|
||||
|
||||
if (opts.paymentProvider) {
|
||||
lines.push(`Primary payment provider: ${opts.paymentProvider}`)
|
||||
}
|
||||
|
||||
if (opts.trialEndAt) {
|
||||
lines.push(
|
||||
`Free trial ends on ${opts.trialEndAt.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}.`
|
||||
)
|
||||
}
|
||||
|
||||
lines.push(
|
||||
'',
|
||||
opts.usesInvitation === false
|
||||
? 'Your workspace is ready and the owner record has been created for this environment.'
|
||||
: 'Open the invitation email from Clerk to confirm your account, finish onboarding, and enter the dashboard.',
|
||||
'',
|
||||
'RentalDriveGo'
|
||||
)
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
async function createOwnerInvitation(email: string, companyId: string, companyName: string) {
|
||||
const dashboardUrl = process.env.DASHBOARD_URL
|
||||
if (!process.env.CLERK_SECRET_KEY || !dashboardUrl) {
|
||||
throw Object.assign(new Error('Clerk signup is not configured on this environment'), {
|
||||
statusCode: 503,
|
||||
code: 'clerk_not_configured',
|
||||
})
|
||||
}
|
||||
|
||||
return clerkClient.invitations.createInvitation({
|
||||
emailAddress: email,
|
||||
redirectUrl: `${dashboardUrl}/onboarding/accept-invite`,
|
||||
publicMetadata: {
|
||||
companyId,
|
||||
companyName,
|
||||
role: 'OWNER',
|
||||
signupFlow: 'company-owner',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function verifyClerkSessionToken(sessionToken: string) {
|
||||
return clerkClient.sessions.verifySession(sessionToken, sessionToken)
|
||||
}
|
||||
|
||||
function getPrimaryEmail(user: Awaited<ReturnType<typeof clerkClient.users.getUser>>) {
|
||||
const primary = user.emailAddresses.find((email) => email.id === user.primaryEmailAddressId)
|
||||
return primary ?? user.emailAddresses[0] ?? null
|
||||
}
|
||||
|
||||
async function createCompanyWorkspaceForOwner(opts: {
|
||||
clerkUserId: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
companyName: string
|
||||
companyPhone?: string | null
|
||||
country?: string | null
|
||||
city?: string | null
|
||||
plan: 'STARTER' | 'GROWTH' | 'PRO'
|
||||
billingPeriod: 'MONTHLY' | 'ANNUAL'
|
||||
currency: 'MAD' | 'USD' | 'EUR'
|
||||
paymentProvider: 'AMANPAY' | 'PAYPAL'
|
||||
}) {
|
||||
const existingEmployeeByClerkUser = await prisma.employee.findUnique({
|
||||
where: { clerkUserId: opts.clerkUserId },
|
||||
include: { company: true },
|
||||
})
|
||||
if (existingEmployeeByClerkUser) {
|
||||
return {
|
||||
company: existingEmployeeByClerkUser.company,
|
||||
employeeId: existingEmployeeByClerkUser.id,
|
||||
invitationId: null,
|
||||
trialEndAt: null,
|
||||
created: false,
|
||||
}
|
||||
}
|
||||
|
||||
const existingCompany = await prisma.company.findUnique({ where: { email: opts.email } })
|
||||
if (existingCompany) {
|
||||
throw Object.assign(new Error('A company account with this email already exists'), {
|
||||
statusCode: 409,
|
||||
code: 'email_taken',
|
||||
})
|
||||
}
|
||||
|
||||
const existingEmployeeByEmail = await prisma.employee.findFirst({ where: { email: opts.email } })
|
||||
if (existingEmployeeByEmail) {
|
||||
throw Object.assign(new Error('An employee account with this email already exists'), {
|
||||
statusCode: 409,
|
||||
code: 'email_taken',
|
||||
})
|
||||
}
|
||||
|
||||
const slug = await generateUniqueSlug(opts.companyName)
|
||||
const now = new Date()
|
||||
const trialEndAt = addDays(now, 14)
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const company = await tx.company.create({
|
||||
data: {
|
||||
name: opts.companyName,
|
||||
slug,
|
||||
email: opts.email,
|
||||
phone: opts.companyPhone || null,
|
||||
status: 'TRIALING',
|
||||
},
|
||||
})
|
||||
|
||||
await tx.brandSettings.create({
|
||||
data: {
|
||||
companyId: company.id,
|
||||
displayName: opts.companyName,
|
||||
subdomain: slug,
|
||||
publicEmail: opts.email,
|
||||
publicPhone: opts.companyPhone || null,
|
||||
publicCountry: opts.country || null,
|
||||
publicCity: opts.city || null,
|
||||
defaultCurrency: opts.currency,
|
||||
},
|
||||
})
|
||||
|
||||
await tx.subscription.create({
|
||||
data: {
|
||||
companyId: company.id,
|
||||
plan: opts.plan,
|
||||
billingPeriod: opts.billingPeriod,
|
||||
currency: opts.currency,
|
||||
status: 'TRIALING',
|
||||
trialStartAt: now,
|
||||
trialEndAt,
|
||||
currentPeriodStart: now,
|
||||
currentPeriodEnd: trialEndAt,
|
||||
},
|
||||
})
|
||||
|
||||
const employee = await tx.employee.create({
|
||||
data: {
|
||||
companyId: company.id,
|
||||
clerkUserId: opts.clerkUserId,
|
||||
firstName: opts.firstName,
|
||||
lastName: opts.lastName,
|
||||
email: opts.email,
|
||||
phone: opts.companyPhone || null,
|
||||
role: 'OWNER',
|
||||
isActive: true,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
company,
|
||||
employeeId: employee.id,
|
||||
trialEndAt,
|
||||
created: true,
|
||||
}
|
||||
})
|
||||
|
||||
await sendNotification({
|
||||
type: 'SUBSCRIPTION_TRIAL_ENDING',
|
||||
title: 'Your workspace is ready',
|
||||
body: buildSignupConfirmationEmail({
|
||||
firstName: opts.firstName,
|
||||
companyName: opts.companyName,
|
||||
plan: opts.plan,
|
||||
billingPeriod: opts.billingPeriod,
|
||||
currency: opts.currency,
|
||||
paymentProvider: opts.paymentProvider,
|
||||
trialEndAt: result.trialEndAt,
|
||||
usesInvitation: false,
|
||||
}),
|
||||
companyId: result.company.id,
|
||||
employeeId: result.employeeId,
|
||||
email: opts.email,
|
||||
channels: ['EMAIL', 'IN_APP'],
|
||||
}).catch(() => null)
|
||||
|
||||
return {
|
||||
...result,
|
||||
invitationId: null,
|
||||
}
|
||||
}
|
||||
|
||||
router.post('/signup', async (req, res, next) => {
|
||||
try {
|
||||
const body = signupSchema.parse(req.body)
|
||||
|
||||
const existingCompany = await prisma.company.findUnique({ where: { email: body.email } })
|
||||
if (existingCompany) {
|
||||
return res.status(409).json({
|
||||
error: 'email_taken',
|
||||
message: 'A company account with this email already exists',
|
||||
statusCode: 409,
|
||||
})
|
||||
}
|
||||
|
||||
const existingEmployee = await prisma.employee.findFirst({ where: { email: body.email } })
|
||||
if (existingEmployee) {
|
||||
return res.status(409).json({
|
||||
error: 'email_taken',
|
||||
message: 'An employee account with this email already exists',
|
||||
statusCode: 409,
|
||||
})
|
||||
}
|
||||
|
||||
const slug = await generateUniqueSlug(body.companyName)
|
||||
const now = new Date()
|
||||
const trialEndAt = addDays(now, 14)
|
||||
const usesInvitation = Boolean(process.env.CLERK_SECRET_KEY && process.env.DASHBOARD_URL)
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const company = await tx.company.create({
|
||||
data: {
|
||||
name: body.companyName,
|
||||
slug,
|
||||
email: body.email,
|
||||
phone: body.companyPhone || null,
|
||||
status: 'TRIALING',
|
||||
},
|
||||
})
|
||||
|
||||
await tx.brandSettings.create({
|
||||
data: {
|
||||
companyId: company.id,
|
||||
displayName: body.companyName,
|
||||
subdomain: slug,
|
||||
publicEmail: body.email,
|
||||
publicPhone: body.companyPhone || null,
|
||||
publicCountry: body.country || null,
|
||||
publicCity: body.city || null,
|
||||
defaultCurrency: body.currency,
|
||||
},
|
||||
})
|
||||
|
||||
const subscription = await tx.subscription.create({
|
||||
data: {
|
||||
companyId: company.id,
|
||||
plan: body.plan,
|
||||
billingPeriod: body.billingPeriod,
|
||||
currency: body.currency,
|
||||
status: 'TRIALING',
|
||||
trialStartAt: now,
|
||||
trialEndAt,
|
||||
currentPeriodStart: now,
|
||||
currentPeriodEnd: trialEndAt,
|
||||
},
|
||||
})
|
||||
|
||||
const invitation = usesInvitation
|
||||
? await createOwnerInvitation(body.email, company.id, body.companyName)
|
||||
: null
|
||||
|
||||
const employee = await tx.employee.create({
|
||||
data: {
|
||||
companyId: company.id,
|
||||
clerkUserId: invitation ? `pending_${invitation.id}` : `local_owner_${company.id}`,
|
||||
firstName: body.firstName,
|
||||
lastName: body.lastName,
|
||||
email: body.email,
|
||||
phone: body.companyPhone || null,
|
||||
role: 'OWNER',
|
||||
isActive: !invitation,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
company,
|
||||
subscription,
|
||||
employeeId: employee.id,
|
||||
invitationId: invitation?.id ?? null,
|
||||
}
|
||||
})
|
||||
|
||||
await sendNotification({
|
||||
type: 'SUBSCRIPTION_TRIAL_ENDING',
|
||||
title: 'Your workspace is ready',
|
||||
body: buildSignupConfirmationEmail({
|
||||
firstName: body.firstName,
|
||||
companyName: body.companyName,
|
||||
plan: body.plan,
|
||||
billingPeriod: body.billingPeriod,
|
||||
currency: body.currency,
|
||||
paymentProvider: body.paymentProvider,
|
||||
trialEndAt,
|
||||
usesInvitation,
|
||||
}),
|
||||
companyId: result.company.id,
|
||||
employeeId: result.employeeId,
|
||||
email: body.email,
|
||||
channels: ['EMAIL', 'IN_APP'],
|
||||
}).catch(() => null)
|
||||
|
||||
res.status(201).json({
|
||||
data: {
|
||||
companyId: result.company.id,
|
||||
companyName: result.company.name,
|
||||
slug: result.company.slug,
|
||||
invitationId: result.invitationId,
|
||||
trialEndsAt: trialEndAt.toISOString(),
|
||||
nextStep: usesInvitation ? 'check_email_for_invitation' : 'workspace_created',
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/complete-signup', async (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization
|
||||
const sessionToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
|
||||
|
||||
if (!sessionToken) {
|
||||
return res.status(401).json({
|
||||
error: 'unauthenticated',
|
||||
message: 'Authentication required',
|
||||
statusCode: 401,
|
||||
})
|
||||
}
|
||||
|
||||
const session = await verifyClerkSessionToken(sessionToken)
|
||||
const user = await clerkClient.users.getUser(session.userId)
|
||||
const primaryEmail = getPrimaryEmail(user)
|
||||
|
||||
if (!primaryEmail?.emailAddress) {
|
||||
return res.status(400).json({
|
||||
error: 'email_missing',
|
||||
message: 'The signed-in Clerk user does not have a primary email address',
|
||||
statusCode: 400,
|
||||
})
|
||||
}
|
||||
|
||||
if (primaryEmail.verification?.status !== 'verified') {
|
||||
return res.status(400).json({
|
||||
error: 'email_not_verified',
|
||||
message: 'Verify the owner email address before creating the workspace',
|
||||
statusCode: 400,
|
||||
})
|
||||
}
|
||||
|
||||
const metadata = completeSignupMetadataSchema.parse(user.unsafeMetadata ?? {})
|
||||
const existingEmployee = await prisma.employee.findUnique({
|
||||
where: { clerkUserId: user.id },
|
||||
include: { company: true },
|
||||
})
|
||||
|
||||
if (existingEmployee) {
|
||||
return res.json({
|
||||
data: {
|
||||
companyId: existingEmployee.companyId,
|
||||
companyName: existingEmployee.company.name,
|
||||
slug: existingEmployee.company.slug,
|
||||
trialEndsAt: null,
|
||||
nextStep: 'enter_dashboard',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const result = await createCompanyWorkspaceForOwner({
|
||||
clerkUserId: user.id,
|
||||
firstName: user.firstName?.trim() || 'Owner',
|
||||
lastName: user.lastName?.trim() || 'Account',
|
||||
email: primaryEmail.emailAddress,
|
||||
companyName: metadata.companyName,
|
||||
companyPhone: metadata.companyPhone ?? null,
|
||||
country: metadata.country ?? null,
|
||||
city: metadata.city ?? null,
|
||||
plan: metadata.plan,
|
||||
billingPeriod: metadata.billingPeriod,
|
||||
currency: metadata.currency,
|
||||
paymentProvider: metadata.paymentProvider,
|
||||
})
|
||||
|
||||
res.status(result.created ? 201 : 200).json({
|
||||
data: {
|
||||
companyId: result.company.id,
|
||||
companyName: result.company.name,
|
||||
slug: result.company.slug,
|
||||
trialEndsAt: result.trialEndAt?.toISOString() ?? null,
|
||||
nextStep: 'enter_dashboard',
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/verify-email', async (req, res, next) => {
|
||||
try {
|
||||
const { email } = verifySchema.parse(req.body)
|
||||
const pendingEmployee = await prisma.employee.findFirst({
|
||||
where: {
|
||||
email,
|
||||
role: 'OWNER',
|
||||
clerkUserId: { startsWith: 'pending_' },
|
||||
},
|
||||
include: { company: true },
|
||||
})
|
||||
|
||||
if (!pendingEmployee) {
|
||||
return res.status(404).json({
|
||||
error: 'pending_signup_not_found',
|
||||
message: 'No pending company signup was found for this email',
|
||||
statusCode: 404,
|
||||
})
|
||||
}
|
||||
|
||||
const newInvitation = await createOwnerInvitation(email, pendingEmployee.companyId, pendingEmployee.company.name)
|
||||
await prisma.employee.update({
|
||||
where: { id: pendingEmployee.id },
|
||||
data: { clerkUserId: `pending_${newInvitation.id}` },
|
||||
})
|
||||
|
||||
const subscription = await prisma.subscription.findUnique({
|
||||
where: { companyId: pendingEmployee.companyId },
|
||||
})
|
||||
|
||||
await sendNotification({
|
||||
type: 'SUBSCRIPTION_TRIAL_ENDING',
|
||||
title: 'Your owner invitation has been resent',
|
||||
body: buildSignupConfirmationEmail({
|
||||
firstName: pendingEmployee.firstName,
|
||||
companyName: pendingEmployee.company.name,
|
||||
plan: subscription?.plan ?? 'STARTER',
|
||||
billingPeriod: subscription?.billingPeriod ?? 'MONTHLY',
|
||||
currency: subscription?.currency === 'USD' || subscription?.currency === 'EUR' ? subscription.currency : 'MAD',
|
||||
trialEndAt: subscription?.trialEndAt ?? undefined,
|
||||
isResend: true,
|
||||
}),
|
||||
companyId: pendingEmployee.companyId,
|
||||
employeeId: pendingEmployee.id,
|
||||
email,
|
||||
channels: ['EMAIL', 'IN_APP'],
|
||||
}).catch(() => null)
|
||||
|
||||
res.json({
|
||||
data: {
|
||||
success: true,
|
||||
invitationId: newInvitation.id,
|
||||
message: 'A fresh invitation link has been sent to your email address',
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -1,68 +1,83 @@
|
||||
import { Router } from 'express'
|
||||
import { z } from 'zod'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { requireRenterAuth } from '../middleware/requireRenterAuth'
|
||||
|
||||
const router = Router()
|
||||
|
||||
function signRenterToken(renterId: string) {
|
||||
return jwt.sign({ sub: renterId, type: 'renter' }, process.env.JWT_SECRET!, {
|
||||
expiresIn: process.env.RENTER_JWT_EXPIRY ?? '7d',
|
||||
router.post('/signup', async (_req, res) => {
|
||||
res.status(403).json({
|
||||
error: 'renter_signup_disabled',
|
||||
message: 'Renter account creation is disabled. Only company owners can sign in to the platform.',
|
||||
statusCode: 403,
|
||||
})
|
||||
}
|
||||
|
||||
router.post('/signup', async (req, res, next) => {
|
||||
try {
|
||||
const body = z.object({
|
||||
firstName: z.string().min(1),
|
||||
lastName: z.string().min(1),
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
phone: z.string().optional(),
|
||||
}).parse(req.body)
|
||||
|
||||
const existing = await prisma.renter.findUnique({ where: { email: body.email } })
|
||||
if (existing) return res.status(409).json({ error: 'email_taken', message: 'Email already in use', statusCode: 409 })
|
||||
|
||||
const passwordHash = await bcrypt.hash(body.password, 12)
|
||||
const renter = await prisma.renter.create({
|
||||
data: { firstName: body.firstName, lastName: body.lastName, email: body.email, passwordHash, phone: body.phone ?? null },
|
||||
})
|
||||
|
||||
const token = signRenterToken(renter.id)
|
||||
res.status(201).json({ data: { token, renter: { id: renter.id, email: renter.email, firstName: renter.firstName, lastName: renter.lastName } } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/login', async (req, res, next) => {
|
||||
try {
|
||||
const body = z.object({ email: z.string().email(), password: z.string() }).parse(req.body)
|
||||
const renter = await prisma.renter.findUnique({ where: { email: body.email } })
|
||||
if (!renter || !renter.isActive) return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid email or password', statusCode: 401 })
|
||||
|
||||
const valid = await bcrypt.compare(body.password, renter.passwordHash)
|
||||
if (!valid) return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid email or password', statusCode: 401 })
|
||||
|
||||
const token = signRenterToken(renter.id)
|
||||
res.json({ data: { token, renter: { id: renter.id, email: renter.email, firstName: renter.firstName, lastName: renter.lastName } } })
|
||||
} catch (err) { next(err) }
|
||||
router.post('/login', async (_req, res) => {
|
||||
res.status(403).json({
|
||||
error: 'renter_login_disabled',
|
||||
message: 'Renter sign-in is disabled. Only company owners can sign in to the platform.',
|
||||
statusCode: 403,
|
||||
})
|
||||
})
|
||||
|
||||
router.get('/me', requireRenterAuth, async (req, res, next) => {
|
||||
try {
|
||||
const renter = await prisma.renter.findUniqueOrThrow({ where: { id: req.renterId }, select: { id: true, firstName: true, lastName: true, email: true, phone: true, preferredLocale: true, preferredCurrency: true, emailVerified: true } })
|
||||
res.json({ data: renter })
|
||||
const renter = await prisma.renter.findUniqueOrThrow({
|
||||
where: { id: req.renterId },
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
preferredLocale: true,
|
||||
preferredCurrency: true,
|
||||
emailVerified: true,
|
||||
savedCompanies: {
|
||||
select: {
|
||||
companyId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const savedCompanyIds = renter.savedCompanies.map((saved) => saved.companyId)
|
||||
const companies = savedCompanyIds.length === 0
|
||||
? []
|
||||
: await prisma.company.findMany({
|
||||
where: { id: { in: savedCompanyIds } },
|
||||
select: {
|
||||
id: true,
|
||||
brand: {
|
||||
select: {
|
||||
displayName: true,
|
||||
subdomain: true,
|
||||
logoUrl: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const companyMap = new Map(companies.map((company) => [company.id, company]))
|
||||
|
||||
const data = {
|
||||
...renter,
|
||||
savedCompanies: renter.savedCompanies.map((saved) => ({
|
||||
id: saved.companyId,
|
||||
brand: companyMap.get(saved.companyId)?.brand ?? null,
|
||||
})),
|
||||
}
|
||||
res.json({ data })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/me', requireRenterAuth, async (req, res, next) => {
|
||||
try {
|
||||
const body = z.object({
|
||||
firstName: z.string().min(1).optional(),
|
||||
lastName: z.string().min(1).optional(),
|
||||
phone: z.string().optional(),
|
||||
firstName: z.string().min(1).max(100).trim().optional(),
|
||||
lastName: z.string().min(1).max(100).trim().optional(),
|
||||
phone: z.string().max(30).trim().optional(),
|
||||
preferredLocale: z.enum(['en', 'fr', 'ar']).optional(),
|
||||
preferredCurrency: z.enum(['MAD', 'USD', 'EUR']).optional(),
|
||||
}).parse(req.body)
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
import { Router } from 'express'
|
||||
import { z } from 'zod'
|
||||
import multer from 'multer'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { uploadImage } from '../lib/cloudinary'
|
||||
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(),
|
||||
})
|
||||
|
||||
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,
|
||||
})
|
||||
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, `brands/${req.companyId}`, '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, `brands/${req.companyId}`, '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
|
||||
@@ -10,35 +10,42 @@ import { validateAndFlagLicense } from '../services/licenseValidationService'
|
||||
const router = Router()
|
||||
router.use(requireCompanyAuth, requireTenant, requireSubscription)
|
||||
|
||||
const paginationSchema = z.object({
|
||||
page: z.coerce.number().int().min(1).max(10000).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
})
|
||||
|
||||
const customerSchema = z.object({
|
||||
firstName: z.string().min(1),
|
||||
lastName: z.string().min(1),
|
||||
email: z.string().email(),
|
||||
phone: z.string().optional(),
|
||||
driverLicense: z.string().optional(),
|
||||
firstName: z.string().min(1).max(100).trim(),
|
||||
lastName: z.string().min(1).max(100).trim(),
|
||||
email: z.string().email().max(255).trim().toLowerCase(),
|
||||
phone: z.string().max(30).trim().optional(),
|
||||
driverLicense: z.string().max(50).trim().optional(),
|
||||
dateOfBirth: z.string().datetime().optional(),
|
||||
nationality: z.string().optional(),
|
||||
nationality: z.string().max(100).trim().optional(),
|
||||
address: z.record(z.unknown()).optional(),
|
||||
notes: z.string().optional(),
|
||||
notes: z.string().max(2000).trim().optional(),
|
||||
licenseExpiry: z.string().datetime().optional(),
|
||||
licenseIssuedAt: z.string().datetime().optional(),
|
||||
licenseCountry: z.string().optional(),
|
||||
licenseNumber: z.string().optional(),
|
||||
licenseCategory: z.string().optional(),
|
||||
licenseCountry: z.string().max(100).trim().optional(),
|
||||
licenseNumber: z.string().max(50).trim().optional(),
|
||||
licenseCategory: z.string().max(20).trim().optional(),
|
||||
})
|
||||
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const { q, flagged, page = '1', pageSize = '20' } = req.query as Record<string, string>
|
||||
const { q, flagged } = req.query as Record<string, string>
|
||||
const { page, pageSize } = paginationSchema.parse(req.query)
|
||||
const safeQ = q ? q.trim().slice(0, 100) : undefined
|
||||
const where: any = { companyId: req.companyId }
|
||||
if (flagged !== undefined) where.flagged = flagged === 'true'
|
||||
if (q) where.OR = [{ firstName: { contains: q, mode: 'insensitive' } }, { lastName: { contains: q, mode: 'insensitive' } }, { email: { contains: q, mode: 'insensitive' } }]
|
||||
if (safeQ) where.OR = [{ firstName: { contains: safeQ, mode: 'insensitive' } }, { lastName: { contains: safeQ, mode: 'insensitive' } }, { email: { contains: safeQ, mode: 'insensitive' } }]
|
||||
|
||||
const [customers, total] = await Promise.all([
|
||||
prisma.customer.findMany({ where, skip: (parseInt(page) - 1) * parseInt(pageSize), take: parseInt(pageSize), orderBy: { createdAt: 'desc' } }),
|
||||
prisma.customer.findMany({ where, skip: (page - 1) * pageSize, take: pageSize, orderBy: { createdAt: 'desc' } }),
|
||||
prisma.customer.count({ where }),
|
||||
])
|
||||
res.json({ data: customers, total, page: parseInt(page), pageSize: parseInt(pageSize), totalPages: Math.ceil(total / parseInt(pageSize)) })
|
||||
res.json({ data: customers, total, page, pageSize, totalPages: Math.ceil(total / pageSize) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
import { Router } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { optionalRenterAuth } from '../middleware/requireRenterAuth'
|
||||
|
||||
const router = Router()
|
||||
router.use(optionalRenterAuth)
|
||||
|
||||
const paginationSchema = z.object({
|
||||
page: z.coerce.number().int().min(1).max(10000).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
})
|
||||
|
||||
function isDatabaseUnavailableError(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') return false
|
||||
|
||||
const candidate = error as { code?: string; message?: string }
|
||||
return candidate.code === 'P1001' || candidate.message?.includes("Can't reach database server") === true
|
||||
}
|
||||
|
||||
router.get('/offers', async (req, res, next) => {
|
||||
try {
|
||||
const offers = await prisma.offer.findMany({
|
||||
@@ -14,14 +27,22 @@ router.get('/offers', async (req, res, next) => {
|
||||
take: 50,
|
||||
})
|
||||
res.json({ data: offers })
|
||||
} catch (err) { next(err) }
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/companies', async (req, res, next) => {
|
||||
try {
|
||||
const { city, hasOffer, page = '1', pageSize = '20' } = req.query as Record<string, string>
|
||||
const { city, hasOffer } = req.query as Record<string, string>
|
||||
const { page, pageSize } = paginationSchema.parse(req.query)
|
||||
const safeCity = city ? city.trim().slice(0, 100) : undefined
|
||||
const where: any = { status: 'ACTIVE', brand: { isListedOnMarketplace: true } }
|
||||
if (city) where.brand = { ...where.brand, publicCity: { contains: city, mode: 'insensitive' } }
|
||||
if (safeCity) where.brand = { ...where.brand, publicCity: { contains: safeCity, mode: 'insensitive' } }
|
||||
if (hasOffer === 'true') {
|
||||
where.offers = { some: { isPublic: true, isActive: true, validUntil: { gte: new Date() } } }
|
||||
}
|
||||
|
||||
const companies = await prisma.company.findMany({
|
||||
where,
|
||||
@@ -29,32 +50,69 @@ router.get('/companies', async (req, res, next) => {
|
||||
brand: { select: { displayName: true, logoUrl: true, subdomain: true, publicCity: true, publicCountry: true, marketplaceRating: true, primaryColor: true } },
|
||||
_count: { select: { vehicles: { where: { isPublished: true } } } },
|
||||
},
|
||||
skip: (parseInt(page) - 1) * parseInt(pageSize),
|
||||
take: parseInt(pageSize),
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
})
|
||||
res.json({ data: companies })
|
||||
} catch (err) { next(err) }
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/search', async (req, res, next) => {
|
||||
try {
|
||||
const { city, startDate, endDate, category, maxPrice, page = '1', pageSize = '20' } = req.query as Record<string, string>
|
||||
const searchSchema = z.object({
|
||||
city: z.string().trim().max(100).optional(),
|
||||
startDate: z.string().datetime().optional(),
|
||||
endDate: z.string().datetime().optional(),
|
||||
category: z.string().trim().max(50).optional(),
|
||||
maxPrice: z.coerce.number().int().min(0).max(1_000_000).optional(),
|
||||
transmission: z.string().trim().max(20).optional(),
|
||||
})
|
||||
const { city, startDate, endDate, category, maxPrice, transmission } = searchSchema.parse(req.query)
|
||||
const { page, pageSize } = paginationSchema.parse(req.query)
|
||||
|
||||
const where: any = { isPublished: true, company: { status: 'ACTIVE', brand: { isListedOnMarketplace: true } } }
|
||||
if (category) where.category = category
|
||||
if (maxPrice) where.dailyRate = { lte: parseInt(maxPrice) }
|
||||
if (maxPrice !== undefined) where.dailyRate = { lte: maxPrice }
|
||||
if (transmission) where.transmission = transmission
|
||||
if (city) where.company = { ...where.company, brand: { isListedOnMarketplace: true, publicCity: { contains: city, mode: 'insensitive' } } }
|
||||
|
||||
const vehicles = await prisma.vehicle.findMany({
|
||||
where,
|
||||
include: {
|
||||
company: { include: { brand: { select: { displayName: true, logoUrl: true, subdomain: true, marketplaceRating: true, primaryColor: true } } } },
|
||||
},
|
||||
skip: (parseInt(page) - 1) * parseInt(pageSize),
|
||||
take: parseInt(pageSize),
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
orderBy: { dailyRate: 'asc' },
|
||||
})
|
||||
|
||||
res.json({ data: vehicles })
|
||||
} catch (err) { next(err) }
|
||||
const typedVehicles = vehicles as Array<(typeof vehicles)[number]>
|
||||
|
||||
const unavailableVehicleIds = startDate && endDate
|
||||
? new Set((await prisma.reservation.findMany({
|
||||
where: {
|
||||
status: { in: ['CONFIRMED', 'ACTIVE'] },
|
||||
vehicleId: { in: typedVehicles.map((vehicle) => vehicle.id) },
|
||||
startDate: { lt: new Date(endDate) },
|
||||
endDate: { gt: new Date(startDate) },
|
||||
},
|
||||
select: { vehicleId: true },
|
||||
})).map((reservation: { vehicleId: string }) => reservation.vehicleId))
|
||||
: null
|
||||
|
||||
res.json({
|
||||
data: typedVehicles.map((vehicle) => ({
|
||||
...vehicle,
|
||||
availability: unavailableVehicleIds ? !unavailableVehicleIds.has(vehicle.id) : null,
|
||||
})),
|
||||
})
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:slug', async (req, res, next) => {
|
||||
@@ -69,7 +127,12 @@ router.get('/:slug', async (req, res, next) => {
|
||||
})
|
||||
if (!company) return res.status(404).json({ error: 'not_found', message: 'Company not found', statusCode: 404 })
|
||||
res.json({ data: company })
|
||||
} catch (err) { next(err) }
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Marketplace data is temporarily unavailable', statusCode: 503 })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:slug/reviews', async (req, res, next) => {
|
||||
@@ -82,7 +145,66 @@ router.get('/:slug/reviews', async (req, res, next) => {
|
||||
take: 50,
|
||||
})
|
||||
res.json({ data: reviews })
|
||||
} catch (err) { next(err) }
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:slug/vehicles', async (req, res, next) => {
|
||||
try {
|
||||
const company = await prisma.company.findFirstOrThrow({ where: { slug: req.params.slug, status: 'ACTIVE' } })
|
||||
const vehicles = await prisma.vehicle.findMany({
|
||||
where: { companyId: company.id, isPublished: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
res.json({ data: vehicles })
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:slug/vehicles/:id', async (req, res, next) => {
|
||||
try {
|
||||
const company = await prisma.company.findFirstOrThrow({ where: { slug: req.params.slug, status: 'ACTIVE' } })
|
||||
const vehicle = await prisma.vehicle.findFirstOrThrow({
|
||||
where: { id: req.params.id, companyId: company.id, isPublished: true },
|
||||
include: {
|
||||
company: { include: { brand: true } },
|
||||
reservations: {
|
||||
where: { status: { in: ['CONFIRMED', 'ACTIVE'] } },
|
||||
select: { startDate: true, endDate: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
res.json({ data: vehicle })
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Vehicle details are temporarily unavailable', statusCode: 503 })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:slug/offers', async (req, res, next) => {
|
||||
try {
|
||||
const company = await prisma.company.findFirstOrThrow({ where: { slug: req.params.slug, status: 'ACTIVE' } })
|
||||
const offers = await prisma.offer.findMany({
|
||||
where: {
|
||||
companyId: company.id,
|
||||
isPublic: true,
|
||||
isActive: true,
|
||||
validFrom: { lte: new Date() },
|
||||
validUntil: { gte: new Date() },
|
||||
},
|
||||
orderBy: [{ isFeatured: 'desc' }, { createdAt: 'desc' }],
|
||||
})
|
||||
res.json({ data: offers })
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/offers/:code/validate', async (req, res, next) => {
|
||||
@@ -95,7 +217,12 @@ router.post('/offers/:code/validate', async (req, res, next) => {
|
||||
return res.status(409).json({ error: 'code_exhausted', message: 'Promo code has reached its maximum redemptions', statusCode: 409 })
|
||||
}
|
||||
res.json({ data: offer })
|
||||
} catch (err) { next(err) }
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Offer validation is temporarily unavailable', statusCode: 503 })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -23,6 +23,15 @@ router.get('/company', ...companyAuth, async (req: any, res: any, next: any) =>
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/unread-count', ...companyAuth, async (req: any, res: any, next: any) => {
|
||||
try {
|
||||
const unread = await prisma.notification.count({
|
||||
where: { companyId: req.companyId, channel: 'IN_APP', readAt: null },
|
||||
})
|
||||
res.json({ data: { unread } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/company/:id/read', ...companyAuth, async (req: any, res: any, next: any) => {
|
||||
try {
|
||||
await prisma.notification.updateMany({ where: { id: req.params.id, companyId: req.companyId }, data: { readAt: new Date(), status: 'READ' } })
|
||||
@@ -74,4 +83,46 @@ router.post('/renter/:id/read', requireRenterAuth, async (req: any, res: any, ne
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/renter/read-all', requireRenterAuth, async (req: any, res: any, next: any) => {
|
||||
try {
|
||||
await prisma.notification.updateMany({
|
||||
where: { renterId: req.renterId, channel: 'IN_APP', readAt: null },
|
||||
data: { readAt: new Date(), status: 'READ' },
|
||||
})
|
||||
res.json({ data: { success: true } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/renter/preferences', requireRenterAuth, async (req: any, res: any, next: any) => {
|
||||
try {
|
||||
const prefs = await prisma.notificationPreference.findMany({ where: { renterId: req.renterId } })
|
||||
res.json({ data: prefs })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/renter/preferences', requireRenterAuth, async (req: any, res: any, next: any) => {
|
||||
try {
|
||||
const body = z.array(z.object({ notificationType: z.string(), channel: z.string(), enabled: z.boolean() })).parse(req.body)
|
||||
for (const pref of body) {
|
||||
await prisma.notificationPreference.upsert({
|
||||
where: {
|
||||
renterId_notificationType_channel: {
|
||||
renterId: req.renterId,
|
||||
notificationType: pref.notificationType as NotificationType,
|
||||
channel: pref.channel as NotificationChannel,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
renterId: req.renterId,
|
||||
notificationType: pref.notificationType as NotificationType,
|
||||
channel: pref.channel as NotificationChannel,
|
||||
enabled: pref.enabled,
|
||||
},
|
||||
update: { enabled: pref.enabled },
|
||||
})
|
||||
}
|
||||
res.json({ data: { success: true } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import { Router } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { requireCompanyAuth } from '../middleware/requireCompanyAuth'
|
||||
import { requireTenant } from '../middleware/requireTenant'
|
||||
import { requireSubscription } from '../middleware/requireSubscription'
|
||||
import * as amanpay from '../services/amanpayService'
|
||||
import * as paypal from '../services/paypalService'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// ─── Webhook endpoints (no auth — must come before middleware) ──────────────
|
||||
|
||||
router.post('/webhooks/amanpay', async (req, res, next) => {
|
||||
try {
|
||||
const rawBody = JSON.stringify(req.body)
|
||||
const signature = req.headers['x-amanpay-signature'] as string ?? ''
|
||||
|
||||
if (amanpay.isConfigured() && !amanpay.verifyWebhookSignature(rawBody, signature)) {
|
||||
return res.status(401).json({ error: 'invalid_signature' })
|
||||
}
|
||||
|
||||
const event = req.body
|
||||
const transactionId = event.transaction_id ?? event.id
|
||||
const orderId = event.order_id ?? event.metadata?.order_id
|
||||
const status = event.status?.toUpperCase()
|
||||
|
||||
if (status === 'PAID' || status === 'SUCCEEDED') {
|
||||
const payment = await prisma.rentalPayment.findFirst({ where: { amanpayTransactionId: transactionId } })
|
||||
if (payment) {
|
||||
await prisma.rentalPayment.update({
|
||||
where: { id: payment.id },
|
||||
data: { status: 'SUCCEEDED', paidAt: new Date() },
|
||||
})
|
||||
await prisma.reservation.update({
|
||||
where: { id: payment.reservationId },
|
||||
data: { paymentStatus: 'PAID', paidAmount: { increment: payment.amount } },
|
||||
})
|
||||
}
|
||||
} else if (status === 'FAILED') {
|
||||
await prisma.rentalPayment.updateMany({
|
||||
where: { amanpayTransactionId: transactionId },
|
||||
data: { status: 'FAILED' },
|
||||
})
|
||||
}
|
||||
|
||||
res.json({ received: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/webhooks/paypal', async (req, res, next) => {
|
||||
try {
|
||||
const rawBody = JSON.stringify(req.body)
|
||||
const isValid = await paypal.verifyWebhookEvent(
|
||||
req.headers as Record<string, string>,
|
||||
rawBody,
|
||||
)
|
||||
if (paypal.isConfigured() && !isValid) {
|
||||
return res.status(401).json({ error: 'invalid_signature' })
|
||||
}
|
||||
|
||||
const event = req.body
|
||||
const eventType = event.event_type as string
|
||||
|
||||
if (eventType === 'PAYMENT.CAPTURE.COMPLETED') {
|
||||
const captureId = event.resource?.id as string
|
||||
const payment = await prisma.rentalPayment.findFirst({ where: { paypalCaptureId: captureId } })
|
||||
if (payment) {
|
||||
await prisma.rentalPayment.update({
|
||||
where: { id: payment.id },
|
||||
data: { status: 'SUCCEEDED', paidAt: new Date() },
|
||||
})
|
||||
await prisma.reservation.update({
|
||||
where: { id: payment.reservationId },
|
||||
data: { paymentStatus: 'PAID', paidAmount: { increment: payment.amount } },
|
||||
})
|
||||
}
|
||||
} else if (eventType === 'PAYMENT.CAPTURE.DENIED') {
|
||||
const captureId = event.resource?.id as string
|
||||
await prisma.rentalPayment.updateMany({
|
||||
where: { paypalCaptureId: captureId },
|
||||
data: { status: 'FAILED' },
|
||||
})
|
||||
}
|
||||
|
||||
res.json({ received: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ─── Authenticated routes ────────────────────────────────────────────────────
|
||||
|
||||
router.use(requireCompanyAuth, requireTenant, requireSubscription)
|
||||
|
||||
const chargeSchema = z.object({
|
||||
provider: z.enum(['AMANPAY', 'PAYPAL']),
|
||||
type: z.enum(['CHARGE', 'DEPOSIT']).default('CHARGE'),
|
||||
currency: z.enum(['MAD', 'USD', 'EUR']).default('MAD'),
|
||||
successUrl: z.string().url(),
|
||||
failureUrl: z.string().url(),
|
||||
})
|
||||
|
||||
router.get('/reservations/:id', async (req, res, next) => {
|
||||
try {
|
||||
const payments = await prisma.rentalPayment.findMany({
|
||||
where: { reservationId: req.params.id, companyId: req.companyId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
res.json({ data: payments })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/reservations/:id/charge', async (req, res, next) => {
|
||||
try {
|
||||
const { provider, type, currency, successUrl, failureUrl } = chargeSchema.parse(req.body)
|
||||
|
||||
const reservation = await prisma.reservation.findFirstOrThrow({
|
||||
where: { id: req.params.id, companyId: req.companyId },
|
||||
include: { vehicle: true, customer: true },
|
||||
})
|
||||
|
||||
if (reservation.paymentStatus === 'PAID') {
|
||||
return res.status(409).json({ error: 'already_paid', message: 'Reservation is already fully paid', statusCode: 409 })
|
||||
}
|
||||
|
||||
const amount = type === 'DEPOSIT' ? reservation.depositAmount : reservation.totalAmount
|
||||
const description = `${type === 'DEPOSIT' ? 'Deposit' : 'Rental'}: ${reservation.vehicle.make} ${reservation.vehicle.model}`
|
||||
const orderId = `${reservation.id}-${type}-${Date.now()}`
|
||||
const webhookBase = process.env.API_URL ?? 'http://localhost:4000'
|
||||
|
||||
let checkoutUrl: string
|
||||
let amanpayTransactionId: string | null = null
|
||||
let paypalCaptureId: string | null = null
|
||||
|
||||
if (provider === 'AMANPAY') {
|
||||
if (!amanpay.isConfigured()) {
|
||||
return res.status(503).json({ error: 'provider_not_configured', message: 'AmanPay is not configured', statusCode: 503 })
|
||||
}
|
||||
const result = await amanpay.createCheckout({
|
||||
amount,
|
||||
currency,
|
||||
orderId,
|
||||
description,
|
||||
customerEmail: reservation.customer.email,
|
||||
customerName: `${reservation.customer.firstName} ${reservation.customer.lastName}`,
|
||||
successUrl,
|
||||
failureUrl,
|
||||
webhookUrl: `${webhookBase}/api/v1/payments/webhooks/amanpay`,
|
||||
})
|
||||
checkoutUrl = result.checkoutUrl
|
||||
amanpayTransactionId = result.transactionId
|
||||
} else {
|
||||
if (!paypal.isConfigured()) {
|
||||
return res.status(503).json({ error: 'provider_not_configured', message: 'PayPal is not configured', statusCode: 503 })
|
||||
}
|
||||
const result = await paypal.createOrder({
|
||||
amount,
|
||||
currency,
|
||||
orderId,
|
||||
description,
|
||||
returnUrl: successUrl,
|
||||
cancelUrl: failureUrl,
|
||||
})
|
||||
checkoutUrl = result.approveUrl
|
||||
paypalCaptureId = result.orderId
|
||||
}
|
||||
|
||||
const payment = await prisma.rentalPayment.create({
|
||||
data: {
|
||||
companyId: req.companyId,
|
||||
reservationId: reservation.id,
|
||||
amount,
|
||||
currency,
|
||||
status: 'PENDING',
|
||||
type,
|
||||
paymentProvider: provider,
|
||||
amanpayTransactionId,
|
||||
paypalCaptureId,
|
||||
},
|
||||
})
|
||||
|
||||
res.json({ data: { payment, checkoutUrl } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/reservations/:id/capture-paypal', async (req, res, next) => {
|
||||
try {
|
||||
const { paypalOrderId } = z.object({ paypalOrderId: z.string() }).parse(req.body)
|
||||
|
||||
const payment = await prisma.rentalPayment.findFirstOrThrow({
|
||||
where: { paypalCaptureId: paypalOrderId, companyId: req.companyId },
|
||||
})
|
||||
|
||||
const capture = await paypal.captureOrder(paypalOrderId)
|
||||
const captureId = capture.purchase_units?.[0]?.payments?.captures?.[0]?.id ?? paypalOrderId
|
||||
|
||||
const updated = await prisma.rentalPayment.update({
|
||||
where: { id: payment.id },
|
||||
data: { status: 'SUCCEEDED', paidAt: new Date(), paypalCaptureId: captureId },
|
||||
})
|
||||
|
||||
await prisma.reservation.update({
|
||||
where: { id: payment.reservationId },
|
||||
data: { paymentStatus: 'PAID', paidAmount: { increment: payment.amount } },
|
||||
})
|
||||
|
||||
res.json({ data: updated })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/reservations/:reservationId/payments/:paymentId/refund', async (req, res, next) => {
|
||||
try {
|
||||
const { amount, reason } = z.object({
|
||||
amount: z.number().int().positive().optional(),
|
||||
reason: z.string().optional(),
|
||||
}).parse(req.body)
|
||||
|
||||
const payment = await prisma.rentalPayment.findFirstOrThrow({
|
||||
where: { id: req.params.paymentId, companyId: req.companyId, reservationId: req.params.reservationId },
|
||||
})
|
||||
|
||||
if (payment.status !== 'SUCCEEDED') {
|
||||
return res.status(400).json({ error: 'not_capturable', message: 'Only succeeded payments can be refunded', statusCode: 400 })
|
||||
}
|
||||
|
||||
const refundAmount = amount ?? payment.amount
|
||||
|
||||
if (payment.paymentProvider === 'AMANPAY') {
|
||||
if (!payment.amanpayTransactionId) throw new Error('No AmanPay transaction ID')
|
||||
await amanpay.refundTransaction(payment.amanpayTransactionId, refundAmount, reason)
|
||||
} else {
|
||||
if (!payment.paypalCaptureId) throw new Error('No PayPal capture ID')
|
||||
await paypal.refundCapture(payment.paypalCaptureId, refundAmount, payment.currency, reason)
|
||||
}
|
||||
|
||||
const isPartial = refundAmount < payment.amount
|
||||
const updated = await prisma.rentalPayment.update({
|
||||
where: { id: payment.id },
|
||||
data: { status: isPartial ? 'PARTIALLY_REFUNDED' : 'REFUNDED' },
|
||||
})
|
||||
|
||||
if (!isPartial) {
|
||||
await prisma.reservation.update({
|
||||
where: { id: payment.reservationId },
|
||||
data: { paymentStatus: 'REFUNDED' },
|
||||
})
|
||||
}
|
||||
|
||||
res.json({ data: updated })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -4,14 +4,47 @@ import { prisma } from '../lib/prisma'
|
||||
import { requireCompanyAuth } from '../middleware/requireCompanyAuth'
|
||||
import { requireTenant } from '../middleware/requireTenant'
|
||||
import { requireSubscription } from '../middleware/requireSubscription'
|
||||
import { applyAdditionalDriversToReservation } from '../services/additionalDriverService'
|
||||
import { applyInsurancesToReservation } from '../services/insuranceService'
|
||||
import { applyPricingRules } from '../services/pricingRuleService'
|
||||
import { validateAndFlagLicense } from '../services/licenseValidationService'
|
||||
import { validateAndFlagLicense, validateLicense } from '../services/licenseValidationService'
|
||||
import { sendNotification } from '../services/notificationService'
|
||||
|
||||
const router = Router()
|
||||
router.use(requireCompanyAuth, requireTenant, requireSubscription)
|
||||
|
||||
const additionalDriverSchema = z.object({
|
||||
firstName: z.string().min(1),
|
||||
lastName: z.string().min(1),
|
||||
email: z.string().email().optional(),
|
||||
phone: z.string().optional(),
|
||||
driverLicense: z.string().min(1),
|
||||
licenseExpiry: z.string().datetime().optional(),
|
||||
licenseIssuedAt: z.string().datetime().optional(),
|
||||
dateOfBirth: z.string().datetime().optional(),
|
||||
nationality: z.string().optional(),
|
||||
})
|
||||
|
||||
const inspectionSchema = z.object({
|
||||
mileage: z.number().int().optional(),
|
||||
fuelLevel: z.enum(['FULL', 'SEVEN_EIGHTHS', 'THREE_QUARTERS', 'FIVE_EIGHTHS', 'HALF', 'THREE_EIGHTHS', 'QUARTER', 'ONE_EIGHTH', 'EMPTY']),
|
||||
fuelCharge: z.number().int().min(0).optional(),
|
||||
generalCondition: z.string().optional(),
|
||||
employeeNotes: z.string().optional(),
|
||||
customerAgreed: z.boolean().default(false),
|
||||
damagePoints: z.array(
|
||||
z.object({
|
||||
viewType: z.enum(['TOP', 'FRONT', 'REAR', 'LEFT', 'RIGHT']),
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
damageType: z.enum(['SCRATCH', 'DENT', 'CRACK', 'CHIP', 'MISSING', 'STAIN', 'OTHER']),
|
||||
severity: z.enum(['MINOR', 'MODERATE', 'MAJOR']).default('MINOR'),
|
||||
description: z.string().optional(),
|
||||
isPreExisting: z.boolean().default(false),
|
||||
}),
|
||||
).default([]),
|
||||
})
|
||||
|
||||
const createSchema = z.object({
|
||||
vehicleId: z.string().cuid(),
|
||||
customerId: z.string().cuid(),
|
||||
@@ -24,8 +57,44 @@ const createSchema = z.object({
|
||||
depositAmount: z.number().int().min(0).default(0),
|
||||
notes: z.string().optional(),
|
||||
selectedInsurancePolicyIds: z.array(z.string()).default([]),
|
||||
additionalDrivers: z.array(additionalDriverSchema).default([]),
|
||||
})
|
||||
|
||||
async function assertReservationLicenseCompliance(reservationId: string, companyId: string) {
|
||||
const reservation = await prisma.reservation.findFirstOrThrow({
|
||||
where: { id: reservationId, companyId },
|
||||
include: { customer: true, additionalDrivers: true },
|
||||
})
|
||||
|
||||
const customerLicense = validateLicense(reservation.customer.licenseExpiry)
|
||||
const customerDenied = ['DENIED', 'EXPIRED'].includes(reservation.customer.licenseValidationStatus)
|
||||
if (customerDenied || customerLicense.status === 'EXPIRED') {
|
||||
throw Object.assign(new Error('Primary driver license is not valid for this reservation'), {
|
||||
statusCode: 400,
|
||||
code: 'invalid_primary_license',
|
||||
})
|
||||
}
|
||||
|
||||
if (customerLicense.requiresApproval && reservation.customer.licenseValidationStatus !== 'APPROVED') {
|
||||
throw Object.assign(new Error('Primary driver license requires manager approval before this reservation can proceed'), {
|
||||
statusCode: 400,
|
||||
code: 'primary_license_requires_approval',
|
||||
})
|
||||
}
|
||||
|
||||
const blockedDriver = reservation.additionalDrivers.find((driver) => {
|
||||
if (driver.licenseExpired) return true
|
||||
return driver.requiresApproval && !driver.approvedAt
|
||||
})
|
||||
|
||||
if (blockedDriver) {
|
||||
throw Object.assign(new Error(`Additional driver ${blockedDriver.firstName} ${blockedDriver.lastName} requires approval before this reservation can proceed`), {
|
||||
statusCode: 400,
|
||||
code: 'additional_driver_requires_approval',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const { status, vehicleId, source, startDate, endDate, page = '1', pageSize = '20' } = req.query as Record<string, string>
|
||||
@@ -87,7 +156,7 @@ router.post('/', async (req, res, next) => {
|
||||
}
|
||||
|
||||
const baseAmount = vehicle.dailyRate * totalDays
|
||||
const { applied, total: pricingTotal } = await applyPricingRules(req.companyId, body.customerId, [], vehicle.dailyRate, totalDays)
|
||||
const { applied, total: pricingTotal } = await applyPricingRules(req.companyId, body.customerId, body.additionalDrivers, vehicle.dailyRate, totalDays)
|
||||
const totalAmount = baseAmount - discountAmount + pricingTotal + body.depositAmount
|
||||
|
||||
const reservation = await prisma.reservation.create({
|
||||
@@ -118,6 +187,10 @@ router.post('/', async (req, res, next) => {
|
||||
await applyInsurancesToReservation(reservation.id, req.companyId, body.selectedInsurancePolicyIds, totalDays, baseAmount)
|
||||
}
|
||||
|
||||
if (body.additionalDrivers.length > 0) {
|
||||
await applyAdditionalDriversToReservation(reservation.id, req.companyId, body.additionalDrivers, totalDays)
|
||||
}
|
||||
|
||||
// Validate customer license
|
||||
await validateAndFlagLicense(body.customerId).catch(() => null)
|
||||
|
||||
@@ -139,6 +212,7 @@ router.post('/:id/confirm', async (req, res, next) => {
|
||||
try {
|
||||
const reservation = await prisma.reservation.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } })
|
||||
if (reservation.status !== 'DRAFT') return res.status(400).json({ error: 'invalid_status', message: 'Only DRAFT reservations can be confirmed', statusCode: 400 })
|
||||
await assertReservationLicenseCompliance(reservation.id, req.companyId)
|
||||
|
||||
const updated = await prisma.reservation.update({ where: { id: req.params.id }, data: { status: 'CONFIRMED' } })
|
||||
|
||||
@@ -155,6 +229,7 @@ router.post('/:id/checkin', async (req, res, next) => {
|
||||
const { mileage } = z.object({ mileage: z.number().int().optional() }).parse(req.body)
|
||||
const reservation = await prisma.reservation.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } })
|
||||
if (reservation.status !== 'CONFIRMED') return res.status(400).json({ error: 'invalid_status', message: 'Only CONFIRMED reservations can be checked in', statusCode: 400 })
|
||||
await assertReservationLicenseCompliance(reservation.id, req.companyId)
|
||||
const updated = await prisma.reservation.update({ where: { id: req.params.id }, data: { status: 'ACTIVE', checkedInAt: new Date(), checkInMileage: mileage ?? null } })
|
||||
await prisma.vehicle.update({ where: { id: reservation.vehicleId }, data: { status: 'RENTED' } })
|
||||
res.json({ data: updated })
|
||||
@@ -201,8 +276,8 @@ router.get('/:id/billing', async (req, res, next) => {
|
||||
const baseAmount = reservation.dailyRate * reservation.totalDays
|
||||
const lineItems = [
|
||||
{ description: `${reservation.vehicle.year} ${reservation.vehicle.make} ${reservation.vehicle.model} — ${reservation.totalDays} day(s)`, qty: reservation.totalDays, unitPrice: reservation.dailyRate, total: baseAmount, category: 'RENTAL' },
|
||||
...reservation.insurances.map((ins) => ({ description: ins.policyName, qty: ins.chargeType === 'PER_DAY' ? reservation.totalDays : 1, unitPrice: ins.chargeType === 'PER_DAY' ? ins.chargeValue : ins.totalCharge, total: ins.totalCharge, category: 'INSURANCE' })),
|
||||
...reservation.additionalDrivers.filter((d) => d.totalCharge > 0).map((d) => ({ description: `Additional Driver — ${d.firstName} ${d.lastName}`, qty: 1, unitPrice: d.totalCharge, total: d.totalCharge, category: 'ADDITIONAL_DRIVER' })),
|
||||
...reservation.insurances.map((ins: (typeof reservation.insurances)[number]) => ({ description: ins.policyName, qty: ins.chargeType === 'PER_DAY' ? reservation.totalDays : 1, unitPrice: ins.chargeType === 'PER_DAY' ? ins.chargeValue : ins.totalCharge, total: ins.totalCharge, category: 'INSURANCE' })),
|
||||
...reservation.additionalDrivers.filter((d: (typeof reservation.additionalDrivers)[number]) => d.totalCharge > 0).map((d: (typeof reservation.additionalDrivers)[number]) => ({ description: `Additional Driver — ${d.firstName} ${d.lastName}`, qty: 1, unitPrice: d.totalCharge, total: d.totalCharge, category: 'ADDITIONAL_DRIVER' })),
|
||||
...(reservation.depositAmount > 0 ? [{ description: 'Security Deposit (refundable)', qty: 1, unitPrice: reservation.depositAmount, total: reservation.depositAmount, category: 'DEPOSIT' }] : []),
|
||||
]
|
||||
|
||||
@@ -212,4 +287,137 @@ router.get('/:id/billing', async (req, res, next) => {
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/:id/inspections', async (req, res, next) => {
|
||||
try {
|
||||
const inspections = await prisma.damageInspection.findMany({
|
||||
where: { reservationId: req.params.id, companyId: req.companyId },
|
||||
include: { damagePoints: true },
|
||||
orderBy: { inspectedAt: 'asc' },
|
||||
})
|
||||
res.json({ data: inspections })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.put('/:id/inspections/:type', async (req, res, next) => {
|
||||
try {
|
||||
const type = z.enum(['CHECKIN', 'CHECKOUT']).parse(req.params.type.toUpperCase())
|
||||
const body = inspectionSchema.parse(req.body)
|
||||
const reservation = await prisma.reservation.findFirstOrThrow({
|
||||
where: { id: req.params.id, companyId: req.companyId },
|
||||
include: { vehicle: true, customer: true },
|
||||
})
|
||||
|
||||
const inspection = await prisma.damageInspection.upsert({
|
||||
where: { reservationId_type: { reservationId: reservation.id, type } },
|
||||
update: {
|
||||
mileage: body.mileage ?? null,
|
||||
fuelLevel: body.fuelLevel,
|
||||
fuelCharge: body.fuelCharge ?? null,
|
||||
generalCondition: body.generalCondition ?? null,
|
||||
employeeNotes: body.employeeNotes ?? null,
|
||||
customerAgreed: body.customerAgreed,
|
||||
inspectedBy: `${req.employee.firstName} ${req.employee.lastName}`,
|
||||
inspectedAt: new Date(),
|
||||
damagePoints: {
|
||||
deleteMany: {},
|
||||
create: body.damagePoints,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
reservationId: reservation.id,
|
||||
companyId: req.companyId,
|
||||
type,
|
||||
mileage: body.mileage ?? null,
|
||||
fuelLevel: body.fuelLevel,
|
||||
fuelCharge: body.fuelCharge ?? null,
|
||||
generalCondition: body.generalCondition ?? null,
|
||||
employeeNotes: body.employeeNotes ?? null,
|
||||
customerAgreed: body.customerAgreed,
|
||||
inspectedBy: `${req.employee.firstName} ${req.employee.lastName}`,
|
||||
damagePoints: {
|
||||
create: body.damagePoints,
|
||||
},
|
||||
},
|
||||
include: { damagePoints: true },
|
||||
})
|
||||
|
||||
await prisma.damageReport.upsert({
|
||||
where: { reservationId_type: { reservationId: reservation.id, type } },
|
||||
update: {
|
||||
damages: body.damagePoints.map((point) => ({
|
||||
viewType: point.viewType,
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
damageType: point.damageType,
|
||||
severity: point.severity,
|
||||
note: point.description ?? '',
|
||||
isPreExisting: point.isPreExisting,
|
||||
})),
|
||||
fuelLevel: body.fuelLevel,
|
||||
mileage: body.mileage ?? null,
|
||||
inspectedAt: new Date(),
|
||||
inspectedBy: `${req.employee.firstName} ${req.employee.lastName}`,
|
||||
customerSignedAt: body.customerAgreed ? new Date() : null,
|
||||
customerName: `${reservation.customer.firstName} ${reservation.customer.lastName}`,
|
||||
},
|
||||
create: {
|
||||
reservationId: reservation.id,
|
||||
companyId: req.companyId,
|
||||
type,
|
||||
damages: body.damagePoints.map((point) => ({
|
||||
viewType: point.viewType,
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
damageType: point.damageType,
|
||||
severity: point.severity,
|
||||
note: point.description ?? '',
|
||||
isPreExisting: point.isPreExisting,
|
||||
})),
|
||||
photos: [],
|
||||
fuelLevel: body.fuelLevel,
|
||||
mileage: body.mileage ?? null,
|
||||
inspectedAt: new Date(),
|
||||
inspectedBy: `${req.employee.firstName} ${req.employee.lastName}`,
|
||||
customerSignedAt: body.customerAgreed ? new Date() : null,
|
||||
customerName: `${reservation.customer.firstName} ${reservation.customer.lastName}`,
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.reservation.update({
|
||||
where: { id: reservation.id },
|
||||
data: type === 'CHECKIN'
|
||||
? {
|
||||
checkInMileage: body.mileage ?? null,
|
||||
checkInFuelLevel: body.fuelLevel,
|
||||
}
|
||||
: {
|
||||
checkOutMileage: body.mileage ?? null,
|
||||
checkOutFuelLevel: body.fuelLevel,
|
||||
},
|
||||
})
|
||||
|
||||
res.json({ data: inspection })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/:id/additional-drivers/:driverId/approval', async (req, res, next) => {
|
||||
try {
|
||||
const { approved, note } = z.object({ approved: z.boolean(), note: z.string().optional() }).parse(req.body)
|
||||
const driver = await prisma.additionalDriver.findFirstOrThrow({
|
||||
where: { id: req.params.driverId, reservationId: req.params.id, companyId: req.companyId },
|
||||
})
|
||||
|
||||
const updated = await prisma.additionalDriver.update({
|
||||
where: { id: driver.id },
|
||||
data: {
|
||||
approvedAt: approved ? new Date() : null,
|
||||
approvedBy: approved ? `${req.employee.firstName} ${req.employee.lastName}` : null,
|
||||
approvalNote: note ?? driver.approvalNote,
|
||||
},
|
||||
})
|
||||
|
||||
res.json({ data: updated })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
import { Router } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { applyAdditionalDriversToReservation } from '../services/additionalDriverService'
|
||||
import * as amanpay from '../services/amanpayService'
|
||||
import { applyInsurancesToReservation } from '../services/insuranceService'
|
||||
import { applyPricingRules } from '../services/pricingRuleService'
|
||||
import { validateAndFlagLicense, validateLicense } from '../services/licenseValidationService'
|
||||
import * as paypal from '../services/paypalService'
|
||||
|
||||
const router = Router()
|
||||
|
||||
function isDatabaseUnavailableError(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') return false
|
||||
|
||||
const candidate = error as { code?: string; message?: string }
|
||||
return candidate.code === 'P1001' || candidate.message?.includes("Can't reach database server") === true
|
||||
}
|
||||
|
||||
async function findCompanyBySlug(slug: string) {
|
||||
return prisma.company.findFirstOrThrow({
|
||||
where: { slug, status: { in: ['ACTIVE', 'TRIALING', 'PAST_DUE', 'SUSPENDED'] } },
|
||||
include: { brand: true, contractSettings: true },
|
||||
})
|
||||
}
|
||||
|
||||
router.get('/:slug/brand', async (req, res, next) => {
|
||||
try {
|
||||
const company = await findCompanyBySlug(req.params.slug)
|
||||
res.json({
|
||||
data: {
|
||||
company: {
|
||||
id: company.id,
|
||||
slug: company.slug,
|
||||
name: company.name,
|
||||
phone: company.phone,
|
||||
},
|
||||
brand: company.brand,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.json({
|
||||
data: {
|
||||
company: { id: 'demo', slug: req.params.slug, name: 'Demo Company', phone: null },
|
||||
brand: null,
|
||||
},
|
||||
})
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:slug/vehicles', async (req, res, next) => {
|
||||
try {
|
||||
const company = await findCompanyBySlug(req.params.slug)
|
||||
const vehicles = await prisma.vehicle.findMany({
|
||||
where: { companyId: company.id, isPublished: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
res.json({ data: vehicles })
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:slug/vehicles/:id', async (req, res, next) => {
|
||||
try {
|
||||
const company = await findCompanyBySlug(req.params.slug)
|
||||
const vehicle = await prisma.vehicle.findFirstOrThrow({
|
||||
where: { id: req.params.id, companyId: company.id, isPublished: true },
|
||||
include: {
|
||||
reservations: {
|
||||
where: { status: { in: ['CONFIRMED', 'ACTIVE'] } },
|
||||
select: { startDate: true, endDate: true, status: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
res.json({ data: vehicle })
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Vehicle details are temporarily unavailable', statusCode: 503 })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:slug/offers', async (req, res, next) => {
|
||||
try {
|
||||
const company = await findCompanyBySlug(req.params.slug)
|
||||
const offers = await prisma.offer.findMany({
|
||||
where: {
|
||||
companyId: company.id,
|
||||
isActive: true,
|
||||
validFrom: { lte: new Date() },
|
||||
validUntil: { gte: new Date() },
|
||||
},
|
||||
orderBy: [{ isFeatured: 'desc' }, { createdAt: 'desc' }],
|
||||
})
|
||||
res.json({ data: offers })
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:slug/booking-options', async (req, res, next) => {
|
||||
try {
|
||||
const company = await findCompanyBySlug(req.params.slug)
|
||||
const insurancePolicies = await prisma.insurancePolicy.findMany({
|
||||
where: { companyId: company.id, isActive: true },
|
||||
orderBy: [{ isRequired: 'desc' }, { sortOrder: 'asc' }, { name: 'asc' }],
|
||||
})
|
||||
|
||||
res.json({
|
||||
data: {
|
||||
insurancePolicies,
|
||||
contractSettings: company.contractSettings,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) return res.json({ data: { insurancePolicies: [], contractSettings: null } })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/:slug/availability', async (req, res, next) => {
|
||||
try {
|
||||
const company = await findCompanyBySlug(req.params.slug)
|
||||
const { vehicleId, startDate, endDate } = z.object({
|
||||
vehicleId: z.string().cuid(),
|
||||
startDate: z.string().datetime(),
|
||||
endDate: z.string().datetime(),
|
||||
}).parse(req.body)
|
||||
|
||||
const conflicts = await prisma.reservation.findMany({
|
||||
where: {
|
||||
companyId: company.id,
|
||||
vehicleId,
|
||||
status: { in: ['CONFIRMED', 'ACTIVE'] },
|
||||
startDate: { lt: new Date(endDate) },
|
||||
endDate: { gt: new Date(startDate) },
|
||||
},
|
||||
select: { startDate: true, endDate: true },
|
||||
})
|
||||
|
||||
res.json({ data: { available: conflicts.length === 0, conflicts } })
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Availability checks are temporarily unavailable', statusCode: 503 })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/:slug/book/validate-code', async (req, res, next) => {
|
||||
try {
|
||||
const company = await findCompanyBySlug(req.params.slug)
|
||||
const { code } = z.object({ code: z.string().min(1) }).parse(req.body)
|
||||
const offer = await prisma.offer.findFirst({
|
||||
where: {
|
||||
companyId: company.id,
|
||||
promoCode: code,
|
||||
isActive: true,
|
||||
validFrom: { lte: new Date() },
|
||||
validUntil: { gte: new Date() },
|
||||
},
|
||||
})
|
||||
if (!offer) {
|
||||
return res.status(404).json({ error: 'invalid_code', message: 'Promo code not found or expired', statusCode: 404 })
|
||||
}
|
||||
res.json({ data: offer })
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Promo code validation is temporarily unavailable', statusCode: 503 })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/:slug/book', async (req, res, next) => {
|
||||
try {
|
||||
const company = await findCompanyBySlug(req.params.slug)
|
||||
const body = z.object({
|
||||
vehicleId: z.string().cuid(),
|
||||
startDate: z.string().datetime(),
|
||||
endDate: z.string().datetime(),
|
||||
firstName: z.string().min(1),
|
||||
lastName: z.string().min(1),
|
||||
email: z.string().email(),
|
||||
phone: z.string().optional(),
|
||||
driverLicense: z.string().optional(),
|
||||
dateOfBirth: z.string().datetime().optional(),
|
||||
licenseExpiry: z.string().datetime().optional(),
|
||||
licenseIssuedAt: z.string().datetime().optional(),
|
||||
nationality: z.string().optional(),
|
||||
offerId: z.string().cuid().optional(),
|
||||
promoCodeUsed: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
source: z.enum(['PUBLIC_SITE', 'MARKETPLACE']).default('PUBLIC_SITE'),
|
||||
selectedInsurancePolicyIds: z.array(z.string()).default([]),
|
||||
additionalDrivers: z.array(z.object({
|
||||
firstName: z.string().min(1),
|
||||
lastName: z.string().min(1),
|
||||
email: z.string().email().optional(),
|
||||
phone: z.string().optional(),
|
||||
driverLicense: z.string().min(1),
|
||||
licenseExpiry: z.string().datetime().optional(),
|
||||
licenseIssuedAt: z.string().datetime().optional(),
|
||||
dateOfBirth: z.string().datetime().optional(),
|
||||
nationality: z.string().optional(),
|
||||
})).default([]),
|
||||
}).parse(req.body)
|
||||
|
||||
const vehicle = await prisma.vehicle.findFirstOrThrow({
|
||||
where: { id: body.vehicleId, companyId: company.id, isPublished: true },
|
||||
})
|
||||
|
||||
const customer = await prisma.customer.upsert({
|
||||
where: { companyId_email: { companyId: company.id, email: body.email } },
|
||||
update: {
|
||||
firstName: body.firstName,
|
||||
lastName: body.lastName,
|
||||
phone: body.phone ?? null,
|
||||
driverLicense: body.driverLicense ?? null,
|
||||
dateOfBirth: body.dateOfBirth ? new Date(body.dateOfBirth) : null,
|
||||
licenseExpiry: body.licenseExpiry ? new Date(body.licenseExpiry) : null,
|
||||
licenseIssuedAt: body.licenseIssuedAt ? new Date(body.licenseIssuedAt) : null,
|
||||
nationality: body.nationality ?? null,
|
||||
},
|
||||
create: {
|
||||
companyId: company.id,
|
||||
firstName: body.firstName,
|
||||
lastName: body.lastName,
|
||||
email: body.email,
|
||||
phone: body.phone ?? null,
|
||||
driverLicense: body.driverLicense ?? null,
|
||||
dateOfBirth: body.dateOfBirth ? new Date(body.dateOfBirth) : null,
|
||||
licenseExpiry: body.licenseExpiry ? new Date(body.licenseExpiry) : null,
|
||||
licenseIssuedAt: body.licenseIssuedAt ? new Date(body.licenseIssuedAt) : null,
|
||||
nationality: body.nationality ?? null,
|
||||
},
|
||||
})
|
||||
|
||||
const start = new Date(body.startDate)
|
||||
const end = new Date(body.endDate)
|
||||
const totalDays = Math.max(1, Math.ceil((end.getTime() - start.getTime()) / 86400000))
|
||||
const baseAmount = vehicle.dailyRate * totalDays
|
||||
|
||||
let discountAmount = 0
|
||||
if (body.promoCodeUsed) {
|
||||
const offer = await prisma.offer.findFirst({
|
||||
where: {
|
||||
companyId: company.id,
|
||||
promoCode: body.promoCodeUsed,
|
||||
isActive: true,
|
||||
validFrom: { lte: new Date() },
|
||||
validUntil: { gte: new Date() },
|
||||
},
|
||||
})
|
||||
if (offer) {
|
||||
if (offer.type === 'PERCENTAGE') discountAmount = Math.round(baseAmount * offer.discountValue / 100)
|
||||
else if (offer.type === 'FIXED_AMOUNT') discountAmount = offer.discountValue
|
||||
else if (offer.type === 'FREE_DAY') discountAmount = vehicle.dailyRate * offer.discountValue
|
||||
}
|
||||
}
|
||||
|
||||
const { applied, total: pricingRulesTotal } = await applyPricingRules(
|
||||
company.id,
|
||||
customer.id,
|
||||
body.additionalDrivers,
|
||||
vehicle.dailyRate,
|
||||
totalDays,
|
||||
)
|
||||
|
||||
const primaryLicenseResult = validateLicense(body.licenseExpiry ? new Date(body.licenseExpiry) : null)
|
||||
if (primaryLicenseResult.status === 'EXPIRED') {
|
||||
return res.status(400).json({ error: 'license_expired', message: 'The primary driver license is expired', statusCode: 400 })
|
||||
}
|
||||
|
||||
const reservation = await prisma.reservation.create({
|
||||
data: {
|
||||
companyId: company.id,
|
||||
vehicleId: vehicle.id,
|
||||
customerId: customer.id,
|
||||
offerId: body.offerId ?? null,
|
||||
promoCodeUsed: body.promoCodeUsed ?? null,
|
||||
source: body.source,
|
||||
startDate: start,
|
||||
endDate: end,
|
||||
dailyRate: vehicle.dailyRate,
|
||||
totalDays,
|
||||
totalAmount: baseAmount - discountAmount + pricingRulesTotal,
|
||||
discountAmount,
|
||||
pricingRulesApplied: applied,
|
||||
pricingRulesTotal,
|
||||
notes: body.notes ?? null,
|
||||
status: 'DRAFT',
|
||||
},
|
||||
})
|
||||
|
||||
if (body.selectedInsurancePolicyIds.length > 0) {
|
||||
await applyInsurancesToReservation(reservation.id, company.id, body.selectedInsurancePolicyIds, totalDays, baseAmount)
|
||||
}
|
||||
|
||||
if (body.additionalDrivers.length > 0) {
|
||||
await applyAdditionalDriversToReservation(reservation.id, company.id, body.additionalDrivers, totalDays)
|
||||
}
|
||||
|
||||
if (body.licenseExpiry) {
|
||||
await validateAndFlagLicense(customer.id).catch(() => null)
|
||||
}
|
||||
|
||||
const refreshedReservation = await prisma.reservation.findUniqueOrThrow({
|
||||
where: { id: reservation.id },
|
||||
include: { insurances: true, additionalDrivers: true },
|
||||
})
|
||||
|
||||
res.status(201).json({
|
||||
data: {
|
||||
...refreshedReservation,
|
||||
requiresManualApproval:
|
||||
primaryLicenseResult.requiresApproval ||
|
||||
refreshedReservation.additionalDrivers.some((driver) => driver.requiresApproval),
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Booking is temporarily unavailable', statusCode: 503 })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:slug/booking/:id', async (req, res, next) => {
|
||||
try {
|
||||
const company = await findCompanyBySlug(req.params.slug)
|
||||
const reservation = await prisma.reservation.findFirstOrThrow({
|
||||
where: { id: req.params.id, companyId: company.id },
|
||||
include: { vehicle: true, customer: true },
|
||||
})
|
||||
res.json({ data: reservation })
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Booking details are temporarily unavailable', statusCode: 503 })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/:slug/booking/:id/pay', async (req, res, next) => {
|
||||
try {
|
||||
const company = await findCompanyBySlug(req.params.slug)
|
||||
const reservation = await prisma.reservation.findFirstOrThrow({
|
||||
where: { id: req.params.id, companyId: company.id },
|
||||
include: { vehicle: true, customer: true, additionalDrivers: true },
|
||||
})
|
||||
|
||||
if (reservation.paymentStatus === 'PAID') {
|
||||
return res.status(409).json({ error: 'already_paid', message: 'This reservation is already paid', statusCode: 409 })
|
||||
}
|
||||
|
||||
const customerLicenseResult = validateLicense(reservation.customer.licenseExpiry)
|
||||
if (
|
||||
reservation.customer.licenseValidationStatus === 'DENIED' ||
|
||||
customerLicenseResult.status === 'EXPIRED' ||
|
||||
(customerLicenseResult.requiresApproval && reservation.customer.licenseValidationStatus !== 'APPROVED') ||
|
||||
reservation.additionalDrivers.some((driver) => driver.licenseExpired || (driver.requiresApproval && !driver.approvedAt))
|
||||
) {
|
||||
return res.status(409).json({
|
||||
error: 'license_review_required',
|
||||
message: 'This reservation requires license review before payment can be processed',
|
||||
statusCode: 409,
|
||||
})
|
||||
}
|
||||
|
||||
const { provider, currency, successUrl, failureUrl } = z.object({
|
||||
provider: z.enum(['AMANPAY', 'PAYPAL']),
|
||||
currency: z.enum(['MAD', 'USD', 'EUR']).default('MAD'),
|
||||
successUrl: z.string().url(),
|
||||
failureUrl: z.string().url(),
|
||||
}).parse(req.body)
|
||||
|
||||
const amount = reservation.totalAmount
|
||||
const description = `Rental: ${reservation.vehicle.make} ${reservation.vehicle.model}`
|
||||
const orderId = `res-${reservation.id}-${Date.now()}`
|
||||
const webhookBase = process.env.API_URL ?? 'http://localhost:4000'
|
||||
|
||||
let checkoutUrl: string
|
||||
let amanpayTransactionId: string | null = null
|
||||
let paypalCaptureId: string | null = null
|
||||
|
||||
if (provider === 'AMANPAY') {
|
||||
if (!amanpay.isConfigured()) {
|
||||
return res.status(503).json({ error: 'provider_not_configured', message: 'Online payment is not available for this company', statusCode: 503 })
|
||||
}
|
||||
const brand = company.brand as any
|
||||
const merchantId = brand?.amanpayMerchantId ?? process.env.AMANPAY_MERCHANT_ID ?? ''
|
||||
const secretKey = brand?.amanpaySecretKey ?? process.env.AMANPAY_SECRET_KEY ?? ''
|
||||
if (!merchantId || !secretKey) {
|
||||
return res.status(503).json({ error: 'provider_not_configured', message: 'AmanPay is not configured for this company', statusCode: 503 })
|
||||
}
|
||||
const result = await amanpay.createCheckout({
|
||||
amount,
|
||||
currency,
|
||||
orderId,
|
||||
description,
|
||||
customerEmail: reservation.customer.email,
|
||||
customerName: `${reservation.customer.firstName} ${reservation.customer.lastName}`,
|
||||
successUrl,
|
||||
failureUrl,
|
||||
webhookUrl: `${webhookBase}/api/v1/payments/webhooks/amanpay`,
|
||||
})
|
||||
checkoutUrl = result.checkoutUrl
|
||||
amanpayTransactionId = result.transactionId
|
||||
} else {
|
||||
if (!paypal.isConfigured()) {
|
||||
return res.status(503).json({ error: 'provider_not_configured', message: 'PayPal is not available for this company', statusCode: 503 })
|
||||
}
|
||||
const result = await paypal.createOrder({
|
||||
amount,
|
||||
currency,
|
||||
orderId,
|
||||
description,
|
||||
returnUrl: successUrl,
|
||||
cancelUrl: failureUrl,
|
||||
})
|
||||
checkoutUrl = result.approveUrl
|
||||
paypalCaptureId = result.orderId
|
||||
}
|
||||
|
||||
await prisma.rentalPayment.create({
|
||||
data: {
|
||||
companyId: company.id,
|
||||
reservationId: reservation.id,
|
||||
amount,
|
||||
currency,
|
||||
status: 'PENDING',
|
||||
type: 'CHARGE',
|
||||
paymentProvider: provider,
|
||||
amanpayTransactionId,
|
||||
paypalCaptureId,
|
||||
},
|
||||
})
|
||||
|
||||
res.json({ data: { checkoutUrl } })
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Payment initiation is temporarily unavailable', statusCode: 503 })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/:slug/booking/:id/capture-paypal', async (req, res, next) => {
|
||||
try {
|
||||
const company = await findCompanyBySlug(req.params.slug)
|
||||
const { paypalOrderId } = z.object({ paypalOrderId: z.string() }).parse(req.body)
|
||||
|
||||
const payment = await prisma.rentalPayment.findFirstOrThrow({
|
||||
where: { paypalCaptureId: paypalOrderId, companyId: company.id },
|
||||
})
|
||||
|
||||
const capture = await paypal.captureOrder(paypalOrderId)
|
||||
const captureId = capture.purchase_units?.[0]?.payments?.captures?.[0]?.id ?? paypalOrderId
|
||||
|
||||
await prisma.rentalPayment.update({
|
||||
where: { id: payment.id },
|
||||
data: { status: 'SUCCEEDED', paidAt: new Date(), paypalCaptureId: captureId },
|
||||
})
|
||||
await prisma.reservation.update({
|
||||
where: { id: payment.reservationId },
|
||||
data: { paymentStatus: 'PAID', paidAmount: { increment: payment.amount } },
|
||||
})
|
||||
|
||||
res.json({ data: { success: true } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/:slug/contact', async (req, res, next) => {
|
||||
try {
|
||||
const company = await findCompanyBySlug(req.params.slug)
|
||||
const body = z.object({
|
||||
name: z.string().min(1),
|
||||
email: z.string().email(),
|
||||
message: z.string().min(1),
|
||||
}).parse(req.body)
|
||||
res.json({
|
||||
data: {
|
||||
success: true,
|
||||
deliveredTo: company.brand?.publicEmail ?? company.email,
|
||||
preview: body,
|
||||
},
|
||||
})
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,288 @@
|
||||
import { Router } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { PLAN_PRICES } from '@rentaldrivego/types'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { requireCompanyAuth } from '../middleware/requireCompanyAuth'
|
||||
import { requireTenant } from '../middleware/requireTenant'
|
||||
import * as amanpay from '../services/amanpayService'
|
||||
import * as paypal from '../services/paypalService'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/plans', (_req, res) => {
|
||||
res.json({ data: PLAN_PRICES })
|
||||
})
|
||||
|
||||
// ─── AmanPay subscription webhook (no auth) ──────────────────────────────────
|
||||
|
||||
router.post('/webhooks/amanpay', async (req, res, next) => {
|
||||
try {
|
||||
const rawBody = JSON.stringify(req.body)
|
||||
const signature = req.headers['x-amanpay-signature'] as string ?? ''
|
||||
|
||||
if (amanpay.isConfigured() && !amanpay.verifyWebhookSignature(rawBody, signature)) {
|
||||
return res.status(401).json({ error: 'invalid_signature' })
|
||||
}
|
||||
|
||||
const event = req.body
|
||||
const transactionId = event.transaction_id ?? event.id
|
||||
const status = event.status?.toUpperCase()
|
||||
|
||||
if (status === 'PAID' || status === 'SUCCEEDED') {
|
||||
const invoice = await prisma.subscriptionInvoice.findFirst({
|
||||
where: { amanpayTransactionId: transactionId },
|
||||
include: { subscription: true },
|
||||
})
|
||||
if (invoice) {
|
||||
await prisma.subscriptionInvoice.update({
|
||||
where: { id: invoice.id },
|
||||
data: { status: 'PAID', paidAt: new Date() },
|
||||
})
|
||||
await prisma.subscription.update({
|
||||
where: { id: invoice.subscriptionId },
|
||||
data: {
|
||||
status: 'ACTIVE',
|
||||
currentPeriodStart: new Date(),
|
||||
currentPeriodEnd: addPeriod(new Date(), invoice.subscription.billingPeriod),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ received: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ─── PayPal subscription webhook (no auth) ───────────────────────────────────
|
||||
|
||||
router.post('/webhooks/paypal', async (req, res, next) => {
|
||||
try {
|
||||
const rawBody = JSON.stringify(req.body)
|
||||
const isValid = await paypal.verifyWebhookEvent(req.headers as Record<string, string>, rawBody)
|
||||
if (paypal.isConfigured() && !isValid) {
|
||||
return res.status(401).json({ error: 'invalid_signature' })
|
||||
}
|
||||
|
||||
const event = req.body
|
||||
if (event.event_type === 'PAYMENT.CAPTURE.COMPLETED') {
|
||||
const captureId = event.resource?.id as string
|
||||
const invoice = await prisma.subscriptionInvoice.findFirst({
|
||||
where: { paypalCaptureId: captureId },
|
||||
include: { subscription: true },
|
||||
})
|
||||
if (invoice) {
|
||||
await prisma.subscriptionInvoice.update({
|
||||
where: { id: invoice.id },
|
||||
data: { status: 'PAID', paidAt: new Date() },
|
||||
})
|
||||
await prisma.subscription.update({
|
||||
where: { id: invoice.subscriptionId },
|
||||
data: {
|
||||
status: 'ACTIVE',
|
||||
currentPeriodStart: new Date(),
|
||||
currentPeriodEnd: addPeriod(new Date(), invoice.subscription.billingPeriod),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ received: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ─── PayPal capture redirect (no full auth needed — just valid session) ───────
|
||||
|
||||
router.post('/capture-paypal', requireCompanyAuth, requireTenant, async (req, res, next) => {
|
||||
try {
|
||||
const { paypalOrderId } = z.object({ paypalOrderId: z.string() }).parse(req.body)
|
||||
|
||||
const invoice = await prisma.subscriptionInvoice.findFirstOrThrow({
|
||||
where: { paypalCaptureId: paypalOrderId, companyId: req.companyId },
|
||||
include: { subscription: true },
|
||||
})
|
||||
|
||||
const capture = await paypal.captureOrder(paypalOrderId)
|
||||
const captureId = capture.purchase_units?.[0]?.payments?.captures?.[0]?.id ?? paypalOrderId
|
||||
|
||||
await prisma.subscriptionInvoice.update({
|
||||
where: { id: invoice.id },
|
||||
data: { status: 'PAID', paidAt: new Date(), paypalCaptureId: captureId },
|
||||
})
|
||||
await prisma.subscription.update({
|
||||
where: { id: invoice.subscriptionId },
|
||||
data: {
|
||||
status: 'ACTIVE',
|
||||
currentPeriodStart: new Date(),
|
||||
currentPeriodEnd: addPeriod(new Date(), invoice.subscription.billingPeriod),
|
||||
},
|
||||
})
|
||||
|
||||
res.json({ data: { success: true } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ─── Authenticated subscription routes ───────────────────────────────────────
|
||||
|
||||
router.use(requireCompanyAuth, requireTenant)
|
||||
|
||||
router.get('/me', async (req, res, next) => {
|
||||
try {
|
||||
const subscription = await prisma.subscription.findUnique({
|
||||
where: { companyId: req.companyId },
|
||||
include: { invoices: { orderBy: { createdAt: 'desc' }, take: 12 } },
|
||||
})
|
||||
res.json({ data: subscription })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/invoices', async (req, res, next) => {
|
||||
try {
|
||||
const invoices = await prisma.subscriptionInvoice.findMany({
|
||||
where: { companyId: req.companyId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50,
|
||||
})
|
||||
res.json({ data: invoices })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
const checkoutSchema = z.object({
|
||||
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
|
||||
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
|
||||
currency: z.enum(['MAD', 'USD', 'EUR']),
|
||||
provider: z.enum(['AMANPAY', 'PAYPAL']),
|
||||
successUrl: z.string().url(),
|
||||
failureUrl: z.string().url(),
|
||||
})
|
||||
|
||||
router.post('/checkout', async (req, res, next) => {
|
||||
try {
|
||||
const body = checkoutSchema.parse(req.body)
|
||||
const prices = PLAN_PRICES[body.plan]?.[body.billingPeriod]
|
||||
if (!prices) return res.status(400).json({ error: 'invalid_plan', message: 'Invalid plan or billing period', statusCode: 400 })
|
||||
const amount = prices[body.currency]
|
||||
if (!amount) return res.status(400).json({ error: 'invalid_currency', message: 'Currency not supported for this plan', statusCode: 400 })
|
||||
|
||||
const company = await prisma.company.findUniqueOrThrow({ where: { id: req.companyId } })
|
||||
|
||||
let subscription = await prisma.subscription.findUnique({ where: { companyId: req.companyId } })
|
||||
if (!subscription) {
|
||||
subscription = await prisma.subscription.create({
|
||||
data: {
|
||||
companyId: req.companyId,
|
||||
plan: body.plan,
|
||||
billingPeriod: body.billingPeriod,
|
||||
currency: body.currency,
|
||||
status: 'PENDING' as any,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const orderId = `sub-${req.companyId}-${Date.now()}`
|
||||
const description = `${body.plan} plan — ${body.billingPeriod}`
|
||||
const webhookBase = process.env.API_URL ?? 'http://localhost:4000'
|
||||
|
||||
let checkoutUrl: string
|
||||
let amanpayTransactionId: string | null = null
|
||||
let paypalCaptureId: string | null = null
|
||||
|
||||
if (body.provider === 'AMANPAY') {
|
||||
if (!amanpay.isConfigured()) {
|
||||
return res.status(503).json({ error: 'provider_not_configured', message: 'AmanPay is not configured on this platform', statusCode: 503 })
|
||||
}
|
||||
const result = await amanpay.createCheckout({
|
||||
amount,
|
||||
currency: body.currency,
|
||||
orderId,
|
||||
description,
|
||||
customerEmail: company.email,
|
||||
customerName: company.name,
|
||||
successUrl: body.successUrl,
|
||||
failureUrl: body.failureUrl,
|
||||
webhookUrl: `${webhookBase}/api/v1/subscriptions/webhooks/amanpay`,
|
||||
})
|
||||
checkoutUrl = result.checkoutUrl
|
||||
amanpayTransactionId = result.transactionId
|
||||
} else {
|
||||
if (!paypal.isConfigured()) {
|
||||
return res.status(503).json({ error: 'provider_not_configured', message: 'PayPal is not configured on this platform', statusCode: 503 })
|
||||
}
|
||||
const result = await paypal.createOrder({
|
||||
amount,
|
||||
currency: body.currency,
|
||||
orderId,
|
||||
description,
|
||||
returnUrl: body.successUrl,
|
||||
cancelUrl: body.failureUrl,
|
||||
})
|
||||
checkoutUrl = result.approveUrl
|
||||
paypalCaptureId = result.orderId
|
||||
}
|
||||
|
||||
const invoice = await prisma.subscriptionInvoice.create({
|
||||
data: {
|
||||
companyId: req.companyId,
|
||||
subscriptionId: subscription.id,
|
||||
amount,
|
||||
currency: body.currency,
|
||||
status: 'PENDING',
|
||||
paymentProvider: body.provider,
|
||||
amanpayTransactionId,
|
||||
paypalCaptureId,
|
||||
},
|
||||
})
|
||||
|
||||
res.json({ data: { invoice, checkoutUrl } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/change-plan', async (req, res, next) => {
|
||||
try {
|
||||
const { plan, billingPeriod, currency } = z.object({
|
||||
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
|
||||
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
|
||||
currency: z.enum(['MAD', 'USD', 'EUR']),
|
||||
}).parse(req.body)
|
||||
|
||||
const updated = await prisma.subscription.update({
|
||||
where: { companyId: req.companyId },
|
||||
data: { plan, billingPeriod, currency },
|
||||
})
|
||||
|
||||
res.json({ data: updated })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/cancel', async (req, res, next) => {
|
||||
try {
|
||||
const updated = await prisma.subscription.update({
|
||||
where: { companyId: req.companyId },
|
||||
data: { cancelAtPeriodEnd: true },
|
||||
})
|
||||
res.json({ data: updated })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/resume', async (req, res, next) => {
|
||||
try {
|
||||
const updated = await prisma.subscription.update({
|
||||
where: { companyId: req.companyId },
|
||||
data: { cancelAtPeriodEnd: false },
|
||||
})
|
||||
res.json({ data: updated })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function addPeriod(date: Date, period: string): Date {
|
||||
const d = new Date(date)
|
||||
if (period === 'ANNUAL') {
|
||||
d.setFullYear(d.getFullYear() + 1)
|
||||
} else {
|
||||
d.setMonth(d.getMonth() + 1)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
export default router
|
||||
@@ -30,27 +30,37 @@ router.get('/stats', async (req, res, next) => {
|
||||
router.post('/invite', requireRole('OWNER'), async (req, res, next) => {
|
||||
try {
|
||||
const body = inviteSchema.parse(req.body)
|
||||
res.status(201).json({ data: await inviteEmployee(req.companyId, req.employee.id, body) })
|
||||
res.status(201).json({ data: await inviteEmployee(req.companyId!, req.employee!.id, body) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/:id/role', requireRole('OWNER'), async (req, res, next) => {
|
||||
try {
|
||||
const { role } = z.object({ role: z.enum(['MANAGER', 'AGENT']) }).parse(req.body)
|
||||
res.json({ data: await updateEmployeeRole(req.companyId, req.employee.id, req.employee.role, req.params.id, { role }) })
|
||||
const memberId = req.params.id!
|
||||
res.json({ data: await updateEmployeeRole(req.companyId!, req.employee!.id, req.employee!.role, memberId, { role }) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/:id/deactivate', requireRole('OWNER'), async (req, res, next) => {
|
||||
try { res.json({ data: await deactivateEmployee(req.companyId, req.employee.role, req.params.id) }) } catch (err) { next(err) }
|
||||
try {
|
||||
const memberId = req.params.id!
|
||||
res.json({ data: await deactivateEmployee(req.companyId!, req.employee!.role, memberId) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/:id/reactivate', requireRole('OWNER'), async (req, res, next) => {
|
||||
try { res.json({ data: await reactivateEmployee(req.companyId, req.employee.role, req.params.id) }) } catch (err) { next(err) }
|
||||
try {
|
||||
const memberId = req.params.id!
|
||||
res.json({ data: await reactivateEmployee(req.companyId!, req.employee!.role, memberId) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.delete('/:id', requireRole('OWNER'), async (req, res, next) => {
|
||||
try { res.json({ data: await removeEmployee(req.companyId, req.employee.role, req.params.id) }) } catch (err) { next(err) }
|
||||
try {
|
||||
const memberId = req.params.id!
|
||||
res.json({ data: await removeEmployee(req.companyId!, req.employee!.role, memberId) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -93,7 +93,7 @@ router.delete('/:id/photos/:idx', async (req, res, next) => {
|
||||
try {
|
||||
const vehicle = await prisma.vehicle.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } })
|
||||
const idx = parseInt(req.params.idx)
|
||||
const photos = vehicle.photos.filter((_, i) => i !== idx)
|
||||
const photos = vehicle.photos.filter((_: string, i: number) => i !== idx)
|
||||
const updated = await prisma.vehicle.update({ where: { id: vehicle.id }, data: { photos } })
|
||||
res.json({ data: updated })
|
||||
} catch (err) { next(err) }
|
||||
|
||||
Reference in New Issue
Block a user