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