fixing platform admin

This commit is contained in:
root
2026-05-06 22:58:23 -04:00
parent 695a7f7cc7
commit 750ae56a29
175 changed files with 31249 additions and 328 deletions
+26 -23
View File
@@ -9,45 +9,48 @@
"type-check": "tsc --noEmit"
},
"dependencies": {
"@clerk/clerk-sdk-node": "^5.0.0",
"@react-pdf/renderer": "^3.4.3",
"@rentaldrivego/database": "*",
"@rentaldrivego/types": "*",
"@clerk/clerk-sdk-node": "^5.0.0",
"express": "^4.19.2",
"cors": "^2.8.5",
"helmet": "^7.1.0",
"morgan": "^1.10.0",
"zod": "^3.23.0",
"jsonwebtoken": "^9.0.2",
"bcryptjs": "^2.4.3",
"multer": "^1.4.5-lts.1",
"cloudinary": "^2.2.0",
"resend": "^3.2.0",
"twilio": "^5.1.0",
"cors": "^2.8.5",
"dayjs": "^1.11.11",
"express": "^4.19.2",
"express-rate-limit": "^8.5.1",
"firebase-admin": "^12.1.0",
"helmet": "^7.1.0",
"ioredis": "^5.3.2",
"socket.io": "^4.7.5",
"svix": "^1.20.0",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"node-cron": "^3.0.3",
"nodemailer": "^6.9.16",
"otplib": "^12.0.1",
"qrcode": "^1.5.3",
"@react-pdf/renderer": "^3.4.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"dayjs": "^1.11.11",
"node-cron": "^3.0.3"
"resend": "^3.2.0",
"socket.io": "^4.7.5",
"svix": "^1.20.0",
"twilio": "^5.1.0",
"zod": "^3.23.0"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/cors": "^2.8.17",
"@types/morgan": "^1.9.9",
"@types/jsonwebtoken": "^9.0.6",
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.6",
"@types/morgan": "^1.9.9",
"@types/multer": "^1.4.11",
"@types/qrcode": "^1.5.5",
"@types/node": "^20.12.0",
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^6.4.17",
"@types/qrcode": "^1.5.5",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.0",
"typescript": "^5.4.0",
"@types/node": "^20.12.0",
"ts-node-dev": "^2.0.0"
"ts-node-dev": "^2.0.0",
"typescript": "^5.4.0"
}
}
+149 -16
View File
@@ -5,11 +5,15 @@ import morgan from 'morgan'
import http from 'http'
import { Server as SocketIOServer } from 'socket.io'
import cron from 'node-cron'
import jwt from 'jsonwebtoken'
import { z } from 'zod'
import { redis } from './lib/redis'
import { prisma } from './lib/prisma'
import { authLimiter, apiLimiter, publicLimiter, adminLimiter } from './middleware/rateLimiter'
// ─── Routes ───────────────────────────────────────────────────
import webhookRouter from './routes/webhooks'
import companyAuthRouter from './routes/auth.company'
import renterAuthRouter from './routes/auth.renter'
import vehiclesRouter from './routes/vehicles'
import reservationsRouter from './routes/reservations'
@@ -20,17 +24,74 @@ import analyticsRouter from './routes/analytics'
import notificationsRouter from './routes/notifications'
import marketplaceRouter from './routes/marketplace'
import adminRouter from './routes/admin'
import companiesRouter from './routes/companies'
import subscriptionsRouter from './routes/subscriptions'
import siteRouter from './routes/site'
import paymentsRouter from './routes/payments'
const app = express()
const server = http.createServer(app)
const v1 = '/api/v1'
const defaultCorsOrigins = [
'http://localhost:3000',
'http://localhost:3001',
'http://localhost:3002',
'http://localhost:3003',
'http://127.0.0.1:3000',
'http://127.0.0.1:3001',
'http://127.0.0.1:3002',
'http://127.0.0.1:3003',
]
const corsOrigins = process.env.CORS_ORIGINS
? process.env.CORS_ORIGINS.split(',').map((origin) => origin.trim()).filter(Boolean)
: defaultCorsOrigins
const routeDocs = [
{ method: 'GET', path: '/health', description: 'Health check' },
{ method: 'GET', path: `${v1}/docs`, description: 'Machine-readable API index' },
{ method: 'GET', path: `${v1}/auth/renter/me`, description: 'Current renter profile' },
{ method: 'GET', path: `${v1}/vehicles`, description: 'List company vehicles' },
{ method: 'POST', path: `${v1}/vehicles`, description: 'Create vehicle' },
{ method: 'GET', path: `${v1}/reservations`, description: 'List reservations' },
{ method: 'POST', path: `${v1}/reservations`, description: 'Create reservation' },
{ method: 'GET', path: `${v1}/customers`, description: 'List customers' },
{ method: 'POST', path: `${v1}/customers`, description: 'Create customer' },
{ method: 'GET', path: `${v1}/offers`, description: 'List offers' },
{ method: 'GET', path: `${v1}/analytics/dashboard`, description: 'Dashboard analytics' },
{ method: 'GET', path: `${v1}/analytics/report`, description: 'Analytics report' },
{ method: 'GET', path: `${v1}/notifications/company`, description: 'Company notifications' },
{ method: 'GET', path: `${v1}/marketplace/search`, description: 'Marketplace search' },
{ method: 'GET', path: `${v1}/site/:slug/brand`, description: 'Public site brand config' },
{ method: 'GET', path: `${v1}/companies/me`, description: 'Current company profile' },
{ method: 'GET', path: `${v1}/subscriptions/plans`, description: 'Subscription plans' },
{ method: 'POST', path: `${v1}/admin/auth/login`, description: 'Admin login' },
]
// ─── Socket.io ────────────────────────────────────────────────
const io = new SocketIOServer(server, {
cors: { origin: '*', methods: ['GET', 'POST'] },
cors: { origin: corsOrigins, credentials: true, methods: ['GET', 'POST'] },
})
const redisMessageSchema = z.object({
type: z.string(),
payload: z.unknown(),
})
// Authenticate socket connections via JWT before joining user rooms
io.use((socket, next) => {
const token = socket.handshake.auth?.token as string | undefined
if (!token) return next() // unauthenticated connections allowed; they just don't join rooms
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
;(socket as any).authenticatedUserId = payload.sub
next()
} catch {
next(new Error('invalid_token'))
}
})
io.on('connection', (socket) => {
const userId = socket.handshake.auth?.userId as string | undefined
const userId = (socket as any).authenticatedUserId as string | undefined
if (userId) {
socket.join(`user:${userId}`)
}
@@ -42,8 +103,14 @@ subscriber.psubscribe('notifications:*', (err) => {
if (err) console.error('[Redis] Subscribe error:', err)
})
subscriber.on('pmessage', (_pattern, channel, message) => {
const userId = channel.replace('notifications:', '')
io.to(`user:${userId}`).emit('notification', JSON.parse(message))
try {
const parsed = JSON.parse(message)
const validated = redisMessageSchema.parse(parsed)
const userId = channel.replace('notifications:', '')
io.to(`user:${userId}`).emit('notification', validated)
} catch (err) {
console.error('[Redis] Invalid notification message:', err)
}
})
// ─── Middleware ────────────────────────────────────────────────
@@ -51,29 +118,95 @@ subscriber.on('pmessage', (_pattern, channel, message) => {
app.use('/api/v1/webhooks', express.raw({ type: 'application/json' }), webhookRouter)
app.use(helmet())
app.use(cors({ origin: process.env.CORS_ORIGINS?.split(',') ?? '*', credentials: true }))
app.use(cors({ origin: corsOrigins, credentials: true }))
app.use(morgan('combined'))
app.use(express.json({ limit: '10mb' }))
// ─── API Routes ───────────────────────────────────────────────
const v1 = '/api/v1'
// Auth routes: strict brute-force protection
app.use(`${v1}/auth/renter`, authLimiter, renterAuthRouter)
app.use(`${v1}/auth/company`, authLimiter, companyAuthRouter)
app.use(`${v1}/auth/renter`, renterAuthRouter)
app.use(`${v1}/vehicles`, vehiclesRouter)
app.use(`${v1}/reservations`, reservationsRouter)
app.use(`${v1}/team`, teamRouter)
app.use(`${v1}/customers`, customersRouter)
app.use(`${v1}/offers`, offersRouter)
app.use(`${v1}/analytics`, analyticsRouter)
app.use(`${v1}/notifications`, notificationsRouter)
app.use(`${v1}/marketplace`, marketplaceRouter)
app.use(`${v1}/admin`, adminRouter)
// Admin routes: dedicated limit
app.use(`${v1}/admin`, adminLimiter, adminRouter)
// Public unauthenticated routes
app.use(`${v1}/marketplace`, publicLimiter, marketplaceRouter)
app.use(`${v1}/site`, publicLimiter, siteRouter)
// Authenticated company/renter routes
app.use(`${v1}/vehicles`, apiLimiter, vehiclesRouter)
app.use(`${v1}/reservations`, apiLimiter, reservationsRouter)
app.use(`${v1}/team`, apiLimiter, teamRouter)
app.use(`${v1}/customers`, apiLimiter, customersRouter)
app.use(`${v1}/offers`, apiLimiter, offersRouter)
app.use(`${v1}/analytics`, apiLimiter, analyticsRouter)
app.use(`${v1}/notifications`, apiLimiter, notificationsRouter)
app.use(`${v1}/companies`, apiLimiter, companiesRouter)
app.use(`${v1}/subscriptions`, apiLimiter, subscriptionsRouter)
app.use(`${v1}/payments`, apiLimiter, paymentsRouter)
// ─── Health check ─────────────────────────────────────────────
app.get('/health', (_req, res) => {
res.json({ status: 'ok', version: '1.0.0', timestamp: new Date().toISOString() })
})
app.get(`${v1}/docs`, (_req, res) => {
res.json({
name: 'rentaldrivego-api',
version: '1.0.0',
baseUrl: v1,
docsUrl: '/docs',
routes: routeDocs,
})
})
app.get('/docs', (_req, res) => {
const rows = routeDocs
.map(
(route) => `
<tr>
<td>${route.method}</td>
<td><code>${route.path}</code></td>
<td>${route.description}</td>
</tr>`,
)
.join('')
res.type('html').send(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>RentalDriveGo API Docs</title>
<style>
body { font-family: Arial, sans-serif; margin: 40px; color: #0f172a; background: #f8fafc; }
h1 { margin-bottom: 8px; }
p { color: #475569; }
.meta { margin-bottom: 24px; }
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 12px; overflow: hidden; }
th, td { text-align: left; padding: 12px 16px; border-bottom: 1px solid #e2e8f0; }
th { background: #e2e8f0; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; }
code { background: #f1f5f9; padding: 2px 6px; border-radius: 6px; }
</style>
</head>
<body>
<h1>RentalDriveGo API</h1>
<p class="meta">Base URL: <code>${v1}</code> · JSON index: <code>${v1}/docs</code></p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Description</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</body>
</html>`)
})
// ─── Error handler ────────────────────────────────────────────
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
const statusCode = err.statusCode ?? 500
+53
View File
@@ -0,0 +1,53 @@
import rateLimit, { ipKeyGenerator } from 'express-rate-limit'
const trustProxy = process.env.NODE_ENV === 'production'
const getClientIpKey = (req: Parameters<typeof ipKeyGenerator>[0]) =>
trustProxy
? ipKeyGenerator((req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() ?? req.ip ?? '')
: ipKeyGenerator(req.ip ?? '')
// Strict limiter for auth endpoints — prevents brute-force and credential stuffing
export const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10,
standardHeaders: 'draft-7',
legacyHeaders: false,
skipSuccessfulRequests: false,
keyGenerator: (req) => getClientIpKey(req),
message: { error: 'too_many_requests', message: 'Too many attempts, please try again later', statusCode: 429 },
})
// Standard limiter for general authenticated API endpoints
export const apiLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 120,
standardHeaders: 'draft-7',
legacyHeaders: false,
keyGenerator: (req) => {
const ip = getClientIpKey(req)
const companyId = (req as any).companyId ?? ''
const renterId = (req as any).renterId ?? ''
return `${ip}:${companyId || renterId}`
},
message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 },
})
// Tight limiter for public marketplace and site endpoints (no auth)
export const publicLimiter = rateLimit({
windowMs: 60 * 1000,
max: 60,
standardHeaders: 'draft-7',
legacyHeaders: false,
keyGenerator: (req) => getClientIpKey(req),
message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 },
})
// Tight limiter for admin endpoints
export const adminLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 30,
standardHeaders: 'draft-7',
legacyHeaders: false,
keyGenerator: (req) => getClientIpKey(req),
message: { error: 'too_many_requests', message: 'Too many admin requests', statusCode: 429 },
})
+66 -6
View File
@@ -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
+132 -4
View File
@@ -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))
}
+540
View File
@@ -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
+60 -45
View File
@@ -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)
+410
View File
@@ -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
+21 -14
View File
@@ -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) }
})
+142 -15
View File
@@ -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
+51
View File
@@ -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
+252
View File
@@ -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
+212 -4
View File
@@ -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
+499
View File
@@ -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
+288
View File
@@ -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
+15 -5
View File
@@ -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
+1 -1
View File
@@ -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) }
@@ -0,0 +1,95 @@
import { AdditionalDriverCharge } from '@rentaldrivego/database'
import { prisma } from '../lib/prisma'
import { validateLicense } from './licenseValidationService'
export interface AdditionalDriverInput {
firstName: string
lastName: string
email?: string
phone?: string
driverLicense: string
licenseExpiry?: string | null
licenseIssuedAt?: string | null
dateOfBirth?: string | null
nationality?: string
}
export function calculateAdditionalDriverCharge(
chargeType: AdditionalDriverCharge,
chargeValue: number,
totalDays: number,
) {
switch (chargeType) {
case 'PER_DAY':
return chargeValue * totalDays
case 'FLAT':
return chargeValue
default:
return 0
}
}
export async function applyAdditionalDriversToReservation(
reservationId: string,
companyId: string,
drivers: AdditionalDriverInput[],
totalDays: number,
) {
if (drivers.length === 0) {
return { records: [], additionalDriverTotal: 0, requiresManualApproval: false }
}
const settings = await prisma.contractSettings.findUnique({ where: { companyId } })
const chargeType = settings?.additionalDriverCharge ?? 'FREE'
const chargeValue =
chargeType === 'PER_DAY'
? settings?.additionalDriverDailyRate ?? 0
: chargeType === 'FLAT'
? settings?.additionalDriverFlatRate ?? 0
: 0
const records = drivers.map((driver) => {
const licenseResult = validateLicense(driver.licenseExpiry ? new Date(driver.licenseExpiry) : null)
const totalCharge = calculateAdditionalDriverCharge(chargeType, chargeValue, totalDays)
return {
reservationId,
companyId,
firstName: driver.firstName,
lastName: driver.lastName,
email: driver.email ?? null,
phone: driver.phone ?? null,
driverLicense: driver.driverLicense,
licenseExpiry: driver.licenseExpiry ? new Date(driver.licenseExpiry) : null,
licenseIssuedAt: driver.licenseIssuedAt ? new Date(driver.licenseIssuedAt) : null,
dateOfBirth: driver.dateOfBirth ? new Date(driver.dateOfBirth) : null,
nationality: driver.nationality ?? null,
chargeType,
chargeValue,
totalCharge,
licenseExpired: licenseResult.status === 'EXPIRED',
licenseExpiringSoon: licenseResult.status === 'EXPIRING',
requiresApproval: licenseResult.requiresApproval,
approvalNote: licenseResult.requiresApproval ? licenseResult.message : null,
}
})
const additionalDriverTotal = records.reduce((sum, record) => sum + record.totalCharge, 0)
await prisma.$transaction([
prisma.additionalDriver.createMany({ data: records }),
prisma.reservation.update({
where: { id: reservationId },
data: {
additionalDriverTotal,
totalAmount: { increment: additionalDriverTotal },
},
}),
])
return {
records,
additionalDriverTotal,
requiresManualApproval: records.some((record) => record.requiresApproval),
}
}
+97
View File
@@ -0,0 +1,97 @@
import crypto from 'crypto'
const BASE_URL = process.env.AMANPAY_BASE_URL ?? 'https://api.amanpay.net'
const MERCHANT_ID = process.env.AMANPAY_MERCHANT_ID ?? ''
const SECRET_KEY = process.env.AMANPAY_SECRET_KEY ?? ''
const WEBHOOK_SECRET = process.env.AMANPAY_WEBHOOK_SECRET ?? ''
export interface AmanPayCreateParams {
amount: number
currency: string
orderId: string
description: string
customerEmail?: string
customerName?: string
successUrl: string
failureUrl: string
webhookUrl: string
}
export interface AmanPayCheckoutResult {
transactionId: string
checkoutUrl: string
status: string
}
async function getAuthHeaders() {
return {
'Content-Type': 'application/json',
'x-merchant-id': MERCHANT_ID,
'x-api-key': SECRET_KEY,
}
}
export async function createCheckout(params: AmanPayCreateParams): Promise<AmanPayCheckoutResult> {
const res = await fetch(`${BASE_URL}/v1/payments`, {
method: 'POST',
headers: await getAuthHeaders(),
body: JSON.stringify({
merchant_id: MERCHANT_ID,
amount: params.amount,
currency: params.currency,
order_id: params.orderId,
description: params.description,
customer_email: params.customerEmail,
customer_name: params.customerName,
success_url: params.successUrl,
failure_url: params.failureUrl,
webhook_url: params.webhookUrl,
}),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(`AmanPay checkout failed: ${err?.message ?? res.statusText}`)
}
const json = await res.json()
return {
transactionId: json.transaction_id ?? json.id,
checkoutUrl: json.checkout_url ?? json.payment_url,
status: json.status ?? 'PENDING',
}
}
export async function getTransaction(transactionId: string) {
const res = await fetch(`${BASE_URL}/v1/payments/${transactionId}`, {
headers: await getAuthHeaders(),
})
if (!res.ok) throw new Error('AmanPay: failed to fetch transaction')
return res.json()
}
export async function refundTransaction(transactionId: string, amount: number, reason?: string) {
const res = await fetch(`${BASE_URL}/v1/payments/${transactionId}/refund`, {
method: 'POST',
headers: await getAuthHeaders(),
body: JSON.stringify({ amount, reason }),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(`AmanPay refund failed: ${err?.message ?? res.statusText}`)
}
return res.json()
}
export function verifyWebhookSignature(rawBody: string, signature: string): boolean {
if (!WEBHOOK_SECRET) return false
const expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(rawBody)
.digest('hex')
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
}
export function isConfigured(): boolean {
return !!(MERCHANT_ID && SECRET_KEY && MERCHANT_ID !== 'your-amanpay-merchant-id')
}
@@ -15,17 +15,17 @@ export async function generateFinancialReport(companyId: string, startDate: Date
const summary = {
totalReservations: reservations.length,
totalRentalRevenue: reservations.reduce((s, r) => s + r.dailyRate * r.totalDays, 0),
totalDiscounts: reservations.reduce((s, r) => s + r.discountAmount, 0),
totalInsurance: reservations.reduce((s, r) => s + r.insuranceTotal, 0),
totalAdditionalDrivers: reservations.reduce((s, r) => s + r.additionalDriverTotal, 0),
totalPricingRulesAdj: reservations.reduce((s, r) => s + r.pricingRulesTotal, 0),
totalDeposits: reservations.reduce((s, r) => s + r.depositAmount, 0),
totalCollected: reservations.reduce((s, r) => s + r.totalAmount, 0),
averageRentalDays: reservations.length > 0 ? reservations.reduce((s, r) => s + r.totalDays, 0) / reservations.length : 0,
totalRentalRevenue: reservations.reduce((s: number, r: (typeof reservations)[number]) => s + r.dailyRate * r.totalDays, 0),
totalDiscounts: reservations.reduce((s: number, r: (typeof reservations)[number]) => s + r.discountAmount, 0),
totalInsurance: reservations.reduce((s: number, r: (typeof reservations)[number]) => s + r.insuranceTotal, 0),
totalAdditionalDrivers: reservations.reduce((s: number, r: (typeof reservations)[number]) => s + r.additionalDriverTotal, 0),
totalPricingRulesAdj: reservations.reduce((s: number, r: (typeof reservations)[number]) => s + r.pricingRulesTotal, 0),
totalDeposits: reservations.reduce((s: number, r: (typeof reservations)[number]) => s + r.depositAmount, 0),
totalCollected: reservations.reduce((s: number, r: (typeof reservations)[number]) => s + r.totalAmount, 0),
averageRentalDays: reservations.length > 0 ? reservations.reduce((s: number, r: (typeof reservations)[number]) => s + r.totalDays, 0) / reservations.length : 0,
}
const rows = reservations.map((r) => ({
const rows = reservations.map((r: (typeof reservations)[number]) => ({
reservationId: r.id,
contractNumber: r.contractNumber ?? '—',
invoiceNumber: r.invoiceNumber ?? '—',
+9 -3
View File
@@ -22,8 +22,8 @@ export async function applyInsurancesToReservation(
baseRentalAmount: number
) {
const allPolicies = await prisma.insurancePolicy.findMany({ where: { companyId, isActive: true } })
const required = allPolicies.filter((p) => p.isRequired)
const selected = allPolicies.filter((p) => selectedPolicyIds.includes(p.id) && !p.isRequired)
const required = allPolicies.filter((p: InsurancePolicy) => p.isRequired)
const selected = allPolicies.filter((p: InsurancePolicy) => selectedPolicyIds.includes(p.id) && !p.isRequired)
const toApply = [...required, ...selected]
const records = toApply.map((policy) => ({
@@ -40,7 +40,13 @@ export async function applyInsurancesToReservation(
await prisma.$transaction([
prisma.reservationInsurance.createMany({ data: records }),
prisma.reservation.update({ where: { id: reservationId }, data: { insuranceTotal } }),
prisma.reservation.update({
where: { id: reservationId },
data: {
insuranceTotal,
totalAmount: { increment: insuranceTotal },
},
}),
])
return { records, insuranceTotal }
@@ -43,6 +43,8 @@ export async function validateAndFlagLicense(customerId: string) {
licenseExpired: result.status === 'EXPIRED',
licenseExpiringSoon: result.status === 'EXPIRING',
licenseValidationStatus: status,
flagged: result.requiresApproval ? true : customer.flagged,
flagReason: result.requiresApproval ? result.message : customer.flagReason,
},
})
+139 -20
View File
@@ -5,21 +5,93 @@ import { prisma } from '../lib/prisma'
import { redis } from '../lib/redis'
import { NotificationType, NotificationChannel } from '@rentaldrivego/database'
const resend = new Resend(process.env.RESEND_API_KEY)
const resend =
process.env.RESEND_API_KEY && process.env.RESEND_API_KEY !== 're_...'
? new Resend(process.env.RESEND_API_KEY)
: null
const twilioClient = twilio(
process.env.TWILIO_ACCOUNT_SID,
process.env.TWILIO_AUTH_TOKEN
)
const emailFromAddress =
process.env.EMAIL_FROM && process.env.EMAIL_FROM !== 'noreply@example.com'
? process.env.EMAIL_FROM
: null
if (!admin.apps.length) {
admin.initializeApp({
credential: admin.credential.cert({
projectId: process.env.FIREBASE_PROJECT_ID,
privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
}),
})
const emailFromName =
process.env.EMAIL_FROM_NAME && process.env.EMAIL_FROM_NAME !== 'Example App'
? process.env.EMAIL_FROM_NAME
: null
const smtpHost = process.env.MAIL_HOST
const smtpPort = Number(process.env.MAIL_PORT ?? 0)
const smtpUser = process.env.MAIL_USERNAME
const smtpPass = process.env.MAIL_PASSWORD
const smtpSecure =
process.env.MAIL_SCHEME === 'smtps' ||
process.env.MAIL_ENCRYPTION === 'ssl' ||
smtpPort === 465
const smtpFromAddress =
process.env.MAIL_FROM_ADDRESS && !process.env.MAIL_FROM_ADDRESS.includes('${')
? process.env.MAIL_FROM_ADDRESS
: null
const smtpFromName =
process.env.MAIL_FROM_NAME && !process.env.MAIL_FROM_NAME.includes('${')
? process.env.MAIL_FROM_NAME
: null
type SmtpTransport = {
sendMail(options: Record<string, unknown>): Promise<{ messageId?: string }>
}
let smtpTransport: SmtpTransport | null = null
if (smtpHost && smtpPort && smtpUser && smtpPass) {
try {
const nodemailer = require('nodemailer') as {
createTransport(options: Record<string, unknown>): SmtpTransport
}
smtpTransport = nodemailer.createTransport({
host: smtpHost,
port: smtpPort,
secure: smtpSecure,
auth: {
user: smtpUser,
pass: smtpPass,
},
})
} catch (err: any) {
console.warn('[Notifications] SMTP transport unavailable:', err?.message ?? String(err))
}
}
const twilioClient =
process.env.TWILIO_ACCOUNT_SID &&
process.env.TWILIO_AUTH_TOKEN &&
process.env.TWILIO_ACCOUNT_SID !== 'AC...' &&
process.env.TWILIO_AUTH_TOKEN !== 'your-twilio-auth-token'
? twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN)
: null
const firebasePrivateKey = process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n')
const hasFirebaseConfig =
!!process.env.FIREBASE_PROJECT_ID &&
!!process.env.FIREBASE_CLIENT_EMAIL &&
!!firebasePrivateKey &&
!firebasePrivateKey.includes('BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY')
if (hasFirebaseConfig && !admin.apps.length) {
try {
admin.initializeApp({
credential: admin.credential.cert({
projectId: process.env.FIREBASE_PROJECT_ID,
privateKey: firebasePrivateKey,
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
}),
})
} catch (err: any) {
console.warn('[Notifications] Firebase init skipped:', err.message)
}
}
interface SendNotificationOptions {
@@ -37,6 +109,22 @@ interface SendNotificationOptions {
locale?: string
}
function escapeHtml(value: string) {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
function renderEmailHtml(body: string) {
return body
.split(/\n{2,}/)
.map((paragraph) => `<p>${escapeHtml(paragraph).replace(/\n/g, '<br />')}</p>`)
.join('')
}
export async function sendNotification(opts: SendNotificationOptions) {
const results: Array<{ channel: NotificationChannel; success: boolean; error?: string }> = []
@@ -60,19 +148,48 @@ export async function sendNotification(opts: SendNotificationOptions) {
let success = false
if (channel === 'EMAIL' && opts.email) {
const { data, error } = await resend.emails.send({
from: `${process.env.EMAIL_FROM_NAME} <${process.env.EMAIL_FROM}>`,
to: opts.email,
subject: opts.title,
html: `<p>${opts.body}</p>`,
})
if (!error) {
if (resend) {
if (!emailFromAddress || !emailFromName) {
throw new Error('Email sender identity is not configured')
}
const { data, error } = await resend.emails.send({
from: `${emailFromName} <${emailFromAddress}>`,
to: opts.email,
subject: opts.title,
html: renderEmailHtml(opts.body),
text: opts.body,
})
if (error) {
throw new Error(error.message)
}
providerMessageId = data?.id ?? null
success = true
} else if (smtpTransport) {
if (!smtpFromAddress) {
throw new Error('SMTP sender identity is not configured')
}
const info = await smtpTransport.sendMail({
from: smtpFromName ? `${smtpFromName} <${smtpFromAddress}>` : smtpFromAddress,
to: opts.email,
subject: opts.title,
html: renderEmailHtml(opts.body),
text: opts.body,
replyTo:
process.env.MAIL_REPLY_TO_ADDRESS && !process.env.MAIL_REPLY_TO_ADDRESS.includes('${')
? process.env.MAIL_REPLY_TO_NAME && !process.env.MAIL_REPLY_TO_NAME.includes('${')
? `${process.env.MAIL_REPLY_TO_NAME} <${process.env.MAIL_REPLY_TO_ADDRESS}>`
: process.env.MAIL_REPLY_TO_ADDRESS
: undefined,
})
providerMessageId = info.messageId
success = true
} else {
throw new Error('No email provider is configured')
}
}
if (channel === 'SMS' && opts.phone) {
if (!twilioClient) throw new Error('Twilio is not configured')
const msg = await twilioClient.messages.create({
body: opts.body,
from: process.env.TWILIO_PHONE_NUMBER!,
@@ -83,6 +200,7 @@ export async function sendNotification(opts: SendNotificationOptions) {
}
if (channel === 'WHATSAPP' && opts.phone) {
if (!twilioClient) throw new Error('Twilio is not configured')
const msg = await twilioClient.messages.create({
body: opts.body,
from: `whatsapp:${process.env.TWILIO_WHATSAPP_NUMBER}`,
@@ -93,6 +211,7 @@ export async function sendNotification(opts: SendNotificationOptions) {
}
if (channel === 'PUSH' && opts.fcmToken) {
if (!admin.apps.length) throw new Error('Firebase is not configured')
const response = await admin.messaging().send({
token: opts.fcmToken,
notification: { title: opts.title, body: opts.body },
+127
View File
@@ -0,0 +1,127 @@
const BASE_URL = process.env.PAYPAL_BASE_URL ?? 'https://api-m.paypal.com'
const CLIENT_ID = process.env.PAYPAL_CLIENT_ID ?? ''
const CLIENT_SECRET = process.env.PAYPAL_CLIENT_SECRET ?? ''
let cachedToken: { token: string; expiresAt: number } | null = null
async function getAccessToken(): Promise<string> {
if (cachedToken && Date.now() < cachedToken.expiresAt - 30_000) {
return cachedToken.token
}
const res = await fetch(`${BASE_URL}/v1/oauth2/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64')}`,
},
body: 'grant_type=client_credentials',
})
if (!res.ok) throw new Error('PayPal: failed to obtain access token')
const json = await res.json()
cachedToken = { token: json.access_token, expiresAt: Date.now() + json.expires_in * 1000 }
return cachedToken.token
}
async function ppFetch(path: string, init?: RequestInit) {
const token = await getAccessToken()
const res = await fetch(`${BASE_URL}${path}`, {
...init,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
...(init?.headers as Record<string, string> ?? {}),
},
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(`PayPal ${path} failed: ${err?.message ?? res.statusText}`)
}
return res.json()
}
export interface PayPalCreateOrderParams {
amount: number
currency: string
orderId: string
description: string
returnUrl: string
cancelUrl: string
}
export interface PayPalOrderResult {
orderId: string
approveUrl: string
status: string
}
export async function createOrder(params: PayPalCreateOrderParams): Promise<PayPalOrderResult> {
const json = await ppFetch('/v2/checkout/orders', {
method: 'POST',
body: JSON.stringify({
intent: 'CAPTURE',
purchase_units: [
{
reference_id: params.orderId,
description: params.description,
amount: {
currency_code: params.currency,
value: (params.amount / 100).toFixed(2),
},
},
],
application_context: {
return_url: params.returnUrl,
cancel_url: params.cancelUrl,
brand_name: 'RentalDriveGo',
user_action: 'PAY_NOW',
},
}),
})
const approveLink = json.links?.find((l: { rel: string; href: string }) => l.rel === 'approve')
return {
orderId: json.id,
approveUrl: approveLink?.href ?? '',
status: json.status,
}
}
export async function captureOrder(paypalOrderId: string) {
return ppFetch(`/v2/checkout/orders/${paypalOrderId}/capture`, { method: 'POST', body: '{}' })
}
export async function refundCapture(captureId: string, amount: number, currency: string, note?: string) {
return ppFetch(`/v2/payments/captures/${captureId}/refund`, {
method: 'POST',
body: JSON.stringify({
amount: { currency_code: currency, value: (amount / 100).toFixed(2) },
note_to_payer: note,
}),
})
}
export async function verifyWebhookEvent(headers: Record<string, string | undefined>, rawBody: string) {
try {
const json = await ppFetch('/v1/notifications/verify-webhook-signature', {
method: 'POST',
body: JSON.stringify({
auth_algo: headers['paypal-auth-algo'],
cert_url: headers['paypal-cert-url'],
transmission_id: headers['paypal-transmission-id'],
transmission_sig: headers['paypal-transmission-sig'],
transmission_time: headers['paypal-transmission-time'],
webhook_id: process.env.PAYPAL_WEBHOOK_ID ?? '',
webhook_event: JSON.parse(rawBody),
}),
})
return json.verification_status === 'SUCCESS'
} catch {
return false
}
}
export function isConfigured(): boolean {
return !!(CLIENT_ID && CLIENT_SECRET && CLIENT_ID !== 'your-paypal-client-id')
}
+2 -2
View File
@@ -31,7 +31,7 @@ export async function listEmployees(companyId: string): Promise<TeamMember[]> {
})
const hydrated = await Promise.allSettled(
employees.map(async (emp) => {
employees.map(async (emp: (typeof employees)[number]) => {
try {
const clerkUser = await clerkClient.users.getUser(emp.clerkUserId)
return { ...emp, lastActiveAt: clerkUser.lastActiveAt ? new Date(clerkUser.lastActiveAt) : null, invitationStatus: 'accepted' as const }
@@ -41,7 +41,7 @@ export async function listEmployees(companyId: string): Promise<TeamMember[]> {
})
)
return hydrated.map((r) => (r.status === 'fulfilled' ? r.value : (r as any).value))
return hydrated.flatMap((r): TeamMember[] => (r.status === 'fulfilled' ? [r.value] : []))
}
export async function inviteEmployee(companyId: string, inviterId: string, payload: InvitePayload) {