refractor code,

This commit is contained in:
root
2026-05-21 12:35:49 -04:00
parent e74681e810
commit f009ca10c6
158 changed files with 215801 additions and 5884 deletions
+140
View File
@@ -0,0 +1,140 @@
import express from 'express'
import cors from 'cors'
import helmet from 'helmet'
import morgan from 'morgan'
import { getLegacyStorageRoots, getStorageRoot } from './lib/storage'
import { authLimiter, apiLimiter, publicLimiter, adminLimiter } from './middleware/rateLimiter'
// ─── Module routes ────────────────────────────────────────────
import webhookRouter from './modules/webhooks/webhook.routes'
import companyAuthRouter from './modules/auth/auth.company.routes'
import employeeAuthRouter from './modules/auth/auth.employee.routes'
import renterAuthRouter from './modules/auth/auth.renter.routes'
import teamRouter from './modules/team/team.routes'
import offersRouter from './modules/offers/offer.routes'
import analyticsRouter from './modules/analytics/analytics.routes'
import notificationsRouter from './modules/notifications/notification.routes'
import adminRouter from './modules/admin/admin.routes'
import subscriptionsRouter from './modules/subscriptions/subscription.routes'
import paymentsRouter from './modules/payments/payment.routes'
import customersRouter from './modules/customers/customer.routes'
import vehiclesRouter from './modules/vehicles/vehicle.routes'
import companiesRouter from './modules/companies/company.routes'
import reservationsRouter from './modules/reservations/reservation.routes'
import marketplaceRouter from './modules/marketplace/marketplace.routes'
import siteRouter from './modules/site/site.routes'
// ─── Centralized error handling ───────────────────────────────
import { errorMiddleware } from './http/errors/errorMiddleware'
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',
]
export const corsOrigins = process.env.CORS_ORIGINS
? process.env.CORS_ORIGINS.split(',').map((o) => o.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/cities`, description: 'Marketplace cities' },
{ 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' },
]
export function createApp() {
const app = express()
const corsMiddleware = cors({ origin: corsOrigins, credentials: true })
if (process.env.NODE_ENV === 'production') {
app.set('trust proxy', 1)
}
app.use(corsMiddleware)
// Customer identity documents must never be anonymously retrievable from the
// public storage mount, even if an older raw storage URL leaks.
app.use('/storage/companies/:companyId/customers/:customerId', (_req, res) => {
res.status(404).end()
})
// Serve uploaded assets from the configured storage root, with a legacy fallback
// for older files that were written under the previous API-local path.
app.use('/storage', express.static(getStorageRoot()))
for (const legacyRoot of getLegacyStorageRoots()) {
app.use('/storage', express.static(legacyRoot))
}
// Webhook must use raw body BEFORE express.json()
app.use('/api/v1/webhooks', express.raw({ type: 'application/json' }), webhookRouter)
app.use(helmet())
if (process.env.NODE_ENV !== 'test') app.use(morgan('combined'))
app.use(express.json({ limit: '10mb' }))
// ─── API Routes ─────────────────────────────────────────────
app.use(`${v1}/auth/renter`, authLimiter, renterAuthRouter)
app.use(`${v1}/auth/company`, authLimiter, companyAuthRouter)
app.use(`${v1}/auth/employee`, authLimiter, employeeAuthRouter)
app.use(`${v1}/admin`, adminLimiter, adminRouter)
app.use(`${v1}/marketplace`, publicLimiter, marketplaceRouter)
app.use(`${v1}/site`, publicLimiter, siteRouter)
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 / Docs ──────────────────────────────────────────
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((r) => `<tr><td>${r.method}</td><td><code>${r.path}</code></td><td>${r.description}</td></tr>`)
.join('')
res.type('html').send(`<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>RentalDriveGo API Docs</title></head><body><h1>RentalDriveGo API</h1><table><thead><tr><th>Method</th><th>Path</th><th>Description</th></tr></thead><tbody>${rows}</tbody></table></body></html>`)
})
// ─── Error handler ──────────────────────────────────────────
app.use(errorMiddleware)
return app
}
@@ -0,0 +1,29 @@
import { Request, Response, NextFunction } from 'express'
import { AppError } from './index'
export function errorMiddleware(err: any, _req: Request, res: Response, _next: NextFunction) {
if (err.name === 'ZodError') {
return res.status(400).json({ error: 'validation_error', message: 'Invalid request body', issues: err.issues, statusCode: 400 })
}
if (err.code === 'P2025') {
return res.status(404).json({ error: 'not_found', message: 'Resource not found', statusCode: 404 })
}
if (err.code === 'P2002') {
return res.status(409).json({ error: 'conflict', message: 'A resource with this value already exists', statusCode: 409 })
}
if (err instanceof AppError) {
if (err.statusCode >= 500) console.error('[API Error]', err)
return res.status(err.statusCode).json({ error: err.error, message: err.message, statusCode: err.statusCode, ...err.data })
}
const statusCode = err.statusCode ?? 500
const message = err.message ?? 'Internal server error'
const code = err.code ?? 'internal_error'
if (statusCode >= 500) console.error('[API Error]', err)
res.status(statusCode).json({ error: code, message, statusCode })
}
+43
View File
@@ -0,0 +1,43 @@
export class AppError extends Error {
readonly statusCode: number
readonly error: string
readonly data?: Record<string, unknown>
constructor(message: string, statusCode: number, error: string, data?: Record<string, unknown>) {
super(message)
this.statusCode = statusCode
this.error = error
this.data = data
this.name = this.constructor.name
}
}
export class ValidationError extends AppError {
constructor(message = 'Validation error') {
super(message, 400, 'validation_error')
}
}
export class NotFoundError extends AppError {
constructor(message = 'Resource not found') {
super(message, 404, 'not_found')
}
}
export class ConflictError extends AppError {
constructor(message = 'A resource with this value already exists') {
super(message, 409, 'conflict')
}
}
export class ForbiddenError extends AppError {
constructor(message = 'Forbidden') {
super(message, 403, 'forbidden')
}
}
export class UnauthorizedError extends AppError {
constructor(message = 'Unauthorized') {
super(message, 401, 'unauthorized')
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Response } from 'express'
export function ok<T>(res: Response, data: T): void {
res.json({ data })
}
export function created<T>(res: Response, data: T): void {
res.status(201).json({ data })
}
export function noContent(res: Response): void {
res.status(204).end()
}
+34
View File
@@ -0,0 +1,34 @@
import multer from 'multer'
import { ValidationError } from '../errors'
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10 MB
/**
* Shared multer instance used by all upload endpoints.
* Files are kept in memory so services receive a Buffer — no temp-file cleanup needed.
*/
export const imageUpload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: MAX_FILE_SIZE },
})
/**
* Asserts that a file was provided and is an image MIME type.
* Call inside the route handler after the multer middleware runs.
*/
export function assertImageFile(
file: Express.Multer.File | undefined,
fieldLabel = 'file',
): asserts file is Express.Multer.File {
if (!file) throw new ValidationError(`A ${fieldLabel} is required`)
if (!file.mimetype.startsWith('image/')) throw new ValidationError('Only image uploads are supported')
}
/**
* Asserts that at least one file was provided and all files are images.
*/
export function assertImageFiles(files: Express.Multer.File[], fieldLabel = 'photos'): void {
if (!files || files.length === 0) throw new ValidationError(`At least one ${fieldLabel} file is required`)
const nonImage = files.find((f) => !f.mimetype.startsWith('image/'))
if (nonImage) throw new ValidationError(`All uploaded files must be images — "${nonImage.originalname}" is not`)
}
+14
View File
@@ -0,0 +1,14 @@
import type { Request } from 'express'
import type { ZodTypeAny, output } from 'zod'
export function parseBody<TSchema extends ZodTypeAny>(schema: TSchema, req: Request): output<TSchema> {
return schema.parse(req.body)
}
export function parseQuery<TSchema extends ZodTypeAny>(schema: TSchema, req: Request): output<TSchema> {
return schema.parse(req.query)
}
export function parseParams<TSchema extends ZodTypeAny>(schema: TSchema, req: Request): output<TSchema> {
return schema.parse(req.params)
}
+4 -194
View File
@@ -1,7 +1,3 @@
import express from 'express'
import cors from 'cors'
import helmet from 'helmet'
import morgan from 'morgan'
import http from 'http'
import { Server as SocketIOServer } from 'socket.io'
import cron from 'node-cron'
@@ -9,72 +5,12 @@ import jwt from 'jsonwebtoken'
import { z } from 'zod'
import { redis } from './lib/redis'
import { prisma } from './lib/prisma'
import { getStorageRoot } from './lib/storage'
import { authLimiter, apiLimiter, publicLimiter, adminLimiter } from './middleware/rateLimiter'
import { assertStorageConfiguration } from './lib/storage'
import { createApp, corsOrigins } from './app'
// ─── Routes ───────────────────────────────────────────────────
import webhookRouter from './routes/webhooks'
import companyAuthRouter from './routes/auth.company'
import employeeAuthRouter from './routes/auth.employee'
import renterAuthRouter from './routes/auth.renter'
import vehiclesRouter from './routes/vehicles'
import reservationsRouter from './routes/reservations'
import teamRouter from './routes/team'
import customersRouter from './routes/customers'
import offersRouter from './routes/offers'
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 app = createApp()
const server = http.createServer(app)
// Trust the first hop from a reverse proxy so req.ip and rate-limiting
// use the real client IP (from X-Forwarded-For) rather than the proxy's IP.
if (process.env.NODE_ENV === 'production') {
app.set('trust proxy', 1)
}
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/cities`, description: 'Marketplace cities' },
{ 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' },
]
assertStorageConfiguration()
// ─── Socket.io ────────────────────────────────────────────────
const io = new SocketIOServer(server, {
@@ -122,132 +58,6 @@ subscriber.on('pmessage', (_pattern, channel, message) => {
}
})
// ─── Middleware ────────────────────────────────────────────────
// Serve uploaded assets from the configured storage root.
app.use('/storage', express.static(getStorageRoot()))
// Webhook must use raw body BEFORE express.json()
app.use('/api/v1/webhooks', express.raw({ type: 'application/json' }), webhookRouter)
app.use(helmet())
app.use(cors({ origin: corsOrigins, credentials: true }))
app.use(morgan('combined'))
app.use(express.json({ limit: '10mb' }))
// ─── API Routes ───────────────────────────────────────────────
// Auth routes: strict brute-force protection
app.use(`${v1}/auth/renter`, authLimiter, renterAuthRouter)
app.use(`${v1}/auth/company`, authLimiter, companyAuthRouter)
app.use(`${v1}/auth/employee`, authLimiter, employeeAuthRouter)
// 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
const message = err.message ?? 'Internal server error'
const code = err.code ?? 'internal_error'
if (statusCode >= 500) {
console.error('[API Error]', err)
}
// Zod validation error
if (err.name === 'ZodError') {
return res.status(400).json({ error: 'validation_error', message: 'Invalid request body', issues: err.issues, statusCode: 400 })
}
// Prisma not found
if (err.code === 'P2025') {
return res.status(404).json({ error: 'not_found', message: 'Resource not found', statusCode: 404 })
}
// Prisma unique constraint
if (err.code === 'P2002') {
return res.status(409).json({ error: 'conflict', message: 'A resource with this value already exists', statusCode: 409 })
}
res.status(statusCode).json({ error: code, message, statusCode })
})
// ─── Scheduled jobs ───────────────────────────────────────────
// Daily: flag expiring/expired licenses
@@ -0,0 +1,5 @@
export 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
}
+76 -10
View File
@@ -2,7 +2,13 @@ import fs from 'fs'
import path from 'path'
import crypto from 'crypto'
const DEFAULT_STORAGE_ROOT = path.resolve(__dirname, '..', '..', 'storage')
const APP_PACKAGE_ROOT = path.resolve(__dirname, '..', '..', '..')
const APP_SOURCE_ROOT = path.join(APP_PACKAGE_ROOT, 'src')
const APP_DIST_ROOT = path.join(APP_PACKAGE_ROOT, 'dist')
const DEFAULT_STORAGE_ROOT = path.join(APP_PACKAGE_ROOT, 'storage')
const LEGACY_STORAGE_ROOTS = [
path.join(APP_SOURCE_ROOT, 'lib', 'storage'),
]
export function getStorageRoot(): string {
return process.env.FILE_STORAGE_ROOT
@@ -10,8 +16,37 @@ export function getStorageRoot(): string {
: DEFAULT_STORAGE_ROOT
}
function ensureStorageRoot(): string {
export function getLegacyStorageRoots(): string[] {
return LEGACY_STORAGE_ROOTS
}
function isWithinPath(targetPath: string, parentPath: string): boolean {
const relative = path.relative(parentPath, targetPath)
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
}
export function assertStorageConfiguration(): string {
const storageRoot = getStorageRoot()
if (process.env.NODE_ENV === 'production' && !process.env.FILE_STORAGE_ROOT) {
throw new Error('FILE_STORAGE_ROOT must be set in production so uploads are stored on the mounted volume.')
}
if (process.env.NODE_ENV === 'production') {
const forbiddenRoots = [APP_PACKAGE_ROOT, APP_SOURCE_ROOT, APP_DIST_ROOT]
const invalidRoot = forbiddenRoots.find((root) => isWithinPath(storageRoot, root))
if (invalidRoot) {
throw new Error(
`FILE_STORAGE_ROOT must point outside the API app tree in production. Received ${storageRoot}, which is inside ${invalidRoot}.`
)
}
}
return storageRoot
}
function ensureStorageRoot(): string {
const storageRoot = assertStorageConfiguration()
fs.mkdirSync(storageRoot, { recursive: true })
return storageRoot
}
@@ -20,6 +55,25 @@ function getApiBase(): string {
return (process.env.API_URL ?? 'http://localhost:4000').replace(/\/$/, '')
}
function getDashboardBase(): string {
return (
process.env.DASHBOARD_URL ??
process.env.NEXT_PUBLIC_DASHBOARD_URL ??
'http://localhost:3001/dashboard'
).replace(/\/$/, '')
}
function getStorageRelativePath(imageUrl: string): string | null {
const marker = '/storage/'
const idx = imageUrl.indexOf(marker)
if (idx === -1) return null
return imageUrl.slice(idx + marker.length)
}
export function getProtectedCustomerLicenseImageUrl(customerId: string): string {
return `${getDashboardBase()}/api/v1/customers/${customerId}/license-image`
}
export async function uploadImage(
buffer: Buffer,
folder: string,
@@ -38,12 +92,24 @@ export async function uploadImage(
return `${getApiBase()}/storage/${folder}/${filename}`
}
export async function deleteImage(imageUrl: string): Promise<void> {
const storageRoot = getStorageRoot()
const marker = '/storage/'
const idx = imageUrl.indexOf(marker)
if (idx === -1) return
const relative = imageUrl.slice(idx + marker.length)
const filePath = path.join(storageRoot, relative)
if (fs.existsSync(filePath)) fs.unlinkSync(filePath)
export function resolveStoredFilePath(imageUrl: string): string | null {
const relative = getStorageRelativePath(imageUrl)
if (!relative) return null
const roots = [assertStorageConfiguration(), ...getLegacyStorageRoots()]
for (const root of roots) {
const filePath = path.join(root, relative)
if (fs.existsSync(filePath)) {
return filePath
}
}
return null
}
export async function deleteImage(imageUrl: string): Promise<void> {
const filePath = resolveStoredFilePath(imageUrl)
if (filePath && fs.existsSync(filePath)) {
fs.unlinkSync(filePath)
}
}
+32
View File
@@ -0,0 +1,32 @@
import { Request, Response } from 'express'
/**
* Sends a uniform auth-error JSON response.
* All auth middleware must go through this function so the shape is identical
* to what the errorMiddleware produces for AppError instances.
*/
export function sendUnauthorized(res: Response, error: string, message: string) {
return res.status(401).json({ error, message, statusCode: 401 })
}
export function getCookie(req: Request, name: string): string | null {
const cookieHeader = req.headers.cookie
if (!cookieHeader) return null
for (const chunk of cookieHeader.split(';')) {
const [rawName, ...rawValue] = chunk.trim().split('=')
if (rawName === name) {
return decodeURIComponent(rawValue.join('='))
}
}
return null
}
export function sendForbidden(res: Response, error: string, message: string, extra?: Record<string, unknown>) {
return res.status(403).json({ error, message, statusCode: 403, ...extra })
}
export function sendPaymentRequired(res: Response, error: string, message: string, extra?: Record<string, unknown>) {
return res.status(402).json({ error, message, statusCode: 402, ...extra })
}
+31 -26
View File
@@ -2,6 +2,7 @@ import { Request, Response, NextFunction } from 'express'
import jwt from 'jsonwebtoken'
import { prisma } from '../lib/prisma'
import { AdminRole } from '@rentaldrivego/database'
import { sendUnauthorized, sendForbidden } from './authHelpers'
const ROLE_RANK: Record<AdminRole, number> = {
SUPER_ADMIN: 5,
@@ -11,48 +12,52 @@ const ROLE_RANK: Record<AdminRole, number> = {
VIEWER: 1,
}
/**
* Requires a valid admin Bearer token.
*
* Guarantees on success:
* req.admin — the full AdminUser record
*/
export async function requireAdminAuth(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
if (!token) {
return res.status(401).json({ error: 'unauthenticated', message: 'Admin authentication required', statusCode: 401 })
}
if (!token) return sendUnauthorized(res, 'unauthenticated', 'Admin authentication required')
let payload: { sub: string; type: string }
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
if (payload.type !== 'admin') {
return res.status(401).json({ error: 'invalid_token', message: 'Invalid token type', statusCode: 401 })
}
const admin = await prisma.adminUser.findUnique({ where: { id: payload.sub } })
if (!admin || !admin.isActive) {
return res.status(401).json({ error: 'unauthenticated', message: 'Admin account not found or deactivated', statusCode: 401 })
}
req.admin = admin
next()
payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
} catch {
return res.status(401).json({ error: 'invalid_token', message: 'Invalid or expired admin token', statusCode: 401 })
return sendUnauthorized(res, 'invalid_token', 'Invalid or expired admin token')
}
if (payload.type !== 'admin') {
return sendUnauthorized(res, 'invalid_token', 'Invalid token type')
}
const admin = await prisma.adminUser.findUnique({ where: { id: payload.sub } })
if (!admin || !admin.isActive) {
return sendUnauthorized(res, 'unauthenticated', 'Admin account not found or deactivated')
}
req.admin = admin
next()
}
/**
* Requires the authenticated admin to have at least `minimumRole`.
* Must be applied after `requireAdminAuth`.
*/
export function requireAdminRole(minimumRole: AdminRole) {
return (req: Request, res: Response, next: NextFunction) => {
const admin = req.admin
if (!admin) {
return res.status(401).json({ error: 'unauthenticated', message: 'Admin authentication required', statusCode: 401 })
}
if (!admin) return sendUnauthorized(res, 'unauthenticated', 'Admin authentication required')
const rank = ROLE_RANK[admin.role] ?? 0
const required = ROLE_RANK[minimumRole] ?? 99
const rank = ROLE_RANK[admin.role] ?? 0
const required = ROLE_RANK[minimumRole] ?? 99
if (rank < required) {
return res.status(403).json({
error: 'forbidden',
message: `This action requires the ${minimumRole} role or higher`,
statusCode: 403,
})
return sendForbidden(res, 'forbidden', `This action requires the ${minimumRole} role or higher`)
}
next()
+48 -39
View File
@@ -1,45 +1,54 @@
import { Request, Response, NextFunction } from 'express'
import jwt from 'jsonwebtoken'
import { prisma } from '../lib/prisma'
import { getCookie, sendUnauthorized } from './authHelpers'
/**
* Validates a company-scoped Bearer token and loads the employee + company onto `req`.
*
* Guarantees on success:
* req.employee — full employee record (with company relation)
* req.company — the employee's company
* req.companyId — string shorthand for req.company.id
*/
async function authenticateCompanyRequest(req: Request, res: Response, next: NextFunction, allowCookie = false) {
const authHeader = req.headers.authorization
const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
const cookieToken = allowCookie ? getCookie(req, 'employee_token') : null
const token = bearerToken ?? cookieToken
if (!token) return sendUnauthorized(res, 'unauthenticated', 'Authentication required')
let payload: { sub: string; type: string }
try {
payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
} catch {
return sendUnauthorized(res, 'invalid_token', 'Invalid or expired session token')
}
if (payload.type !== 'employee') {
return sendUnauthorized(res, 'invalid_token', 'Invalid token type for this endpoint')
}
const employee = await prisma.employee.findUnique({
where: { id: payload.sub },
include: { company: true },
})
if (!employee || !employee.isActive) {
return sendUnauthorized(res, 'unauthenticated', 'Employee account not found or inactive')
}
req.employee = employee
req.company = employee.company
req.companyId = employee.companyId
next()
}
export async function requireCompanyAuth(req: Request, res: Response, next: NextFunction) {
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,
})
}
try {
const payload = jwt.verify(sessionToken, process.env.JWT_SECRET!) as { sub: string; type: string }
if (payload.type === 'employee') {
const employee = await prisma.employee.findUnique({
where: { id: payload.sub },
include: { company: true },
})
if (!employee || !employee.isActive) {
return res.status(401).json({
error: 'unauthenticated',
message: 'Employee account not found or inactive',
statusCode: 401,
})
}
req.employee = employee
req.company = employee.company
req.companyId = employee.companyId
return next()
}
} catch {
return res.status(401).json({
error: 'invalid_token',
message: 'Invalid or expired session token',
statusCode: 401,
})
}
return authenticateCompanyRequest(req, res, next)
}
export async function requireCompanyDocumentAuth(req: Request, res: Response, next: NextFunction) {
return authenticateCompanyRequest(req, res, next, true)
}
+33 -22
View File
@@ -1,46 +1,57 @@
import { Request, Response, NextFunction } from 'express'
import jwt from 'jsonwebtoken'
import { prisma } from '../lib/prisma'
import { sendUnauthorized } from './authHelpers'
/**
* Requires a valid renter Bearer token.
*
* Guarantees on success:
* req.renterId — the authenticated renter's id
*/
export async function requireRenterAuth(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
if (!token) {
return res.status(401).json({ error: 'unauthenticated', message: 'Renter authentication required', statusCode: 401 })
}
if (!token) return sendUnauthorized(res, 'unauthenticated', 'Renter authentication required')
let payload: { sub: string; type: string }
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
if (payload.type !== 'renter') {
return res.status(401).json({ error: 'invalid_token', message: 'Invalid token type', statusCode: 401 })
}
const renter = await prisma.renter.findUnique({ where: { id: payload.sub } })
if (!renter || !renter.isActive) {
return res.status(401).json({ error: 'unauthenticated', message: 'Renter account not found or inactive', statusCode: 401 })
}
req.renterId = renter.id
next()
payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
} catch {
return res.status(401).json({ error: 'invalid_token', message: 'Invalid or expired token', statusCode: 401 })
return sendUnauthorized(res, 'invalid_token', 'Invalid or expired token')
}
if (payload.type !== 'renter') {
return sendUnauthorized(res, 'invalid_token', 'Invalid token type')
}
const renter = await prisma.renter.findUnique({ where: { id: payload.sub } })
if (!renter || !renter.isActive) {
return sendUnauthorized(res, 'unauthenticated', 'Renter account not found or inactive')
}
req.renterId = renter.id
next()
}
export async function optionalRenterAuth(req: Request, res: Response, next: NextFunction) {
/**
* Optionally extracts renter identity from a Bearer token.
* Never blocks the request — invalid or absent tokens are silently ignored.
*
* Sets on success:
* req.renterId — the authenticated renter's id (if token is valid)
*/
export async function optionalRenterAuth(req: Request, _res: Response, next: NextFunction) {
const authHeader = req.headers.authorization
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
if (!token) return next()
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
if (payload.type === 'renter') {
req.renterId = payload.sub
}
if (payload.type === 'renter') req.renterId = payload.sub
} catch {
// Optional — ignore invalid tokens
// Optional — silently ignore invalid tokens
}
next()
+8 -8
View File
@@ -1,5 +1,6 @@
import { Request, Response, NextFunction } from 'express'
import { EmployeeRole } from '@rentaldrivego/database'
import { sendUnauthorized, sendForbidden } from './authHelpers'
const ROLE_RANK: Record<EmployeeRole, number> = {
OWNER: 3,
@@ -7,21 +8,20 @@ const ROLE_RANK: Record<EmployeeRole, number> = {
AGENT: 1,
}
/**
* Requires the authenticated employee to have at least `minimumRole`.
* Must be applied after `requireCompanyAuth`.
*/
export function requireRole(minimumRole: EmployeeRole) {
return (req: Request, res: Response, next: NextFunction) => {
const employee = req.employee
if (!employee) {
return res.status(401).json({ error: 'unauthenticated', message: 'Authentication required', statusCode: 401 })
}
if (!employee) return sendUnauthorized(res, 'unauthenticated', 'Authentication required')
const employeeRank = ROLE_RANK[employee.role] ?? 0
const requiredRank = ROLE_RANK[minimumRole] ?? 99
const requiredRank = ROLE_RANK[minimumRole] ?? 99
if (employeeRank < requiredRank) {
return res.status(403).json({
error: 'forbidden',
message: `This action requires the ${minimumRole} role or higher`,
statusCode: 403,
return sendForbidden(res, 'forbidden', `This action requires the ${minimumRole} role or higher`, {
requiredRole: minimumRole,
yourRole: employee.role,
})
+17 -12
View File
@@ -1,23 +1,28 @@
import { Request, Response, NextFunction } from 'express'
import { sendUnauthorized, sendPaymentRequired } from './authHelpers'
const BLOCKED_STATUSES = ['SUSPENDED', 'PENDING']
/**
* Blocks requests for companies with lapsed or unactivated subscriptions.
* Must be applied after `requireTenant`.
*
* Guarantees on success:
* req.company.status is not SUSPENDED or PENDING
*/
export function requireSubscription(req: Request, res: Response, next: NextFunction) {
const company = req.company
if (!company) {
return res.status(401).json({ error: 'unauthenticated', message: 'No company context', statusCode: 401 })
}
if (!company) return sendUnauthorized(res, 'unauthenticated', 'No company context')
if (BLOCKED_STATUSES.includes(company.status)) {
return res.status(402).json({
error: 'subscription_' + company.status.toLowerCase(),
message:
company.status === 'SUSPENDED'
? 'Your account has been suspended. Please contact support or renew your subscription.'
: 'Your account is pending activation. Please complete your subscription setup.',
statusCode: 402,
billingUrl: `${process.env.NEXT_PUBLIC_DASHBOARD_URL}/billing`,
})
return sendPaymentRequired(
res,
`subscription_${company.status.toLowerCase()}`,
company.status === 'SUSPENDED'
? 'Your account has been suspended. Please contact support or renew your subscription.'
: 'Your account is pending activation. Please complete your subscription setup.',
{ billingUrl: `${process.env.NEXT_PUBLIC_DASHBOARD_URL}/billing` },
)
}
next()
+11 -10
View File
@@ -1,22 +1,23 @@
import { Request, Response, NextFunction } from 'express'
import { prisma } from '../lib/prisma'
import { sendUnauthorized } from './authHelpers'
/**
* Loads the company record and validates it exists.
* Must be applied after `requireCompanyAuth`.
*
* Guarantees on success:
* req.company — full Company record
* req.companyId — string id (already set by requireCompanyAuth)
*/
export async function requireTenant(req: Request, res: Response, next: NextFunction) {
if (!req.companyId) {
return res.status(401).json({
error: 'unauthenticated',
message: 'Tenant context missing — requireCompanyAuth must run first',
statusCode: 401,
})
return sendUnauthorized(res, 'unauthenticated', 'Tenant context missing — requireCompanyAuth must run first')
}
const company = await prisma.company.findUnique({ where: { id: req.companyId } })
if (!company) {
return res.status(401).json({
error: 'company_not_found',
message: 'Company not found',
statusCode: 401,
})
return sendUnauthorized(res, 'company_not_found', 'Company not found')
}
req.company = company
@@ -0,0 +1,28 @@
export function presentAdminUser<T extends Record<string, any>>(admin: T) {
const { passwordHash, totpSecret, ...safe } = admin
return safe
}
export function presentAdminSession<T extends Record<string, any>>(admin: T, token: string) {
return {
token,
admin: presentAdminUser(admin),
}
}
export function presentPaginated<T>(
data: T[],
total: number,
page: number,
pageSize: number,
extra: Record<string, unknown> = {},
) {
return {
data,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
...extra,
}
}
+400
View File
@@ -0,0 +1,400 @@
import { prisma } from '../../lib/prisma'
const companyListInclude = {
brand: { select: { displayName: true, logoUrl: true, subdomain: true } },
subscription: { select: { plan: true, status: true } },
_count: { select: { employees: true, vehicles: true } },
} as const
const companyDetailInclude = {
brand: true,
contractSettings: true,
accountingSettings: true,
subscription: { include: { invoices: { orderBy: { createdAt: 'desc' }, take: 10 } } },
employees: true,
_count: { select: { employees: true, vehicles: true, customers: true, reservations: true } },
} as const
const billingInclude = {
company: { select: { id: true, name: true, email: true, slug: true, status: true } },
invoices: {
select: { id: true, amount: true, currency: true, status: true, paidAt: true, createdAt: true },
orderBy: { createdAt: 'desc' },
take: 5,
},
_count: { select: { invoices: true } },
} as const
function parseOptionalDate(value?: string | null) {
if (!value) return null
return new Date(value)
}
export function findAdminByEmail(email: string) {
return prisma.adminUser.findUnique({ where: { email } })
}
export function findAdminByIdOrThrow(id: string) {
return prisma.adminUser.findUniqueOrThrow({ where: { id } })
}
export function updateAdminLastLogin(id: string) {
return prisma.adminUser.update({ where: { id }, data: { lastLoginAt: new Date() } })
}
export function updateAdminTotpSecret(id: string, secret: string) {
return prisma.adminUser.update({ where: { id }, data: { totpSecret: secret } })
}
export function enableAdminTotp(id: string) {
return prisma.adminUser.update({ where: { id }, data: { totpEnabled: true } })
}
export function setAdminPasswordReset(id: string, token: string, expiresAt: Date) {
return prisma.adminUser.update({
where: { id },
data: { passwordResetToken: token, passwordResetExpiresAt: expiresAt },
})
}
export function findAdminByResetToken(token: string) {
return prisma.adminUser.findFirst({
where: {
passwordResetToken: token,
passwordResetExpiresAt: { gt: new Date() },
},
})
}
export function updateAdminPassword(id: string, passwordHash: string) {
return prisma.adminUser.update({
where: { id },
data: {
passwordHash,
passwordResetToken: null,
passwordResetExpiresAt: null,
},
})
}
export function createAuditLog(data: Record<string, unknown>) {
return prisma.auditLog.create({ data: data as any })
}
export async function listCompaniesPage(query: { q?: string; status?: string; plan?: string; page: number; pageSize: number }) {
const where: any = {}
if (query.status) where.status = query.status
if (query.q) {
where.OR = [
{ name: { contains: query.q, mode: 'insensitive' } },
{ email: { contains: query.q, mode: 'insensitive' } },
{ slug: { contains: query.q, mode: 'insensitive' } },
]
}
if (query.plan) where.subscription = { plan: query.plan }
const [data, total] = await Promise.all([
prisma.company.findMany({
where,
include: companyListInclude,
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
orderBy: { createdAt: 'desc' },
}),
prisma.company.count({ where }),
])
return { data, total }
}
export function getCompanyDetail(id: string) {
return prisma.company.findUniqueOrThrow({ where: { id }, include: companyDetailInclude })
}
export function getCompanyUpdateSnapshot(id: string) {
return prisma.company.findUniqueOrThrow({
where: { id },
include: { brand: true, contractSettings: true, accountingSettings: true, subscription: true },
})
}
export async function applyCompanyUpdate(id: string, body: any, current: { name: string; slug: string; brand?: { paymentMethodsEnabled?: any[] | null } | null }) {
return prisma.$transaction(async (tx) => {
if (body.company) await tx.company.update({ where: { id }, data: body.company })
if (body.subscription) {
const sub = body.subscription
await tx.subscription.upsert({
where: { companyId: id },
update: {
...sub,
trialStartAt: parseOptionalDate(sub.trialStartAt),
trialEndAt: parseOptionalDate(sub.trialEndAt),
currentPeriodStart: parseOptionalDate(sub.currentPeriodStart),
currentPeriodEnd: parseOptionalDate(sub.currentPeriodEnd),
cancelledAt: parseOptionalDate(sub.cancelledAt),
},
create: {
companyId: id,
plan: sub.plan ?? 'STARTER',
billingPeriod: sub.billingPeriod ?? 'MONTHLY',
status: sub.status ?? 'TRIALING',
currency: sub.currency ?? 'MAD',
trialStartAt: parseOptionalDate(sub.trialStartAt),
trialEndAt: parseOptionalDate(sub.trialEndAt),
currentPeriodStart: parseOptionalDate(sub.currentPeriodStart),
currentPeriodEnd: parseOptionalDate(sub.currentPeriodEnd),
cancelledAt: parseOptionalDate(sub.cancelledAt),
cancelAtPeriodEnd: sub.cancelAtPeriodEnd ?? false,
} as any,
})
}
if (body.brand) {
await tx.brandSettings.upsert({
where: { companyId: id },
update: body.brand,
create: {
companyId: id,
displayName: body.brand.displayName ?? current.name,
subdomain: body.brand.subdomain ?? current.slug,
paymentMethodsEnabled: current.brand?.paymentMethodsEnabled ?? [],
...body.brand,
} as any,
})
}
if (body.contractSettings) {
await tx.contractSettings.upsert({
where: { companyId: id },
update: body.contractSettings,
create: { companyId: id, ...body.contractSettings } as any,
})
}
if (body.accountingSettings) {
await tx.accountingSettings.upsert({
where: { companyId: id },
update: body.accountingSettings,
create: { companyId: id, ...body.accountingSettings } as any,
})
}
return tx.company.findUniqueOrThrow({ where: { id }, include: companyDetailInclude })
})
}
export function updateCompanyStatus(id: string, status: string) {
return prisma.company.update({ where: { id }, data: { status: status as any } })
}
export function deleteCompany(id: string) {
return prisma.company.delete({ where: { id } })
}
export function getCompanyForImpersonation(id: string) {
return prisma.company.findUniqueOrThrow({
where: { id },
include: { employees: { where: { role: 'OWNER' } } },
})
}
export async function listRentersPage(query: { q?: string; blocked?: string; page: number; pageSize: number }) {
const where: any = {}
if (query.blocked !== undefined) where.isActive = query.blocked === 'false'
if (query.q) {
where.OR = [
{ firstName: { contains: query.q, mode: 'insensitive' } },
{ email: { contains: query.q, mode: 'insensitive' } },
]
}
const [data, total] = await Promise.all([
prisma.renter.findMany({
where,
select: {
id: true,
firstName: true,
lastName: true,
email: true,
phone: true,
isActive: true,
createdAt: true,
_count: { select: { reservations: true } },
},
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
orderBy: { createdAt: 'desc' },
}),
prisma.renter.count({ where }),
])
return { data, total }
}
export function updateRenterActive(id: string, isActive: boolean) {
return prisma.renter.update({ where: { id }, data: { isActive } })
}
export async function getPlatformMetricCounts() {
const [
totalCompanies,
activeCompanies,
trialingCompanies,
suspendedCompanies,
totalRenters,
totalReservations,
] = await Promise.all([
prisma.company.count(),
prisma.company.count({ where: { status: 'ACTIVE' } }),
prisma.company.count({ where: { status: 'TRIALING' } }),
prisma.company.count({ where: { status: 'SUSPENDED' } }),
prisma.renter.count(),
prisma.reservation.count(),
])
return {
totalCompanies,
activeCompanies,
trialingCompanies,
suspendedCompanies,
totalRenters,
totalReservations,
}
}
export async function listAuditLogsPage(query: { adminId?: string; action?: string; companyId?: string; entityId?: string; page: number; pageSize: number }) {
const where: any = {}
if (query.adminId) where.adminUserId = query.adminId
if (query.action) where.action = { contains: query.action }
if (query.companyId) where.companyId = query.companyId
if (query.entityId) where.resourceId = query.entityId
const [data, total] = await Promise.all([
prisma.auditLog.findMany({
where,
include: { adminUser: { select: { firstName: true, lastName: true, email: true } } },
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
orderBy: { createdAt: 'desc' },
}),
prisma.auditLog.count({ where }),
])
return { data, total }
}
export function listAdmins() {
return prisma.adminUser.findMany({
include: { permissions: true },
})
}
export function createAdmin(data: {
email: string
firstName: string
lastName: string
role: string
passwordHash: string
permissions?: any[]
}) {
return prisma.adminUser.create({
data: {
email: data.email,
firstName: data.firstName,
lastName: data.lastName,
role: data.role as any,
passwordHash: data.passwordHash,
permissions: data.permissions ? { create: data.permissions } : undefined,
},
include: { permissions: true },
})
}
export function updateAdminRole(id: string, role: string) {
return prisma.adminUser.update({ where: { id }, data: { role: role as any } })
}
export async function replaceAdminPermissions(id: string, permissions: any[]) {
await prisma.adminUser.findUniqueOrThrow({ where: { id } })
await prisma.$transaction([
prisma.adminPermission.deleteMany({ where: { adminUserId: id } }),
prisma.adminPermission.createMany({
data: permissions.map((permission) => ({
adminUserId: id,
resource: permission.resource,
actions: permission.actions,
})) as any,
}),
])
return prisma.adminUser.findUniqueOrThrow({ where: { id }, include: { permissions: true } })
}
export async function listBillingPage(query: { status?: string; plan?: string; page: number; pageSize: number }) {
const where: any = {}
if (query.status) where.status = query.status
if (query.plan) where.plan = query.plan
const [data, total] = await Promise.all([
prisma.subscription.findMany({
where,
include: billingInclude,
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
orderBy: { createdAt: 'desc' },
}),
prisma.subscription.count({ where }),
])
return { data, total }
}
export function listActiveSubscriptionsForMrr() {
return prisma.subscription.findMany({
where: { status: { in: ['ACTIVE', 'TRIALING'] as any[] } },
select: { plan: true, billingPeriod: true },
})
}
export async function getBillingStatusCounts() {
const [activeCount, trialingCount, pastDueCount, cancelledCount] = await Promise.all([
prisma.subscription.count({ where: { status: 'ACTIVE' } }),
prisma.subscription.count({ where: { status: 'TRIALING' } }),
prisma.subscription.count({ where: { status: 'PAST_DUE' } }),
prisma.subscription.count({ where: { status: 'CANCELLED' } }),
])
return { activeCount, trialingCount, pastDueCount, cancelledCount }
}
export async function listCompanyInvoicesPage(companyId: string, query: { page: number; pageSize: number }) {
const [data, total] = await Promise.all([
prisma.subscriptionInvoice.findMany({
where: { companyId },
orderBy: { createdAt: 'desc' },
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
}),
prisma.subscriptionInvoice.count({ where: { companyId } }),
])
return { data, total }
}
export function getInvoicePdfRecord(invoiceId: string) {
return prisma.subscriptionInvoice.findUniqueOrThrow({
where: { id: invoiceId },
include: {
company: { select: { name: true, email: true, phone: true, address: true } },
subscription: {
select: {
plan: true,
billingPeriod: true,
currency: true,
currentPeriodStart: true,
currentPeriodEnd: true,
},
},
},
})
}
+226
View File
@@ -0,0 +1,226 @@
import { Router } from 'express'
import { requireAdminAuth, requireAdminRole } from '../../middleware/requireAdminAuth'
import { parseBody, parseQuery, parseParams } from '../../http/validate'
import { ok, created } from '../../http/respond'
import * as service from './admin.service'
import { presentAdminUser } from './admin.presenter'
import {
loginSchema, forgotPasswordSchema, resetPasswordSchema, totpVerifySchema,
companiesQuerySchema, rentersQuerySchema, auditLogQuerySchema, billingQuerySchema,
invoicesQuerySchema, adminCompanyUpdateSchema, companyStatusSchema,
createAdminSchema, adminRoleSchema, adminPermissionsSchema,
homepageUpdateSchema, idParamSchema, companyIdParamSchema, invoiceIdParamSchema,
} from './admin.schemas'
const router = Router()
// ─── Auth (public) ─────────────────────────────────────────────
router.post('/auth/login', async (req, res, next) => {
try {
const { email, password, totpCode } = parseBody(loginSchema, req)
const result = await service.login(email, password, totpCode)
if (!result) return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid email or password', statusCode: 401 })
if ('totpRequired' in result) return res.status(401).json({ error: 'totp_required', message: '2FA code required', statusCode: 401 })
if ('invalidTotp' in result) return res.status(401).json({ error: 'invalid_totp', message: 'Invalid 2FA code', statusCode: 401 })
ok(res, { data: result })
} catch (err) { next(err) }
})
router.post('/auth/forgot-password', async (req, res, next) => {
try {
const { email } = parseBody(forgotPasswordSchema, req)
await service.forgotPassword(email)
ok(res, { data: { message: 'If that email is registered, a reset link has been sent.' } })
} catch (err) { next(err) }
})
router.post('/auth/reset-password', async (req, res, next) => {
try {
const { token, password } = parseBody(resetPasswordSchema, req)
const ok2 = await service.resetPassword(token, password)
if (!ok2) return res.status(400).json({ error: 'invalid_token', message: 'Reset link is invalid or has expired.', statusCode: 400 })
ok(res, { data: { message: 'Password updated successfully. You can now sign in.' } })
} catch (err) { next(err) }
})
// ─── Auth (protected) ──────────────────────────────────────────
router.get('/auth/me', requireAdminAuth, (req, res) => {
ok(res, { data: presentAdminUser(req.admin as any) })
})
router.post('/auth/2fa/setup', requireAdminAuth, async (req, res, next) => {
try {
ok(res, { data: await service.setupTotp(req.admin.id, req.admin.email) })
} catch (err) { next(err) }
})
router.post('/auth/2fa/verify', requireAdminAuth, async (req, res, next) => {
try {
const { code } = parseBody(totpVerifySchema, req)
const valid = await service.verifyTotp(req.admin.id, code)
if (!valid) return res.status(400).json({ error: 'invalid_code', message: 'Invalid 2FA code', statusCode: 400 })
ok(res, { data: { success: true } })
} catch (err) { next(err) }
})
// ─── Companies ─────────────────────────────────────────────────
router.get('/companies', requireAdminAuth, async (req, res, next) => {
try {
ok(res, await service.listCompanies(parseQuery(companiesQuerySchema, req)))
} catch (err) { next(err) }
})
router.get('/companies/:id', requireAdminAuth, async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
ok(res, { data: await service.getCompany(id) })
} catch (err) { next(err) }
})
router.patch('/companies/:id', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const body = parseBody(adminCompanyUpdateSchema, req)
ok(res, { data: await service.updateCompany(id, body, req.admin.id, req.ip) })
} catch (err) { next(err) }
})
router.patch('/companies/:id/status', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { status, reason } = parseBody(companyStatusSchema, req)
ok(res, { data: await service.setCompanyStatus(id, status, reason, req.admin.id, req.ip) })
} catch (err) { next(err) }
})
router.delete('/companies/:id', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
await service.deleteCompany(id, req.admin.id, req.ip)
ok(res, { data: { success: true } })
} catch (err) { next(err) }
})
router.post('/companies/:id/impersonate', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
ok(res, { data: await service.impersonateCompany(id, req.admin.id, req.ip) })
} catch (err) { next(err) }
})
// ─── Renters ───────────────────────────────────────────────────
router.get('/renters', requireAdminAuth, async (req, res, next) => {
try {
ok(res, await service.listRenters(parseQuery(rentersQuerySchema, req)))
} catch (err) { next(err) }
})
router.post('/renters/:id/block', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
await service.setRenterActive(id, false)
ok(res, { data: { success: true } })
} catch (err) { next(err) }
})
router.post('/renters/:id/unblock', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
await service.setRenterActive(id, true)
ok(res, { data: { success: true } })
} catch (err) { next(err) }
})
// ─── Metrics ───────────────────────────────────────────────────
router.get('/metrics', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
ok(res, { data: await service.getPlatformMetrics() })
} catch (err) { next(err) }
})
// ─── Audit logs ────────────────────────────────────────────────
router.get('/audit-logs', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
try {
ok(res, await service.getAuditLogs(parseQuery(auditLogQuerySchema, req)))
} catch (err) { next(err) }
})
// ─── Admin users ───────────────────────────────────────────────
router.get('/admins', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => {
try {
ok(res, { data: await service.listAdmins() })
} catch (err) { next(err) }
})
router.post('/admins', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => {
try {
created(res, { data: await service.createAdmin(parseBody(createAdminSchema, req)) })
} catch (err) { next(err) }
})
router.patch('/admins/:id/role', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { role } = parseBody(adminRoleSchema, req)
await service.updateAdminRole(id, role)
ok(res, { data: { success: true } })
} catch (err) { next(err) }
})
router.patch('/admins/:id/permissions', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { permissions } = parseBody(adminPermissionsSchema, req)
ok(res, { data: await service.updateAdminPermissions(id, permissions) })
} catch (err) { next(err) }
})
// ─── Billing ───────────────────────────────────────────────────
router.get('/billing', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
ok(res, await service.getBilling(parseQuery(billingQuerySchema, req)))
} catch (err) { next(err) }
})
router.get('/billing/:companyId/invoices', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { companyId } = parseParams(companyIdParamSchema, req)
ok(res, await service.getCompanyInvoices(companyId, parseQuery(invoicesQuerySchema, req)))
} catch (err) { next(err) }
})
router.get('/billing/invoices/:invoiceId/pdf', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { invoiceId } = parseParams(invoiceIdParamSchema, req)
const { pdfBuffer, invoiceNumber } = await service.getInvoicePdf(invoiceId)
res.setHeader('Content-Type', 'application/pdf')
res.setHeader('Content-Disposition', `attachment; filename="${invoiceNumber}.pdf"`)
res.setHeader('Content-Length', pdfBuffer.length)
res.end(pdfBuffer)
} catch (err) { next(err) }
})
// ─── Site config ───────────────────────────────────────────────
router.get('/site-config/marketplace-homepage', requireAdminAuth, async (req, res, next) => {
try {
ok(res, { data: await service.getMarketplaceHomepage() })
} catch (err) { next(err) }
})
router.patch('/site-config/marketplace-homepage', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
try {
const { homepage } = parseBody(homepageUpdateSchema, req)
ok(res, { data: await service.updateMarketplaceHomepage(homepage, req.admin.id, req.ip) })
} catch (err) { next(err) }
})
export default router
+176
View File
@@ -0,0 +1,176 @@
import { z } from 'zod'
import type { MarketplaceHomepageContent, MarketplaceHomepageMetric, MarketplaceHomepagePillar, MarketplaceHomepageSectionType, MarketplaceHomepageStep } from '@rentaldrivego/types'
export const loginSchema = z.object({
email: z.string().email().max(255).trim().toLowerCase(),
password: z.string().max(128),
totpCode: z.string().length(6).optional(),
})
export const forgotPasswordSchema = z.object({
email: z.string().email().max(255).trim().toLowerCase(),
})
export const resetPasswordSchema = z.object({
token: z.string().min(1),
password: z.string().min(8).max(128),
})
export const totpVerifySchema = z.object({
code: z.string().length(6),
})
export const companiesQuerySchema = z.object({
q: z.string().optional(),
status: z.string().optional(),
plan: z.string().optional(),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
})
export const rentersQuerySchema = z.object({
q: z.string().optional(),
blocked: z.string().optional(),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
})
export const auditLogQuerySchema = z.object({
adminId: z.string().optional(),
action: z.string().optional(),
companyId: z.string().optional(),
entityId: z.string().optional(),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(50),
})
export const billingQuerySchema = z.object({
status: z.string().optional(),
plan: z.string().optional(),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
})
export const invoicesQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
})
export 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),
})
export const createAdminSchema = 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(),
})
export const adminRoleSchema = z.object({
role: z.enum(['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER']),
})
export const adminPermissionsSchema = z.object({
permissions: z.array(permissionSchema),
})
export const companyStatusSchema = z.object({
status: z.enum(['ACTIVE', 'SUSPENDED', 'CANCELLED']),
reason: z.string().optional(),
})
const nullableString = z.union([z.string(), z.null()]).optional()
const nullableEmail = z.union([z.string().email(), z.null()]).optional()
const nullableUrl = z.union([z.string().url(), z.null()]).optional()
const nullableDate = z.union([z.string().datetime(), z.string().regex(/^\d{4}-\d{2}-\d{2}$/), z.null()]).optional()
export const adminCompanyUpdateSchema = z.object({
company: z.object({
name: z.string().min(1).optional(), slug: z.string().min(1).optional(),
email: z.string().email().optional(), phone: nullableString,
status: z.enum(['PENDING', 'TRIALING', 'ACTIVE', 'PAST_DUE', 'SUSPENDED', 'CANCELLED']).optional(),
subscriptionPaymentRef: nullableString,
}).optional(),
subscription: z.object({
plan: z.enum(['STARTER', 'GROWTH', 'PRO']).optional(),
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']).optional(),
status: z.enum(['TRIALING', 'ACTIVE', 'PAST_DUE', 'CANCELLED', 'UNPAID']).optional(),
currency: z.enum(['MAD', 'USD', 'EUR']).optional(),
trialStartAt: nullableDate, trialEndAt: nullableDate,
currentPeriodStart: nullableDate, currentPeriodEnd: nullableDate,
cancelledAt: nullableDate, cancelAtPeriodEnd: z.boolean().optional(),
}).optional(),
brand: z.object({
displayName: z.string().min(1).optional(), tagline: nullableString,
subdomain: z.string().min(1).optional(), customDomain: nullableString,
publicEmail: nullableEmail, publicPhone: nullableString, publicAddress: nullableString,
publicCity: nullableString, publicCountry: nullableString, websiteUrl: nullableUrl,
whatsappNumber: nullableString, defaultLocale: z.string().min(2).optional(),
defaultCurrency: z.enum(['MAD', 'USD', 'EUR']).optional(),
isListedOnMarketplace: z.boolean().optional(),
homePageConfig: z.any().optional(),
menuConfig: z.any().optional(),
}).optional(),
contractSettings: z.object({
legalName: nullableString, registrationNumber: nullableString, taxId: nullableString,
terms: z.string().optional(),
fuelPolicyType: z.enum(['FULL_TO_FULL', 'FULL_TO_EMPTY', 'SAME_TO_SAME', 'PREPAID', 'FREE']).optional(),
lateFeePerHour: z.number().int().nullable().optional(), taxRate: z.number().nullable().optional(),
signatureRequired: z.boolean().optional(), showTax: z.boolean().optional(),
}).optional(),
accountingSettings: 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: nullableEmail, accountantName: nullableString,
autoSendReport: z.boolean().optional(),
reportFormat: z.enum(['PDF', 'CSV', 'BOTH']).optional(),
}).optional(),
})
const marketplaceMetricSchema: z.ZodType<MarketplaceHomepageMetric> = z.object({ value: z.string().min(1), label: z.string().min(1) })
const marketplacePillarSchema: z.ZodType<MarketplaceHomepagePillar> = z.object({ title: z.string().min(1), body: z.string().min(1) })
const marketplaceStepSchema: z.ZodType<MarketplaceHomepageStep> = z.object({ step: z.string().min(1), title: z.string().min(1), body: z.string().min(1) })
const marketplaceSectionSchema: z.ZodType<MarketplaceHomepageSectionType> = z.enum(['hero', 'surface', 'pillars', 'audiences', 'features', 'steps', 'closing'])
const marketplaceHomepageContentSchema: z.ZodType<MarketplaceHomepageContent> = z.object({
sections: z.array(marketplaceSectionSchema).min(1),
heroKicker: z.string().min(1), heroTitle: z.string().min(1), heroBody: z.string().min(1),
startTrial: z.string().min(1), exploreVehicles: z.string().min(1),
surfaceLabel: z.string().min(1), surfaceTitle: z.string().min(1), surfaceBody: z.string().min(1),
liveLabel: z.string().min(1), trustedFleets: z.string().min(1), brandedFlows: z.string().min(1), multiTenant: z.string().min(1),
companyKicker: z.string().min(1), companyTitle: z.string().min(1), companyBody: z.string().min(1),
renterKicker: z.string().min(1), renterTitle: z.string().min(1), renterBody: z.string().min(1),
pillars: z.array(marketplacePillarSchema).length(3),
metrics: z.array(marketplaceMetricSchema).length(3),
featureLabel: z.string().min(1), features: z.array(z.string().min(1)).min(1),
stepsTitle: z.string().min(1), steps: z.array(marketplaceStepSchema).length(3), stepLabel: z.string().min(1),
readyKicker: z.string().min(1), readyTitle: z.string().min(1), readyBody: z.string().min(1),
viewPricing: z.string().min(1), createWorkspace: z.string().min(1),
})
export const marketplaceHomepageConfigSchema = z.object({
en: marketplaceHomepageContentSchema,
fr: marketplaceHomepageContentSchema,
ar: marketplaceHomepageContentSchema,
})
export const idParamSchema = z.object({
id: z.string().min(1),
})
export const companyIdParamSchema = z.object({
companyId: z.string().min(1),
})
export const invoiceIdParamSchema = z.object({
invoiceId: z.string().min(1),
})
export const homepageUpdateSchema = z.object({
homepage: marketplaceHomepageConfigSchema,
})
+298
View File
@@ -0,0 +1,298 @@
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
import crypto from 'crypto'
import { authenticator } from 'otplib'
import qrcode from 'qrcode'
import { getMarketplaceHomepageContent, saveMarketplaceHomepageContent } from '../../services/platformContentService'
import { generateInvoicePdf, buildInvoiceNumber } from '../../services/invoicePdfService'
import { sendTransactionalEmail } from '../../services/notificationService'
import * as presenter from './admin.presenter'
import * as repo from './admin.repo'
const ADMIN_RESET_TTL_MINUTES = 60
const PLAN_MONTHLY_AMOUNT: Record<string, number> = {
STARTER: 29900,
GROWTH: 59900,
PRO: 99900,
}
function signAdminToken(adminId: string) {
return jwt.sign({ sub: adminId, type: 'admin' }, process.env.JWT_SECRET!, { expiresIn: '8h' })
}
function toAuditJson<T>(value: T) {
return JSON.parse(JSON.stringify(value))
}
function ensureAdminBasePath(baseUrl: string) {
try {
const url = new URL(baseUrl)
const pathname = url.pathname.replace(/\/$/, '')
if (!pathname.endsWith('/admin')) url.pathname = `${pathname}/admin`
return url.toString().replace(/\/$/, '')
} catch {
const trimmed = baseUrl.replace(/\/$/, '')
return trimmed.endsWith('/admin') ? trimmed : `${trimmed}/admin`
}
}
export async function login(email: string, password: string, totpCode?: string) {
const admin = await repo.findAdminByEmail(email)
if (!admin || !admin.isActive) return null
const valid = await bcrypt.compare(password, admin.passwordHash)
if (!valid) return null
if (admin.totpEnabled) {
if (!totpCode) return { totpRequired: true } as const
if (!authenticator.verify({ token: totpCode, secret: admin.totpSecret! })) {
return { invalidTotp: true } as const
}
}
await repo.updateAdminLastLogin(admin.id)
await repo.createAuditLog({
adminUserId: admin.id,
action: 'ADMIN_LOGIN',
resource: 'AdminUser',
resourceId: admin.id,
})
return presenter.presentAdminSession(admin, signAdminToken(admin.id))
}
export async function setupTotp(adminId: string, email: string) {
const secret = authenticator.generateSecret()
await repo.updateAdminTotpSecret(adminId, secret)
const otpauth = authenticator.keyuri(email, 'RentalDriveGo Admin', secret)
const qrCode = await qrcode.toDataURL(otpauth)
return { secret, qrCode }
}
export async function verifyTotp(adminId: string, code: string) {
const admin = await repo.findAdminByIdOrThrow(adminId)
if (!admin.totpSecret) return false
const valid = authenticator.verify({ token: code, secret: admin.totpSecret })
if (valid) await repo.enableAdminTotp(adminId)
return valid
}
export async function forgotPassword(email: string) {
const admin = await repo.findAdminByEmail(email)
if (!admin || !admin.isActive) return
const rawToken = crypto.randomBytes(32).toString('hex')
const expiresAt = new Date(Date.now() + ADMIN_RESET_TTL_MINUTES * 60 * 1000)
await repo.setAdminPasswordReset(admin.id, rawToken, expiresAt)
const adminUrl = ensureAdminBasePath(
process.env.ADMIN_URL ?? process.env.NEXT_PUBLIC_ADMIN_URL ?? 'http://localhost:3002/admin',
)
const resetUrl = `${adminUrl}/reset-password?token=${rawToken}`
await sendTransactionalEmail({
to: email,
subject: 'Reset your RentalDriveGo admin password',
html: `<p>Hi ${admin.firstName},</p><p><a href="${resetUrl}">Reset password</a></p><p>Expires in ${ADMIN_RESET_TTL_MINUTES} minutes.</p>`,
text: `Hi ${admin.firstName},\n\nReset here: ${resetUrl}\n\nExpires in ${ADMIN_RESET_TTL_MINUTES} minutes.`,
}).catch((err) => console.error('[AdminForgotPassword]', err?.message))
}
export async function resetPassword(token: string, password: string) {
const admin = await repo.findAdminByResetToken(token)
if (!admin) return false
await repo.updateAdminPassword(admin.id, await bcrypt.hash(password, 12))
return true
}
export async function listCompanies(query: { q?: string; status?: string; plan?: string; page: number; pageSize: number }) {
const { data, total } = await repo.listCompaniesPage(query)
return presenter.presentPaginated(data, total, query.page, query.pageSize)
}
export function getCompany(id: string) {
return repo.getCompanyDetail(id)
}
export async function updateCompany(id: string, body: any, adminId: string, ip?: string) {
const before = await repo.getCompanyUpdateSnapshot(id)
const updated = await repo.applyCompanyUpdate(id, body, before)
await repo.createAuditLog({
adminUserId: adminId,
action: 'UPDATE_COMPANY',
resource: 'Company',
resourceId: id,
companyId: id,
before: toAuditJson(before),
after: toAuditJson(body),
ipAddress: ip,
})
return updated
}
export async function setCompanyStatus(id: string, status: string, reason: string | undefined, adminId: string, ip?: string) {
const before = await repo.getCompanyUpdateSnapshot(id)
const updated = await repo.updateCompanyStatus(id, status)
await repo.createAuditLog({
adminUserId: adminId,
action: `SET_COMPANY_STATUS_${status}`,
resource: 'Company',
resourceId: id,
companyId: id,
before: { status: before.status },
after: { status },
note: reason,
ipAddress: ip,
})
return updated
}
export async function deleteCompany(id: string, adminId: string, ip?: string) {
await repo.deleteCompany(id)
await repo.createAuditLog({
adminUserId: adminId,
action: 'DELETE_COMPANY',
resource: 'Company',
resourceId: id,
ipAddress: ip,
})
}
export async function impersonateCompany(id: string, adminId: string, ip?: string) {
const company = await repo.getCompanyForImpersonation(id)
const token = jwt.sign(
{
sub: company.employees[0]?.id,
companyId: company.id,
isImpersonation: true,
type: 'employee',
},
process.env.JWT_SECRET!,
{ expiresIn: '30m' },
)
await repo.createAuditLog({
adminUserId: adminId,
action: 'IMPERSONATE_COMPANY',
resource: 'Company',
resourceId: id,
companyId: id,
ipAddress: ip,
})
return { token, expiresIn: 1800 }
}
export async function listRenters(query: { q?: string; blocked?: string; page: number; pageSize: number }) {
const { data, total } = await repo.listRentersPage(query)
return presenter.presentPaginated(data, total, query.page, query.pageSize)
}
export function setRenterActive(id: string, isActive: boolean) {
return repo.updateRenterActive(id, isActive)
}
export function getPlatformMetrics() {
return repo.getPlatformMetricCounts()
}
export async function getAuditLogs(query: { adminId?: string; action?: string; companyId?: string; entityId?: string; page: number; pageSize: number }) {
const { data, total } = await repo.listAuditLogsPage(query)
return presenter.presentPaginated(data, total, query.page, query.pageSize)
}
export async function listAdmins() {
const admins = await repo.listAdmins()
return admins.map((admin) => presenter.presentAdminUser(admin))
}
export async function createAdmin(body: { email: string; firstName: string; lastName: string; role: string; password: string; permissions?: any[] }) {
const admin = await repo.createAdmin({
...body,
passwordHash: await bcrypt.hash(body.password, 12),
})
return presenter.presentAdminUser(admin)
}
export function updateAdminRole(id: string, role: string) {
return repo.updateAdminRole(id, role)
}
export async function updateAdminPermissions(id: string, permissions: any[]) {
const admin = await repo.replaceAdminPermissions(id, permissions)
return presenter.presentAdminUser(admin)
}
export async function getBilling(query: { status?: string; plan?: string; page: number; pageSize: number }) {
const [{ data, total }, activeSubscriptions, stats] = await Promise.all([
repo.listBillingPage(query),
repo.listActiveSubscriptionsForMrr(),
repo.getBillingStatusCounts(),
])
const mrr = activeSubscriptions.reduce((acc, subscription) => {
const monthlyAmount = PLAN_MONTHLY_AMOUNT[subscription.plan] ?? 0
return acc + (subscription.billingPeriod === 'ANNUAL' ? Math.round(monthlyAmount * 10 / 12) : monthlyAmount)
}, 0)
return presenter.presentPaginated(data, total, query.page, query.pageSize, {
stats: { mrr, ...stats },
})
}
export async function getCompanyInvoices(companyId: string, query: { page: number; pageSize: number }) {
const { data, total } = await repo.listCompanyInvoicesPage(companyId, query)
return presenter.presentPaginated(data, total, query.page, query.pageSize)
}
export async function getInvoicePdf(invoiceId: string) {
const invoice = await repo.getInvoicePdfRecord(invoiceId)
const invoiceNumber = buildInvoiceNumber(invoice.id, invoice.createdAt)
const transactionId = invoice.amanpayTransactionId ?? invoice.paypalCaptureId ?? null
const pdfBuffer = await generateInvoicePdf({
invoiceNumber,
issueDate: invoice.createdAt.toISOString(),
dueDate: invoice.status === 'PENDING' ? invoice.createdAt.toISOString() : null,
company: {
name: invoice.company.name,
email: invoice.company.email,
phone: invoice.company.phone,
address: invoice.company.address,
},
subscription: {
plan: invoice.subscription.plan,
billingPeriod: invoice.subscription.billingPeriod,
currentPeriodStart: invoice.subscription.currentPeriodStart?.toISOString(),
currentPeriodEnd: invoice.subscription.currentPeriodEnd?.toISOString(),
currency: invoice.subscription.currency,
},
amount: invoice.amount,
currency: invoice.currency,
status: invoice.status,
paymentProvider: invoice.paymentProvider,
transactionId,
paidAt: invoice.paidAt?.toISOString(),
})
return { pdfBuffer, invoiceNumber }
}
export function getMarketplaceHomepage() {
return getMarketplaceHomepageContent()
}
export async function updateMarketplaceHomepage(homepage: any, adminId: string, ip?: string) {
const saved = await saveMarketplaceHomepageContent(homepage)
await repo.createAuditLog({
adminUserId: adminId,
action: 'UPDATE',
resource: 'MarketplaceHomepage',
after: toAuditJson(saved),
ipAddress: ip,
userAgent: undefined,
})
return saved
}
@@ -0,0 +1,49 @@
import { Router } from 'express'
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
import { requireTenant } from '../../middleware/requireTenant'
import { requireSubscription } from '../../middleware/requireSubscription'
import { requireRole } from '../../middleware/requireRole'
import { parseQuery } from '../../http/validate'
import { ok } from '../../http/respond'
import * as service from './analytics.service'
import { summaryQuerySchema, reportQuerySchema } from './analytics.schemas'
const router = Router()
router.use(requireCompanyAuth, requireTenant, requireSubscription)
router.get('/summary', async (req, res, next) => {
try {
const { period } = parseQuery(summaryQuerySchema, req)
ok(res, { data: await service.getSummary(req.companyId, period) })
} catch (err) { next(err) }
})
router.get('/dashboard', async (req, res, next) => {
try {
ok(res, { data: await service.getDashboard(req.companyId) })
} catch (err) { next(err) }
})
router.get('/sources', async (req, res, next) => {
try {
ok(res, { data: await service.getSources(req.companyId) })
} catch (err) { next(err) }
})
router.get('/report', requireRole('MANAGER'), async (req, res, next) => {
try {
const query = parseQuery(reportQuerySchema, req)
const result = await service.getReport(req.companyId, query)
if (result.format === 'CSV') {
const start = result.startDate.toISOString().slice(0, 10)
const end = result.endDate.toISOString().slice(0, 10)
res.setHeader('Content-Type', 'text/csv')
res.setHeader('Content-Disposition', `attachment; filename="report-${start}-${end}.csv"`)
return res.send(result.csv)
}
ok(res, { data: result.report })
} catch (err) { next(err) }
})
export default router
@@ -0,0 +1,12 @@
import { z } from 'zod'
export const summaryQuerySchema = z.object({
period: z.string().default('30d'),
})
export const reportQuerySchema = z.object({
from: z.string().optional(),
to: z.string().optional(),
format: z.string().default('JSON'),
period: z.string().optional(),
})
@@ -0,0 +1,100 @@
import { prisma } from '../../lib/prisma'
import { generateFinancialReport, toCsv } from '../../services/financialReportService'
function getRangeFromPeriod(period: string) {
const now = new Date()
switch (period) {
case 'WEEKLY': return { from: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000), to: now }
case 'QUARTERLY': return { from: new Date(now.getFullYear(), now.getMonth() - 2, 1), to: now }
case 'ANNUAL': return { from: new Date(now.getFullYear(), 0, 1), to: now }
default: return { from: new Date(now.getFullYear(), now.getMonth(), 1), to: now }
}
}
function pct(current: number, previous: number) {
if (previous === 0) return current > 0 ? 100 : 0
return Math.round(((current - previous) / previous) * 100)
}
export async function getSummary(companyId: string, period: string) {
const days = parseInt(period.replace('d', '')) || 30
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000)
const [totalReservations, activeVehicles, totalRevenue, totalCustomers] = await Promise.all([
prisma.reservation.count({ where: { companyId, createdAt: { gte: since } } }),
prisma.vehicle.count({ where: { companyId, status: 'AVAILABLE' } }),
prisma.reservation.aggregate({ where: { companyId, status: { in: ['COMPLETED'] }, createdAt: { gte: since } }, _sum: { totalAmount: true } }),
prisma.customer.count({ where: { companyId } }),
])
return { totalReservations, activeVehicles, totalRevenue: totalRevenue._sum.totalAmount ?? 0, totalCustomers, period }
}
export async function getDashboard(companyId: string) {
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, createdAt: { gte: monthStart } } }),
prisma.reservation.count({ where: { companyId, createdAt: { gte: prevMonthStart, lte: prevMonthEnd } } }),
prisma.vehicle.count({ where: { companyId, status: { in: ['AVAILABLE', 'RENTED'] } } }),
prisma.vehicle.count({ where: { companyId, createdAt: { lte: prevMonthEnd }, status: { in: ['AVAILABLE', 'RENTED'] } } }),
prisma.customer.count({ where: { companyId } }),
prisma.customer.count({ where: { companyId, createdAt: { lte: prevMonthEnd } } }),
prisma.reservation.aggregate({ where: { companyId, status: { in: ['CONFIRMED', 'ACTIVE', 'COMPLETED'] }, createdAt: { gte: monthStart } }, _sum: { totalAmount: true } }),
prisma.reservation.aggregate({ where: { companyId, status: { in: ['CONFIRMED', 'ACTIVE', 'COMPLETED'] }, createdAt: { gte: prevMonthStart, lte: prevMonthEnd } }, _sum: { totalAmount: true } }),
prisma.reservation.findMany({ where: { companyId }, include: { customer: true, vehicle: true }, orderBy: { createdAt: 'desc' }, take: 8 }),
prisma.reservation.groupBy({ by: ['source'], where: { companyId }, _count: { id: true }, _sum: { totalAmount: true } }),
prisma.subscription.findUnique({ where: { companyId } }),
])
return {
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: (recentReservations as any[]).map((r) => ({
id: r.id,
bookingRef: r.contractNumber ?? r.id.slice(-8).toUpperCase(),
customerName: `${r.customer.firstName} ${r.customer.lastName}`,
vehicleName: `${r.vehicle.make} ${r.vehicle.model}`,
startDate: r.startDate,
endDate: r.endDate,
status: r.status,
totalAmount: r.totalAmount,
})),
sourceBreakdown: (sourceGroups as any[]).map((g) => ({
source: g.source,
count: g._count.id,
revenue: g._sum.totalAmount ?? 0,
})),
subscription: subscription ? {
status: subscription.status,
planName: subscription.plan,
trialEndsAt: subscription.trialEndAt,
} : null,
}
}
export async function getSources(companyId: string) {
return prisma.reservation.groupBy({ by: ['source'], where: { companyId }, _count: { id: true } })
}
export async function getReport(companyId: string, query: { from?: string; to?: string; format: string; period?: string }) {
const accountingSettings = await prisma.accountingSettings.findUnique({ where: { companyId } })
const derivedRange = getRangeFromPeriod(query.period || accountingSettings?.reportingPeriod || 'MONTHLY')
const startDate = query.from ? new Date(query.from) : derivedRange.from
const endDate = query.to ? new Date(query.to) : derivedRange.to
const report = await generateFinancialReport(companyId, startDate, endDate)
return { report, startDate, endDate, format: query.format, csv: query.format === 'CSV' ? toCsv(report.rows) : null }
}
@@ -0,0 +1,115 @@
import { prisma } from '../../lib/prisma'
type DbClient = Pick<typeof prisma, 'company' | 'brandSettings' | 'contractSettings' | 'subscription' | 'employee'>
type CompanySignupInput = {
companyName: string
slug: string
email: string
companyPhone: string
streetAddress: string
city: string
country: string
zipCode: string
legalForm: string
managerName: string
fax?: string
yearsActive: string
currency: 'MAD' | 'USD' | 'EUR'
registrationNumber: string
plan: 'STARTER' | 'GROWTH' | 'PRO'
billingPeriod: 'MONTHLY' | 'ANNUAL'
preferredLanguage: 'en' | 'fr' | 'ar'
firstName: string
lastName: string
passwordHash: string
now: Date
trialEndAt: Date
}
export function findCompanyBySlug(slug: string) {
return prisma.company.findUnique({ where: { slug } })
}
export function findCompanyByEmail(email: string) {
return prisma.company.findUnique({ where: { email } })
}
export function findEmployeeByEmail(email: string) {
return prisma.employee.findFirst({ where: { email } })
}
export async function createCompanySignup(db: DbClient, input: CompanySignupInput) {
const company = await db.company.create({
data: {
name: input.companyName,
slug: input.slug,
email: input.email,
phone: input.companyPhone,
address: {
streetAddress: input.streetAddress,
city: input.city,
country: input.country,
zipCode: input.zipCode,
legalForm: input.legalForm,
managerName: input.managerName,
fax: input.fax || null,
yearsActive: input.yearsActive,
},
status: 'TRIALING',
},
})
await db.brandSettings.create({
data: {
companyId: company.id,
displayName: input.companyName,
subdomain: input.slug,
publicEmail: input.email,
publicPhone: input.companyPhone,
publicAddress: input.streetAddress,
publicCity: input.city,
publicCountry: input.country,
defaultCurrency: input.currency,
},
})
await db.contractSettings.create({
data: {
companyId: company.id,
legalName: input.companyName,
registrationNumber: input.registrationNumber,
},
})
await db.subscription.create({
data: {
companyId: company.id,
plan: input.plan,
billingPeriod: input.billingPeriod,
currency: input.currency,
status: 'TRIALING',
trialStartAt: input.now,
trialEndAt: input.trialEndAt,
currentPeriodStart: input.now,
currentPeriodEnd: input.trialEndAt,
},
})
const employee = await db.employee.create({
data: {
companyId: company.id,
clerkUserId: `local_owner_${company.id}`,
firstName: input.firstName,
lastName: input.lastName,
email: input.email,
phone: input.companyPhone,
passwordHash: input.passwordHash,
role: 'OWNER',
preferredLanguage: input.preferredLanguage,
isActive: true,
},
})
return { company, employee }
}
@@ -0,0 +1,26 @@
import { Router } from 'express'
import { parseBody } from '../../http/validate'
import { created } from '../../http/respond'
import { companySignupSchema } from './auth.company.schemas'
import * as service from './auth.company.service'
const router = Router()
router.post('/signup', async (req, res, next) => {
try {
const body = parseBody(companySignupSchema, req)
created(res, await service.signup(body))
} catch (err) { next(err) }
})
router.post('/complete-signup', (_req, res) => {
service.completeSignupDisabled()
res.end()
})
router.post('/verify-email', (_req, res) => {
service.verifyEmailDisabled()
res.end()
})
export default router
@@ -0,0 +1,23 @@
import { z } from 'zod'
export const companySignupSchema = z.object({
firstName: z.string().min(1).max(80),
lastName: z.string().min(1).max(80),
email: z.string().email(),
password: z.string().min(8).max(128),
companyName: z.string().min(2).max(120),
legalForm: z.string().min(1).max(80),
registrationNumber: z.string().min(1).max(120),
streetAddress: z.string().min(1).max(200),
city: z.string().min(1).max(120),
country: z.string().min(1).max(120),
zipCode: z.string().min(1).max(40),
companyPhone: z.string().min(1).max(80),
fax: z.string().max(80).optional(),
yearsActive: z.string().min(1).max(80),
preferredLanguage: z.enum(['en', 'fr', 'ar']).default('en'),
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
currency: z.enum(['MAD', 'USD', 'EUR']),
paymentProvider: z.enum(['AMANPAY', 'PAYPAL']),
})
@@ -0,0 +1,103 @@
import bcrypt from 'bcryptjs'
import { AppError } from '../../http/errors'
import { prisma } from '../../lib/prisma'
import { sendNotification } from '../../services/notificationService'
import { signupEmail, type Lang } from '../../lib/emailTranslations'
import { presentCompanySignup } from './auth.presenter'
import * as repo from './auth.company.repo'
import { companySignupSchema } from './auth.company.schemas'
import type { output } from 'zod'
type CompanySignupInput = output<typeof companySignupSchema>
function slugify(value: string) {
return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 50) || 'company'
}
async function generateUniqueSlug(baseName: string) {
const base = slugify(baseName)
for (let attempt = 0; attempt < 25; attempt++) {
const slug = attempt === 0 ? base : `${base}-${attempt + 1}`
const existing = await repo.findCompanyBySlug(slug)
if (!existing) return slug
}
return `${base}-${Date.now().toString(36)}`
}
export async function signup(body: CompanySignupInput) {
if (await repo.findCompanyByEmail(body.email)) {
throw new AppError('A company account with this email already exists', 409, 'email_taken')
}
if (await repo.findEmployeeByEmail(body.email)) {
throw new AppError('An employee account with this email already exists', 409, 'email_taken')
}
const slug = await generateUniqueSlug(body.companyName)
const now = new Date()
const trialEndAt = new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000)
const passwordHash = await bcrypt.hash(body.password, 12)
const result = await prisma.$transaction((tx) => repo.createCompanySignup(tx, {
companyName: body.companyName,
slug,
email: body.email,
companyPhone: body.companyPhone,
streetAddress: body.streetAddress,
city: body.city,
country: body.country,
zipCode: body.zipCode,
legalForm: body.legalForm,
managerName: `${body.firstName} ${body.lastName}`.trim(),
fax: body.fax,
yearsActive: body.yearsActive,
currency: body.currency,
registrationNumber: body.registrationNumber,
plan: body.plan,
billingPeriod: body.billingPeriod,
preferredLanguage: body.preferredLanguage,
firstName: body.firstName,
lastName: body.lastName,
passwordHash,
now,
trialEndAt,
}))
const lang = body.preferredLanguage as Lang
const emailResult = await sendNotification({
type: 'SUBSCRIPTION_TRIAL_ENDING',
title: signupEmail.subject(lang),
body: signupEmail.text({
firstName: body.firstName,
companyName: body.companyName,
plan: body.plan,
billingPeriod: body.billingPeriod,
currency: body.currency,
paymentProvider: body.paymentProvider,
trialEnd: trialEndAt,
}, lang),
companyId: result.company.id,
employeeId: result.employee.id,
email: body.email,
channels: ['EMAIL', 'IN_APP'],
locale: lang,
}).catch(() => [])
const emailDelivery = (emailResult as Array<{ channel?: string }>).find((entry) => entry.channel === 'EMAIL')
return presentCompanySignup({
company: result.company,
trialEndAt,
emailDelivery,
})
}
export function completeSignupDisabled() {
throw new AppError('Clerk-based signup has been removed. Use /auth/company/signup instead.', 410, 'disabled')
}
export function verifyEmailDisabled() {
throw new AppError('Email verification resend via Clerk has been removed from this project.', 410, 'disabled')
}
@@ -0,0 +1,55 @@
import { prisma } from '../../lib/prisma'
export function findEmployeeWithCompanyById(id: string) {
return prisma.employee.findUnique({
where: { id },
include: { company: true },
})
}
export function findEmployeeWithCompanyByEmail(email: string) {
return prisma.employee.findFirst({
where: { email },
include: { company: true },
})
}
export function findActiveEmployeeByEmail(email: string) {
return prisma.employee.findFirst({
where: { email, isActive: true },
})
}
export function setPasswordResetToken(id: string, passwordResetToken: string, passwordResetExpiresAt: Date) {
return prisma.employee.update({
where: { id },
data: { passwordResetToken, passwordResetExpiresAt },
})
}
export function updatePreferredLanguage(id: string, preferredLanguage: 'en' | 'fr' | 'ar') {
return prisma.employee.update({
where: { id },
data: { preferredLanguage },
})
}
export function findEmployeeByResetToken(token: string) {
return prisma.employee.findFirst({
where: {
passwordResetToken: token,
passwordResetExpiresAt: { gt: new Date() },
},
})
}
export function resetPassword(id: string, passwordHash: string) {
return prisma.employee.update({
where: { id },
data: {
passwordHash,
passwordResetToken: null,
passwordResetExpiresAt: null,
},
})
}
@@ -0,0 +1,49 @@
import { Router } from 'express'
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
import { parseBody } from '../../http/validate'
import { ok } from '../../http/respond'
import {
employeeForgotPasswordSchema,
employeeLanguageSchema,
employeeLoginSchema,
employeeResetPasswordSchema,
} from './auth.employee.schemas'
import * as service from './auth.employee.service'
const router = Router()
router.get('/me', requireCompanyAuth, async (req, res, next) => {
try {
ok(res, await service.getMe(req.employee.id))
} catch (err) { next(err) }
})
router.post('/login', async (req, res, next) => {
try {
const body = parseBody(employeeLoginSchema, req)
ok(res, await service.login(body))
} catch (err) { next(err) }
})
router.post('/forgot-password', async (req, res, next) => {
try {
const { email } = parseBody(employeeForgotPasswordSchema, req)
ok(res, await service.forgotPassword(email))
} catch (err) { next(err) }
})
router.patch('/me/language', requireCompanyAuth, async (req, res, next) => {
try {
const { language } = parseBody(employeeLanguageSchema, req)
ok(res, await service.updateLanguage(req.employee.id, language))
} catch (err) { next(err) }
})
router.post('/reset-password', async (req, res, next) => {
try {
const { token, password } = parseBody(employeeResetPasswordSchema, req)
ok(res, await service.resetPassword(token, password))
} catch (err) { next(err) }
})
export default router
@@ -0,0 +1,19 @@
import { z } from 'zod'
export const employeeLoginSchema = z.object({
email: z.string().email().max(255).trim().toLowerCase(),
password: z.string().max(128),
})
export const employeeForgotPasswordSchema = z.object({
email: z.string().email().max(255).trim().toLowerCase(),
})
export const employeeLanguageSchema = z.object({
language: z.enum(['en', 'fr', 'ar']),
})
export const employeeResetPasswordSchema = z.object({
token: z.string().min(1),
password: z.string().min(8).max(128),
})
@@ -0,0 +1,105 @@
import bcrypt from 'bcryptjs'
import crypto from 'crypto'
import jwt from 'jsonwebtoken'
import { AppError } from '../../http/errors'
import { sendTransactionalEmail } from '../../services/notificationService'
import { resetPasswordEmail, type Lang } from '../../lib/emailTranslations'
import { presentEmployeeSession } from './auth.presenter'
import * as repo from './auth.employee.repo'
import { employeeLanguageSchema, employeeLoginSchema } from './auth.employee.schemas'
import type { output } from 'zod'
const RESET_TOKEN_TTL_MINUTES = 60
type EmployeeLoginInput = output<typeof employeeLoginSchema>
type EmployeeLanguageInput = output<typeof employeeLanguageSchema>
function signEmployeeToken(employeeId: string) {
return jwt.sign(
{ sub: employeeId, type: 'employee' },
process.env.JWT_SECRET!,
{ expiresIn: (process.env.JWT_EXPIRY ?? '8h') as jwt.SignOptions['expiresIn'] },
)
}
function ensureDashboardBasePath(baseUrl: string) {
try {
const url = new URL(baseUrl)
const pathname = url.pathname.replace(/\/$/, '')
if (!pathname.endsWith('/dashboard')) url.pathname = `${pathname}/dashboard`
return url.toString().replace(/\/$/, '')
} catch {
const trimmed = baseUrl.replace(/\/$/, '')
return trimmed.endsWith('/dashboard') ? trimmed : `${trimmed}/dashboard`
}
}
export async function getMe(employeeId: string) {
const employee = await repo.findEmployeeWithCompanyById(employeeId)
if (!employee || !employee.isActive) {
throw new AppError('Employee account not found or inactive', 401, 'unauthenticated')
}
return presentEmployeeSession(employee)
}
export async function login(body: EmployeeLoginInput) {
const employee = await repo.findEmployeeWithCompanyByEmail(body.email)
if (!employee || !employee.isActive) {
throw new AppError('Invalid email or password', 401, 'invalid_credentials')
}
if (!employee.passwordHash) {
throw new AppError('This account does not have a password yet. Use the invitation or reset email to set one first.', 401, 'password_not_set')
}
const validPassword = await bcrypt.compare(body.password, employee.passwordHash)
if (!validPassword) {
throw new AppError('Invalid email or password', 401, 'invalid_credentials')
}
return presentEmployeeSession(employee, signEmployeeToken(employee.id))
}
export async function forgotPassword(email: string) {
const employee = await repo.findActiveEmployeeByEmail(email)
if (employee) {
const rawToken = crypto.randomBytes(32).toString('hex')
const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_MINUTES * 60 * 1000)
await repo.setPasswordResetToken(employee.id, rawToken, expiresAt)
const dashboardUrl = ensureDashboardBasePath(
process.env.DASHBOARD_URL ?? process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001/dashboard',
)
const resetUrl = `${dashboardUrl}/reset-password?token=${rawToken}`
const lang = (employee.preferredLanguage as Lang) ?? 'fr'
await sendTransactionalEmail({
to: email,
subject: resetPasswordEmail.subject(lang),
html: resetPasswordEmail.html(resetUrl, employee.firstName, RESET_TOKEN_TTL_MINUTES, lang),
text: resetPasswordEmail.text(resetUrl, employee.firstName, RESET_TOKEN_TTL_MINUTES, lang),
}).catch((err) => console.error('[ForgotPassword]', err?.message))
}
return { message: 'If that email is registered, a reset link has been sent.' }
}
export async function updateLanguage(employeeId: string, language: EmployeeLanguageInput['language']) {
await repo.updatePreferredLanguage(employeeId, language)
return { language }
}
export async function resetPassword(token: string, password: string) {
const employee = await repo.findEmployeeByResetToken(token)
if (!employee) {
throw new AppError('Reset link is invalid or has expired.', 400, 'invalid_token')
}
await repo.resetPassword(employee.id, await bcrypt.hash(password, 12))
return { message: 'Password updated successfully. You can now sign in.' }
}
@@ -0,0 +1,86 @@
type EmployeeWithCompany = {
id: string
email: string
firstName: string
lastName: string
role: string
preferredLanguage?: string | null
companyId: string
company: {
name: string
slug: string
}
}
type CompanySignupResult = {
company: {
id: string
name: string
slug: string
}
trialEndAt: Date
emailDelivery?: unknown
}
type RenterProfile = {
id: string
firstName: string
lastName: string
email: string
phone: string | null
preferredLocale: string | null
preferredCurrency: string | null
emailVerified: boolean
savedCompanies: Array<{ companyId: string }>
}
type SavedCompany = {
id: string
brand: {
displayName: string | null
subdomain: string | null
logoUrl: string | null
} | null
}
export function presentEmployeeSession(employee: EmployeeWithCompany, token?: string) {
const data = {
employee: {
id: employee.id,
email: employee.email,
firstName: employee.firstName,
lastName: employee.lastName,
role: employee.role,
preferredLanguage: employee.preferredLanguage,
companyId: employee.companyId,
companyName: employee.company.name,
companySlug: employee.company.slug,
},
}
return token ? { token, ...data } : data
}
export function presentCompanySignup(result: CompanySignupResult) {
return {
companyId: result.company.id,
companyName: result.company.name,
slug: result.company.slug,
invitationId: null,
trialEndsAt: result.trialEndAt.toISOString(),
nextStep: 'workspace_created',
emailDelivery: result.emailDelivery ?? { attempted: false, success: false, error: null },
}
}
export function presentRenterProfile(renter: RenterProfile, companies: SavedCompany[]) {
const companyMap = new Map(companies.map((company) => [company.id, company]))
return {
...renter,
savedCompanies: renter.savedCompanies.map((savedCompany) => ({
id: savedCompany.companyId,
brand: companyMap.get(savedCompany.companyId)?.brand ?? null,
})),
}
}
@@ -0,0 +1,50 @@
import { prisma } from '../../lib/prisma'
import type { output } from 'zod'
import { renterUpdateSchema } from './auth.renter.schemas'
type RenterUpdateInput = output<typeof renterUpdateSchema>
export function findRenterProfile(id: string) {
return prisma.renter.findUniqueOrThrow({
where: { id },
select: {
id: true,
firstName: true,
lastName: true,
email: true,
phone: true,
preferredLocale: true,
preferredCurrency: true,
emailVerified: true,
savedCompanies: { select: { companyId: true } },
},
})
}
export function findSavedCompanies(companyIds: string[]) {
return companyIds.length === 0
? Promise.resolve([])
: prisma.company.findMany({
where: { id: { in: companyIds } },
select: {
id: true,
brand: {
select: { displayName: true, subdomain: true, logoUrl: true },
},
},
})
}
export function updateRenterProfile(id: string, data: RenterUpdateInput) {
return prisma.renter.update({
where: { id },
data,
})
}
export function updateRenterFcmToken(id: string, fcmToken: string) {
return prisma.renter.update({
where: { id },
data: { fcmToken },
})
}
@@ -0,0 +1,40 @@
import { Router } from 'express'
import { requireRenterAuth } from '../../middleware/requireRenterAuth'
import { parseBody } from '../../http/validate'
import { ok } from '../../http/respond'
import { renterFcmTokenSchema, renterUpdateSchema } from './auth.renter.schemas'
import * as service from './auth.renter.service'
const router = Router()
router.post('/signup', (_req, res) => {
service.signupDisabled()
res.end()
})
router.post('/login', (_req, res) => {
service.loginDisabled()
res.end()
})
router.get('/me', requireRenterAuth, async (req, res, next) => {
try {
ok(res, await service.getMe(req.renterId))
} catch (err) { next(err) }
})
router.patch('/me', requireRenterAuth, async (req, res, next) => {
try {
const body = parseBody(renterUpdateSchema, req)
ok(res, await service.updateMe(req.renterId, body))
} catch (err) { next(err) }
})
router.post('/me/fcm-token', requireRenterAuth, async (req, res, next) => {
try {
const { fcmToken } = parseBody(renterFcmTokenSchema, req)
ok(res, await service.updateFcmToken(req.renterId, fcmToken))
} catch (err) { next(err) }
})
export default router
@@ -0,0 +1,13 @@
import { z } from 'zod'
export const renterUpdateSchema = z.object({
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(),
})
export const renterFcmTokenSchema = z.object({
fcmToken: z.string(),
})
@@ -0,0 +1,30 @@
import { AppError } from '../../http/errors'
import { presentRenterProfile } from './auth.presenter'
import * as repo from './auth.renter.repo'
import type { output } from 'zod'
import { renterUpdateSchema } from './auth.renter.schemas'
type RenterUpdateInput = output<typeof renterUpdateSchema>
export function signupDisabled() {
throw new AppError('Renter account creation is disabled. Only company owners can sign in to the platform.', 403, 'renter_signup_disabled')
}
export function loginDisabled() {
throw new AppError('Renter sign-in is disabled. Only company owners can sign in to the platform.', 403, 'renter_login_disabled')
}
export async function getMe(renterId: string) {
const renter = await repo.findRenterProfile(renterId)
const savedCompanies = await repo.findSavedCompanies(renter.savedCompanies.map((savedCompany) => savedCompany.companyId))
return presentRenterProfile(renter, savedCompanies)
}
export function updateMe(renterId: string, body: RenterUpdateInput) {
return repo.updateRenterProfile(renterId, body)
}
export async function updateFcmToken(renterId: string, fcmToken: string) {
await repo.updateRenterFcmToken(renterId, fcmToken)
return { success: true }
}
@@ -0,0 +1,7 @@
export function presentCompany(company: any) {
return company
}
export function presentBrand(brand: any) {
return brand
}
@@ -0,0 +1,125 @@
import { prisma } from '../../lib/prisma'
export async function findCompany(companyId: string) {
return prisma.company.findUniqueOrThrow({
where: { id: companyId },
include: {
brand: true,
subscription: true,
_count: { select: { vehicles: true, customers: true, reservations: true } },
},
})
}
export async function updateCompany(companyId: string, data: any) {
return prisma.company.update({ where: { id: companyId }, data })
}
export async function findBrand(companyId: string) {
return prisma.brandSettings.findUnique({ where: { companyId } })
}
export async function upsertBrand(companyId: string, updateData: any, createData: any) {
return prisma.brandSettings.upsert({
where: { companyId },
update: updateData,
create: { companyId, ...createData },
})
}
export async function findContractSettings(companyId: string) {
return prisma.contractSettings.findUnique({ where: { companyId } })
}
export async function upsertContractSettings(companyId: string, data: any) {
return prisma.contractSettings.upsert({
where: { companyId },
update: data,
create: { companyId, ...data },
})
}
export async function findInsurancePolicies(companyId: string) {
return prisma.insurancePolicy.findMany({
where: { companyId },
orderBy: [{ isRequired: 'desc' }, { sortOrder: 'asc' }, { name: 'asc' }],
})
}
export async function createInsurancePolicy(companyId: string, data: any) {
return prisma.insurancePolicy.create({ data: { companyId, ...data } })
}
export async function updateInsurancePolicy(id: string, companyId: string, data: any) {
return prisma.insurancePolicy.updateMany({ where: { id, companyId }, data })
}
export async function findInsurancePolicyOrThrow(id: string) {
return prisma.insurancePolicy.findUniqueOrThrow({ where: { id } })
}
export async function deleteInsurancePolicy(id: string, companyId: string) {
return prisma.insurancePolicy.deleteMany({ where: { id, companyId } })
}
export async function findPricingRules(companyId: string) {
return prisma.pricingRule.findMany({
where: { companyId },
orderBy: [{ isActive: 'desc' }, { createdAt: 'asc' }],
})
}
export async function createPricingRule(companyId: string, data: any) {
return prisma.pricingRule.create({ data: { companyId, ...data } })
}
export async function updatePricingRule(id: string, companyId: string, data: any) {
return prisma.pricingRule.updateMany({ where: { id, companyId }, data })
}
export async function findPricingRuleOrThrow(id: string) {
return prisma.pricingRule.findUniqueOrThrow({ where: { id } })
}
export async function deletePricingRule(id: string, companyId: string) {
return prisma.pricingRule.deleteMany({ where: { id, companyId } })
}
export async function findAccountingSettings(companyId: string) {
return prisma.accountingSettings.findUnique({ where: { companyId } })
}
export async function upsertAccountingSettings(companyId: string, data: any) {
return prisma.accountingSettings.upsert({
where: { companyId },
update: data,
create: { companyId, ...data },
})
}
export async function findApiKey(companyId: string) {
return prisma.company.findUniqueOrThrow({ where: { id: companyId }, select: { apiKey: true } })
}
export async function regenerateApiKey(companyId: string) {
return prisma.company.update({
where: { id: companyId },
data: { apiKey: `api_${Math.random().toString(36).slice(2)}${Date.now()}` },
select: { apiKey: true },
})
}
export async function findBrandBySubdomain(subdomain: string, excludeCompanyId: string) {
return prisma.brandSettings.findFirst({ where: { subdomain, companyId: { not: excludeCompanyId } } })
}
export async function findBrandByCustomDomain(customDomain: string, excludeCompanyId: string) {
return prisma.brandSettings.findFirst({ where: { customDomain, companyId: { not: excludeCompanyId } } })
}
export async function clearCustomDomain(companyId: string) {
return prisma.brandSettings.updateMany({
where: { companyId },
data: { customDomain: null, customDomainVerified: false, customDomainAddedAt: null },
})
}
@@ -0,0 +1,204 @@
import { Router } from 'express'
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
import { requireTenant } from '../../middleware/requireTenant'
import { requireSubscription } from '../../middleware/requireSubscription'
import { requireRole } from '../../middleware/requireRole'
import { parseBody, parseParams } from '../../http/validate'
import { ok, created, noContent } from '../../http/respond'
import { imageUpload, assertImageFile } from '../../http/upload'
import * as service from './company.service'
import {
companySchema, brandSchema, contractSettingsSchema,
insurancePolicySchema, pricingRuleSchema, accountingSettingsSchema,
subdomainSchema, customDomainSchema, idParamSchema,
} from './company.schemas'
const router = Router()
router.use(requireCompanyAuth, requireTenant, requireSubscription)
router.get('/me', async (req, res, next) => {
try {
const company = await service.getCompany(req.companyId)
ok(res, company)
} catch (err) { next(err) }
})
router.patch('/me', requireRole('OWNER'), async (req, res, next) => {
try {
const body = parseBody(companySchema, req)
const company = await service.updateCompany(req.companyId, body)
ok(res, company)
} catch (err) { next(err) }
})
router.get('/me/brand', async (req, res, next) => {
try {
const brand = await service.getBrand(req.companyId)
ok(res, brand)
} catch (err) { next(err) }
})
router.patch('/me/brand', requireRole('OWNER'), async (req, res, next) => {
try {
const body = parseBody(brandSchema, req)
const brand = await service.updateBrand(req.companyId, body, req.company.name, req.company.slug)
ok(res, brand)
} catch (err) { next(err) }
})
router.post('/me/brand/logo', requireRole('OWNER'), imageUpload.single('file'), async (req, res, next) => {
try {
assertImageFile(req.file, 'logo')
const brand = await service.uploadLogo(req.companyId, req.company.name, req.company.slug, req.file.buffer)
ok(res, brand)
} catch (err) { next(err) }
})
router.post('/me/brand/hero', requireRole('OWNER'), imageUpload.single('file'), async (req, res, next) => {
try {
assertImageFile(req.file, 'hero image')
const brand = await service.uploadHeroImage(req.companyId, req.company.name, req.company.slug, req.file.buffer)
ok(res, brand)
} catch (err) { next(err) }
})
router.post('/me/brand/subdomain/check', requireRole('OWNER'), async (req, res, next) => {
try {
const { subdomain } = parseBody(subdomainSchema, req)
const result = await service.checkSubdomainAvailability(subdomain, req.companyId)
ok(res, result)
} catch (err) { next(err) }
})
router.post('/me/brand/custom-domain', requireRole('OWNER'), async (req, res, next) => {
try {
const { customDomain } = parseBody(customDomainSchema, req)
const brand = await service.setCustomDomain(req.companyId, req.company.name, req.company.slug, customDomain)
ok(res, brand)
} catch (err) { next(err) }
})
router.get('/me/brand/custom-domain/status', async (req, res, next) => {
try {
const status = await service.getCustomDomainStatus(req.companyId)
ok(res, status)
} catch (err) { next(err) }
})
router.delete('/me/brand/custom-domain', requireRole('OWNER'), async (req, res, next) => {
try {
const result = await service.removeCustomDomain(req.companyId)
ok(res, result)
} catch (err) { next(err) }
})
router.get('/me/contract-settings', async (req, res, next) => {
try {
const settings = await service.getContractSettings(req.companyId)
ok(res, settings)
} catch (err) { next(err) }
})
router.patch('/me/contract-settings', requireRole('MANAGER'), async (req, res, next) => {
try {
const body = parseBody(contractSettingsSchema, req)
const settings = await service.updateContractSettings(req.companyId, body)
ok(res, settings)
} catch (err) { next(err) }
})
router.get('/me/insurance-policies', async (req, res, next) => {
try {
const policies = await service.getInsurancePolicies(req.companyId)
ok(res, policies)
} catch (err) { next(err) }
})
router.post('/me/insurance-policies', requireRole('MANAGER'), async (req, res, next) => {
try {
const body = parseBody(insurancePolicySchema, req)
const policy = await service.createInsurancePolicy(req.companyId, body)
created(res, policy)
} catch (err) { next(err) }
})
router.patch('/me/insurance-policies/:id', requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const body = parseBody(insurancePolicySchema.partial(), req)
const policy = await service.updateInsurancePolicy(id, req.companyId, body)
ok(res, policy)
} catch (err) { next(err) }
})
router.delete('/me/insurance-policies/:id', requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
await service.deleteInsurancePolicy(id, req.companyId)
ok(res, { success: true })
} catch (err) { next(err) }
})
router.get('/me/pricing-rules', async (req, res, next) => {
try {
const rules = await service.getPricingRules(req.companyId)
ok(res, rules)
} catch (err) { next(err) }
})
router.post('/me/pricing-rules', requireRole('MANAGER'), async (req, res, next) => {
try {
const body = parseBody(pricingRuleSchema, req)
const rule = await service.createPricingRule(req.companyId, body)
created(res, rule)
} catch (err) { next(err) }
})
router.patch('/me/pricing-rules/:id', requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const body = parseBody(pricingRuleSchema.partial(), req)
const rule = await service.updatePricingRule(id, req.companyId, body)
ok(res, rule)
} catch (err) { next(err) }
})
router.delete('/me/pricing-rules/:id', async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
await service.deletePricingRule(id, req.companyId)
ok(res, { success: true })
} catch (err) { next(err) }
})
router.get('/me/accounting-settings', async (req, res, next) => {
try {
const settings = await service.getAccountingSettings(req.companyId)
ok(res, settings)
} catch (err) { next(err) }
})
router.patch('/me/accounting-settings', async (req, res, next) => {
try {
const body = parseBody(accountingSettingsSchema, req)
const settings = await service.updateAccountingSettings(req.companyId, body)
ok(res, settings)
} catch (err) { next(err) }
})
router.get('/me/api-key', async (req, res, next) => {
try {
const company = await service.getApiKey(req.companyId)
ok(res, company)
} catch (err) { next(err) }
})
router.post('/me/api-key/regenerate', async (req, res, next) => {
try {
const company = await service.regenerateApiKey(req.companyId)
ok(res, company)
} catch (err) { next(err) }
})
export default router
@@ -0,0 +1,158 @@
import { z } from 'zod'
export const companySchema = z.object({
name: z.string().min(1).optional(),
email: z.string().email().optional(),
phone: z.string().optional(),
address: z.record(z.unknown()).optional(),
})
export const brandSchema = z.object({
displayName: z.string().min(1).optional(),
tagline: z.string().optional(),
primaryColor: z.string().optional(),
accentColor: z.string().optional(),
publicEmail: z.string().email().optional(),
publicPhone: z.string().optional(),
publicAddress: z.string().optional(),
publicCity: z.string().optional(),
publicCountry: z.string().optional(),
websiteUrl: z.string().url().optional(),
whatsappNumber: z.string().optional(),
defaultLocale: z.string().optional(),
defaultCurrency: z.enum(['MAD', 'USD', 'EUR']).optional(),
amanpayMerchantId: z.string().optional(),
amanpaySecretKey: z.string().optional(),
paypalEmail: z.string().email().optional(),
paypalMerchantId: z.string().optional(),
isListedOnMarketplace: z.boolean().optional(),
homePageConfig: z.object({
heroTitle: z.union([z.string(), z.null()]).optional(),
heroDescription: z.union([z.string(), z.null()]).optional(),
viewOffersLabel: z.union([z.string(), z.null()]).optional(),
viewPricingLabel: z.union([z.string(), z.null()]).optional(),
contactCompanyLabel: z.union([z.string(), z.null()]).optional(),
activeOffersTitle: z.union([z.string(), z.null()]).optional(),
seeAllOffersLabel: z.union([z.string(), z.null()]).optional(),
noActiveOffersLabel: z.union([z.string(), z.null()]).optional(),
publishedVehiclesTitle: z.union([z.string(), z.null()]).optional(),
viewVehicleLabel: z.union([z.string(), z.null()]).optional(),
pricingEyebrow: z.union([z.string(), z.null()]).optional(),
pricingTitle: z.union([z.string(), z.null()]).optional(),
pricingDescription: z.union([z.string(), z.null()]).optional(),
showOffers: z.boolean().optional(),
showVehicles: z.boolean().optional(),
showPricing: z.boolean().optional(),
layout: z.object({
items: z.array(z.object({
id: z.string().min(1),
type: z.enum(['hero', 'offers', 'vehicles', 'pricing']),
x: z.number().int().min(1),
y: z.number().int().min(1),
w: z.number().int().min(1),
h: z.number().int().min(1),
})),
}).optional(),
}).optional(),
menuConfig: z.object({
aboutLabel: z.union([z.string(), z.null()]).optional(),
vehiclesLabel: z.union([z.string(), z.null()]).optional(),
offersLabel: z.union([z.string(), z.null()]).optional(),
pricingLabel: z.union([z.string(), z.null()]).optional(),
blogLabel: z.union([z.string(), z.null()]).optional(),
contactLabel: z.union([z.string(), z.null()]).optional(),
bookCarLabel: z.union([z.string(), z.null()]).optional(),
siteNavigationLabel: z.union([z.string(), z.null()]).optional(),
showAbout: z.boolean().optional(),
showVehicles: z.boolean().optional(),
showOffers: z.boolean().optional(),
showPricing: z.boolean().optional(),
showBlog: z.boolean().optional(),
showContact: z.boolean().optional(),
pageSections: z.object({
home: z.object({
hero: z.boolean().optional(), offers: z.boolean().optional(),
vehicles: z.boolean().optional(), pricing: z.boolean().optional(),
}).optional(),
about: z.object({
hero: z.boolean().optional(), highlights: z.boolean().optional(), details: z.boolean().optional(),
}).optional(),
offers: z.object({ header: z.boolean().optional(), grid: z.boolean().optional() }).optional(),
pricing: z.object({
hero: z.boolean().optional(), plans: z.boolean().optional(),
payments: z.boolean().optional(), faq: z.boolean().optional(),
}).optional(),
vehicles: z.object({
header: z.boolean().optional(), filters: z.boolean().optional(), grid: z.boolean().optional(),
}).optional(),
vehicleDetail: z.object({ gallery: z.boolean().optional(), summary: z.boolean().optional() }).optional(),
blog: z.object({ hero: z.boolean().optional(), posts: z.boolean().optional() }).optional(),
contact: z.object({ content: z.boolean().optional() }).optional(),
booking: z.object({ content: z.boolean().optional() }).optional(),
bookingConfirmation: z.object({
hero: z.boolean().optional(), summary: z.boolean().optional(), actions: z.boolean().optional(),
}).optional(),
}).optional(),
}).optional(),
})
export const contractSettingsSchema = z.object({
legalName: z.string().optional(),
registrationNumber: z.string().optional(),
taxId: z.string().optional(),
terms: z.string().optional(),
fuelPolicy: z.string().optional(),
depositPolicy: z.string().optional(),
lateFeePolicy: z.string().optional(),
damagePolicy: z.string().optional(),
contractFooterNote: z.string().optional(),
invoiceFooterNote: z.string().optional(),
signatureRequired: z.boolean().optional(),
showTax: z.boolean().optional(),
taxRate: z.number().optional(),
taxLabel: z.string().optional(),
fuelPolicyType: z.enum(['FULL_TO_FULL', 'FULL_TO_EMPTY', 'SAME_TO_SAME', 'PREPAID', 'FREE']).optional(),
fuelPolicyNote: z.string().optional(),
fuelChargePerLiter: z.number().int().optional(),
fuelShortfallFee: z.number().int().optional(),
lateFeePerHour: z.number().int().optional(),
additionalDriverCharge: z.enum(['FREE', 'PER_DAY', 'FLAT']).optional(),
additionalDriverDailyRate: z.number().int().optional(),
additionalDriverFlatRate: z.number().int().optional(),
})
export const insurancePolicySchema = z.object({
name: z.string().min(1),
description: z.string().optional(),
type: z.enum(['CDW', 'SCDW', 'THEFT', 'THIRD_PARTY', 'FULL', 'BASIC', 'ROADSIDE', 'PERSONAL', 'CUSTOM']),
chargeType: z.enum(['PER_DAY', 'PER_RENTAL', 'PERCENTAGE_OF_RENTAL']),
chargeValue: z.number().int().min(0),
isRequired: z.boolean().default(false),
isActive: z.boolean().default(true),
sortOrder: z.number().int().default(0),
})
export const pricingRuleSchema = z.object({
name: z.string().min(1),
type: z.enum(['SURCHARGE', 'DISCOUNT']),
condition: z.enum(['AGE_LESS_THAN', 'AGE_GREATER_THAN', 'LICENSE_YEARS_LESS_THAN', 'LICENSE_YEARS_GREATER_THAN']),
conditionValue: z.number().int(),
adjustmentType: z.enum(['PERCENTAGE', 'FLAT_PER_DAY', 'FLAT_TOTAL']),
adjustmentValue: z.number().int(),
isActive: z.boolean().default(true),
description: z.string().optional(),
})
export const accountingSettingsSchema = z.object({
reportingPeriod: z.enum(['WEEKLY', 'MONTHLY', 'QUARTERLY', 'ANNUAL']).optional(),
fiscalYearStart: z.number().int().min(1).max(12).optional(),
currency: z.enum(['MAD', 'USD', 'EUR']).optional(),
accountantEmail: z.string().email().optional(),
accountantName: z.string().optional(),
autoSendReport: z.boolean().optional(),
reportFormat: z.enum(['PDF', 'CSV', 'BOTH']).optional(),
})
export const subdomainSchema = z.object({ subdomain: z.string().min(3) })
export const customDomainSchema = z.object({ customDomain: z.string().min(3) })
export const idParamSchema = z.object({ id: z.string() })
@@ -0,0 +1,153 @@
import { uploadImage } from '../../lib/storage'
import { ConflictError, NotFoundError } from '../../http/errors'
import { presentCompany, presentBrand } from './company.presenter'
import * as repo from './company.repo'
function buildPaymentMethodsEnabled(input: {
amanpayMerchantId?: string | null
amanpaySecretKey?: string | null
paypalEmail?: string | null
}) {
const methods: Array<'AMANPAY' | 'PAYPAL'> = []
if (input.amanpayMerchantId && input.amanpaySecretKey) methods.push('AMANPAY')
if (input.paypalEmail) methods.push('PAYPAL')
return methods
}
export async function getCompany(companyId: string) {
return presentCompany(await repo.findCompany(companyId))
}
export async function updateCompany(companyId: string, data: any) {
return presentCompany(await repo.updateCompany(companyId, data))
}
export async function getBrand(companyId: string) {
return presentBrand(await repo.findBrand(companyId))
}
export async function updateBrand(companyId: string, body: any, companyName: string, companySlug: string) {
const current = await repo.findBrand(companyId)
const paymentMethodsEnabled = buildPaymentMethodsEnabled({
amanpayMerchantId: body.amanpayMerchantId ?? current?.amanpayMerchantId,
amanpaySecretKey: body.amanpaySecretKey ?? current?.amanpaySecretKey,
paypalEmail: body.paypalEmail ?? current?.paypalEmail,
})
return presentBrand(await repo.upsertBrand(
companyId,
{ ...body, paymentMethodsEnabled },
{ displayName: body.displayName ?? companyName, subdomain: companySlug, paymentMethodsEnabled, ...body },
))
}
export async function uploadLogo(companyId: string, companyName: string, companySlug: string, file: Buffer) {
const url = await uploadImage(file, `companies/${companyId}/brand`, 'logo')
return presentBrand(await repo.upsertBrand(
companyId,
{ logoUrl: url },
{ displayName: companyName, subdomain: companySlug, logoUrl: url },
))
}
export async function uploadHeroImage(companyId: string, companyName: string, companySlug: string, file: Buffer) {
const url = await uploadImage(file, `companies/${companyId}/brand`, 'hero')
return presentBrand(await repo.upsertBrand(
companyId,
{ heroImageUrl: url },
{ displayName: companyName, subdomain: companySlug, heroImageUrl: url },
))
}
export async function checkSubdomainAvailability(subdomain: string, companyId: string) {
const existing = await repo.findBrandBySubdomain(subdomain, companyId)
return { available: !existing }
}
export async function setCustomDomain(companyId: string, companyName: string, companySlug: string, customDomain: string) {
const normalized = customDomain.toLowerCase().trim()
const existing = await repo.findBrandByCustomDomain(normalized, companyId)
if (existing) throw new ConflictError('This custom domain is already in use')
return repo.upsertBrand(
companyId,
{ customDomain: normalized, customDomainVerified: false, customDomainAddedAt: new Date() },
{ displayName: companyName, subdomain: companySlug, customDomain: normalized, customDomainVerified: false, customDomainAddedAt: new Date() },
)
}
export async function getCustomDomainStatus(companyId: string) {
const brand = await repo.findBrand(companyId)
const customDomain = brand?.customDomain ?? null
return {
customDomain,
verified: brand?.customDomainVerified ?? false,
status: customDomain ? (brand?.customDomainVerified ? 'verified' : 'pending_dns') : 'not_configured',
dnsTarget: process.env.NEXT_PUBLIC_CUSTOM_DOMAIN_TARGET ?? 'cname.vercel-dns.com',
}
}
export async function removeCustomDomain(companyId: string) {
const result = await repo.clearCustomDomain(companyId)
return { success: result.count > 0 }
}
export async function getContractSettings(companyId: string) {
return repo.findContractSettings(companyId)
}
export async function updateContractSettings(companyId: string, data: any) {
return repo.upsertContractSettings(companyId, data)
}
export async function getInsurancePolicies(companyId: string) {
return repo.findInsurancePolicies(companyId)
}
export async function createInsurancePolicy(companyId: string, data: any) {
return repo.createInsurancePolicy(companyId, data)
}
export async function updateInsurancePolicy(id: string, companyId: string, data: any) {
const result = await repo.updateInsurancePolicy(id, companyId, data)
if (result.count === 0) throw new NotFoundError('Insurance policy not found')
return repo.findInsurancePolicyOrThrow(id)
}
export async function deleteInsurancePolicy(id: string, companyId: string) {
const result = await repo.deleteInsurancePolicy(id, companyId)
if (result.count === 0) throw new NotFoundError('Insurance policy not found')
}
export async function getPricingRules(companyId: string) {
return repo.findPricingRules(companyId)
}
export async function createPricingRule(companyId: string, data: any) {
return repo.createPricingRule(companyId, data)
}
export async function updatePricingRule(id: string, companyId: string, data: any) {
const result = await repo.updatePricingRule(id, companyId, data)
if (result.count === 0) throw new NotFoundError('Pricing rule not found')
return repo.findPricingRuleOrThrow(id)
}
export async function deletePricingRule(id: string, companyId: string) {
const result = await repo.deletePricingRule(id, companyId)
if (result.count === 0) throw new NotFoundError('Pricing rule not found')
}
export async function getAccountingSettings(companyId: string) {
return repo.findAccountingSettings(companyId)
}
export async function updateAccountingSettings(companyId: string, data: any) {
return repo.upsertAccountingSettings(companyId, data)
}
export async function getApiKey(companyId: string) {
return repo.findApiKey(companyId)
}
export async function regenerateApiKey(companyId: string) {
return repo.regenerateApiKey(companyId)
}
@@ -0,0 +1,125 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import * as repo from './company.repo'
import * as service from './company.service'
vi.mock('./company.repo')
vi.mock('../../lib/storage', () => ({
uploadImage: vi.fn().mockResolvedValue('http://localhost:4000/storage/companies/comp_1/brand/logo.jpg'),
}))
const mockCompany = {
id: 'comp_1',
name: 'Test Rentals',
slug: 'test-rentals',
email: 'info@test.com',
brand: null,
subscription: null,
_count: { vehicles: 5, customers: 10, reservations: 20 },
}
const mockBrand = {
id: 'brand_1',
companyId: 'comp_1',
displayName: 'Test Rentals',
subdomain: 'test-rentals',
logoUrl: null,
heroImageUrl: null,
customDomain: null,
customDomainVerified: false,
amanpayMerchantId: null,
amanpaySecretKey: null,
paypalEmail: null,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('company.service', () => {
describe('getCompany', () => {
it('returns company with brand and subscription', async () => {
vi.mocked(repo.findCompany).mockResolvedValue(mockCompany as any)
const result = await service.getCompany('comp_1')
expect(result).toEqual(mockCompany)
expect(repo.findCompany).toHaveBeenCalledWith('comp_1')
})
})
describe('updateCompany', () => {
it('updates company profile', async () => {
vi.mocked(repo.updateCompany).mockResolvedValue({ ...mockCompany, name: 'Updated Rentals' } as any)
const result = await service.updateCompany('comp_1', { name: 'Updated Rentals' })
expect(repo.updateCompany).toHaveBeenCalledWith('comp_1', { name: 'Updated Rentals' })
})
})
describe('uploadLogo', () => {
it('uploads logo and upserts brand', async () => {
vi.mocked(repo.upsertBrand).mockResolvedValue({ ...mockBrand, logoUrl: 'http://localhost:4000/storage/companies/comp_1/brand/logo.jpg' } as any)
const result = await service.uploadLogo('comp_1', 'Test Rentals', 'test-rentals', Buffer.from(''))
expect(repo.upsertBrand).toHaveBeenCalledWith(
'comp_1',
expect.objectContaining({ logoUrl: 'http://localhost:4000/storage/companies/comp_1/brand/logo.jpg' }),
expect.objectContaining({ logoUrl: 'http://localhost:4000/storage/companies/comp_1/brand/logo.jpg' }),
)
})
})
describe('uploadHeroImage', () => {
it('uploads hero image and upserts brand', async () => {
vi.mocked(repo.upsertBrand).mockResolvedValue({ ...mockBrand, heroImageUrl: 'http://localhost:4000/storage/companies/comp_1/brand/logo.jpg' } as any)
await service.uploadHeroImage('comp_1', 'Test Rentals', 'test-rentals', Buffer.from(''))
expect(repo.upsertBrand).toHaveBeenCalledWith(
'comp_1',
expect.objectContaining({ heroImageUrl: expect.any(String) }),
expect.objectContaining({ heroImageUrl: expect.any(String) }),
)
})
})
describe('checkSubdomainAvailability', () => {
it('returns available=true when subdomain is free', async () => {
vi.mocked(repo.findBrandBySubdomain).mockResolvedValue(null)
const result = await service.checkSubdomainAvailability('my-rental', 'comp_1')
expect(result.available).toBe(true)
})
it('returns available=false when subdomain is taken', async () => {
vi.mocked(repo.findBrandBySubdomain).mockResolvedValue(mockBrand as any)
const result = await service.checkSubdomainAvailability('taken-rental', 'comp_1')
expect(result.available).toBe(false)
})
})
describe('setCustomDomain', () => {
it('throws ConflictError when custom domain is already in use', async () => {
vi.mocked(repo.findBrandByCustomDomain).mockResolvedValue(mockBrand as any)
await expect(service.setCustomDomain('comp_1', 'Test Rentals', 'test-rentals', 'taken.com')).rejects.toThrow('This custom domain is already in use')
})
it('sets custom domain when available', async () => {
vi.mocked(repo.findBrandByCustomDomain).mockResolvedValue(null)
vi.mocked(repo.upsertBrand).mockResolvedValue({ ...mockBrand, customDomain: 'my-rental.com' } as any)
await service.setCustomDomain('comp_1', 'Test Rentals', 'test-rentals', 'My-Rental.com')
expect(repo.upsertBrand).toHaveBeenCalledWith(
'comp_1',
expect.objectContaining({ customDomain: 'my-rental.com' }),
expect.objectContaining({ customDomain: 'my-rental.com' }),
)
})
})
describe('updateInsurancePolicy', () => {
it('throws NotFoundError when policy does not belong to company', async () => {
vi.mocked(repo.updateInsurancePolicy).mockResolvedValue({ count: 0 } as any)
await expect(service.updateInsurancePolicy('pol_1', 'comp_1', { name: 'CDW' })).rejects.toThrow('Insurance policy not found')
})
})
describe('deletePricingRule', () => {
it('throws NotFoundError when rule does not belong to company', async () => {
vi.mocked(repo.deletePricingRule).mockResolvedValue({ count: 0 } as any)
await expect(service.deletePricingRule('rule_1', 'comp_1')).rejects.toThrow('Pricing rule not found')
})
})
})
@@ -0,0 +1,14 @@
import { getProtectedCustomerLicenseImageUrl } from '../../lib/storage'
export function presentCustomer(customer: any) {
return {
...customer,
licenseImageUrl: customer?.licenseImageUrl
? getProtectedCustomerLicenseImageUrl(customer.id)
: null,
}
}
export function presentCustomerList(customers: any[], meta: { total: number; page: number; pageSize: number; totalPages: number }) {
return { data: customers, ...meta }
}
@@ -0,0 +1,37 @@
import { prisma } from '../../lib/prisma'
export async function findMany(where: any, skip: number, take: number) {
return Promise.all([
prisma.customer.findMany({ where, skip, take, orderBy: { createdAt: 'desc' } }),
prisma.customer.count({ where }),
])
}
export async function findById(id: string, companyId: string) {
return prisma.customer.findFirst({
where: { id, companyId },
include: {
reservations: { include: { vehicle: true }, orderBy: { createdAt: 'desc' }, take: 20 },
},
})
}
export async function findByIdSimple(id: string, companyId: string) {
return prisma.customer.findFirst({ where: { id, companyId } })
}
export async function create(data: any) {
return prisma.customer.create({ data })
}
export async function updateMany(id: string, companyId: string, data: any) {
return prisma.customer.updateMany({ where: { id, companyId }, data })
}
export async function updateById(id: string, data: any) {
return prisma.customer.update({ where: { id }, data })
}
export async function findUniqueOrThrow(id: string) {
return prisma.customer.findUniqueOrThrow({ where: { id } })
}
@@ -0,0 +1,100 @@
import { Router } from 'express'
import { requireCompanyAuth, requireCompanyDocumentAuth } from '../../middleware/requireCompanyAuth'
import { requireTenant } from '../../middleware/requireTenant'
import { requireSubscription } from '../../middleware/requireSubscription'
import { requireRole } from '../../middleware/requireRole'
import { parseBody, parseQuery, parseParams } from '../../http/validate'
import { ok, created } from '../../http/respond'
import { imageUpload, assertImageFile } from '../../http/upload'
import * as service from './customer.service'
import { customerSchema, listQuerySchema, approveLicenseSchema, flagSchema, idParamSchema } from './customer.schemas'
const router = Router()
router.get('/:id/license-image', requireCompanyDocumentAuth, requireTenant, requireSubscription, async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const filePath = await service.getLicenseImageFile(id, req.companyId)
res.sendFile(filePath)
} catch (err) { next(err) }
})
router.use(requireCompanyAuth, requireTenant, requireSubscription)
router.get('/', async (req, res, next) => {
try {
const query = parseQuery(listQuerySchema, req)
const result = await service.listCustomers(req.companyId, query)
res.json(result)
} catch (err) { next(err) }
})
router.post('/', async (req, res, next) => {
try {
const body = parseBody(customerSchema, req)
const customer = await service.createCustomer(body, req.companyId)
created(res, customer)
} catch (err) { next(err) }
})
router.get('/:id', async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const customer = await service.getCustomer(id, req.companyId)
ok(res, customer)
} catch (err) { next(err) }
})
router.patch('/:id', requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const body = parseBody(customerSchema.partial(), req)
const customer = await service.updateCustomer(id, req.companyId, body)
ok(res, customer)
} catch (err) { next(err) }
})
router.post('/:id/flag', requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { reason } = parseBody(flagSchema, req)
await service.flagCustomer(id, req.companyId, reason)
ok(res, { success: true })
} catch (err) { next(err) }
})
router.delete('/:id/flag', requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
await service.unflagCustomer(id, req.companyId)
ok(res, { success: true })
} catch (err) { next(err) }
})
router.post('/:id/validate-license', async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const result = await service.validateCustomerLicense(id, req.companyId)
ok(res, result)
} catch (err) { next(err) }
})
router.post('/:id/license-image', imageUpload.single('file'), async (req, res, next) => {
try {
assertImageFile(req.file, 'license image')
const { id } = parseParams(idParamSchema, req)
const updated = await service.uploadLicenseImage(id, req.companyId, req.file)
ok(res, updated)
} catch (err) { next(err) }
})
router.post('/:id/approve-license', requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { decision, note } = parseBody(approveLicenseSchema, req)
const approverName = `${req.employee.firstName} ${req.employee.lastName}`
const updated = await service.approveLicense(id, req.companyId, decision, note, approverName)
ok(res, updated)
} catch (err) { next(err) }
})
export default router
@@ -0,0 +1,39 @@
import { z } from 'zod'
export 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),
})
export const customerSchema = z.object({
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().max(100).trim().optional(),
address: z.record(z.unknown()).optional(),
notes: z.string().max(2000).trim().optional(),
licenseExpiry: z.string().datetime().optional(),
licenseIssuedAt: z.string().datetime().optional(),
licenseCountry: z.string().max(100).trim().optional(),
licenseNumber: z.string().max(50).trim().optional(),
licenseCategory: z.string().max(20).trim().optional(),
})
export const listQuerySchema = paginationSchema.extend({
q: z.string().max(100).optional(),
flagged: z.enum(['true', 'false']).optional(),
})
export const approveLicenseSchema = z.object({
decision: z.enum(['APPROVE', 'DENY']),
note: z.string().optional(),
})
export const flagSchema = z.object({
reason: z.string().optional(),
})
export const idParamSchema = z.object({ id: z.string() })
@@ -0,0 +1,100 @@
import { validateAndFlagLicense } from '../../services/licenseValidationService'
import { uploadImage, deleteImage, resolveStoredFilePath } from '../../lib/storage'
import { NotFoundError } from '../../http/errors'
import { presentCustomer, presentCustomerList } from './customer.presenter'
import * as repo from './customer.repo'
export async function listCustomers(companyId: string, query: { page?: number; pageSize?: number; q?: string; flagged?: string }) {
const page = query.page ?? 1
const pageSize = query.pageSize ?? 20
const { q, flagged } = query
const safeQ = q ? q.trim().slice(0, 100) : undefined
const where: any = { companyId }
if (flagged !== undefined) where.flagged = flagged === 'true'
if (safeQ) {
where.OR = [
{ firstName: { contains: safeQ, mode: 'insensitive' } },
{ lastName: { contains: safeQ, mode: 'insensitive' } },
{ email: { contains: safeQ, mode: 'insensitive' } },
]
}
const [customers, total] = await repo.findMany(where, (page - 1) * pageSize, pageSize)
return presentCustomerList(customers.map(presentCustomer), { total, page, pageSize, totalPages: Math.ceil(total / pageSize) })
}
export async function getCustomer(id: string, companyId: string) {
const customer = await repo.findById(id, companyId)
if (!customer) throw new NotFoundError('Customer not found')
return presentCustomer(customer)
}
export async function createCustomer(data: any, companyId: string) {
const customer = await repo.create({
...data,
companyId,
dateOfBirth: data.dateOfBirth ? new Date(data.dateOfBirth) : null,
licenseExpiry: data.licenseExpiry ? new Date(data.licenseExpiry) : null,
licenseIssuedAt: data.licenseIssuedAt ? new Date(data.licenseIssuedAt) : null,
})
if (data.licenseExpiry) {
await validateAndFlagLicense(customer.id).catch(() => null)
}
return presentCustomer(customer)
}
export async function updateCustomer(id: string, companyId: string, data: any) {
const result = await repo.updateMany(id, companyId, {
...data,
dateOfBirth: data.dateOfBirth ? new Date(data.dateOfBirth) : undefined,
licenseExpiry: data.licenseExpiry ? new Date(data.licenseExpiry) : undefined,
licenseIssuedAt: data.licenseIssuedAt ? new Date(data.licenseIssuedAt) : undefined,
})
if (result.count === 0) throw new NotFoundError('Customer not found')
if (data.licenseExpiry) await validateAndFlagLicense(id).catch(() => null)
return presentCustomer(await repo.findUniqueOrThrow(id))
}
export async function flagCustomer(id: string, companyId: string, reason?: string) {
await repo.updateMany(id, companyId, { flagged: true, flagReason: reason ?? null })
}
export async function unflagCustomer(id: string, companyId: string) {
await repo.updateMany(id, companyId, { flagged: false, flagReason: null })
}
export async function validateCustomerLicense(id: string, companyId: string) {
const customer = await repo.findByIdSimple(id, companyId)
if (!customer) throw new NotFoundError('Customer not found')
return validateAndFlagLicense(customer.id)
}
export async function uploadLicenseImage(id: string, companyId: string, file: Express.Multer.File) {
const customer = await repo.findByIdSimple(id, companyId)
if (!customer) throw new NotFoundError('Customer not found')
const url = await uploadImage(file.buffer, `companies/${companyId}/customers/${customer.id}`)
if (customer.licenseImageUrl) {
await deleteImage(customer.licenseImageUrl).catch(() => null)
}
return presentCustomer(await repo.updateById(customer.id, { licenseImageUrl: url }))
}
export async function getLicenseImageFile(id: string, companyId: string) {
const customer = await repo.findByIdSimple(id, companyId)
if (!customer || !customer.licenseImageUrl) throw new NotFoundError('License image not found')
const filePath = resolveStoredFilePath(customer.licenseImageUrl)
if (!filePath) throw new NotFoundError('License image not found')
return filePath
}
export async function approveLicense(id: string, companyId: string, decision: 'APPROVE' | 'DENY', note: string | undefined, approverName: string) {
const customer = await repo.findByIdSimple(id, companyId)
if (!customer) throw new NotFoundError('Customer not found')
return presentCustomer(await repo.updateById(customer.id, {
licenseValidationStatus: decision === 'APPROVE' ? 'APPROVED' : 'DENIED',
licenseApprovedBy: approverName,
licenseApprovedAt: new Date(),
licenseApprovalNote: note ?? null,
}))
}
@@ -0,0 +1,134 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import * as repo from './customer.repo'
import * as service from './customer.service'
vi.mock('./customer.repo')
vi.mock('../../services/licenseValidationService', () => ({
validateAndFlagLicense: vi.fn().mockResolvedValue({ status: 'VALID', daysUntilExpiry: 365, requiresApproval: false, message: 'Valid' }),
}))
vi.mock('../../lib/storage', () => ({
uploadImage: vi.fn().mockResolvedValue('http://localhost:4000/storage/test.jpg'),
deleteImage: vi.fn().mockResolvedValue(undefined),
resolveStoredFilePath: vi.fn().mockReturnValue('/tmp/license.jpg'),
getProtectedCustomerLicenseImageUrl: vi.fn((customerId: string) => `http://localhost:3001/dashboard/api/v1/customers/${customerId}/license-image`),
}))
const mockCustomer = {
id: 'cust_1',
companyId: 'comp_1',
firstName: 'John',
lastName: 'Doe',
email: 'john@example.com',
licenseImageUrl: null,
flagged: false,
flagReason: null,
licenseExpiry: null,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('customer.service', () => {
describe('listCustomers', () => {
it('returns paginated customer list', async () => {
vi.mocked(repo.findMany).mockResolvedValue([[mockCustomer], 1] as any)
const result = await service.listCustomers('comp_1', { page: 1, pageSize: 20 })
expect(result.total).toBe(1)
expect(result.data).toHaveLength(1)
expect(result.totalPages).toBe(1)
})
it('filters by search query', async () => {
vi.mocked(repo.findMany).mockResolvedValue([[], 0] as any)
await service.listCustomers('comp_1', { page: 1, pageSize: 20, q: 'john' })
expect(repo.findMany).toHaveBeenCalledWith(
expect.objectContaining({ OR: expect.arrayContaining([expect.objectContaining({ firstName: expect.anything() })]) }),
0, 20,
)
})
it('filters by flagged status', async () => {
vi.mocked(repo.findMany).mockResolvedValue([[], 0] as any)
await service.listCustomers('comp_1', { page: 1, pageSize: 20, flagged: 'true' })
expect(repo.findMany).toHaveBeenCalledWith(
expect.objectContaining({ flagged: true }),
0, 20,
)
})
})
describe('createCustomer', () => {
it('creates customer and returns it', async () => {
vi.mocked(repo.create).mockResolvedValue(mockCustomer as any)
const result = await service.createCustomer({ firstName: 'John', lastName: 'Doe', email: 'john@example.com' }, 'comp_1')
expect(result).toEqual({
...mockCustomer,
licenseImageUrl: null,
})
expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({ companyId: 'comp_1' }))
})
})
describe('updateCustomer', () => {
it('throws NotFoundError when customer does not belong to company', async () => {
vi.mocked(repo.updateMany).mockResolvedValue({ count: 0 } as any)
await expect(service.updateCustomer('cust_1', 'comp_1', { firstName: 'Jane' })).rejects.toThrow('Customer not found')
})
it('updates customer and returns refreshed record', async () => {
vi.mocked(repo.updateMany).mockResolvedValue({ count: 1 } as any)
vi.mocked(repo.findUniqueOrThrow).mockResolvedValue({ ...mockCustomer, firstName: 'Jane' } as any)
const result = await service.updateCustomer('cust_1', 'comp_1', { firstName: 'Jane' })
expect(result.firstName).toBe('Jane')
expect(result.licenseImageUrl).toBeNull()
})
})
describe('flagCustomer', () => {
it('sets flagged=true with reason', async () => {
vi.mocked(repo.updateMany).mockResolvedValue({ count: 1 } as any)
await service.flagCustomer('cust_1', 'comp_1', 'Suspicious behavior')
expect(repo.updateMany).toHaveBeenCalledWith('cust_1', 'comp_1', { flagged: true, flagReason: 'Suspicious behavior' })
})
})
describe('unflagCustomer', () => {
it('clears flag and reason', async () => {
vi.mocked(repo.updateMany).mockResolvedValue({ count: 1 } as any)
await service.unflagCustomer('cust_1', 'comp_1')
expect(repo.updateMany).toHaveBeenCalledWith('cust_1', 'comp_1', { flagged: false, flagReason: null })
})
})
describe('uploadLicenseImage', () => {
it('uploads image and updates customer record', async () => {
vi.mocked(repo.findByIdSimple).mockResolvedValue(mockCustomer as any)
vi.mocked(repo.updateById).mockResolvedValue({ ...mockCustomer, licenseImageUrl: 'http://localhost:4000/storage/test.jpg' } as any)
const result = await service.uploadLicenseImage('cust_1', 'comp_1', { buffer: Buffer.from(''), mimetype: 'image/jpeg' } as any)
expect(result.licenseImageUrl).toBe('http://localhost:3001/dashboard/api/v1/customers/cust_1/license-image')
})
it('resolves a stored file path for protected downloads', async () => {
vi.mocked(repo.findByIdSimple).mockResolvedValue({ ...mockCustomer, licenseImageUrl: 'http://localhost:4000/storage/test.jpg' } as any)
const result = await service.getLicenseImageFile('cust_1', 'comp_1')
expect(result).toBe('/tmp/license.jpg')
})
})
describe('approveLicense', () => {
it('sets APPROVED status', async () => {
vi.mocked(repo.findByIdSimple).mockResolvedValue(mockCustomer as any)
vi.mocked(repo.updateById).mockResolvedValue({ ...mockCustomer, licenseValidationStatus: 'APPROVED' } as any)
const result = await service.approveLicense('cust_1', 'comp_1', 'APPROVE', undefined, 'Admin User')
expect(repo.updateById).toHaveBeenCalledWith('cust_1', expect.objectContaining({ licenseValidationStatus: 'APPROVED' }))
})
it('sets DENIED status', async () => {
vi.mocked(repo.findByIdSimple).mockResolvedValue(mockCustomer as any)
vi.mocked(repo.updateById).mockResolvedValue({ ...mockCustomer, licenseValidationStatus: 'DENIED' } as any)
await service.approveLicense('cust_1', 'comp_1', 'DENY', 'Expired document', 'Admin User')
expect(repo.updateById).toHaveBeenCalledWith('cust_1', expect.objectContaining({ licenseValidationStatus: 'DENIED' }))
})
})
})
@@ -0,0 +1,11 @@
export function presentVehicleWithAvailability<T extends { id: string }>(
vehicle: T,
availability: { available: boolean; status: string; nextAvailableAt: Date | null },
) {
return {
...vehicle,
availability: availability.available,
availabilityStatus: availability.status,
nextAvailableAt: availability.nextAvailableAt,
}
}
@@ -0,0 +1,153 @@
import { prisma } from '../../lib/prisma'
export async function findPublicOffers() {
return prisma.offer.findMany({
where: { isPublic: true, isActive: true, validFrom: { lte: new Date() }, validUntil: { gte: new Date() } },
include: { company: { include: { brand: { select: { displayName: true, logoUrl: true, subdomain: true, primaryColor: true } } } } },
orderBy: [{ isFeatured: 'desc' }, { createdAt: 'desc' }],
take: 50,
})
}
export async function findCitiesFromCompanies() {
return prisma.company.findMany({
where: {
status: { in: ['ACTIVE', 'TRIALING'] },
brand: { isListedOnMarketplace: true, publicCity: { not: null } },
},
select: { brand: { select: { publicCity: true } } },
})
}
export async function findListedCompanies(where: any, skip: number, take: number) {
return prisma.company.findMany({
where,
include: {
brand: { select: { displayName: true, logoUrl: true, subdomain: true, publicCity: true, publicCountry: true, marketplaceRating: true, primaryColor: true } },
_count: { select: { vehicles: { where: { isPublished: true, status: 'AVAILABLE' } } } },
},
skip,
take,
})
}
export async function findPublishedVehicles(where: any, skip: number, take: number) {
return prisma.vehicle.findMany({
where,
include: {
company: { include: { brand: { select: { displayName: true, logoUrl: true, subdomain: true, marketplaceRating: true, primaryColor: true } } } },
},
skip,
take,
orderBy: { dailyRate: 'asc' },
})
}
export async function findVehicleForMarketplace(vehicleId: string, companySlug: string) {
return prisma.vehicle.findFirst({
where: { id: vehicleId, isPublished: true, company: { slug: companySlug, status: { in: ['ACTIVE', 'TRIALING'] } } },
include: { company: { include: { brand: true } } },
})
}
export async function upsertMarketplaceCustomer(companyId: string, data: {
email: string; firstName: string; lastName: string; phone?: string
}) {
return prisma.customer.upsert({
where: { companyId_email: { companyId, email: data.email } },
create: { companyId, firstName: data.firstName, lastName: data.lastName, email: data.email, phone: data.phone },
update: { firstName: data.firstName, lastName: data.lastName, ...(data.phone ? { phone: data.phone } : {}) },
})
}
export async function createMarketplaceReservation(data: {
companyId: string; vehicleId: string; customerId: string
startDate: Date; endDate: Date; dailyRate: number; totalDays: number; totalAmount: number; notes?: string
}) {
return prisma.reservation.create({
data: { ...data, source: 'MARKETPLACE', status: 'DRAFT' },
})
}
export async function findCompanyPage(slug: string) {
return prisma.company.findFirst({
where: { slug, status: { in: ['ACTIVE', 'TRIALING'] } },
include: {
brand: true,
vehicles: { where: { isPublished: true, status: 'AVAILABLE' }, orderBy: { createdAt: 'desc' } },
offers: { where: { isPublic: true, isActive: true, validUntil: { gte: new Date() } }, orderBy: { isFeatured: 'desc' } },
},
})
}
export async function findCompanyBySlug(slug: string) {
return prisma.company.findFirstOrThrow({ where: { slug, status: { in: ['ACTIVE', 'TRIALING'] } } })
}
export async function findCompanyReviews(companyId: string) {
return prisma.review.findMany({
where: { companyId, isPublished: true },
include: { renter: { select: { firstName: true, lastName: true } } },
orderBy: { createdAt: 'desc' },
take: 50,
})
}
export async function findCompanyVehicles(companyId: string) {
return prisma.vehicle.findMany({
where: { companyId, isPublished: true, status: 'AVAILABLE' },
orderBy: { createdAt: 'desc' },
})
}
export async function findVehicleById(slug: string, vehicleId: string) {
const company = await prisma.company.findFirstOrThrow({ where: { slug, status: { in: ['ACTIVE', 'TRIALING'] } } })
return prisma.vehicle.findFirstOrThrow({
where: { id: vehicleId, companyId: company.id, isPublished: true, status: 'AVAILABLE' },
include: { company: { include: { brand: true } } },
})
}
export async function findCompanyOffers(companyId: string) {
return prisma.offer.findMany({
where: { companyId, isPublic: true, isActive: true, validFrom: { lte: new Date() }, validUntil: { gte: new Date() } },
orderBy: [{ isFeatured: 'desc' }, { createdAt: 'desc' }],
})
}
export async function findReservationByReviewToken(token: string) {
return prisma.reservation.findUnique({
where: { reviewToken: token },
include: {
vehicle: { select: { make: true, model: true, year: true, photos: true } },
company: { include: { brand: { select: { displayName: true, logoUrl: true } } } },
review: { select: { id: true } },
},
})
}
export async function findReservationForReviewSubmit(token: string) {
return prisma.reservation.findUnique({
where: { reviewToken: token },
include: { review: { select: { id: true } } },
})
}
export async function createReview(data: {
reservationId: string; companyId: string; renterId?: string | null
overallRating: number; vehicleRating?: number; serviceRating?: number; comment?: string
}) {
return prisma.review.create({
data: { ...data, renterId: data.renterId ?? undefined, isPublished: true },
})
}
export async function invalidateReviewToken(reservationId: string) {
return prisma.reservation.update({ where: { id: reservationId }, data: { reviewToken: null } })
}
export async function findOfferByCode(code: string) {
return prisma.offer.findFirst({
where: { promoCode: code, isActive: true, validFrom: { lte: new Date() }, validUntil: { gte: new Date() } },
})
}
@@ -0,0 +1,150 @@
import { Router } from 'express'
import { optionalRenterAuth } from '../../middleware/requireRenterAuth'
import { parseBody, parseParams, parseQuery } from '../../http/validate'
import { ok, created } from '../../http/respond'
import { isDatabaseUnavailableError } from '../../lib/isDatabaseUnavailable'
import * as service from './marketplace.service'
import {
paginationSchema, searchSchema, companiesQuerySchema, marketplaceReservationSchema,
reviewBodySchema, slugParamSchema, vehicleParamSchema, reviewTokenSchema, offerCodeParamSchema,
} from './marketplace.schemas'
const router = Router()
router.use(optionalRenterAuth)
router.get('/offers', async (_req, res, next) => {
try {
ok(res, await service.getPublicOffers())
} catch (err) {
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
next(err)
}
})
router.get('/cities', async (_req, res, next) => {
try {
ok(res, await service.getCities())
} catch (err) {
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
next(err)
}
})
router.get('/companies', async (req, res, next) => {
try {
const { city, hasOffer } = parseQuery(companiesQuerySchema, req)
const { page, pageSize } = parseQuery(paginationSchema, req)
ok(res, await service.getListedCompanies({ city, hasOffer, page, pageSize }))
} catch (err) {
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
next(err)
}
})
router.get('/search', async (req, res, next) => {
try {
const filters = parseQuery(searchSchema, req)
const pagination = parseQuery(paginationSchema, req)
ok(res, await service.searchVehicles({ ...filters, ...pagination }))
} catch (err) {
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
next(err)
}
})
router.post('/reservations', async (req, res, next) => {
try {
const body = parseBody(marketplaceReservationSchema, req)
const result = await service.createMarketplaceReservation(body)
created(res, result)
} catch (err) {
if (isDatabaseUnavailableError(err)) {
return res.status(503).json({ error: 'database_unavailable', message: 'Service temporarily unavailable', statusCode: 503 })
}
next(err)
}
})
router.get('/review/:token', async (req, res, next) => {
try {
const { token } = parseParams(reviewTokenSchema, req)
ok(res, await service.getReviewContext(token))
} catch (err) { next(err) }
})
router.post('/review/:token', async (req, res, next) => {
try {
const { token } = parseParams(reviewTokenSchema, req)
const body = parseBody(reviewBodySchema, req)
const result = await service.submitReview(token, body)
created(res, result)
} catch (err) { next(err) }
})
router.post('/offers/:code/validate', async (req, res, next) => {
try {
const { code } = parseParams(offerCodeParamSchema, req)
ok(res, await service.validateOfferCode(code))
} catch (err) {
if (isDatabaseUnavailableError(err)) {
return res.status(503).json({ error: 'database_unavailable', message: 'Offer validation is temporarily unavailable', statusCode: 503 })
}
next(err)
}
})
router.get('/:slug', async (req, res, next) => {
try {
const { slug } = parseParams(slugParamSchema, req)
ok(res, await service.getCompanyPage(slug))
} 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) => {
try {
const { slug } = parseParams(slugParamSchema, req)
ok(res, await service.getCompanyReviews(slug))
} catch (err) {
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
next(err)
}
})
router.get('/:slug/vehicles', async (req, res, next) => {
try {
const { slug } = parseParams(slugParamSchema, req)
ok(res, await service.getCompanyVehicles(slug))
} catch (err) {
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
next(err)
}
})
router.get('/:slug/vehicles/:id', async (req, res, next) => {
try {
const { slug, id } = parseParams(vehicleParamSchema, req)
ok(res, await service.getVehicleDetail(slug, id))
} 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 { slug } = parseParams(slugParamSchema, req)
ok(res, await service.getCompanyOffers(slug))
} catch (err) {
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
next(err)
}
})
export default router
@@ -0,0 +1,47 @@
import { z } from 'zod'
export 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),
})
export 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(),
make: z.string().trim().max(60).optional(),
model: z.string().trim().max(60).optional(),
})
export const companiesQuerySchema = z.object({
city: z.string().trim().max(100).optional(),
hasOffer: z.string().optional(),
})
export const marketplaceReservationSchema = z.object({
vehicleId: z.string().cuid(),
companySlug: z.string().trim().max(100),
firstName: z.string().min(1).max(100),
lastName: z.string().min(1).max(100),
email: z.string().email(),
phone: z.string().max(30).optional(),
startDate: z.string().datetime(),
endDate: z.string().datetime(),
notes: z.string().max(500).optional(),
language: z.enum(['en', 'fr', 'ar']).default('fr'),
})
export const reviewBodySchema = z.object({
overallRating: z.number().int().min(1).max(5),
vehicleRating: z.number().int().min(1).max(5).optional(),
serviceRating: z.number().int().min(1).max(5).optional(),
comment: z.string().max(2000).optional(),
})
export const slugParamSchema = z.object({ slug: z.string() })
export const vehicleParamSchema = z.object({ slug: z.string(), id: z.string() })
export const reviewTokenSchema = z.object({ token: z.string() })
export const offerCodeParamSchema = z.object({ code: z.string() })
@@ -0,0 +1,213 @@
import { AppError, NotFoundError, ConflictError } from '../../http/errors'
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
import { sendNotification, sendTransactionalEmail } from '../../services/notificationService'
import { marketplaceReservationEmail, type Lang } from '../../lib/emailTranslations'
import * as repo from './marketplace.repo'
export async function getPublicOffers() {
return repo.findPublicOffers()
}
export async function getCities() {
const rows = await repo.findCitiesFromCompanies()
const map = new Map<string, string>()
for (const row of rows) {
const city = row.brand?.publicCity?.trim()
if (!city) continue
const key = city.toLocaleLowerCase()
if (!map.has(key)) map.set(key, city)
}
return Array.from(map.values()).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
}
export async function getListedCompanies(params: {
city?: string; hasOffer?: string; page?: number; pageSize?: number
}) {
const page = params.page ?? 1
const pageSize = params.pageSize ?? 20
const where: any = { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnMarketplace: true } }
if (params.city) where.brand = { ...where.brand, publicCity: { contains: params.city, mode: 'insensitive' } }
if (params.hasOffer === 'true') {
where.offers = { some: { isPublic: true, isActive: true, validUntil: { gte: new Date() } } }
}
return repo.findListedCompanies(where, (page - 1) * pageSize, pageSize)
}
export async function searchVehicles(params: {
city?: string; startDate?: string; endDate?: string; category?: string
maxPrice?: number; transmission?: string; make?: string; model?: string
page?: number; pageSize?: number
}) {
const page = params.page ?? 1
const pageSize = params.pageSize ?? 20
const where: any = {
isPublished: true,
status: 'AVAILABLE',
company: { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnMarketplace: true } },
}
if (params.category) where.category = params.category
if (params.maxPrice !== undefined) where.dailyRate = { lte: params.maxPrice }
if (params.transmission) where.transmission = params.transmission
if (params.make) where.make = { contains: params.make, mode: 'insensitive' }
if (params.model) where.model = { contains: params.model, mode: 'insensitive' }
if (params.city) {
where.company = { ...where.company, brand: { isListedOnMarketplace: true, publicCity: { contains: params.city, mode: 'insensitive' } } }
}
const vehicles = await repo.findPublishedVehicles(where, (page - 1) * pageSize, pageSize)
const availability = await Promise.all(
vehicles.map(async (v) => {
const a = await getVehicleAvailabilitySummary(v.id, {
range: params.startDate && params.endDate
? { startDate: new Date(params.startDate), endDate: new Date(params.endDate) }
: undefined,
})
return [v.id, a] as const
}),
)
const availMap = new Map(availability)
return vehicles.map((v) => ({
...v,
availability: availMap.get(v.id)?.available ?? null,
availabilityStatus: availMap.get(v.id)?.status ?? 'AVAILABLE',
nextAvailableAt: availMap.get(v.id)?.nextAvailableAt ?? null,
}))
}
export async function createMarketplaceReservation(body: {
vehicleId: string; companySlug: string; firstName: string; lastName: string
email: string; phone?: string; startDate: string; endDate: string
notes?: string; language?: string
}) {
const startDate = new Date(body.startDate)
const endDate = new Date(body.endDate)
if (endDate <= startDate) {
throw new AppError('End date must be after start date', 400, 'invalid_dates')
}
const vehicle = await repo.findVehicleForMarketplace(body.vehicleId, body.companySlug)
if (!vehicle) throw new NotFoundError('Vehicle not found')
const availability = await getVehicleAvailabilitySummary(body.vehicleId, { range: { startDate, endDate } })
if (!availability.available) {
throw new AppError('Vehicle is not available for the selected dates', 409, 'unavailable', {
nextAvailableAt: availability.nextAvailableAt?.toISOString() ?? null,
})
}
const customer = await repo.upsertMarketplaceCustomer(vehicle.companyId, {
email: body.email, firstName: body.firstName, lastName: body.lastName, phone: body.phone,
})
const totalDays = Math.max(1, Math.ceil((endDate.getTime() - startDate.getTime()) / 86_400_000))
const totalAmount = vehicle.dailyRate * totalDays
const reservation = await repo.createMarketplaceReservation({
companyId: vehicle.companyId, vehicleId: body.vehicleId, customerId: customer.id,
startDate, endDate, dailyRate: vehicle.dailyRate, totalDays, totalAmount, notes: body.notes,
})
const lang = (body.language ?? 'fr') as Lang
const companyName = vehicle.company.brand?.displayName ?? (vehicle.company as any).name
const emailOpts = {
firstName: body.firstName, vehicleYear: vehicle.year, vehicleMake: vehicle.make, vehicleModel: vehicle.model,
companyName, startDate, endDate, totalDays,
rateDisplay: (vehicle.dailyRate / 100).toFixed(2),
totalDisplay: (totalAmount / 100).toFixed(2),
email: body.email, phone: body.phone,
}
sendTransactionalEmail({
to: body.email,
subject: marketplaceReservationEmail.subject(`${vehicle.make} ${vehicle.model}`, lang),
html: marketplaceReservationEmail.html(emailOpts, lang),
text: marketplaceReservationEmail.text(emailOpts, lang),
}).catch(() => null)
sendNotification({
type: 'NEW_BOOKING',
title: 'New Marketplace Reservation Request',
body: `${body.firstName} ${body.lastName} requested ${vehicle.make} ${vehicle.model} from ${startDate.toISOString().slice(0, 10)} to ${endDate.toISOString().slice(0, 10)}.`,
companyId: vehicle.companyId,
channels: ['IN_APP'],
}).catch(() => null)
return { reservationId: reservation.id }
}
export async function getCompanyPage(slug: string) {
const company = await repo.findCompanyPage(slug)
if (!company) throw new NotFoundError('Company not found')
const vehicles = await Promise.all(
company.vehicles.map(async (v) => {
const a = await getVehicleAvailabilitySummary(v.id)
return { ...v, availability: a.available, availabilityStatus: a.status, nextAvailableAt: a.nextAvailableAt }
}),
)
return { ...company, vehicles }
}
export async function getCompanyReviews(slug: string) {
const company = await repo.findCompanyBySlug(slug)
return repo.findCompanyReviews(company.id)
}
export async function getCompanyVehicles(slug: string) {
const company = await repo.findCompanyBySlug(slug)
return repo.findCompanyVehicles(company.id)
}
export async function getVehicleDetail(slug: string, vehicleId: string) {
const vehicle = await repo.findVehicleById(slug, vehicleId)
const availability = await getVehicleAvailabilitySummary(vehicle.id)
return { ...vehicle, availability: availability.available, availabilityStatus: availability.status, nextAvailableAt: availability.nextAvailableAt }
}
export async function getCompanyOffers(slug: string) {
const company = await repo.findCompanyBySlug(slug)
return repo.findCompanyOffers(company.id)
}
export async function getReviewContext(token: string) {
const reservation = await repo.findReservationByReviewToken(token)
if (!reservation) throw new NotFoundError('Review link is invalid or has expired')
if (reservation.review) throw new ConflictError('A review has already been submitted for this reservation')
return {
reservationId: reservation.id,
companyName: reservation.company.brand?.displayName ?? (reservation.company as any).name,
companyLogoUrl: reservation.company.brand?.logoUrl ?? null,
vehicle: `${reservation.vehicle.year} ${reservation.vehicle.make} ${reservation.vehicle.model}`,
vehiclePhoto: reservation.vehicle.photos[0] ?? null,
}
}
export async function submitReview(token: string, body: {
overallRating: number; vehicleRating?: number; serviceRating?: number; comment?: string
}) {
const reservation = await repo.findReservationForReviewSubmit(token)
if (!reservation) throw new NotFoundError('Review link is invalid or has expired')
if (reservation.review) throw new ConflictError('A review has already been submitted for this reservation')
const review = await repo.createReview({
reservationId: reservation.id,
companyId: reservation.companyId,
renterId: reservation.renterId,
...body,
})
await repo.invalidateReviewToken(reservation.id)
return { reviewId: review.id }
}
export async function validateOfferCode(code: string) {
const offer = await repo.findOfferByCode(code)
if (!offer) throw new NotFoundError('Promo code not found or expired')
if (offer.maxRedemptions && offer.redemptionCount >= offer.maxRedemptions) {
throw new ConflictError('Promo code has reached its maximum redemptions')
}
return offer
}
@@ -0,0 +1,205 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NotFoundError, ConflictError, AppError } from '../../http/errors'
vi.mock('./marketplace.repo', () => ({
findPublicOffers: vi.fn(),
findCitiesFromCompanies: vi.fn(),
findListedCompanies: vi.fn(),
findPublishedVehicles: vi.fn(),
findVehicleForMarketplace: vi.fn(),
upsertMarketplaceCustomer: vi.fn(),
createMarketplaceReservation: vi.fn(),
findCompanyPage: vi.fn(),
findCompanyBySlug: vi.fn(),
findCompanyReviews: vi.fn(),
findCompanyVehicles: vi.fn(),
findVehicleById: vi.fn(),
findCompanyOffers: vi.fn(),
findReservationByReviewToken: vi.fn(),
findReservationForReviewSubmit: vi.fn(),
createReview: vi.fn(),
invalidateReviewToken: vi.fn(),
findOfferByCode: vi.fn(),
}))
vi.mock('../../services/vehicleAvailabilityService', () => ({
getVehicleAvailabilitySummary: vi.fn(),
}))
vi.mock('../../services/notificationService', () => ({
sendNotification: vi.fn().mockResolvedValue(undefined),
sendTransactionalEmail: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('../../lib/emailTranslations', () => ({
marketplaceReservationEmail: {
subject: vi.fn(() => 'Subject'),
html: vi.fn(() => '<html>'),
text: vi.fn(() => 'text'),
},
}))
import * as repo from './marketplace.repo'
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
import {
getCities, searchVehicles, createMarketplaceReservation,
getReviewContext, submitReview, validateOfferCode,
} from './marketplace.service'
const SLUG = 'test-company'
function makeVehicle(overrides: object = {}) {
return {
id: 'v-1', dailyRate: 10000, year: 2022, make: 'Toyota', model: 'Camry',
isPublished: true, status: 'AVAILABLE', companyId: 'co-1',
company: { name: 'Test Co', brand: { displayName: 'Test Co' } },
...overrides,
}
}
beforeEach(() => { vi.clearAllMocks() })
// ────────────────────────────────────────────────────────────────────────────
describe('getCities', () => {
it('deduplicates and sorts cities case-insensitively', async () => {
vi.mocked(repo.findCitiesFromCompanies).mockResolvedValue([
{ brand: { publicCity: 'Casablanca' } },
{ brand: { publicCity: 'casablanca' } },
{ brand: { publicCity: 'Rabat' } },
{ brand: null },
] as any)
const result = await getCities()
expect(result).toEqual(['Casablanca', 'Rabat'])
})
})
// ────────────────────────────────────────────────────────────────────────────
describe('searchVehicles', () => {
it('returns vehicles enriched with availability data', async () => {
vi.mocked(repo.findPublishedVehicles).mockResolvedValue([makeVehicle()] as any)
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
const result = await searchVehicles({ city: 'Casablanca' })
expect(repo.findPublishedVehicles).toHaveBeenCalledOnce()
expect(result[0].availability).toBe(true)
expect(result[0].availabilityStatus).toBe('AVAILABLE')
})
it('passes date range to availability service when provided', async () => {
vi.mocked(repo.findPublishedVehicles).mockResolvedValue([makeVehicle()] as any)
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: false, status: 'RESERVED', nextAvailableAt: new Date('2025-07-01'), blockingReason: 'RESERVATION' })
const result = await searchVehicles({ startDate: '2025-06-01T00:00:00.000Z', endDate: '2025-06-04T00:00:00.000Z' })
expect(getVehicleAvailabilitySummary).toHaveBeenCalledWith('v-1', {
range: { startDate: new Date('2025-06-01T00:00:00.000Z'), endDate: new Date('2025-06-04T00:00:00.000Z') },
})
expect(result[0].availability).toBe(false)
})
})
// ────────────────────────────────────────────────────────────────────────────
describe('createMarketplaceReservation', () => {
const baseBody = {
vehicleId: 'v-1', companySlug: SLUG,
firstName: 'Ali', lastName: 'Ben', email: 'ali@test.com',
startDate: '2025-06-01T00:00:00.000Z', endDate: '2025-06-04T00:00:00.000Z',
}
it('creates reservation and returns reservationId', async () => {
vi.mocked(repo.findVehicleForMarketplace).mockResolvedValue(makeVehicle() as any)
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
vi.mocked(repo.upsertMarketplaceCustomer).mockResolvedValue({ id: 'c-1' } as any)
vi.mocked(repo.createMarketplaceReservation).mockResolvedValue({ id: 'r-1' } as any)
const result = await createMarketplaceReservation(baseBody)
expect(result.reservationId).toBe('r-1')
})
it('throws NotFoundError when vehicle not found', async () => {
vi.mocked(repo.findVehicleForMarketplace).mockResolvedValue(null)
await expect(createMarketplaceReservation(baseBody)).rejects.toBeInstanceOf(NotFoundError)
})
it('throws AppError when vehicle is unavailable', async () => {
vi.mocked(repo.findVehicleForMarketplace).mockResolvedValue(makeVehicle() as any)
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: false, status: 'RESERVED', nextAvailableAt: null, blockingReason: 'RESERVATION' })
await expect(createMarketplaceReservation(baseBody)).rejects.toMatchObject({ error: 'unavailable' })
})
it('throws AppError for invalid date range', async () => {
await expect(
createMarketplaceReservation({ ...baseBody, startDate: '2025-06-04T00:00:00.000Z', endDate: '2025-06-01T00:00:00.000Z' }),
).rejects.toMatchObject({ error: 'invalid_dates' })
})
})
// ────────────────────────────────────────────────────────────────────────────
describe('getReviewContext', () => {
it('returns review context for a valid unreviewd token', async () => {
vi.mocked(repo.findReservationByReviewToken).mockResolvedValue({
id: 'r-1', companyId: 'co-1', renterId: null, reviewToken: 'tok',
vehicle: { year: 2022, make: 'Toyota', model: 'Camry', photos: ['photo.jpg'] },
company: { name: 'Test Co', brand: { displayName: 'Test Co', logoUrl: 'logo.png' } },
review: null,
} as any)
const result = await getReviewContext('tok')
expect(result.reservationId).toBe('r-1')
expect(result.vehiclePhoto).toBe('photo.jpg')
})
it('throws NotFoundError for unknown token', async () => {
vi.mocked(repo.findReservationByReviewToken).mockResolvedValue(null)
await expect(getReviewContext('bad')).rejects.toBeInstanceOf(NotFoundError)
})
it('throws ConflictError when already reviewed', async () => {
vi.mocked(repo.findReservationByReviewToken).mockResolvedValue({
id: 'r-1', companyId: 'co-1', renterId: null,
vehicle: { year: 2022, make: 'Toyota', model: 'Camry', photos: [] },
company: { name: 'Test Co', brand: null },
review: { id: 'rev-1' },
} as any)
await expect(getReviewContext('tok')).rejects.toBeInstanceOf(ConflictError)
})
})
// ────────────────────────────────────────────────────────────────────────────
describe('submitReview', () => {
it('creates review and invalidates token', async () => {
vi.mocked(repo.findReservationForReviewSubmit).mockResolvedValue({ id: 'r-1', companyId: 'co-1', renterId: null, review: null } as any)
vi.mocked(repo.createReview).mockResolvedValue({ id: 'rev-1' } as any)
vi.mocked(repo.invalidateReviewToken).mockResolvedValue(undefined as any)
const result = await submitReview('tok', { overallRating: 5 })
expect(repo.createReview).toHaveBeenCalledOnce()
expect(repo.invalidateReviewToken).toHaveBeenCalledWith('r-1')
expect(result.reviewId).toBe('rev-1')
})
})
// ────────────────────────────────────────────────────────────────────────────
describe('validateOfferCode', () => {
it('returns offer when code is valid and not exhausted', async () => {
vi.mocked(repo.findOfferByCode).mockResolvedValue({ id: 'o-1', maxRedemptions: null, redemptionCount: 0 } as any)
const result = await validateOfferCode('PROMO10')
expect((result as any).id).toBe('o-1')
})
it('throws NotFoundError for unknown code', async () => {
vi.mocked(repo.findOfferByCode).mockResolvedValue(null)
await expect(validateOfferCode('BAD')).rejects.toBeInstanceOf(NotFoundError)
})
it('throws ConflictError when max redemptions reached', async () => {
vi.mocked(repo.findOfferByCode).mockResolvedValue({ id: 'o-1', maxRedemptions: 10, redemptionCount: 10 } as any)
await expect(validateOfferCode('PROMO10')).rejects.toBeInstanceOf(ConflictError)
})
})
@@ -0,0 +1,64 @@
import { prisma } from '../../lib/prisma'
import { NotificationType, NotificationChannel } from '@rentaldrivego/database'
// ─── Company notifications ────────────────────────────────────
export function findCompany(companyId: string, unread?: string) {
const where: any = { companyId, channel: 'IN_APP' }
if (unread === 'true') where.readAt = null
return prisma.notification.findMany({ where, orderBy: { createdAt: 'desc' }, take: 50 })
}
export function countUnread(companyId: string) {
return prisma.notification.count({ where: { companyId, channel: 'IN_APP', readAt: null } })
}
export function markRead(id: string, companyId: string) {
return prisma.notification.updateMany({ where: { id, companyId }, data: { readAt: new Date(), status: 'READ' } })
}
export function markAllRead(companyId: string) {
return prisma.notification.updateMany({ where: { companyId, readAt: null }, data: { readAt: new Date(), status: 'READ' } })
}
export function findEmployeePreferences(employeeId: string) {
return prisma.notificationPreference.findMany({ where: { employeeId } })
}
export async function upsertEmployeePreferences(employeeId: string, prefs: Array<{ notificationType: string; channel: string; enabled: boolean }>) {
for (const pref of prefs) {
await prisma.notificationPreference.upsert({
where: { employeeId_notificationType_channel: { employeeId, notificationType: pref.notificationType as NotificationType, channel: pref.channel as NotificationChannel } },
create: { employeeId, notificationType: pref.notificationType as NotificationType, channel: pref.channel as NotificationChannel, enabled: pref.enabled },
update: { enabled: pref.enabled },
})
}
}
// ─── Renter notifications ─────────────────────────────────────
export function findRenter(renterId: string) {
return prisma.notification.findMany({ where: { renterId, channel: 'IN_APP' }, orderBy: { createdAt: 'desc' }, take: 50 })
}
export function markRenterRead(id: string, renterId: string) {
return prisma.notification.updateMany({ where: { id, renterId }, data: { readAt: new Date(), status: 'READ' } })
}
export function markAllRenterRead(renterId: string) {
return prisma.notification.updateMany({ where: { renterId, channel: 'IN_APP', readAt: null }, data: { readAt: new Date(), status: 'READ' } })
}
export function findRenterPreferences(renterId: string) {
return prisma.notificationPreference.findMany({ where: { renterId } })
}
export async function upsertRenterPreferences(renterId: string, prefs: Array<{ notificationType: string; channel: string; enabled: boolean }>) {
for (const pref of prefs) {
await prisma.notificationPreference.upsert({
where: { renterId_notificationType_channel: { renterId, notificationType: pref.notificationType as NotificationType, channel: pref.channel as NotificationChannel } },
create: { renterId, notificationType: pref.notificationType as NotificationType, channel: pref.channel as NotificationChannel, enabled: pref.enabled },
update: { enabled: pref.enabled },
})
}
}
@@ -0,0 +1,96 @@
import { Router } from 'express'
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
import { requireTenant } from '../../middleware/requireTenant'
import { requireSubscription } from '../../middleware/requireSubscription'
import { requireRenterAuth } from '../../middleware/requireRenterAuth'
import { parseBody, parseQuery, parseParams } from '../../http/validate'
import { ok } from '../../http/respond'
import * as service from './notification.service'
import { preferencesSchema, idParamSchema, unreadQuerySchema } from './notification.schemas'
const router = Router()
const companyAuth = [requireCompanyAuth, requireTenant, requireSubscription] as const
// ─── Company notifications ────────────────────────────────────
router.get('/company', ...companyAuth, async (req, res, next) => {
try {
const { unread } = parseQuery(unreadQuerySchema, req)
ok(res, { data: await service.listCompany(req.companyId, unread) })
} catch (err) { next(err) }
})
router.get('/unread-count', ...companyAuth, async (req, res, next) => {
try {
ok(res, { data: { unread: await service.countUnread(req.companyId) } })
} catch (err) { next(err) }
})
router.post('/company/:id/read', ...companyAuth, async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
await service.markRead(id, req.companyId)
ok(res, { data: { success: true } })
} catch (err) { next(err) }
})
router.post('/company/read-all', ...companyAuth, async (req, res, next) => {
try {
await service.markAllRead(req.companyId)
ok(res, { data: { success: true } })
} catch (err) { next(err) }
})
router.get('/company/preferences', ...companyAuth, async (req, res, next) => {
try {
ok(res, { data: await service.getPreferences(req.employee.id) })
} catch (err) { next(err) }
})
router.patch('/company/preferences', ...companyAuth, async (req, res, next) => {
try {
const prefs = parseBody(preferencesSchema, req)
await service.setPreferences(req.employee.id, prefs)
ok(res, { data: { success: true } })
} catch (err) { next(err) }
})
// ─── Renter notifications ─────────────────────────────────────
router.get('/renter', requireRenterAuth, async (req, res, next) => {
try {
ok(res, { data: await service.listRenter(req.renterId) })
} catch (err) { next(err) }
})
router.post('/renter/:id/read', requireRenterAuth, async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
await service.markRenterRead(id, req.renterId)
ok(res, { data: { success: true } })
} catch (err) { next(err) }
})
router.post('/renter/read-all', requireRenterAuth, async (req, res, next) => {
try {
await service.markAllRenterRead(req.renterId)
ok(res, { data: { success: true } })
} catch (err) { next(err) }
})
router.get('/renter/preferences', requireRenterAuth, async (req, res, next) => {
try {
ok(res, { data: await service.getRenterPreferences(req.renterId) })
} catch (err) { next(err) }
})
router.patch('/renter/preferences', requireRenterAuth, async (req, res, next) => {
try {
const prefs = parseBody(preferencesSchema, req)
await service.setRenterPreferences(req.renterId, prefs)
ok(res, { data: { success: true } })
} catch (err) { next(err) }
})
export default router
@@ -0,0 +1,17 @@
import { z } from 'zod'
export const preferenceItemSchema = z.object({
notificationType: z.string(),
channel: z.string(),
enabled: z.boolean(),
})
export const preferencesSchema = z.array(preferenceItemSchema)
export const idParamSchema = z.object({
id: z.string().min(1),
})
export const unreadQuerySchema = z.object({
unread: z.string().optional(),
})
@@ -0,0 +1,14 @@
import * as repo from './notification.repo'
export const listCompany = (companyId: string, unread?: string) => repo.findCompany(companyId, unread)
export const countUnread = (companyId: string) => repo.countUnread(companyId)
export const markRead = (id: string, companyId: string) => repo.markRead(id, companyId)
export const markAllRead = (companyId: string) => repo.markAllRead(companyId)
export const getPreferences = (employeeId: string) => repo.findEmployeePreferences(employeeId)
export const setPreferences = (employeeId: string, prefs: any[]) => repo.upsertEmployeePreferences(employeeId, prefs)
export const listRenter = (renterId: string) => repo.findRenter(renterId)
export const markRenterRead = (id: string, renterId: string) => repo.markRenterRead(id, renterId)
export const markAllRenterRead = (renterId: string) => repo.markAllRenterRead(renterId)
export const getRenterPreferences = (renterId: string) => repo.findRenterPreferences(renterId)
export const setRenterPreferences = (renterId: string, prefs: any[]) => repo.upsertRenterPreferences(renterId, prefs)
+63
View File
@@ -0,0 +1,63 @@
import { prisma } from '../../lib/prisma'
export function findMany(companyId: string, filters: { active?: string; public?: string }) {
const where: any = { companyId }
if (filters.active !== undefined) where.isActive = filters.active === 'true'
if (filters.public !== undefined) where.isPublic = filters.public === 'true'
return prisma.offer.findMany({ where, orderBy: { createdAt: 'desc' } })
}
export function findByIdOrThrow(id: string, companyId: string) {
return prisma.offer.findFirstOrThrow({ where: { id, companyId }, include: { vehicles: true } })
}
export function findFirst(id: string, companyId: string) {
return prisma.offer.findFirst({ where: { id, companyId } })
}
export async function create(companyId: string, data: any, vehicleIds: string[]) {
return prisma.offer.create({
data: {
...data,
companyId,
validFrom: new Date(data.validFrom),
validUntil: new Date(data.validUntil),
vehicles: vehicleIds.length > 0 ? { create: vehicleIds.map((id) => ({ vehicleId: id })) } : undefined,
},
include: { vehicles: true },
})
}
export async function update(id: string, data: any, vehicleIds?: string[]) {
return prisma.$transaction(async (tx) => {
await tx.offer.update({
where: { id },
data: {
...data,
validFrom: data.validFrom ? new Date(data.validFrom) : undefined,
validUntil: data.validUntil ? new Date(data.validUntil) : undefined,
},
})
if (vehicleIds !== undefined) {
await tx.offerVehicle.deleteMany({ where: { offerId: id } })
if (vehicleIds.length > 0) {
await tx.offerVehicle.createMany({ data: vehicleIds.map((vehicleId) => ({ offerId: id, vehicleId })) })
}
}
return tx.offer.findUniqueOrThrow({ where: { id }, include: { vehicles: true } })
})
}
export function deleteMany(id: string, companyId: string) {
return prisma.offer.deleteMany({ where: { id, companyId } })
}
export function setActive(id: string, companyId: string, isActive: boolean) {
return prisma.offer.updateMany({ where: { id, companyId }, data: { isActive } })
}
export async function getStats(id: string, companyId: string) {
const offer = await prisma.offer.findFirstOrThrow({ where: { id, companyId } })
const reservations = await prisma.reservation.count({ where: { offerId: offer.id } })
return { redemptions: offer.redemptionCount, reservations }
}
@@ -0,0 +1,75 @@
import { Router } from 'express'
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
import { requireTenant } from '../../middleware/requireTenant'
import { requireSubscription } from '../../middleware/requireSubscription'
import { requireRole } from '../../middleware/requireRole'
import { parseBody, parseQuery, parseParams } from '../../http/validate'
import { ok, created } from '../../http/respond'
import * as service from './offer.service'
import { offerSchema, listQuerySchema, idParamSchema } from './offer.schemas'
const router = Router()
router.use(requireCompanyAuth, requireTenant, requireSubscription)
router.get('/', async (req, res, next) => {
try {
const query = parseQuery(listQuerySchema, req)
ok(res, { data: await service.listOffers(req.companyId, query) })
} catch (err) { next(err) }
})
router.post('/', requireRole('MANAGER'), async (req, res, next) => {
try {
const { vehicleIds, ...body } = parseBody(offerSchema, req)
created(res, { data: await service.createOffer(req.companyId, body, vehicleIds) })
} catch (err) { next(err) }
})
router.get('/:id', async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
ok(res, { data: await service.getOffer(id, req.companyId) })
} catch (err) { next(err) }
})
router.patch('/:id', requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { vehicleIds, ...body } = parseBody(offerSchema.partial(), req)
ok(res, { data: await service.updateOffer(id, req.companyId, body, vehicleIds) })
} catch (err) { next(err) }
})
router.delete('/:id', requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
await service.deleteOffer(id, req.companyId)
ok(res, { data: { success: true } })
} catch (err) { next(err) }
})
router.post('/:id/activate', requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
await service.setOfferActive(id, req.companyId, true)
ok(res, { data: { success: true } })
} catch (err) { next(err) }
})
router.post('/:id/deactivate', requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
await service.setOfferActive(id, req.companyId, false)
ok(res, { data: { success: true } })
} catch (err) { next(err) }
})
router.get('/:id/stats', async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
ok(res, { data: await service.getOfferStats(id, req.companyId) })
} catch (err) { next(err) }
})
export default router
@@ -0,0 +1,31 @@
import { z } from 'zod'
export const offerSchema = z.object({
title: z.string().min(1),
description: z.string().optional(),
termsAndConds: z.string().optional(),
type: z.enum(['PERCENTAGE', 'FIXED_AMOUNT', 'FREE_DAY', 'SPECIAL_RATE']),
discountValue: z.number().int().min(0),
specialRate: z.number().int().optional(),
appliesToAll: z.boolean().default(true),
categories: z.array(z.enum(['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'])).default([]),
minRentalDays: z.number().int().optional(),
maxRentalDays: z.number().int().optional(),
promoCode: z.string().optional(),
maxRedemptions: z.number().int().optional(),
validFrom: z.string().datetime(),
validUntil: z.string().datetime(),
isActive: z.boolean().default(true),
isPublic: z.boolean().default(true),
isFeatured: z.boolean().default(false),
vehicleIds: z.array(z.string()).default([]),
})
export const listQuerySchema = z.object({
active: z.string().optional(),
public: z.string().optional(),
})
export const idParamSchema = z.object({
id: z.string().min(1),
})
@@ -0,0 +1,32 @@
import { NotFoundError } from '../../http/errors'
import * as repo from './offer.repo'
export function listOffers(companyId: string, filters: { active?: string; public?: string }) {
return repo.findMany(companyId, filters)
}
export function getOffer(id: string, companyId: string) {
return repo.findByIdOrThrow(id, companyId)
}
export function createOffer(companyId: string, data: any, vehicleIds: string[]) {
return repo.create(companyId, data, vehicleIds)
}
export async function updateOffer(id: string, companyId: string, data: any, vehicleIds?: string[]) {
const existing = await repo.findFirst(id, companyId)
if (!existing) throw new NotFoundError('Offer not found')
return repo.update(id, data, vehicleIds)
}
export function deleteOffer(id: string, companyId: string) {
return repo.deleteMany(id, companyId)
}
export function setOfferActive(id: string, companyId: string, isActive: boolean) {
return repo.setActive(id, companyId, isActive)
}
export function getOfferStats(id: string, companyId: string) {
return repo.getStats(id, companyId)
}
@@ -0,0 +1,77 @@
import { prisma } from '../../lib/prisma'
export function findByCompany(companyId: string) {
return prisma.rentalPayment.findMany({
where: { companyId },
include: { reservation: { include: { customer: true, vehicle: true } } },
orderBy: { createdAt: 'desc' },
take: 100,
})
}
export function findByReservation(reservationId: string, companyId: string) {
return prisma.rentalPayment.findMany({ where: { reservationId, companyId }, orderBy: { createdAt: 'desc' } })
}
export function findByAmanpay(transactionId: string) {
return prisma.rentalPayment.findFirst({ where: { amanpayTransactionId: transactionId } })
}
export function findByPaypal(captureId: string) {
return prisma.rentalPayment.findFirst({ where: { paypalCaptureId: captureId } })
}
export function findByPaypalForCompany(paypalOrderId: string, companyId: string) {
return prisma.rentalPayment.findFirstOrThrow({ where: { paypalCaptureId: paypalOrderId, companyId } })
}
export function findPaymentOrThrow(paymentId: string, companyId: string, reservationId: string) {
return prisma.rentalPayment.findFirstOrThrow({ where: { id: paymentId, companyId, reservationId } })
}
export function findReservationOrThrow(id: string, companyId: string) {
return prisma.reservation.findFirstOrThrow({
where: { id, companyId },
include: { vehicle: true, customer: true },
})
}
export function findReservation(id: string, companyId: string) {
return prisma.reservation.findFirstOrThrow({ where: { id, companyId } })
}
export function markPaymentSucceeded(id: string) {
return prisma.rentalPayment.update({ where: { id }, data: { status: 'SUCCEEDED', paidAt: new Date() } })
}
export function markPaymentFailed(query: { amanpayTransactionId?: string; paypalCaptureId?: string }) {
return prisma.rentalPayment.updateMany({ where: query, data: { status: 'FAILED' } })
}
export function incrementReservationPaid(reservationId: string, amount: number) {
return prisma.reservation.update({ where: { id: reservationId }, data: { paymentStatus: 'PAID', paidAmount: { increment: amount } } })
}
export function createPayment(data: {
companyId: string; reservationId: string; amount: number; currency: string
status: string; type: string; paymentProvider: string
amanpayTransactionId?: string | null; paypalCaptureId?: string | null; paymentMethod?: string; paidAt?: Date
}) {
return prisma.rentalPayment.create({ data: data as any })
}
export function updatePaypalCapture(id: string, captureId: string) {
return prisma.rentalPayment.update({ where: { id }, data: { status: 'SUCCEEDED', paidAt: new Date(), paypalCaptureId: captureId } })
}
export function setReservationPaidAmount(reservationId: string, paidAmount: number, paymentStatus: string) {
return prisma.reservation.update({ where: { id: reservationId }, data: { paidAmount, paymentStatus: paymentStatus as any } })
}
export function setReservationRefunded(reservationId: string) {
return prisma.reservation.update({ where: { id: reservationId }, data: { paymentStatus: 'REFUNDED' } })
}
export function setPaymentRefunded(id: string, partial: boolean) {
return prisma.rentalPayment.update({ where: { id }, data: { status: partial ? 'PARTIALLY_REFUNDED' : 'REFUNDED' } })
}
@@ -0,0 +1,88 @@
import { Router } from 'express'
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
import { requireTenant } from '../../middleware/requireTenant'
import { requireSubscription } from '../../middleware/requireSubscription'
import { requireRole } from '../../middleware/requireRole'
import { parseBody, parseParams } from '../../http/validate'
import { ok } from '../../http/respond'
import * as amanpay from '../../services/amanpayService'
import * as paypal from '../../services/paypalService'
import * as service from './payment.service'
import { chargeSchema, manualPaymentSchema, refundSchema, capturePaypalSchema, reservationParamSchema, paymentParamSchema } from './payment.schemas'
const router = Router()
// ─── Webhooks (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' })
}
await service.handleAmanpayWebhook(req.body)
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' })
await service.handlePaypalWebhook(req.body)
res.json({ received: true })
} catch (err) { next(err) }
})
// ─── Authenticated ────────────────────────────────────────────
router.use(requireCompanyAuth, requireTenant, requireSubscription)
router.get('/company', async (req, res, next) => {
try {
ok(res, { data: await service.listByCompany(req.companyId) })
} catch (err) { next(err) }
})
router.get('/reservations/:id', async (req, res, next) => {
try {
const { id } = parseParams(reservationParamSchema, req)
ok(res, { data: await service.listByReservation(id, req.companyId) })
} catch (err) { next(err) }
})
router.post('/reservations/:id/charge', requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(reservationParamSchema, req)
const body = parseBody(chargeSchema, req)
ok(res, { data: await service.initCharge(id, req.companyId, body) })
} catch (err) { next(err) }
})
router.post('/reservations/:id/capture-paypal', requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(reservationParamSchema, req)
const { paypalOrderId } = parseBody(capturePaypalSchema, req)
ok(res, { data: await service.capturePaypal(id, req.companyId, paypalOrderId) })
} catch (err) { next(err) }
})
router.post('/reservations/:id/manual', requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(reservationParamSchema, req)
const body = parseBody(manualPaymentSchema, req)
ok(res, { data: await service.recordManualPayment(id, req.companyId, body) })
} catch (err) { next(err) }
})
router.post('/reservations/:reservationId/payments/:paymentId/refund', requireRole('MANAGER'), async (req, res, next) => {
try {
const { reservationId, paymentId } = parseParams(paymentParamSchema, req)
const { amount, reason } = parseBody(refundSchema, req)
ok(res, { data: await service.refundPayment(reservationId, paymentId, req.companyId, amount, reason) })
} catch (err) { next(err) }
})
export default router
@@ -0,0 +1,34 @@
import { z } from 'zod'
export 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(),
})
export const manualPaymentSchema = z.object({
amount: z.number().int().positive(),
currency: z.enum(['MAD', 'USD', 'EUR']).default('MAD'),
type: z.enum(['CHARGE', 'DEPOSIT']).default('CHARGE'),
paymentMethod: z.enum(['CASH', 'CHECK', 'BANK_TRANSFER', 'CARD', 'PAYPAL']),
})
export const refundSchema = z.object({
amount: z.number().int().positive().optional(),
reason: z.string().optional(),
})
export const capturePaypalSchema = z.object({
paypalOrderId: z.string(),
})
export const reservationParamSchema = z.object({
id: z.string().min(1),
})
export const paymentParamSchema = z.object({
reservationId: z.string().min(1),
paymentId: z.string().min(1),
})
@@ -0,0 +1,121 @@
import { ConflictError, ValidationError } from '../../http/errors'
import * as amanpay from '../../services/amanpayService'
import * as paypal from '../../services/paypalService'
import * as repo from './payment.repo'
export function listByCompany(companyId: string) {
return repo.findByCompany(companyId)
}
export function listByReservation(reservationId: string, companyId: string) {
return repo.findByReservation(reservationId, companyId)
}
export async function handleAmanpayWebhook(event: any) {
const transactionId = event.transaction_id ?? event.id
const status = event.status?.toUpperCase()
if (status === 'PAID' || status === 'SUCCEEDED') {
const payment = await repo.findByAmanpay(transactionId)
if (payment) {
await repo.markPaymentSucceeded(payment.id)
await repo.incrementReservationPaid(payment.reservationId, payment.amount)
}
} else if (status === 'FAILED') {
await repo.markPaymentFailed({ amanpayTransactionId: transactionId })
}
}
export async function handlePaypalWebhook(event: any) {
const eventType = event.event_type as string
if (eventType === 'PAYMENT.CAPTURE.COMPLETED') {
const captureId = event.resource?.id as string
const payment = await repo.findByPaypal(captureId)
if (payment) {
await repo.markPaymentSucceeded(payment.id)
await repo.incrementReservationPaid(payment.reservationId, payment.amount)
}
} else if (eventType === 'PAYMENT.CAPTURE.DENIED') {
await repo.markPaymentFailed({ paypalCaptureId: event.resource?.id })
}
}
export async function initCharge(reservationId: string, companyId: string, body: {
provider: 'AMANPAY' | 'PAYPAL'; type: 'CHARGE' | 'DEPOSIT'
currency: 'MAD' | 'USD' | 'EUR'; successUrl: string; failureUrl: string
}) {
const reservation = await repo.findReservationOrThrow(reservationId, companyId)
if (reservation.paymentStatus === 'PAID') throw new ConflictError('Reservation is already fully paid')
const amount = body.type === 'DEPOSIT' ? reservation.depositAmount : reservation.totalAmount
const description = `${body.type === 'DEPOSIT' ? 'Deposit' : 'Rental'}: ${reservation.vehicle.make} ${reservation.vehicle.model}`
const orderId = `${reservationId}-${body.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 (body.provider === 'AMANPAY') {
if (!amanpay.isConfigured()) throw new ValidationError('AmanPay is not configured')
const result = await amanpay.createCheckout({
amount, currency: body.currency, orderId, description,
customerEmail: reservation.customer.email,
customerName: `${reservation.customer.firstName} ${reservation.customer.lastName}`,
successUrl: body.successUrl, failureUrl: body.failureUrl,
webhookUrl: `${webhookBase}/api/v1/payments/webhooks/amanpay`,
})
checkoutUrl = result.checkoutUrl
amanpayTransactionId = result.transactionId
} else {
if (!paypal.isConfigured()) throw new ValidationError('PayPal is not configured')
const result = await paypal.createOrder({ amount, currency: body.currency, orderId, description, returnUrl: body.successUrl, cancelUrl: body.failureUrl })
checkoutUrl = result.approveUrl
paypalCaptureId = result.orderId
}
const payment = await repo.createPayment({ companyId, reservationId, amount, currency: body.currency, status: 'PENDING', type: body.type, paymentProvider: body.provider, amanpayTransactionId, paypalCaptureId })
return { payment, checkoutUrl }
}
export async function capturePaypal(reservationId: string, companyId: string, paypalOrderId: string) {
const payment = await repo.findByPaypalForCompany(paypalOrderId, companyId)
const capture = await paypal.captureOrder(paypalOrderId) as Record<string, any>
const captureId = capture.purchase_units?.[0]?.payments?.captures?.[0]?.id ?? paypalOrderId
const updated = await repo.updatePaypalCapture(payment.id, captureId)
await repo.incrementReservationPaid(payment.reservationId, payment.amount)
return updated
}
export async function recordManualPayment(reservationId: string, companyId: string, body: {
amount: number; currency: string; type: string; paymentMethod: string
}) {
const reservation = await repo.findReservation(reservationId, companyId)
const remaining = Math.max(reservation.totalAmount - reservation.paidAmount, 0)
if (remaining <= 0) throw new ConflictError('Reservation is already fully paid')
if (body.amount > remaining) throw new ValidationError('Payment amount exceeds remaining balance')
const payment = await repo.createPayment({ companyId, reservationId, amount: body.amount, currency: body.currency, status: 'SUCCEEDED', type: body.type, paymentProvider: body.paymentMethod === 'PAYPAL' ? 'PAYPAL' : 'AMANPAY', paymentMethod: body.paymentMethod, paidAt: new Date() })
const newPaidAmount = reservation.paidAmount + body.amount
await repo.setReservationPaidAmount(reservationId, newPaidAmount, newPaidAmount >= reservation.totalAmount ? 'PAID' : 'PARTIAL')
return payment
}
export async function refundPayment(reservationId: string, paymentId: string, companyId: string, amount?: number, reason?: string) {
const payment = await repo.findPaymentOrThrow(paymentId, companyId, reservationId)
if (payment.status !== 'SUCCEEDED') throw new ValidationError('Only succeeded payments can be refunded')
if (!payment.amanpayTransactionId && !payment.paypalCaptureId) throw new ValidationError('Manual payments must be refunded outside the online gateway flow')
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 repo.setPaymentRefunded(payment.id, isPartial)
if (!isPartial) await repo.setReservationRefunded(payment.reservationId)
return updated
}
@@ -0,0 +1,19 @@
export { applyAdditionalDriversToReservation, calculateAdditionalDriverCharge } from '../../services/additionalDriverService'
import * as repo from './reservation.repo'
export async function approveAdditionalDriver(
reservationId: string,
driverId: string,
companyId: string,
approved: boolean,
note: string | undefined,
approvedBy: string,
) {
const driver = await repo.findAdditionalDriver(driverId, reservationId, companyId)
return repo.updateAdditionalDriver(driver.id, {
approvedAt: approved ? new Date() : null,
approvedBy: approved ? approvedBy : null,
approvalNote: note ?? driver.approvalNote,
})
}
@@ -0,0 +1,263 @@
import { prisma } from '../../lib/prisma'
import * as repo from './reservation.repo'
export function formatDocumentNumber(prefix: string, sequence: number): string {
return `${prefix}-${String(sequence).padStart(6, '0')}`
}
export async function ensureReservationDocumentNumbers(companyId: string, reservationId: string) {
return prisma.$transaction(async (tx) => {
const reservation = await tx.reservation.findFirstOrThrow({
where: { id: reservationId, companyId },
select: { id: true, contractNumber: true, invoiceNumber: true },
})
if (reservation.contractNumber && reservation.invoiceNumber) return reservation
const settings = await tx.contractSettings.upsert({
where: { companyId },
update: {},
create: { companyId },
})
const data: { contractNumber?: string; invoiceNumber?: string } = {}
const settingsUpdate: { lastContractSeq?: number; lastInvoiceSeq?: number } = {}
if (!reservation.contractNumber) {
const nextSeq = settings.lastContractSeq + 1
data.contractNumber = formatDocumentNumber(settings.contractPrefix, nextSeq)
settingsUpdate.lastContractSeq = nextSeq
}
if (!reservation.invoiceNumber) {
const nextSeq = settings.lastInvoiceSeq + 1
data.invoiceNumber = formatDocumentNumber(settings.invoicePrefix, nextSeq)
settingsUpdate.lastInvoiceSeq = nextSeq
}
if (Object.keys(settingsUpdate).length > 0) {
await tx.contractSettings.update({ where: { companyId }, data: settingsUpdate })
}
return tx.reservation.update({
where: { id: reservationId },
data,
select: { id: true, contractNumber: true, invoiceNumber: true },
})
})
}
export function buildReservationInvoiceLineItems(reservation: {
dailyRate: number
totalDays: number
discountAmount: number
depositAmount: number
pricingRulesApplied: Array<{ name?: string; amount?: number; type?: string }> | null
insurances: Array<{ id: string; policyName: string; chargeType: string; chargeValue: number; totalCharge: number }>
additionalDrivers: Array<{ id: string; firstName: string; lastName: string; totalCharge: number }>
vehicle: { year: number; make: string; model: string }
}) {
const baseAmount = reservation.dailyRate * reservation.totalDays
return [
{
description: `${reservation.vehicle.year} ${reservation.vehicle.make} ${reservation.vehicle.model}`,
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.pricingRulesApplied ?? []).map((rule, i) => ({
description: rule.name?.trim() || `Pricing adjustment ${i + 1}`,
qty: 1,
unitPrice: Number(rule.amount ?? 0),
total: Number(rule.amount ?? 0),
category: 'PRICING_RULE',
}))),
...(reservation.discountAmount > 0 ? [{
description: 'Discount',
qty: 1,
unitPrice: -reservation.discountAmount,
total: -reservation.discountAmount,
category: 'DISCOUNT',
}] : []),
...(reservation.depositAmount > 0 ? [{
description: 'Security deposit',
qty: 1,
unitPrice: reservation.depositAmount,
total: reservation.depositAmount,
category: 'DEPOSIT',
}] : []),
]
}
export async function getContract(id: string, companyId: string) {
await ensureReservationDocumentNumbers(companyId, id)
const reservation = await repo.findForContract(id, companyId)
const contractSettings = reservation.company.contractSettings ?? await prisma.contractSettings.upsert({
where: { companyId },
update: {},
create: { companyId },
})
const extras = reservation.extras && typeof reservation.extras === 'object' && !Array.isArray(reservation.extras)
? (reservation.extras as Record<string, unknown>)
: {}
const invoiceLineItems = buildReservationInvoiceLineItems({
dailyRate: reservation.dailyRate,
totalDays: reservation.totalDays,
discountAmount: reservation.discountAmount,
depositAmount: reservation.depositAmount,
pricingRulesApplied: Array.isArray(reservation.pricingRulesApplied)
? (reservation.pricingRulesApplied as any)
: [],
insurances: reservation.insurances,
additionalDrivers: reservation.additionalDrivers,
vehicle: reservation.vehicle,
})
const subtotal = invoiceLineItems.reduce((s, i) => s + i.total, 0)
const taxes = contractSettings.showTax && contractSettings.taxRate
? [{ label: contractSettings.taxLabel?.trim() || 'Tax', rate: contractSettings.taxRate, amount: Math.round(subtotal * contractSettings.taxRate / 100) }]
: []
const taxTotal = taxes.reduce((s, t) => s + t.amount, 0)
const grandTotal = subtotal + taxTotal
const amountPaid = reservation.rentalPayments
.filter((p: any) => p.status === 'SUCCEEDED')
.reduce((s: number, p: any) => s + p.amount, 0)
const checkInInspection = reservation.inspections.find((i: any) => i.type === 'CHECKIN') ?? null
const checkOutInspection = reservation.inspections.find((i: any) => i.type === 'CHECKOUT') ?? null
return {
reservationId: reservation.id,
contractLocale: reservation.company.brand?.defaultLocale ?? 'en',
contractNumber: reservation.contractNumber,
invoiceNumber: reservation.invoiceNumber,
generatedAt: new Date().toISOString(),
status: reservation.status,
paymentStatus: reservation.paymentStatus,
paymentMode: typeof extras.paymentMode === 'string' ? extras.paymentMode : null,
notes: reservation.notes,
company: {
name: reservation.company.brand?.displayName ?? reservation.company.name,
legalName: contractSettings.legalName || reservation.company.name,
email: reservation.company.brand?.publicEmail ?? reservation.company.email,
phone: reservation.company.brand?.publicPhone ?? reservation.company.phone,
address: reservation.company.brand?.publicAddress ?? reservation.company.address ?? null,
city: reservation.company.brand?.publicCity ?? null,
country: reservation.company.brand?.publicCountry ?? null,
registrationNumber: contractSettings.registrationNumber,
taxId: contractSettings.taxId,
logoUrl: reservation.company.brand?.logoUrl ?? null,
},
driver: {
firstName: reservation.customer.firstName,
lastName: reservation.customer.lastName,
email: reservation.customer.email,
phone: reservation.customer.phone,
dateOfBirth: reservation.customer.dateOfBirth,
driverLicense: reservation.customer.driverLicense,
licenseCountry: reservation.customer.licenseCountry,
licenseCategory: reservation.customer.licenseCategory,
licenseIssuedAt: reservation.customer.licenseIssuedAt,
licenseExpiry: reservation.customer.licenseExpiry,
nationality: reservation.customer.nationality,
},
additionalDrivers: reservation.additionalDrivers.map((d: any) => ({
id: d.id, firstName: d.firstName, lastName: d.lastName,
email: d.email, phone: d.phone, driverLicense: d.driverLicense,
licenseIssuedAt: d.licenseIssuedAt, licenseExpiry: d.licenseExpiry,
licenseCountry: d.nationality, dateOfBirth: d.dateOfBirth, totalCharge: d.totalCharge,
})),
vehicle: {
make: reservation.vehicle.make, model: reservation.vehicle.model, year: reservation.vehicle.year,
color: reservation.vehicle.color, licensePlate: reservation.vehicle.licensePlate,
vin: reservation.vehicle.vin, category: reservation.vehicle.category,
},
rentalPeriod: {
startDate: reservation.startDate, endDate: reservation.endDate, totalDays: reservation.totalDays,
pickupLocation: reservation.pickupLocation, returnLocation: reservation.returnLocation,
},
insurance: {
total: reservation.insuranceTotal,
policies: reservation.insurances.map((ins: any) => ({
id: ins.id, name: ins.policyName, chargeType: ins.chargeType,
unitPrice: ins.chargeValue, totalCharge: ins.totalCharge,
})),
},
inspections: { checkIn: checkInInspection, checkOut: checkOutInspection },
terms: {
terms: contractSettings.terms,
fuelPolicy: contractSettings.fuelPolicy,
fuelPolicyType: contractSettings.fuelPolicyType,
depositPolicy: contractSettings.depositPolicy,
lateFeePolicy: contractSettings.lateFeePolicy,
damagePolicy: contractSettings.damagePolicy,
footerNote: contractSettings.contractFooterNote,
signatureRequired: contractSettings.signatureRequired,
},
invoice: {
currency: reservation.company.accountingSettings?.currency ?? reservation.company.brand?.defaultCurrency ?? 'MAD',
lineItems: invoiceLineItems,
subtotal, taxes, taxTotal, total: grandTotal, amountPaid,
balanceDue: grandTotal - amountPaid,
payments: reservation.rentalPayments.map((p: any) => ({
id: p.id, amount: p.amount, currency: p.currency, type: p.type,
status: p.status, provider: p.paymentProvider,
paymentMethod: p.paymentMethod, paidAt: p.paidAt, createdAt: p.createdAt,
})),
},
}
}
export async function getBilling(id: string, companyId: string) {
const reservation = await repo.findForBilling(id, companyId)
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: any) => ({
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: any) => d.totalCharge > 0)
.map((d: any) => ({
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',
}] : []),
]
const grandTotal = lineItems.reduce((s, i) => s + i.total, 0) - reservation.discountAmount
return {
lineItems,
discountAmount: reservation.discountAmount,
pricingRulesApplied: reservation.pricingRulesApplied,
pricingRulesTotal: reservation.pricingRulesTotal,
grandTotal,
}
}
@@ -0,0 +1,103 @@
import { prisma } from '../../lib/prisma'
import { AppError } from '../../http/errors'
import { buildReservationWorkflow } from './reservation.presenter'
import * as repo from './reservation.repo'
export async function getInspections(reservationId: string, companyId: string) {
return repo.findInspections(reservationId, companyId)
}
export async function upsertInspection(
reservationId: string,
companyId: string,
type: 'CHECKIN' | 'CHECKOUT',
body: {
mileage?: number
fuelLevel: string
fuelCharge?: number
generalCondition?: string
employeeNotes?: string
customerAgreed?: boolean
damagePoints?: Array<{
viewType: string; x: number; y: number; damageType: string
severity?: string; description?: string; isPreExisting?: boolean
}>
},
inspectedBy: string,
) {
const reservation = await repo.findForInspection(reservationId, companyId)
const workflow = buildReservationWorkflow(reservation)
if (workflow.closed) throw new AppError('This reservation has already been closed', 400, 'reservation_closed')
if (type === 'CHECKIN' && !workflow.checkInInspectionEditable) {
throw new AppError('Check-in inspection is not editable at this stage', 400, 'invalid_status')
}
if (type === 'CHECKOUT' && !workflow.checkOutInspectionEditable) {
throw new AppError('Check-out inspection is only editable after the vehicle is returned', 400, 'invalid_status')
}
const inspection = await prisma.damageInspection.upsert({
where: { reservationId_type: { reservationId, type } },
update: {
mileage: body.mileage ?? null,
fuelLevel: body.fuelLevel as any,
fuelCharge: body.fuelCharge ?? null,
generalCondition: body.generalCondition ?? null,
employeeNotes: body.employeeNotes ?? null,
customerAgreed: body.customerAgreed ?? false,
inspectedBy,
inspectedAt: new Date(),
damagePoints: { deleteMany: {}, create: body.damagePoints as any },
},
create: {
reservationId, companyId, type,
mileage: body.mileage ?? null,
fuelLevel: body.fuelLevel as any,
fuelCharge: body.fuelCharge ?? null,
generalCondition: body.generalCondition ?? null,
employeeNotes: body.employeeNotes ?? null,
customerAgreed: body.customerAgreed ?? false,
inspectedBy,
damagePoints: { create: body.damagePoints as any },
},
include: { damagePoints: true },
})
const damageData = (body.damagePoints ?? []).map((p) => ({
viewType: p.viewType, x: p.x, y: p.y, damageType: p.damageType,
severity: p.severity ?? 'MINOR', note: p.description ?? '', isPreExisting: p.isPreExisting ?? false,
}))
await prisma.damageReport.upsert({
where: { reservationId_type: { reservationId, type } },
update: {
damages: damageData,
fuelLevel: body.fuelLevel as any,
mileage: body.mileage ?? null,
inspectedAt: new Date(),
inspectedBy,
customerSignedAt: (body.customerAgreed ?? false) ? new Date() : null,
customerName: `${reservation.customer.firstName} ${reservation.customer.lastName}`,
},
create: {
reservationId, companyId, type,
damages: damageData,
photos: [],
fuelLevel: body.fuelLevel as any,
mileage: body.mileage ?? null,
inspectedAt: new Date(),
inspectedBy,
customerSignedAt: (body.customerAgreed ?? false) ? new Date() : null,
customerName: `${reservation.customer.firstName} ${reservation.customer.lastName}`,
},
})
await prisma.reservation.update({
where: { id: reservationId },
data: type === 'CHECKIN'
? { checkInMileage: body.mileage ?? null, checkInFuelLevel: body.fuelLevel }
: { checkOutMileage: body.mileage ?? null, checkOutFuelLevel: body.fuelLevel },
})
return inspection
}
@@ -0,0 +1 @@
export { applyInsurancesToReservation, calculateInsuranceCharge } from '../../services/insuranceService'
@@ -0,0 +1,135 @@
import crypto from 'crypto'
import { prisma } from '../../lib/prisma'
import { AppError } from '../../http/errors'
import { validateLicense } from '../../services/licenseValidationService'
import { sendNotification, sendTransactionalEmail } from '../../services/notificationService'
import { bookingConfirmedNotif, reviewRequestEmail, type Lang } from '../../lib/emailTranslations'
import { buildReservationWorkflow, parseReservationExtras, serializeReservationForDashboard } from './reservation.presenter'
import * as repo from './reservation.repo'
async function assertLicenseCompliance(reservationId: string, companyId: string) {
const reservation = await repo.findForLicenseCheck(reservationId, companyId)
const customerLicense = validateLicense(reservation.customer.licenseExpiry)
const customerDenied = ['DENIED', 'EXPIRED'].includes(reservation.customer.licenseValidationStatus)
if (customerDenied || customerLicense.status === 'EXPIRED') {
throw new AppError('Primary driver license is not valid for this reservation', 400, 'invalid_primary_license')
}
if (customerLicense.requiresApproval && reservation.customer.licenseValidationStatus !== 'APPROVED') {
throw new AppError('Primary driver license requires manager approval before this reservation can proceed', 400, 'primary_license_requires_approval')
}
const blockedDriver = reservation.additionalDrivers.find((d: any) => {
if (d.licenseExpired) return true
return d.requiresApproval && !d.approvedAt
})
if (blockedDriver) {
throw new AppError(
`Additional driver ${blockedDriver.firstName} ${blockedDriver.lastName} requires approval before this reservation can proceed`,
400,
'additional_driver_requires_approval',
)
}
}
export async function confirmReservation(id: string, companyId: string) {
const reservation = await repo.findByIdSimple(id, companyId)
if (reservation.status !== 'DRAFT') throw new AppError('Only DRAFT reservations can be confirmed', 400, 'invalid_status')
await assertLicenseCompliance(reservation.id, companyId)
const updated = await repo.updateById(id, { status: 'CONFIRMED' })
const [customer, brand] = await Promise.all([
repo.findCustomerById(reservation.customerId),
repo.findBrandLocale(companyId),
])
const lang = (brand?.defaultLocale ?? 'fr') as Lang
await sendNotification({
type: 'BOOKING_CONFIRMED',
title: bookingConfirmedNotif.title(lang),
body: bookingConfirmedNotif.body(lang),
companyId,
email: customer.email,
channels: ['EMAIL', 'IN_APP'],
locale: lang,
}).catch(() => null)
return updated
}
export async function checkinReservation(id: string, companyId: string, mileage?: number) {
const reservation = await repo.findByIdSimple(id, companyId)
if (reservation.status !== 'CONFIRMED') throw new AppError('Only CONFIRMED reservations can be checked in', 400, 'invalid_status')
await assertLicenseCompliance(reservation.id, companyId)
const updated = await repo.updateById(id, {
status: 'ACTIVE',
checkedInAt: new Date(),
checkInMileage: mileage ?? null,
})
await repo.updateVehicleStatus(reservation.vehicleId, 'RENTED')
return updated
}
export async function checkoutReservation(id: string, companyId: string, mileage?: number) {
const reservation = await repo.findByIdForCheckout(id, companyId)
if (reservation.status !== 'ACTIVE') throw new AppError('Only ACTIVE reservations can be checked out', 400, 'invalid_status')
const reviewToken = crypto.randomBytes(32).toString('hex')
const updated = await repo.updateById(id, {
status: 'COMPLETED',
checkedOutAt: new Date(),
checkOutMileage: mileage ?? null,
reviewToken,
})
await repo.updateVehicleStatus(reservation.vehicleId, 'AVAILABLE', mileage)
const [customer, company] = await Promise.all([
repo.findCustomerById(reservation.customerId),
repo.findCompanyWithBrand(companyId),
])
const lang = (company?.brand?.defaultLocale ?? 'fr') as Lang
const companyName = company?.brand?.displayName ?? company?.name ?? 'the rental company'
const vehicleLabel = `${reservation.vehicle.year} ${reservation.vehicle.make} ${reservation.vehicle.model}`
const reviewUrl = `${process.env.MARKETPLACE_URL ?? process.env.NEXT_PUBLIC_MARKETPLACE_URL ?? 'http://localhost:3000'}/review?token=${reviewToken}`
sendTransactionalEmail({
to: customer.email,
subject: reviewRequestEmail.subject(vehicleLabel, lang),
html: reviewRequestEmail.html({ firstName: customer.firstName, companyName, vehicleLabel, reviewUrl }, lang),
text: reviewRequestEmail.text({ firstName: customer.firstName, companyName, vehicleLabel, reviewUrl }, lang),
}).catch(() => null)
return updated
}
export async function closeReservation(id: string, companyId: string, closedBy: string) {
const reservation = await repo.findForClose(id, companyId)
const workflow = buildReservationWorkflow(reservation)
if (reservation.status !== 'COMPLETED') throw new AppError('Only completed reservations can be closed', 400, 'invalid_status')
if (workflow.closed) throw new AppError('This reservation is already closed', 400, 'already_closed')
const extras = parseReservationExtras(reservation.extras)
extras.reservationClosedAt = new Date().toISOString()
extras.reservationClosedBy = closedBy
const updated = await prisma.reservation.update({
where: { id: reservation.id },
data: { extras: extras as any },
include: { vehicle: true, customer: true, insurances: true, additionalDrivers: true, rentalPayments: true, damageReports: true },
})
return serializeReservationForDashboard(updated)
}
export async function cancelReservation(id: string, companyId: string, reason?: string) {
const reservation = await repo.findByIdSimple(id, companyId)
if (['COMPLETED', 'CANCELLED'].includes(reservation.status)) {
throw new AppError('Reservation cannot be cancelled', 400, 'invalid_status')
}
const updated = await repo.updateById(id, { status: 'CANCELLED', cancelReason: reason ?? null, cancelledBy: 'COMPANY' })
if (['CONFIRMED', 'ACTIVE'].includes(reservation.status)) {
await repo.updateVehicleStatus(reservation.vehicleId, 'AVAILABLE')
}
return updated
}
@@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest'
import { serializeReservationForDashboard } from './reservation.presenter'
describe('serializeReservationForDashboard', () => {
it('rewrites customer license image URLs to the protected route', () => {
const result = serializeReservationForDashboard({
id: 'reservation-1',
status: 'CONFIRMED',
contractNumber: null,
invoiceNumber: null,
extras: {},
customer: {
id: 'customer-1',
licenseImageUrl: 'http://localhost:4000/storage/companies/company-1/customers/customer-1/license.jpg',
},
})
expect(result.customer?.licenseImageUrl).toBe('http://localhost:3001/dashboard/api/v1/customers/customer-1/license-image')
})
})
@@ -0,0 +1,63 @@
import { getProtectedCustomerLicenseImageUrl } from '../../lib/storage'
export function parseReservationExtras(extras: unknown): Record<string, unknown> {
if (!extras || typeof extras !== 'object' || Array.isArray(extras)) return {}
return { ...(extras as Record<string, unknown>) }
}
export function normalizeOptionalString(value: string | null | undefined): string | null {
const trimmed = typeof value === 'string' ? value.trim() : value
return trimmed || null
}
export function buildReservationWorkflow(reservation: {
status: string
contractNumber: string | null
invoiceNumber: string | null
extras: unknown
}) {
const extras = parseReservationExtras(reservation.extras)
const closedAt = typeof extras.reservationClosedAt === 'string' ? extras.reservationClosedAt : null
const contractGenerated = Boolean(reservation.contractNumber || reservation.invoiceNumber)
const closed = Boolean(closedAt)
return {
contractGenerated,
closed,
closedAt,
closedBy: typeof extras.reservationClosedBy === 'string' ? extras.reservationClosedBy : null,
coreEditable: !closed && !contractGenerated && ['DRAFT', 'CONFIRMED'].includes(reservation.status),
returnEditable: !closed && reservation.status === 'COMPLETED',
checkInInspectionEditable: !closed && ['CONFIRMED', 'ACTIVE'].includes(reservation.status),
checkOutInspectionEditable: !closed && reservation.status === 'COMPLETED',
}
}
export function serializeReservationForDashboard<T extends {
extras: unknown
status: string
contractNumber: string | null
invoiceNumber: string | null
customer?: {
id: string
licenseImageUrl?: string | null
} | null
}>(reservation: T): T & { paymentMode: string | null; workflow: ReturnType<typeof buildReservationWorkflow> } {
const extras = parseReservationExtras(reservation.extras)
const customer =
reservation.customer
? {
...reservation.customer,
licenseImageUrl: reservation.customer.licenseImageUrl
? getProtectedCustomerLicenseImageUrl(reservation.customer.id)
: null,
}
: reservation.customer
return {
...reservation,
...(customer !== undefined ? { customer } : {}),
paymentMode: typeof extras.paymentMode === 'string' ? extras.paymentMode : null,
workflow: buildReservationWorkflow(reservation),
}
}
@@ -0,0 +1,27 @@
export { applyPricingRules } from '../../services/pricingRuleService'
export function calculateUpdatedInsuranceCharge(
chargeType: 'PER_DAY' | 'PER_RENTAL' | 'PERCENTAGE_OF_RENTAL',
chargeValue: number,
totalDays: number,
baseRentalAmount: number,
): number {
switch (chargeType) {
case 'PER_DAY': return chargeValue * totalDays
case 'PER_RENTAL': return chargeValue
case 'PERCENTAGE_OF_RENTAL': return Math.round(baseRentalAmount * chargeValue / 100)
default: return 0
}
}
export function calculateUpdatedAdditionalDriverCharge(
chargeType: 'PER_DAY' | 'FLAT' | 'FREE',
chargeValue: number,
totalDays: number,
): number {
switch (chargeType) {
case 'PER_DAY': return chargeValue * totalDays
case 'FLAT': return chargeValue
default: return 0
}
}
@@ -0,0 +1,161 @@
import { prisma } from '../../lib/prisma'
const FULL_INCLUDE = {
vehicle: true,
customer: true,
insurances: true,
additionalDrivers: true,
rentalPayments: true,
damageReports: true,
}
export async function findMany(where: any, skip: number, take: number) {
return Promise.all([
prisma.reservation.findMany({
where,
include: { vehicle: true, customer: true },
skip,
take,
orderBy: { createdAt: 'desc' },
}),
prisma.reservation.count({ where }),
])
}
export async function findById(id: string, companyId: string) {
return prisma.reservation.findFirstOrThrow({ where: { id, companyId }, include: FULL_INCLUDE })
}
export async function findByIdSimple(id: string, companyId: string) {
return prisma.reservation.findFirstOrThrow({ where: { id, companyId } })
}
export async function findByIdWithRelations(id: string, companyId: string) {
return prisma.reservation.findFirstOrThrow({
where: { id, companyId },
include: { insurances: true, additionalDrivers: true },
})
}
export async function findByIdForCheckout(id: string, companyId: string) {
return prisma.reservation.findFirstOrThrow({
where: { id, companyId },
include: { vehicle: true },
})
}
export async function findForLicenseCheck(id: string, companyId: string) {
return prisma.reservation.findFirstOrThrow({
where: { id, companyId },
include: { customer: true, additionalDrivers: true },
})
}
export async function findForContract(id: string, companyId: string) {
return prisma.reservation.findFirstOrThrow({
where: { id, companyId },
include: {
vehicle: true,
customer: true,
insurances: true,
additionalDrivers: true,
rentalPayments: { orderBy: { createdAt: 'asc' } },
inspections: { include: { damagePoints: true }, orderBy: { inspectedAt: 'asc' } },
company: { include: { brand: true, contractSettings: true, accountingSettings: true } },
},
})
}
export async function findForBilling(id: string, companyId: string) {
return prisma.reservation.findFirstOrThrow({
where: { id, companyId },
include: { vehicle: true, insurances: true, additionalDrivers: true },
})
}
export async function findForClose(id: string, companyId: string) {
return prisma.reservation.findFirstOrThrow({ where: { id, companyId } })
}
export async function findForInspection(id: string, companyId: string) {
return prisma.reservation.findFirstOrThrow({
where: { id, companyId },
include: { vehicle: true, customer: true },
})
}
export async function findConflict(vehicleId: string, start: Date, end: Date, excludeId?: string) {
return prisma.reservation.findFirst({
where: {
vehicleId,
status: { in: ['CONFIRMED', 'ACTIVE'] },
startDate: { lt: end },
endDate: { gt: start },
...(excludeId ? { id: { not: excludeId } } : {}),
},
})
}
export async function create(data: any) {
return prisma.reservation.create({ data, include: { vehicle: true, customer: true } })
}
export async function updateById(id: string, data: any) {
return prisma.reservation.update({ where: { id }, data })
}
export async function findActiveOffer(companyId: string, promoCode: string) {
return prisma.offer.findFirst({
where: { companyId, promoCode, isActive: true, validFrom: { lte: new Date() }, validUntil: { gte: new Date() } },
})
}
export async function incrementOfferRedemption(offerId: string) {
return prisma.offer.update({ where: { id: offerId }, data: { redemptionCount: { increment: 1 } } })
}
export async function findVehicle(vehicleId: string, companyId: string) {
return prisma.vehicle.findFirstOrThrow({ where: { id: vehicleId, companyId } })
}
export async function findCustomer(customerId: string, companyId: string) {
return prisma.customer.findFirstOrThrow({ where: { id: customerId, companyId } })
}
export async function findCustomerById(customerId: string) {
return prisma.customer.findUniqueOrThrow({ where: { id: customerId } })
}
export async function updateVehicleStatus(vehicleId: string, status: string, mileage?: number) {
return prisma.vehicle.update({
where: { id: vehicleId },
data: { status: status as any, ...(mileage !== undefined ? { mileage } : {}) },
})
}
export async function findCompanyWithBrand(companyId: string) {
return prisma.company.findUnique({
where: { id: companyId },
include: { brand: { select: { displayName: true, defaultLocale: true } } },
})
}
export async function findBrandLocale(companyId: string) {
return prisma.brandSettings.findUnique({ where: { companyId }, select: { defaultLocale: true } })
}
export async function findInspections(reservationId: string, companyId: string) {
return prisma.damageInspection.findMany({
where: { reservationId, companyId },
include: { damagePoints: true },
orderBy: { inspectedAt: 'asc' },
})
}
export async function findAdditionalDriver(driverId: string, reservationId: string, companyId: string) {
return prisma.additionalDriver.findFirstOrThrow({ where: { id: driverId, reservationId, companyId } })
}
export async function updateAdditionalDriver(id: string, data: any) {
return prisma.additionalDriver.update({ where: { id }, data })
}
@@ -0,0 +1,144 @@
import { Router } from 'express'
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
import { requireTenant } from '../../middleware/requireTenant'
import { requireSubscription } from '../../middleware/requireSubscription'
import { parseBody, parseQuery, parseParams } from '../../http/validate'
import { ok, created } from '../../http/respond'
import * as service from './reservation.service'
import * as lifecycle from './reservation.lifecycle.service'
import * as inspectionService from './reservation.inspection.service'
import * as additionalDriverService from './reservation.additional-driver.service'
import { getContract, getBilling } from './reservation.document.service'
import {
createSchema, updateSchema, listQuerySchema,
checkinSchema, checkoutSchema, cancelSchema, approvalSchema,
inspectionSchema, inspectionTypeParam,
idParamSchema, driverParamSchema, inspectionParamSchema,
} from './reservation.schemas'
const router = Router()
router.use(requireCompanyAuth, requireTenant, requireSubscription)
router.get('/', async (req, res, next) => {
try {
const query = parseQuery(listQuerySchema, req)
const result = await service.listReservations(req.companyId, query)
res.json(result)
} catch (err) { next(err) }
})
router.post('/', async (req, res, next) => {
try {
const body = parseBody(createSchema, req)
const reservation = await service.createReservation(req.companyId, body)
created(res, reservation)
} catch (err) { next(err) }
})
router.get('/:id', async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const reservation = await service.getReservation(id, req.companyId)
ok(res, reservation)
} catch (err) { next(err) }
})
router.patch('/:id', async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const body = parseBody(updateSchema, req)
const reservation = await service.updateReservation(id, req.companyId, body)
ok(res, reservation)
} catch (err) { next(err) }
})
router.get('/:id/contract', async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const contract = await getContract(id, req.companyId)
ok(res, contract)
} catch (err) { next(err) }
})
router.get('/:id/billing', async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const billing = await getBilling(id, req.companyId)
ok(res, billing)
} catch (err) { next(err) }
})
router.post('/:id/confirm', async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const updated = await lifecycle.confirmReservation(id, req.companyId)
ok(res, updated)
} catch (err) { next(err) }
})
router.post('/:id/checkin', async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { mileage } = parseBody(checkinSchema, req)
const updated = await lifecycle.checkinReservation(id, req.companyId, mileage)
ok(res, updated)
} catch (err) { next(err) }
})
router.post('/:id/checkout', async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { mileage } = parseBody(checkoutSchema, req)
const updated = await lifecycle.checkoutReservation(id, req.companyId, mileage)
ok(res, updated)
} catch (err) { next(err) }
})
router.post('/:id/close', async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const closedBy = `${req.employee.firstName} ${req.employee.lastName}`
const reservation = await lifecycle.closeReservation(id, req.companyId, closedBy)
ok(res, reservation)
} catch (err) { next(err) }
})
router.post('/:id/cancel', async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { reason } = parseBody(cancelSchema, req)
const updated = await lifecycle.cancelReservation(id, req.companyId, reason)
ok(res, updated)
} catch (err) { next(err) }
})
router.get('/:id/inspections', async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const inspections = await inspectionService.getInspections(id, req.companyId)
ok(res, inspections)
} catch (err) { next(err) }
})
router.put('/:id/inspections/:type', async (req, res, next) => {
try {
const { id, type: rawType } = parseParams(inspectionParamSchema, req)
const type = inspectionTypeParam.parse(rawType.toUpperCase())
const body = parseBody(inspectionSchema, req)
const inspectedBy = `${req.employee.firstName} ${req.employee.lastName}`
const result = await inspectionService.upsertInspection(id, req.companyId, type, body, inspectedBy)
ok(res, result)
} catch (err) { next(err) }
})
router.patch('/:id/additional-drivers/:driverId/approval', async (req, res, next) => {
try {
const { id, driverId } = parseParams(driverParamSchema, req)
const { approved, note } = parseBody(approvalSchema, req)
const approvedBy = `${req.employee.firstName} ${req.employee.lastName}`
const updated = await additionalDriverService.approveAdditionalDriver(id, driverId, req.companyId, approved, note, approvedBy)
ok(res, updated)
} catch (err) { next(err) }
})
export default router
@@ -0,0 +1,79 @@
import { z } from 'zod'
export 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(),
})
export 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([]),
})
export const createSchema = z.object({
vehicleId: z.string().cuid(),
customerId: z.string().cuid(),
startDate: z.string().datetime(),
endDate: z.string().datetime(),
pickupLocation: z.string().optional(),
returnLocation: z.string().optional(),
offerId: z.string().cuid().optional(),
promoCodeUsed: z.string().optional(),
depositAmount: z.number().int().min(0).default(0),
paymentMode: z.string().max(50).optional(),
notes: z.string().optional(),
selectedInsurancePolicyIds: z.array(z.string()).default([]),
additionalDrivers: z.array(additionalDriverSchema).default([]),
})
export const updateSchema = z.object({
startDate: z.string().datetime().optional(),
endDate: z.string().datetime().optional(),
pickupLocation: z.string().optional().nullable(),
returnLocation: z.string().optional().nullable(),
depositAmount: z.number().int().min(0).optional(),
notes: z.string().optional().nullable(),
paymentMode: z.string().max(50).optional().nullable(),
damageChargeAmount: z.number().int().min(0).optional(),
damageChargeNote: z.string().optional().nullable(),
})
export const listQuerySchema = z.object({
status: z.string().optional(),
vehicleId: z.string().optional(),
source: z.string().optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
})
export const checkinSchema = z.object({ mileage: z.number().int().optional() })
export const checkoutSchema = z.object({ mileage: z.number().int().optional() })
export const cancelSchema = z.object({ reason: z.string().optional() })
export const approvalSchema = z.object({ approved: z.boolean(), note: z.string().optional() })
export const inspectionTypeParam = z.enum(['CHECKIN', 'CHECKOUT'])
export const idParamSchema = z.object({ id: z.string() })
export const driverParamSchema = z.object({ id: z.string(), driverId: z.string() })
export const inspectionParamSchema = z.object({ id: z.string(), type: z.string() })
@@ -0,0 +1,216 @@
import { prisma } from '../../lib/prisma'
import { AppError } from '../../http/errors'
import { validateAndFlagLicense } from '../../services/licenseValidationService'
import { applyPricingRules, calculateUpdatedInsuranceCharge, calculateUpdatedAdditionalDriverCharge } from './reservation.pricing.service'
import { applyInsurancesToReservation } from './reservation.insurance.service'
import { applyAdditionalDriversToReservation } from './reservation.additional-driver.service'
import { parseReservationExtras, normalizeOptionalString, serializeReservationForDashboard, buildReservationWorkflow } from './reservation.presenter'
import * as repo from './reservation.repo'
export async function listReservations(companyId: string, query: {
status?: string; vehicleId?: string; source?: string
startDate?: string; endDate?: string; page?: number; pageSize?: number
}) {
const page = query.page ?? 1
const pageSize = query.pageSize ?? 20
const where: any = { companyId }
if (query.status) where.status = query.status
if (query.vehicleId) where.vehicleId = query.vehicleId
if (query.source) where.source = query.source
if (query.startDate) where.startDate = { gte: new Date(query.startDate) }
if (query.endDate) where.endDate = { lte: new Date(query.endDate) }
const [reservations, total] = await repo.findMany(where, (page - 1) * pageSize, pageSize)
return {
data: reservations.map(serializeReservationForDashboard),
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
}
}
export async function getReservation(id: string, companyId: string) {
const reservation = await repo.findById(id, companyId)
return serializeReservationForDashboard(reservation)
}
export async function createReservation(companyId: string, body: {
vehicleId: string; customerId: string; startDate: string; endDate: string
pickupLocation?: string; returnLocation?: string; offerId?: string
promoCodeUsed?: string; depositAmount?: number; paymentMode?: string; notes?: string
selectedInsurancePolicyIds?: string[]; additionalDrivers?: any[]
}) {
const vehicle = await repo.findVehicle(body.vehicleId, companyId)
await repo.findCustomer(body.customerId, companyId)
const start = new Date(body.startDate)
const end = new Date(body.endDate)
const totalDays = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24))
const conflict = await repo.findConflict(body.vehicleId, start, end)
if (conflict) throw new AppError('Vehicle is not available for the selected dates', 409, 'vehicle_unavailable')
let discountAmount = 0
let offerId: string | null = body.offerId ?? null
if (body.promoCodeUsed) {
const offer = await repo.findActiveOffer(companyId, body.promoCodeUsed)
if (offer) {
offerId = offer.id
const base = vehicle.dailyRate * totalDays
if (offer.type === 'PERCENTAGE') discountAmount = Math.round(base * offer.discountValue / 100)
else if (offer.type === 'FIXED_AMOUNT') discountAmount = offer.discountValue
else if (offer.type === 'FREE_DAY') discountAmount = vehicle.dailyRate * offer.discountValue
await repo.incrementOfferRedemption(offer.id)
}
}
const depositAmount = body.depositAmount ?? 0
const additionalDriversList = body.additionalDrivers ?? []
const baseAmount = vehicle.dailyRate * totalDays
const { applied, total: pricingTotal } = await applyPricingRules(companyId, body.customerId, additionalDriversList, vehicle.dailyRate, totalDays)
const totalAmount = baseAmount - discountAmount + pricingTotal + depositAmount
const reservation = await repo.create({
companyId,
vehicleId: body.vehicleId,
customerId: body.customerId,
startDate: start,
endDate: end,
pickupLocation: body.pickupLocation ?? null,
returnLocation: body.returnLocation ?? null,
offerId,
promoCodeUsed: body.promoCodeUsed ?? null,
source: 'DASHBOARD',
dailyRate: vehicle.dailyRate,
discountAmount,
totalDays,
totalAmount,
depositAmount,
notes: body.notes ?? null,
extras: body.paymentMode ? { paymentMode: body.paymentMode } : undefined,
pricingRulesApplied: applied,
pricingRulesTotal: pricingTotal,
})
const insuranceIds = body.selectedInsurancePolicyIds ?? []
const additionalDrivers = body.additionalDrivers ?? []
if (insuranceIds.length > 0) {
await applyInsurancesToReservation(reservation.id, companyId, insuranceIds, totalDays, baseAmount)
}
if (additionalDrivers.length > 0) {
await applyAdditionalDriversToReservation(reservation.id, companyId, additionalDrivers, totalDays)
}
await validateAndFlagLicense(body.customerId).catch(() => null)
return reservation
}
export async function updateReservation(id: string, companyId: string, body: {
startDate?: string; endDate?: string; pickupLocation?: string | null
returnLocation?: string | null; depositAmount?: number; notes?: string | null
paymentMode?: string | null; damageChargeAmount?: number; damageChargeNote?: string | null
}) {
const reservation = await repo.findByIdWithRelations(id, companyId)
if (reservation.status === 'CANCELLED' || reservation.status === 'NO_SHOW') {
throw new AppError('This reservation can no longer be edited', 400, 'invalid_status')
}
const workflow = buildReservationWorkflow(reservation)
const requested = Object.keys(body).filter((k) => body[k as keyof typeof body] !== undefined)
const bookingFields = ['startDate', 'endDate', 'pickupLocation', 'returnLocation', 'depositAmount', 'notes', 'paymentMode']
const returnFields = ['returnLocation', 'damageChargeAmount', 'damageChargeNote']
if (!workflow.coreEditable && !workflow.returnEditable) {
throw new AppError('This reservation is locked for editing', 400, 'reservation_locked')
}
if (workflow.coreEditable) {
const invalid = requested.filter((f) => !bookingFields.includes(f))
if (invalid.length > 0) throw new AppError('Only booking details can be edited at this stage', 400, 'invalid_fields')
const nextStart = body.startDate ? new Date(body.startDate) : reservation.startDate
const nextEnd = body.endDate ? new Date(body.endDate) : reservation.endDate
const totalDays = Math.ceil((nextEnd.getTime() - nextStart.getTime()) / (1000 * 60 * 60 * 24))
if (nextEnd <= nextStart || totalDays <= 0) throw new AppError('End date must be after start date', 400, 'invalid_dates')
if (body.startDate || body.endDate) {
const conflict = await repo.findConflict(reservation.vehicleId, nextStart, nextEnd, reservation.id)
if (conflict) throw new AppError('Vehicle is not available for the selected dates', 409, 'vehicle_unavailable')
}
const baseAmount = reservation.dailyRate * totalDays
const { applied, total: pricingRulesTotal } = await applyPricingRules(
companyId,
reservation.customerId,
reservation.additionalDrivers.map((d: any) => ({ dateOfBirth: d.dateOfBirth, licenseIssuedAt: d.licenseIssuedAt })),
reservation.dailyRate,
totalDays,
)
const insuranceUpdates = reservation.insurances.map((ins: any) => ({
id: ins.id,
totalCharge: calculateUpdatedInsuranceCharge(ins.chargeType, ins.chargeValue, totalDays, baseAmount),
}))
const driverUpdates = reservation.additionalDrivers.map((d: any) => ({
id: d.id,
totalCharge: calculateUpdatedAdditionalDriverCharge(d.chargeType, d.chargeValue, totalDays),
}))
const insuranceTotal = insuranceUpdates.reduce((s, i) => s + i.totalCharge, 0)
const additionalDriverTotal = driverUpdates.reduce((s, d) => s + d.totalCharge, 0)
const depositAmount = body.depositAmount ?? reservation.depositAmount
const totalAmount = baseAmount - reservation.discountAmount + pricingRulesTotal + insuranceTotal + additionalDriverTotal + depositAmount
const extras = parseReservationExtras(reservation.extras)
if (body.paymentMode !== undefined) {
const next = normalizeOptionalString(body.paymentMode)
if (next) extras.paymentMode = next
else delete extras.paymentMode
}
await prisma.$transaction([
prisma.reservation.update({
where: { id: reservation.id },
data: {
startDate: nextStart,
endDate: nextEnd,
totalDays,
pickupLocation: body.pickupLocation !== undefined ? normalizeOptionalString(body.pickupLocation) : reservation.pickupLocation,
returnLocation: body.returnLocation !== undefined ? normalizeOptionalString(body.returnLocation) : reservation.returnLocation,
depositAmount,
notes: body.notes !== undefined ? normalizeOptionalString(body.notes) : reservation.notes,
pricingRulesApplied: applied,
pricingRulesTotal,
insuranceTotal,
additionalDriverTotal,
totalAmount,
extras: (Object.keys(extras).length > 0 ? extras : {}) as any,
},
}),
...insuranceUpdates.map((ins) =>
prisma.reservationInsurance.update({ where: { id: ins.id }, data: { totalCharge: ins.totalCharge } })
),
...driverUpdates.map((d) =>
prisma.additionalDriver.update({ where: { id: d.id }, data: { totalCharge: d.totalCharge } })
),
])
} else {
const invalid = requested.filter((f) => !returnFields.includes(f))
if (invalid.length > 0) throw new AppError('Only return details can be edited after vehicle return', 400, 'invalid_fields')
await prisma.reservation.update({
where: { id: reservation.id },
data: {
returnLocation: body.returnLocation !== undefined ? normalizeOptionalString(body.returnLocation) : reservation.returnLocation,
damageChargeAmount: body.damageChargeAmount !== undefined ? body.damageChargeAmount : reservation.damageChargeAmount,
damageChargeNote: body.damageChargeNote !== undefined ? normalizeOptionalString(body.damageChargeNote) : reservation.damageChargeNote,
},
})
}
return repo.findById(id, companyId).then(serializeReservationForDashboard)
}
@@ -0,0 +1,342 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { AppError } from '../../http/errors'
// ── repo mock ──────────────────────────────────────────────────────────────
vi.mock('./reservation.repo', () => ({
findMany: vi.fn(),
findById: vi.fn(),
findByIdSimple: vi.fn(),
findByIdWithRelations: vi.fn(),
findByIdForCheckout: vi.fn(),
findForLicenseCheck: vi.fn(),
findForClose: vi.fn(),
findForInspection: vi.fn(),
findConflict: vi.fn(),
create: vi.fn(),
updateById: vi.fn(),
findActiveOffer: vi.fn(),
incrementOfferRedemption: vi.fn(),
findVehicle: vi.fn(),
findCustomer: vi.fn(),
findCustomerById: vi.fn(),
updateVehicleStatus: vi.fn(),
findCompanyWithBrand: vi.fn(),
findBrandLocale: vi.fn(),
findInspections: vi.fn(),
findAdditionalDriver: vi.fn(),
updateAdditionalDriver: vi.fn(),
}))
vi.mock('./reservation.pricing.service', () => ({
applyPricingRules: vi.fn(),
calculateUpdatedInsuranceCharge: vi.fn(),
calculateUpdatedAdditionalDriverCharge: vi.fn(),
}))
vi.mock('./reservation.insurance.service', () => ({
applyInsurancesToReservation: vi.fn(),
}))
vi.mock('./reservation.additional-driver.service', () => ({
applyAdditionalDriversToReservation: vi.fn(),
approveAdditionalDriver: vi.fn(),
}))
vi.mock('../../services/licenseValidationService', () => ({
validateAndFlagLicense: vi.fn().mockResolvedValue(undefined),
validateLicense: vi.fn(),
}))
vi.mock('../../services/notificationService', () => ({
sendNotification: vi.fn().mockResolvedValue(undefined),
sendTransactionalEmail: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('../../lib/prisma', () => ({
prisma: {
reservation: { update: vi.fn() },
damageInspection: { upsert: vi.fn() },
damageReport: { upsert: vi.fn() },
},
}))
vi.mock('./reservation.presenter', () => ({
serializeReservationForDashboard: vi.fn((r) => r),
parseReservationExtras: vi.fn(() => ({})),
buildReservationWorkflow: vi.fn(),
normalizeOptionalString: vi.fn((s) => s),
}))
import * as repo from './reservation.repo'
import * as pricingService from './reservation.pricing.service'
import * as insuranceService from './reservation.insurance.service'
import * as additionalDriverService from './reservation.additional-driver.service'
import { validateLicense } from '../../services/licenseValidationService'
import { buildReservationWorkflow } from './reservation.presenter'
import { createReservation } from './reservation.service'
import { confirmReservation, checkinReservation, checkoutReservation, closeReservation } from './reservation.lifecycle.service'
import { approveAdditionalDriver } from './reservation.additional-driver.service'
const COMPANY = 'company-1'
const RES_ID = 'reservation-1'
function makeVehicle(overrides: object = {}) {
return { id: 'vehicle-1', dailyRate: 100, status: 'AVAILABLE', isPublished: true, mileage: 50000, ...overrides }
}
function makeReservation(overrides: object = {}) {
return {
id: RES_ID,
companyId: COMPANY,
vehicleId: 'vehicle-1',
customerId: 'customer-1',
status: 'DRAFT',
startDate: new Date('2025-06-01'),
endDate: new Date('2025-06-04'),
totalDays: 3,
dailyRate: 100,
depositAmount: 0,
discountAmount: 0,
totalAmount: 300,
additionalDrivers: [],
insurances: [],
customer: { firstName: 'Ali', lastName: 'Ben', email: 'ali@test.com', licenseExpiry: null, licenseValidationStatus: 'APPROVED' },
vehicle: { year: 2022, make: 'Toyota', model: 'Camry' },
extras: {},
...overrides,
}
}
beforeEach(() => {
vi.clearAllMocks()
})
// ────────────────────────────────────────────────────────────────────────────
describe('createReservation', () => {
it('creates a reservation and returns it', async () => {
vi.mocked(repo.findVehicle).mockResolvedValue(makeVehicle())
vi.mocked(repo.findCustomer).mockResolvedValue({ id: 'customer-1' } as any)
vi.mocked(repo.findConflict).mockResolvedValue(null)
vi.mocked(repo.findActiveOffer).mockResolvedValue(null)
vi.mocked(pricingService.applyPricingRules).mockResolvedValue({ applied: [], total: 0 })
vi.mocked(repo.create).mockResolvedValue(makeReservation() as any)
const result = await createReservation(COMPANY, {
vehicleId: 'vehicle-1',
customerId: 'customer-1',
startDate: '2025-06-01T00:00:00.000Z',
endDate: '2025-06-04T00:00:00.000Z',
depositAmount: 0,
selectedInsurancePolicyIds: [],
additionalDrivers: [],
})
expect(repo.create).toHaveBeenCalledOnce()
expect(result.id).toBe(RES_ID)
})
it('throws conflict error when vehicle is unavailable', async () => {
vi.mocked(repo.findVehicle).mockResolvedValue(makeVehicle())
vi.mocked(repo.findCustomer).mockResolvedValue({ id: 'customer-1' } as any)
vi.mocked(repo.findConflict).mockResolvedValue({ id: 'other-res' } as any)
await expect(
createReservation(COMPANY, {
vehicleId: 'vehicle-1',
customerId: 'customer-1',
startDate: '2025-06-01T00:00:00.000Z',
endDate: '2025-06-04T00:00:00.000Z',
selectedInsurancePolicyIds: [],
additionalDrivers: [],
}),
).rejects.toThrow('Vehicle is not available for the selected dates')
expect(repo.create).not.toHaveBeenCalled()
})
})
// ────────────────────────────────────────────────────────────────────────────
describe('confirmReservation', () => {
it('confirms a DRAFT reservation', async () => {
const res = makeReservation({ status: 'DRAFT' })
vi.mocked(repo.findByIdSimple).mockResolvedValue(res as any)
vi.mocked(repo.findForLicenseCheck).mockResolvedValue({
...res,
customer: { ...res.customer, licenseExpiry: null, licenseValidationStatus: 'APPROVED' },
additionalDrivers: [],
} as any)
vi.mocked(validateLicense).mockReturnValue({ status: 'VALID', requiresApproval: false } as any)
vi.mocked(repo.updateById).mockResolvedValue({ ...res, status: 'CONFIRMED' } as any)
vi.mocked(repo.findCustomerById).mockResolvedValue({ email: 'ali@test.com', firstName: 'Ali' } as any)
vi.mocked(repo.findBrandLocale).mockResolvedValue({ defaultLocale: 'fr' } as any)
const result = await confirmReservation(RES_ID, COMPANY)
expect(repo.updateById).toHaveBeenCalledWith(RES_ID, { status: 'CONFIRMED' })
expect((result as any).status).toBe('CONFIRMED')
})
it('rejects non-DRAFT reservation', async () => {
vi.mocked(repo.findByIdSimple).mockResolvedValue(makeReservation({ status: 'CONFIRMED' }) as any)
await expect(confirmReservation(RES_ID, COMPANY)).rejects.toThrow('Only DRAFT reservations can be confirmed')
})
})
// ────────────────────────────────────────────────────────────────────────────
describe('checkinReservation', () => {
it('transitions CONFIRMED → ACTIVE and marks vehicle RENTED', async () => {
const res = makeReservation({ status: 'CONFIRMED' })
vi.mocked(repo.findByIdSimple).mockResolvedValue(res as any)
vi.mocked(repo.findForLicenseCheck).mockResolvedValue({
...res,
customer: { ...res.customer, licenseExpiry: null, licenseValidationStatus: 'APPROVED' },
additionalDrivers: [],
} as any)
vi.mocked(validateLicense).mockReturnValue({ status: 'VALID', requiresApproval: false } as any)
vi.mocked(repo.updateById).mockResolvedValue({ ...res, status: 'ACTIVE' } as any)
vi.mocked(repo.updateVehicleStatus).mockResolvedValue(undefined as any)
await checkinReservation(RES_ID, COMPANY, 55000)
expect(repo.updateById).toHaveBeenCalledWith(RES_ID, expect.objectContaining({ status: 'ACTIVE', checkInMileage: 55000 }))
expect(repo.updateVehicleStatus).toHaveBeenCalledWith('vehicle-1', 'RENTED')
})
it('rejects non-CONFIRMED reservation', async () => {
vi.mocked(repo.findByIdSimple).mockResolvedValue(makeReservation({ status: 'ACTIVE' }) as any)
await expect(checkinReservation(RES_ID, COMPANY)).rejects.toThrow('Only CONFIRMED reservations can be checked in')
})
})
// ────────────────────────────────────────────────────────────────────────────
describe('checkoutReservation', () => {
it('transitions ACTIVE → COMPLETED and marks vehicle AVAILABLE', async () => {
const res = makeReservation({ status: 'ACTIVE' })
vi.mocked(repo.findByIdForCheckout).mockResolvedValue(res as any)
vi.mocked(repo.updateById).mockResolvedValue({ ...res, status: 'COMPLETED' } as any)
vi.mocked(repo.updateVehicleStatus).mockResolvedValue(undefined as any)
vi.mocked(repo.findCustomerById).mockResolvedValue({ email: 'ali@test.com', firstName: 'Ali' } as any)
vi.mocked(repo.findCompanyWithBrand).mockResolvedValue({ name: 'TestCo', brand: { displayName: 'TestCo', defaultLocale: 'fr' } } as any)
await checkoutReservation(RES_ID, COMPANY, 58000)
expect(repo.updateById).toHaveBeenCalledWith(RES_ID, expect.objectContaining({ status: 'COMPLETED', checkOutMileage: 58000 }))
expect(repo.updateVehicleStatus).toHaveBeenCalledWith('vehicle-1', 'AVAILABLE', 58000)
})
it('rejects non-ACTIVE reservation', async () => {
vi.mocked(repo.findByIdForCheckout).mockResolvedValue(makeReservation({ status: 'CONFIRMED' }) as any)
await expect(checkoutReservation(RES_ID, COMPANY)).rejects.toThrow('Only ACTIVE reservations can be checked out')
})
})
// ────────────────────────────────────────────────────────────────────────────
describe('closeReservation', () => {
it('closes a COMPLETED reservation', async () => {
const res = makeReservation({ status: 'COMPLETED' })
vi.mocked(repo.findForClose).mockResolvedValue(res as any)
vi.mocked(buildReservationWorkflow).mockReturnValue({ closed: false } as any)
const { prisma } = await import('../../lib/prisma')
vi.mocked(prisma.reservation.update).mockResolvedValue({ ...res, extras: { reservationClosedBy: 'Admin User' } } as any)
await closeReservation(RES_ID, COMPANY, 'Admin User')
expect(prisma.reservation.update).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: RES_ID } }),
)
})
it('rejects already closed reservation', async () => {
vi.mocked(repo.findForClose).mockResolvedValue(makeReservation({ status: 'COMPLETED' }) as any)
vi.mocked(buildReservationWorkflow).mockReturnValue({ closed: true } as any)
await expect(closeReservation(RES_ID, COMPANY, 'Admin')).rejects.toThrow('already closed')
})
})
// ────────────────────────────────────────────────────────────────────────────
describe('approveAdditionalDriver', () => {
it('calls through to the additional driver service', async () => {
vi.mocked(additionalDriverService.approveAdditionalDriver).mockResolvedValue({ id: 'driver-1', approvedAt: new Date() } as any)
const result = await approveAdditionalDriver(RES_ID, 'driver-1', COMPANY, true, 'Looks good', 'Manager')
expect(additionalDriverService.approveAdditionalDriver).toHaveBeenCalledWith(RES_ID, 'driver-1', COMPANY, true, 'Looks good', 'Manager')
expect((result as any).id).toBe('driver-1')
})
})
// ────────────────────────────────────────────────────────────────────────────
describe('pricing rule application', () => {
it('applies pricing rules and adjusts totalAmount', async () => {
vi.mocked(repo.findVehicle).mockResolvedValue(makeVehicle({ dailyRate: 100 }))
vi.mocked(repo.findCustomer).mockResolvedValue({ id: 'customer-1' } as any)
vi.mocked(repo.findConflict).mockResolvedValue(null)
vi.mocked(repo.findActiveOffer).mockResolvedValue(null)
vi.mocked(pricingService.applyPricingRules).mockResolvedValue({ applied: [{ id: 'rule-1' }], total: 50 })
vi.mocked(repo.create).mockResolvedValue(makeReservation({ totalAmount: 350 }) as any)
await createReservation(COMPANY, {
vehicleId: 'vehicle-1',
customerId: 'customer-1',
startDate: '2025-06-01T00:00:00.000Z',
endDate: '2025-06-04T00:00:00.000Z',
selectedInsurancePolicyIds: [],
additionalDrivers: [],
})
expect(pricingService.applyPricingRules).toHaveBeenCalledOnce()
const createCall = vi.mocked(repo.create).mock.calls[0][0] as any
expect(createCall.pricingRulesApplied).toEqual([{ id: 'rule-1' }])
expect(createCall.totalAmount).toBe(350)
})
})
// ────────────────────────────────────────────────────────────────────────────
describe('insurance application', () => {
it('calls applyInsurancesToReservation when insurance IDs are provided', async () => {
vi.mocked(repo.findVehicle).mockResolvedValue(makeVehicle())
vi.mocked(repo.findCustomer).mockResolvedValue({ id: 'customer-1' } as any)
vi.mocked(repo.findConflict).mockResolvedValue(null)
vi.mocked(repo.findActiveOffer).mockResolvedValue(null)
vi.mocked(pricingService.applyPricingRules).mockResolvedValue({ applied: [], total: 0 })
vi.mocked(repo.create).mockResolvedValue(makeReservation() as any)
vi.mocked(insuranceService.applyInsurancesToReservation).mockResolvedValue(undefined as any)
await createReservation(COMPANY, {
vehicleId: 'vehicle-1',
customerId: 'customer-1',
startDate: '2025-06-01T00:00:00.000Z',
endDate: '2025-06-04T00:00:00.000Z',
selectedInsurancePolicyIds: ['policy-1', 'policy-2'],
additionalDrivers: [],
})
expect(insuranceService.applyInsurancesToReservation).toHaveBeenCalledWith(
RES_ID, COMPANY, ['policy-1', 'policy-2'], 3, 300,
)
})
it('skips insurance call when no IDs provided', async () => {
vi.mocked(repo.findVehicle).mockResolvedValue(makeVehicle())
vi.mocked(repo.findCustomer).mockResolvedValue({ id: 'customer-1' } as any)
vi.mocked(repo.findConflict).mockResolvedValue(null)
vi.mocked(repo.findActiveOffer).mockResolvedValue(null)
vi.mocked(pricingService.applyPricingRules).mockResolvedValue({ applied: [], total: 0 })
vi.mocked(repo.create).mockResolvedValue(makeReservation() as any)
await createReservation(COMPANY, {
vehicleId: 'vehicle-1',
customerId: 'customer-1',
startDate: '2025-06-01T00:00:00.000Z',
endDate: '2025-06-04T00:00:00.000Z',
selectedInsurancePolicyIds: [],
additionalDrivers: [],
})
expect(insuranceService.applyInsurancesToReservation).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,39 @@
export function presentBrand(company: {
id: string; slug: string; name: string; phone: string | null; brand: any
}) {
const brand = company.brand
return {
company: { id: company.id, slug: company.slug, name: company.name, phone: company.phone },
brand: brand ? {
displayName: brand.displayName,
tagline: brand.tagline,
logoUrl: brand.logoUrl,
faviconUrl: brand.faviconUrl,
heroImageUrl: brand.heroImageUrl,
primaryColor: brand.primaryColor,
accentColor: brand.accentColor,
subdomain: brand.subdomain,
customDomain: brand.customDomain,
customDomainVerified: brand.customDomainVerified,
customDomainAddedAt: brand.customDomainAddedAt,
publicEmail: brand.publicEmail,
publicPhone: brand.publicPhone,
publicAddress: brand.publicAddress,
publicCity: brand.publicCity,
publicCountry: brand.publicCountry,
websiteUrl: brand.websiteUrl,
whatsappNumber: brand.whatsappNumber,
facebookUrl: brand.facebookUrl,
instagramUrl: brand.instagramUrl,
defaultLocale: brand.defaultLocale,
defaultCurrency: brand.defaultCurrency,
paypalEmail: brand.paypalEmail,
paypalMerchantId: brand.paypalMerchantId,
paymentMethodsEnabled: brand.paymentMethodsEnabled,
isListedOnMarketplace: brand.isListedOnMarketplace,
marketplaceRating: brand.marketplaceRating,
homePageConfig: brand.homePageConfig,
menuConfig: brand.menuConfig,
} : null,
}
}
+101
View File
@@ -0,0 +1,101 @@
import { prisma } from '../../lib/prisma'
export async function findCompanyBySlug(slug: string) {
return prisma.company.findFirstOrThrow({
where: { slug, status: { in: ['ACTIVE', 'TRIALING', 'PAST_DUE', 'SUSPENDED'] } },
include: { brand: true, contractSettings: true },
})
}
export async function findPublishedVehicles(companyId: string) {
return prisma.vehicle.findMany({
where: { companyId, isPublished: true },
orderBy: { createdAt: 'desc' },
})
}
export async function findVehicleById(vehicleId: string, companyId: string) {
return prisma.vehicle.findFirstOrThrow({
where: { id: vehicleId, companyId, isPublished: true },
})
}
export async function findActiveOffers(companyId: string) {
return prisma.offer.findMany({
where: { companyId, isActive: true, validFrom: { lte: new Date() }, validUntil: { gte: new Date() } },
orderBy: [{ isFeatured: 'desc' }, { createdAt: 'desc' }],
})
}
export async function findInsurancePolicies(companyId: string) {
return prisma.insurancePolicy.findMany({
where: { companyId, isActive: true },
orderBy: [{ isRequired: 'desc' }, { sortOrder: 'asc' }, { name: 'asc' }],
})
}
export async function findOfferByPromoCode(companyId: string, code: string) {
return prisma.offer.findFirst({
where: { companyId, promoCode: code, isActive: true, validFrom: { lte: new Date() }, validUntil: { gte: new Date() } },
})
}
export async function upsertCustomer(companyId: string, data: {
email: string; firstName: string; lastName: string; phone?: string | null
driverLicense?: string | null; dateOfBirth?: string | null; licenseExpiry?: string | null
licenseIssuedAt?: string | null; nationality?: string | null
}) {
const parsed = {
dateOfBirth: data.dateOfBirth ? new Date(data.dateOfBirth) : null,
licenseExpiry: data.licenseExpiry ? new Date(data.licenseExpiry) : null,
licenseIssuedAt: data.licenseIssuedAt ? new Date(data.licenseIssuedAt) : null,
}
return prisma.customer.upsert({
where: { companyId_email: { companyId, email: data.email } },
create: { companyId, firstName: data.firstName, lastName: data.lastName, email: data.email, phone: data.phone ?? null, driverLicense: data.driverLicense ?? null, nationality: data.nationality ?? null, ...parsed },
update: { firstName: data.firstName, lastName: data.lastName, phone: data.phone ?? null, driverLicense: data.driverLicense ?? null, nationality: data.nationality ?? null, ...parsed },
})
}
export async function createReservation(data: object) {
return prisma.reservation.create({ data: data as any })
}
export async function findReservationWithDetails(reservationId: string) {
return prisma.reservation.findUniqueOrThrow({
where: { id: reservationId },
include: { insurances: true, additionalDrivers: true },
})
}
export async function findBooking(reservationId: string, companyId: string) {
return prisma.reservation.findFirstOrThrow({
where: { id: reservationId, companyId },
include: { vehicle: true, customer: true },
})
}
export async function findReservationForPayment(reservationId: string, companyId: string) {
return prisma.reservation.findFirstOrThrow({
where: { id: reservationId, companyId },
include: { vehicle: true, customer: true, additionalDrivers: true },
})
}
export async function createRentalPayment(data: {
companyId: string; reservationId: string; amount: number; currency: string
paymentProvider: string; amanpayTransactionId?: string | null; paypalCaptureId?: string | null
}) {
return prisma.rentalPayment.create({
data: { ...data, paymentProvider: data.paymentProvider as any, status: 'PENDING', type: 'CHARGE' },
})
}
export async function findPaymentByPaypalOrderId(paypalOrderId: string, companyId: string) {
return prisma.rentalPayment.findFirstOrThrow({ where: { paypalCaptureId: paypalOrderId, companyId } })
}
export async function capturePaypalPayment(paymentId: string, captureId: string, reservationId: string, amount: number) {
await prisma.rentalPayment.update({ where: { id: paymentId }, data: { status: 'SUCCEEDED', paidAt: new Date(), paypalCaptureId: captureId } })
await prisma.reservation.update({ where: { id: reservationId }, data: { paymentStatus: 'PAID', paidAmount: { increment: amount } } })
}
+154
View File
@@ -0,0 +1,154 @@
import { Router } from 'express'
import { parseBody, parseParams } from '../../http/validate'
import { ok, created } from '../../http/respond'
import { isDatabaseUnavailableError } from '../../lib/isDatabaseUnavailable'
import * as service from './site.service'
import {
slugParamSchema, bookingParamSchema,
availabilitySchema, validateCodeSchema, bookSchema, paySchema, capturePaypalSchema, contactSchema,
} from './site.schemas'
const router = Router()
router.get('/platform/homepage', async (_req, res, next) => {
try {
ok(res, await service.getPlatformHomepage())
} catch (err) { next(err) }
})
router.get('/:slug/brand', async (req, res, next) => {
try {
const { slug } = parseParams(slugParamSchema, req)
ok(res, await service.getBrand(slug))
} 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 { slug } = parseParams(slugParamSchema, req)
ok(res, await service.getPublicVehicles(slug))
} catch (err) {
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
next(err)
}
})
router.get('/:slug/vehicles/:id', async (req, res, next) => {
try {
const { slug, id } = parseParams(bookingParamSchema, req)
ok(res, await service.getVehicleDetail(slug, id))
} 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 { slug } = parseParams(slugParamSchema, req)
ok(res, await service.getOffers(slug))
} catch (err) {
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
next(err)
}
})
router.get('/:slug/booking-options', async (req, res, next) => {
try {
const { slug } = parseParams(slugParamSchema, req)
ok(res, await service.getBookingOptions(slug))
} catch (err) {
if (isDatabaseUnavailableError(err)) return res.json({ data: { insurancePolicies: [], contractSettings: null } })
next(err)
}
})
router.post('/:slug/availability', async (req, res, next) => {
try {
const { slug } = parseParams(slugParamSchema, req)
const { vehicleId, startDate, endDate } = parseBody(availabilitySchema, req)
ok(res, await service.checkAvailability(slug, vehicleId, startDate, endDate))
} 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 { slug } = parseParams(slugParamSchema, req)
const { code } = parseBody(validateCodeSchema, req)
ok(res, await service.validatePromoCode(slug, code))
} 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 { slug } = parseParams(slugParamSchema, req)
const body = parseBody(bookSchema, req)
const result = await service.createBooking(slug, body)
created(res, result)
} 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 { slug, id } = parseParams(bookingParamSchema, req)
ok(res, await service.getBooking(slug, id))
} 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 { slug, id } = parseParams(bookingParamSchema, req)
const body = parseBody(paySchema, req)
ok(res, await service.initPayment(slug, id, body))
} 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 { slug } = parseParams(slugParamSchema, req)
const { paypalOrderId } = parseBody(capturePaypalSchema, req)
ok(res, await service.capturePaypal(slug, paypalOrderId))
} catch (err) { next(err) }
})
router.post('/:slug/contact', async (req, res, next) => {
try {
const { slug } = parseParams(slugParamSchema, req)
const body = parseBody(contactSchema, req)
ok(res, await service.handleContact(slug, body))
} catch (err) { next(err) }
})
export default router
+58
View File
@@ -0,0 +1,58 @@
import { z } from 'zod'
export const slugParamSchema = z.object({ slug: z.string() })
export const bookingParamSchema = z.object({ slug: z.string(), id: z.string() })
export const availabilitySchema = z.object({
vehicleId: z.string().cuid(),
startDate: z.string().datetime(),
endDate: z.string().datetime(),
})
export const validateCodeSchema = z.object({ code: z.string().min(1) })
export const bookSchema = 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([]),
})
export const paySchema = z.object({
provider: z.enum(['AMANPAY', 'PAYPAL']),
currency: z.enum(['MAD', 'USD', 'EUR']).default('MAD'),
successUrl: z.string().url(),
failureUrl: z.string().url(),
})
export const capturePaypalSchema = z.object({ paypalOrderId: z.string() })
export const contactSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
message: z.string().min(1),
})
+254
View File
@@ -0,0 +1,254 @@
import { AppError, NotFoundError } from '../../http/errors'
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
import { applyInsurancesToReservation } from '../../services/insuranceService'
import { applyAdditionalDriversToReservation } from '../../services/additionalDriverService'
import { applyPricingRules } from '../../services/pricingRuleService'
import { validateLicense, validateAndFlagLicense } from '../../services/licenseValidationService'
import { getMarketplaceHomepageContent } from '../../services/platformContentService'
import * as amanpay from '../../services/amanpayService'
import * as paypal from '../../services/paypalService'
import * as repo from './site.repo'
import { presentBrand } from './site.presenter'
export async function getPlatformHomepage() {
return getMarketplaceHomepageContent()
}
export async function getBrand(slug: string) {
const company = await repo.findCompanyBySlug(slug)
return presentBrand(company)
}
export async function getPublicVehicles(slug: string) {
const company = await repo.findCompanyBySlug(slug)
const vehicles = await repo.findPublishedVehicles(company.id)
return Promise.all(
vehicles.map(async (v) => {
const a = await getVehicleAvailabilitySummary(v.id)
return { ...v, availability: a.available, availabilityStatus: a.status, nextAvailableAt: a.nextAvailableAt }
}),
)
}
export async function getVehicleDetail(slug: string, vehicleId: string) {
const company = await repo.findCompanyBySlug(slug)
const vehicle = await repo.findVehicleById(vehicleId, company.id)
const a = await getVehicleAvailabilitySummary(vehicle.id)
return { ...vehicle, availability: a.available, availabilityStatus: a.status, nextAvailableAt: a.nextAvailableAt }
}
export async function getOffers(slug: string) {
const company = await repo.findCompanyBySlug(slug)
return repo.findActiveOffers(company.id)
}
export async function getBookingOptions(slug: string) {
const company = await repo.findCompanyBySlug(slug)
const insurancePolicies = await repo.findInsurancePolicies(company.id)
return { insurancePolicies, contractSettings: company.contractSettings }
}
export async function checkAvailability(slug: string, vehicleId: string, startDate: string, endDate: string) {
await repo.findCompanyBySlug(slug)
const availability = await getVehicleAvailabilitySummary(vehicleId, {
range: { startDate: new Date(startDate), endDate: new Date(endDate) },
})
return { available: availability.available, nextAvailableAt: availability.nextAvailableAt }
}
export async function validatePromoCode(slug: string, code: string) {
const company = await repo.findCompanyBySlug(slug)
const offer = await repo.findOfferByPromoCode(company.id, code)
if (!offer) throw new NotFoundError('Promo code not found or expired')
return offer
}
export async function createBooking(slug: string, body: {
vehicleId: string; startDate: string; endDate: string
firstName: string; lastName: string; email: string; phone?: string
driverLicense?: string; dateOfBirth?: string; licenseExpiry?: string; licenseIssuedAt?: string; nationality?: string
offerId?: string; promoCodeUsed?: string; notes?: string; source?: string
selectedInsurancePolicyIds?: string[]; additionalDrivers?: any[]
}) {
const company = await repo.findCompanyBySlug(slug)
const vehicle = await repo.findVehicleById(body.vehicleId, company.id)
const start = new Date(body.startDate)
const end = new Date(body.endDate)
if (end <= start) throw new AppError('End date must be after start date', 400, 'invalid_dates')
const availability = await getVehicleAvailabilitySummary(vehicle.id, { range: { startDate: start, endDate: end } })
if (!availability.available) {
throw new AppError('Vehicle is not available for the selected dates', 409, 'unavailable', {
nextAvailableAt: availability.nextAvailableAt?.toISOString() ?? null,
})
}
const customer = await repo.upsertCustomer(company.id, {
email: body.email, firstName: body.firstName, lastName: body.lastName, phone: body.phone,
driverLicense: body.driverLicense, dateOfBirth: body.dateOfBirth,
licenseExpiry: body.licenseExpiry, licenseIssuedAt: body.licenseIssuedAt, nationality: body.nationality,
})
const totalDays = Math.max(1, Math.ceil((end.getTime() - start.getTime()) / 86_400_000))
const baseAmount = vehicle.dailyRate * totalDays
let discountAmount = 0
if (body.promoCodeUsed) {
const offer = await repo.findOfferByPromoCode(company.id, body.promoCodeUsed)
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 additionalDriversList = body.additionalDrivers ?? []
const { applied, total: pricingRulesTotal } = await applyPricingRules(
company.id, customer.id, additionalDriversList as any[], vehicle.dailyRate, totalDays,
)
const primaryLicenseResult = validateLicense(body.licenseExpiry ? new Date(body.licenseExpiry) : null)
if (primaryLicenseResult.status === 'EXPIRED') {
throw new AppError('The primary driver license is expired', 400, 'license_expired')
}
const reservation = await repo.createReservation({
companyId: company.id,
vehicleId: vehicle.id,
customerId: customer.id,
offerId: body.offerId ?? null,
promoCodeUsed: body.promoCodeUsed ?? null,
vehicleCategory: vehicle.category,
source: body.source ?? 'PUBLIC_SITE',
startDate: start,
endDate: end,
dailyRate: vehicle.dailyRate,
totalDays,
totalAmount: baseAmount - discountAmount + pricingRulesTotal,
discountAmount,
pricingRulesApplied: applied,
pricingRulesTotal,
notes: body.notes ?? null,
status: 'DRAFT',
})
const insuranceIds = body.selectedInsurancePolicyIds ?? []
if (insuranceIds.length > 0) {
await applyInsurancesToReservation(reservation.id, company.id, insuranceIds, totalDays, baseAmount)
}
if (additionalDriversList.length > 0) {
await applyAdditionalDriversToReservation(reservation.id, company.id, additionalDriversList, totalDays)
}
if (body.licenseExpiry) {
await validateAndFlagLicense(customer.id).catch(() => null)
}
const refreshed = await repo.findReservationWithDetails(reservation.id)
return {
...refreshed,
requiresManualApproval:
primaryLicenseResult.requiresApproval ||
refreshed.additionalDrivers.some((d) => d.requiresApproval),
}
}
export async function getBooking(slug: string, reservationId: string) {
const company = await repo.findCompanyBySlug(slug)
return repo.findBooking(reservationId, company.id)
}
export async function initPayment(slug: string, reservationId: string, body: {
provider: 'AMANPAY' | 'PAYPAL'; currency?: 'MAD' | 'USD' | 'EUR'; successUrl: string; failureUrl: string
}) {
const company = await repo.findCompanyBySlug(slug)
const reservation = await repo.findReservationForPayment(reservationId, company.id)
if (reservation.paymentStatus === 'PAID') {
throw new AppError('This reservation is already paid', 409, 'already_paid')
}
const customerLicenseResult = validateLicense(reservation.customer.licenseExpiry)
const licenseBlocked =
reservation.customer.licenseValidationStatus === 'DENIED' ||
customerLicenseResult.status === 'EXPIRED' ||
(customerLicenseResult.requiresApproval && reservation.customer.licenseValidationStatus !== 'APPROVED') ||
reservation.additionalDrivers.some((d) => d.licenseExpired || (d.requiresApproval && !d.approvedAt))
if (licenseBlocked) {
throw new AppError('This reservation requires license review before payment can be processed', 409, 'license_review_required')
}
const currency = body.currency ?? 'MAD'
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 (body.provider === 'AMANPAY') {
if (!amanpay.isConfigured()) {
throw new AppError('Online payment is not available for this company', 503, 'provider_not_configured')
}
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) {
throw new AppError('AmanPay is not configured for this company', 503, 'provider_not_configured')
}
const result = await amanpay.createCheckout({
amount, currency, orderId, description,
customerEmail: reservation.customer.email,
customerName: `${reservation.customer.firstName} ${reservation.customer.lastName}`,
successUrl: body.successUrl,
failureUrl: body.failureUrl,
webhookUrl: `${webhookBase}/api/v1/payments/webhooks/amanpay`,
})
checkoutUrl = result.checkoutUrl
amanpayTransactionId = result.transactionId
} else {
if (!paypal.isConfigured()) {
throw new AppError('PayPal is not available for this company', 503, 'provider_not_configured')
}
const result = await paypal.createOrder({
amount, currency, orderId, description,
returnUrl: body.successUrl,
cancelUrl: body.failureUrl,
})
checkoutUrl = result.approveUrl
paypalCaptureId = result.orderId
}
await repo.createRentalPayment({
companyId: company.id,
reservationId: reservation.id,
amount,
currency,
paymentProvider: body.provider,
amanpayTransactionId,
paypalCaptureId,
})
return { checkoutUrl }
}
export async function capturePaypal(slug: string, paypalOrderId: string) {
const company = await repo.findCompanyBySlug(slug)
const payment = await repo.findPaymentByPaypalOrderId(paypalOrderId, company.id)
const capture = await paypal.captureOrder(paypalOrderId) as Record<string, any>
const captureId = capture.purchase_units?.[0]?.payments?.captures?.[0]?.id ?? paypalOrderId
await repo.capturePaypalPayment(payment.id, captureId, payment.reservationId, payment.amount)
return { success: true }
}
export async function handleContact(slug: string, body: { name: string; email: string; message: string }) {
const company = await repo.findCompanyBySlug(slug)
return {
success: true,
deliveredTo: company.brand?.publicEmail ?? company.email,
preview: body,
}
}
+257
View File
@@ -0,0 +1,257 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { AppError, NotFoundError } from '../../http/errors'
vi.mock('./site.repo', () => ({
findCompanyBySlug: vi.fn(),
findPublishedVehicles: vi.fn(),
findVehicleById: vi.fn(),
findActiveOffers: vi.fn(),
findInsurancePolicies: vi.fn(),
findOfferByPromoCode: vi.fn(),
upsertCustomer: vi.fn(),
createReservation: vi.fn(),
findReservationWithDetails: vi.fn(),
findBooking: vi.fn(),
findReservationForPayment: vi.fn(),
createRentalPayment: vi.fn(),
findPaymentByPaypalOrderId: vi.fn(),
capturePaypalPayment: vi.fn(),
}))
vi.mock('../../services/vehicleAvailabilityService', () => ({
getVehicleAvailabilitySummary: vi.fn(),
}))
vi.mock('../../services/insuranceService', () => ({
applyInsurancesToReservation: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('../../services/additionalDriverService', () => ({
applyAdditionalDriversToReservation: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('../../services/pricingRuleService', () => ({
applyPricingRules: vi.fn(),
}))
vi.mock('../../services/licenseValidationService', () => ({
validateLicense: vi.fn(),
validateAndFlagLicense: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('../../services/amanpayService', () => ({
isConfigured: vi.fn(),
createCheckout: vi.fn(),
}))
vi.mock('../../services/paypalService', () => ({
isConfigured: vi.fn(),
createOrder: vi.fn(),
captureOrder: vi.fn(),
}))
vi.mock('../../services/platformContentService', () => ({
getMarketplaceHomepageContent: vi.fn(),
}))
import * as repo from './site.repo'
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
import { applyPricingRules } from '../../services/pricingRuleService'
import { validateLicense } from '../../services/licenseValidationService'
import * as amanpay from '../../services/amanpayService'
import * as paypalSvc from '../../services/paypalService'
import {
getBrand, getPublicVehicles, checkAvailability, validatePromoCode,
createBooking, initPayment,
} from './site.service'
const SLUG = 'test-company'
function makeCompany(overrides: object = {}) {
return {
id: 'co-1', slug: SLUG, name: 'Test Co', phone: null, email: 'co@test.com',
brand: { publicEmail: null }, contractSettings: null,
...overrides,
}
}
function makeVehicle(overrides: object = {}) {
return {
id: 'v-1', dailyRate: 10000, year: 2022, make: 'Toyota', model: 'Camry',
isPublished: true, status: 'AVAILABLE', companyId: 'co-1', category: 'SEDAN',
...overrides,
}
}
beforeEach(() => { vi.clearAllMocks() })
// ────────────────────────────────────────────────────────────────────────────
describe('getBrand', () => {
it('returns company and public brand data only', async () => {
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany({
brand: {
displayName: 'Test Co',
paymentMethodsEnabled: ['AMANPAY', 'PAYPAL'],
amanpayMerchantId: 'merchant-id',
amanpaySecretKey: 'top-secret',
paypalMerchantId: 'paypal-merchant-id',
},
}) as any)
const result = await getBrand(SLUG)
expect(result.company.id).toBe('co-1')
expect(result.brand).toMatchObject({
displayName: 'Test Co',
paymentMethodsEnabled: ['AMANPAY', 'PAYPAL'],
paypalMerchantId: 'paypal-merchant-id',
})
expect(result.brand).not.toHaveProperty('amanpayMerchantId')
expect(result.brand).not.toHaveProperty('amanpaySecretKey')
})
})
// ────────────────────────────────────────────────────────────────────────────
describe('getPublicVehicles', () => {
it('returns vehicles enriched with availability', async () => {
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany() as any)
vi.mocked(repo.findPublishedVehicles).mockResolvedValue([makeVehicle()] as any)
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
const result = await getPublicVehicles(SLUG)
expect(result[0].availability).toBe(true)
})
})
// ────────────────────────────────────────────────────────────────────────────
describe('checkAvailability', () => {
it('returns availability result for given date range', async () => {
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany() as any)
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
const result = await checkAvailability(SLUG, 'v-1', '2025-06-01T00:00:00.000Z', '2025-06-04T00:00:00.000Z')
expect(result.available).toBe(true)
expect(getVehicleAvailabilitySummary).toHaveBeenCalledWith('v-1', { range: { startDate: new Date('2025-06-01T00:00:00.000Z'), endDate: new Date('2025-06-04T00:00:00.000Z') } })
})
})
// ────────────────────────────────────────────────────────────────────────────
describe('validatePromoCode', () => {
it('returns offer when code is valid', async () => {
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany() as any)
vi.mocked(repo.findOfferByPromoCode).mockResolvedValue({ id: 'o-1' } as any)
const result = await validatePromoCode(SLUG, 'SUMMER10')
expect((result as any).id).toBe('o-1')
})
it('throws NotFoundError for unknown promo code', async () => {
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany() as any)
vi.mocked(repo.findOfferByPromoCode).mockResolvedValue(null)
await expect(validatePromoCode(SLUG, 'BADCODE')).rejects.toBeInstanceOf(NotFoundError)
})
})
// ────────────────────────────────────────────────────────────────────────────
describe('createBooking', () => {
const baseBody = {
vehicleId: 'v-1',
startDate: '2025-06-01T00:00:00.000Z',
endDate: '2025-06-04T00:00:00.000Z',
firstName: 'Ali', lastName: 'Ben', email: 'ali@test.com',
}
beforeEach(() => {
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany() as any)
vi.mocked(repo.findVehicleById).mockResolvedValue(makeVehicle() as any)
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
vi.mocked(repo.upsertCustomer).mockResolvedValue({ id: 'c-1' } as any)
vi.mocked(applyPricingRules).mockResolvedValue({ applied: [], total: 0 })
vi.mocked(validateLicense).mockReturnValue({ status: 'VALID', requiresApproval: false } as any)
vi.mocked(repo.createReservation).mockResolvedValue({ id: 'r-1' } as any)
vi.mocked(repo.findReservationWithDetails).mockResolvedValue({ id: 'r-1', additionalDrivers: [], insurances: [] } as any)
})
it('creates a booking and returns reservation with requiresManualApproval flag', async () => {
const result = await createBooking(SLUG, baseBody)
expect(repo.createReservation).toHaveBeenCalledOnce()
expect((result as any).requiresManualApproval).toBe(false)
})
it('throws AppError for invalid dates', async () => {
await expect(
createBooking(SLUG, { ...baseBody, startDate: '2025-06-04T00:00:00.000Z', endDate: '2025-06-01T00:00:00.000Z' }),
).rejects.toMatchObject({ error: 'invalid_dates' })
})
it('throws AppError when vehicle unavailable', async () => {
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: false, status: 'RESERVED', nextAvailableAt: null, blockingReason: 'RESERVATION' })
await expect(createBooking(SLUG, baseBody)).rejects.toMatchObject({ error: 'unavailable' })
})
it('throws AppError when primary driver license is expired', async () => {
vi.mocked(validateLicense).mockReturnValue({ status: 'EXPIRED', requiresApproval: false } as any)
await expect(createBooking(SLUG, { ...baseBody, licenseExpiry: '2020-01-01T00:00:00.000Z' })).rejects.toMatchObject({ error: 'license_expired' })
})
})
// ────────────────────────────────────────────────────────────────────────────
describe('initPayment — payment guard paths', () => {
const payBody = { provider: 'PAYPAL' as const, successUrl: 'http://ok', failureUrl: 'http://fail' }
it('throws AppError when reservation is already paid', async () => {
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany() as any)
vi.mocked(repo.findReservationForPayment).mockResolvedValue({
paymentStatus: 'PAID', totalAmount: 100,
customer: { licenseExpiry: null, licenseValidationStatus: 'APPROVED', email: 'a@b.com', firstName: 'A', lastName: 'B' },
additionalDrivers: [],
vehicle: { make: 'Toyota', model: 'Camry' },
} as any)
vi.mocked(validateLicense).mockReturnValue({ status: 'VALID', requiresApproval: false } as any)
await expect(initPayment(SLUG, 'r-1', payBody)).rejects.toMatchObject({ error: 'already_paid' })
})
it('throws AppError when license review is required', async () => {
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany() as any)
vi.mocked(repo.findReservationForPayment).mockResolvedValue({
paymentStatus: 'UNPAID', totalAmount: 100,
customer: { licenseExpiry: null, licenseValidationStatus: 'DENIED', email: 'a@b.com', firstName: 'A', lastName: 'B' },
additionalDrivers: [],
vehicle: { make: 'Toyota', model: 'Camry' },
} as any)
vi.mocked(validateLicense).mockReturnValue({ status: 'VALID', requiresApproval: false } as any)
await expect(initPayment(SLUG, 'r-1', payBody)).rejects.toMatchObject({ error: 'license_review_required' })
})
it('throws AppError when PayPal is not configured', async () => {
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany() as any)
vi.mocked(repo.findReservationForPayment).mockResolvedValue({
paymentStatus: 'UNPAID', totalAmount: 30000,
customer: { licenseExpiry: null, licenseValidationStatus: 'APPROVED', email: 'a@b.com', firstName: 'Ali', lastName: 'Ben' },
additionalDrivers: [],
vehicle: { make: 'Toyota', model: 'Camry' },
} as any)
vi.mocked(validateLicense).mockReturnValue({ status: 'VALID', requiresApproval: false } as any)
vi.mocked(paypalSvc.isConfigured).mockReturnValue(false)
await expect(initPayment(SLUG, 'r-1', payBody)).rejects.toMatchObject({ error: 'provider_not_configured' })
})
it('returns checkoutUrl when PayPal is configured', async () => {
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany() as any)
vi.mocked(repo.findReservationForPayment).mockResolvedValue({
id: 'r-1', paymentStatus: 'UNPAID', totalAmount: 30000, companyId: 'co-1',
customer: { licenseExpiry: null, licenseValidationStatus: 'APPROVED', email: 'a@b.com', firstName: 'Ali', lastName: 'Ben' },
additionalDrivers: [],
vehicle: { make: 'Toyota', model: 'Camry' },
} as any)
vi.mocked(validateLicense).mockReturnValue({ status: 'VALID', requiresApproval: false } as any)
vi.mocked(paypalSvc.isConfigured).mockReturnValue(true)
vi.mocked(paypalSvc.createOrder).mockResolvedValue({ approveUrl: 'https://paypal.com/approve', orderId: 'pp-1' } as any)
vi.mocked(repo.createRentalPayment).mockResolvedValue(undefined as any)
const result = await initPayment(SLUG, 'r-1', payBody)
expect(result.checkoutUrl).toBe('https://paypal.com/approve')
expect(repo.createRentalPayment).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,60 @@
import { prisma } from '../../lib/prisma'
export function findByCompany(companyId: string) {
return prisma.subscription.findUnique({
where: { companyId },
include: { invoices: { orderBy: { createdAt: 'desc' }, take: 12 } },
})
}
export function findInvoices(companyId: string) {
return prisma.subscriptionInvoice.findMany({ where: { companyId }, orderBy: { createdAt: 'desc' }, take: 50 })
}
export function findInvoiceByAmanpay(transactionId: string) {
return prisma.subscriptionInvoice.findFirst({ where: { amanpayTransactionId: transactionId }, include: { subscription: true } })
}
export function findInvoiceByPaypal(captureId: string) {
return prisma.subscriptionInvoice.findFirst({ where: { paypalCaptureId: captureId }, include: { subscription: true } })
}
export function findInvoiceByPaypalForCompany(paypalOrderId: string, companyId: string) {
return prisma.subscriptionInvoice.findFirstOrThrow({ where: { paypalCaptureId: paypalOrderId, companyId }, include: { subscription: true } })
}
export function markInvoicePaid(id: string) {
return prisma.subscriptionInvoice.update({ where: { id }, data: { status: 'PAID', paidAt: new Date() } })
}
export function activateSubscription(id: string, periodEnd: Date) {
return prisma.subscription.update({
where: { id },
data: { status: 'ACTIVE', currentPeriodStart: new Date(), currentPeriodEnd: periodEnd },
})
}
export async function findOrCreateSubscription(companyId: string, plan: string, billingPeriod: string, currency: string) {
const existing = await prisma.subscription.findUnique({ where: { companyId } })
if (existing) return existing
return prisma.subscription.create({ data: { companyId, plan: plan as any, billingPeriod: billingPeriod as any, currency, status: 'PENDING' as any } })
}
export function createInvoice(data: {
companyId: string; subscriptionId: string; amount: number; currency: string
paymentProvider: string; amanpayTransactionId?: string | null; paypalCaptureId?: string | null
}) {
return prisma.subscriptionInvoice.create({ data: { ...data, status: 'PENDING', paymentProvider: data.paymentProvider as any } })
}
export function updateInvoicePaypal(id: string, captureId: string) {
return prisma.subscriptionInvoice.update({ where: { id }, data: { status: 'PAID', paidAt: new Date(), paypalCaptureId: captureId } })
}
export function updatePlan(companyId: string, data: { plan: any; billingPeriod: any; currency: string }) {
return prisma.subscription.update({ where: { companyId }, data })
}
export function setCancelAtPeriodEnd(companyId: string, value: boolean) {
return prisma.subscription.update({ where: { companyId }, data: { cancelAtPeriodEnd: value } })
}
@@ -0,0 +1,99 @@
import { Router } from 'express'
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
import { requireTenant } from '../../middleware/requireTenant'
import { requireRole } from '../../middleware/requireRole'
import { parseBody } from '../../http/validate'
import { ok } from '../../http/respond'
import * as amanpay from '../../services/amanpayService'
import * as paypal from '../../services/paypalService'
import * as service from './subscription.service'
import { checkoutSchema, changePlanSchema, capturePaypalSchema } from './subscription.schemas'
const router = Router()
// ─── Public ────────────────────────────────────────────────────
router.get('/plans', (_req, res) => {
ok(res, { data: service.getPlans() })
})
router.get('/providers', (_req, res) => {
ok(res, { data: service.getProviders() })
})
// ─── Webhooks (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' })
}
await service.handleAmanpayWebhook(req.body)
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' })
await service.handlePaypalWebhook(req.body)
res.json({ received: true })
} catch (err) { next(err) }
})
// ─── PayPal capture (auth but no subscription check) ──────────
router.post('/capture-paypal', requireCompanyAuth, requireTenant, async (req, res, next) => {
try {
const { paypalOrderId } = parseBody(capturePaypalSchema, req)
ok(res, { data: await service.capturePaypal(req.companyId, paypalOrderId) })
} catch (err) { next(err) }
})
// ─── Authenticated ────────────────────────────────────────────
router.use(requireCompanyAuth, requireTenant)
router.get('/me', async (req, res, next) => {
try {
ok(res, { data: await service.getSubscription(req.companyId) })
} catch (err) { next(err) }
})
router.get('/invoices', async (req, res, next) => {
try {
ok(res, { data: await service.getInvoices(req.companyId) })
} catch (err) { next(err) }
})
router.post('/checkout', requireRole('OWNER'), async (req, res, next) => {
try {
const body = parseBody(checkoutSchema, req)
ok(res, { data: await service.checkout(req.companyId, body) })
} catch (err) { next(err) }
})
router.post('/change-plan', requireRole('OWNER'), async (req, res, next) => {
try {
const body = parseBody(changePlanSchema, req)
ok(res, { data: await service.changePlan(req.companyId, body) })
} catch (err) { next(err) }
})
router.post('/cancel', requireRole('OWNER'), async (req, res, next) => {
try {
ok(res, { data: await service.cancel(req.companyId) })
} catch (err) { next(err) }
})
router.post('/resume', requireRole('OWNER'), async (req, res, next) => {
try {
ok(res, { data: await service.resume(req.companyId) })
} catch (err) { next(err) }
})
export default router
@@ -0,0 +1,20 @@
import { z } from 'zod'
export 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(),
})
export const changePlanSchema = z.object({
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
currency: z.enum(['MAD', 'USD', 'EUR']),
})
export const capturePaypalSchema = z.object({
paypalOrderId: z.string(),
})
@@ -0,0 +1,114 @@
import { PLAN_PRICES } from '@rentaldrivego/types'
import { prisma } from '../../lib/prisma'
import { ValidationError } from '../../http/errors'
import * as amanpay from '../../services/amanpayService'
import * as paypal from '../../services/paypalService'
import * as repo from './subscription.repo'
export function addPeriod(date: Date, period: string): Date {
const d = new Date(date)
period === 'ANNUAL' ? d.setFullYear(d.getFullYear() + 1) : d.setMonth(d.getMonth() + 1)
return d
}
export function getPlans() {
return PLAN_PRICES
}
export function getProviders() {
return { amanpay: amanpay.isConfigured(), paypal: paypal.isConfigured() }
}
export function getSubscription(companyId: string) {
return repo.findByCompany(companyId)
}
export function getInvoices(companyId: string) {
return repo.findInvoices(companyId)
}
export async function handleAmanpayWebhook(event: any) {
const transactionId = event.transaction_id ?? event.id
const status = event.status?.toUpperCase()
if (status === 'PAID' || status === 'SUCCEEDED') {
const invoice = await repo.findInvoiceByAmanpay(transactionId)
if (invoice) {
await repo.markInvoicePaid(invoice.id)
await repo.activateSubscription(invoice.subscriptionId, addPeriod(new Date(), invoice.subscription.billingPeriod))
}
}
}
export async function handlePaypalWebhook(event: any) {
if (event.event_type === 'PAYMENT.CAPTURE.COMPLETED') {
const captureId = event.resource?.id as string
const invoice = await repo.findInvoiceByPaypal(captureId)
if (invoice) {
await repo.markInvoicePaid(invoice.id)
await repo.activateSubscription(invoice.subscriptionId, addPeriod(new Date(), invoice.subscription.billingPeriod))
}
}
}
export async function checkout(companyId: string, body: {
plan: 'STARTER' | 'GROWTH' | 'PRO'; billingPeriod: 'MONTHLY' | 'ANNUAL'
currency: 'MAD' | 'USD' | 'EUR'; provider: 'AMANPAY' | 'PAYPAL'
successUrl: string; failureUrl: string
}) {
const prices = PLAN_PRICES[body.plan]?.[body.billingPeriod]
if (!prices) throw new ValidationError('Invalid plan or billing period')
const amount = (prices as any)[body.currency]
if (!amount) throw new ValidationError('Currency not supported for this plan')
const company = await prisma.company.findUniqueOrThrow({ where: { id: companyId } })
const subscription = await repo.findOrCreateSubscription(companyId, body.plan, body.billingPeriod, body.currency)
const orderId = `sub-${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()) throw new ValidationError('AmanPay is not configured on this platform')
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()) throw new ValidationError('PayPal is not configured on this platform')
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 repo.createInvoice({ companyId, subscriptionId: subscription.id, amount, currency: body.currency, paymentProvider: body.provider, amanpayTransactionId, paypalCaptureId })
return { invoice, checkoutUrl }
}
export async function capturePaypal(companyId: string, paypalOrderId: string) {
const invoice = await repo.findInvoiceByPaypalForCompany(paypalOrderId, companyId)
const capture = await paypal.captureOrder(paypalOrderId) as Record<string, any>
const captureId = capture.purchase_units?.[0]?.payments?.captures?.[0]?.id ?? paypalOrderId
await repo.updateInvoicePaypal(invoice.id, captureId)
await repo.activateSubscription(invoice.subscriptionId, addPeriod(new Date(), invoice.subscription.billingPeriod))
return { success: true }
}
export function changePlan(companyId: string, data: { plan: any; billingPeriod: any; currency: string }) {
return repo.updatePlan(companyId, data)
}
export function cancel(companyId: string) {
return repo.setCancelAtPeriodEnd(companyId, true)
}
export function resume(companyId: string) {
return repo.setCancelAtPeriodEnd(companyId, false)
}
+63
View File
@@ -0,0 +1,63 @@
import { Router } from 'express'
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
import { requireTenant } from '../../middleware/requireTenant'
import { requireSubscription } from '../../middleware/requireSubscription'
import { requireRole } from '../../middleware/requireRole'
import { parseBody, parseParams } from '../../http/validate'
import { ok, created } from '../../http/respond'
import * as service from './team.service'
import { inviteSchema, roleSchema, idParamSchema } from './team.schemas'
const router = Router()
router.use(requireCompanyAuth, requireTenant, requireSubscription)
router.get('/', async (req, res, next) => {
try {
ok(res, { data: await service.getMembers(req.companyId) })
} catch (err) { next(err) }
})
router.get('/stats', async (req, res, next) => {
try {
ok(res, await service.getMemberStats(req.companyId))
} catch (err) { next(err) }
})
router.post('/invite', requireRole('OWNER'), async (req, res, next) => {
try {
const body = parseBody(inviteSchema, req)
created(res, { data: await service.inviteEmployee(req.companyId, req.employee.id, body) })
} catch (err) { next(err) }
})
router.patch('/:id/role', requireRole('OWNER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { role } = parseBody(roleSchema, req)
ok(res, { data: await service.updateEmployeeRole(req.companyId, req.employee.id, req.employee.role, id, { role }) })
} catch (err) { next(err) }
})
router.post('/:id/deactivate', requireRole('OWNER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
ok(res, { data: await service.deactivateEmployee(req.companyId, req.employee.role, id) })
} catch (err) { next(err) }
})
router.post('/:id/reactivate', requireRole('OWNER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
ok(res, { data: await service.reactivateEmployee(req.companyId, req.employee.role, id) })
} catch (err) { next(err) }
})
router.delete('/:id', requireRole('OWNER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
ok(res, { data: await service.removeEmployee(req.companyId, req.employee.role, id) })
} catch (err) { next(err) }
})
export default router
+16
View File
@@ -0,0 +1,16 @@
import { z } from 'zod'
export const inviteSchema = z.object({
firstName: z.string().min(1).max(64),
lastName: z.string().min(1).max(64),
email: z.string().email(),
role: z.enum(['MANAGER', 'AGENT']),
})
export const roleSchema = z.object({
role: z.enum(['MANAGER', 'AGENT']),
})
export const idParamSchema = z.object({
id: z.string().min(1),
})
+24
View File
@@ -0,0 +1,24 @@
import {
listEmployees,
inviteEmployee,
updateEmployeeRole,
deactivateEmployee,
reactivateEmployee,
removeEmployee,
} from '../../services/teamService'
export async function getMembers(companyId: string) {
return listEmployees(companyId)
}
export async function getMemberStats(companyId: string) {
const members = await listEmployees(companyId)
return {
total: members.length,
active: members.filter((m) => m.isActive && m.invitationStatus === 'accepted').length,
pending: members.filter((m) => m.invitationStatus === 'pending').length,
inactive: members.filter((m) => !m.isActive && m.invitationStatus === 'accepted').length,
}
}
export { inviteEmployee, updateEmployeeRole, deactivateEmployee, reactivateEmployee, removeEmployee }
@@ -0,0 +1,7 @@
export function presentVehicle(vehicle: any) {
return vehicle
}
export function presentVehicleList(vehicles: any[], meta: { total: number; page: number; pageSize: number; totalPages: number }) {
return { data: vehicles, ...meta }
}
@@ -0,0 +1,91 @@
import { prisma } from '../../lib/prisma'
export async function findMany(where: any, skip: number, take: number) {
return Promise.all([
prisma.vehicle.findMany({ where, skip, take, orderBy: { createdAt: 'desc' } }),
prisma.vehicle.count({ where }),
])
}
export async function findById(id: string, companyId: string) {
return prisma.vehicle.findFirst({ where: { id, companyId } })
}
export async function findFirst(id: string, companyId: string) {
return prisma.vehicle.findFirst({ where: { id, companyId } })
}
export async function create(data: any) {
return prisma.vehicle.create({ data })
}
export async function updateById(id: string, data: any) {
return prisma.vehicle.update({ where: { id }, data })
}
export async function softDelete(id: string, companyId: string) {
return prisma.vehicle.updateMany({ where: { id, companyId }, data: { status: 'OUT_OF_SERVICE', isPublished: false } })
}
export async function setPublished(id: string, companyId: string, isPublished: boolean) {
return prisma.vehicle.updateMany({ where: { id, companyId }, data: { isPublished } })
}
export async function findReservationConflicts(vehicleId: string, companyId: string, start: Date, end: Date) {
return prisma.reservation.findMany({
where: {
vehicleId,
companyId,
status: { in: ['CONFIRMED', 'ACTIVE'] },
startDate: { lt: end },
endDate: { gt: start },
},
select: { id: true, startDate: true, endDate: true, status: true },
})
}
export async function findCalendarBlockConflicts(vehicleId: string, start: Date, end: Date) {
return prisma.vehicleCalendarBlock.findMany({
where: { vehicleId, startDate: { lt: end }, endDate: { gt: start } },
select: { id: true, startDate: true, endDate: true, type: true, reason: true },
})
}
export async function findCalendarEvents(vehicleId: string, companyId: string, rangeStart: Date, rangeEnd: Date) {
return Promise.all([
prisma.reservation.findMany({
where: {
vehicleId,
companyId,
status: { in: ['DRAFT', 'CONFIRMED', 'ACTIVE'] },
startDate: { lt: rangeEnd },
endDate: { gt: rangeStart },
},
select: {
id: true, startDate: true, endDate: true, status: true,
customer: { select: { firstName: true, lastName: true } },
},
orderBy: { startDate: 'asc' },
}),
prisma.vehicleCalendarBlock.findMany({
where: { vehicleId, startDate: { lt: rangeEnd }, endDate: { gt: rangeStart } },
orderBy: { startDate: 'asc' },
}),
])
}
export async function createCalendarBlock(data: any) {
return prisma.vehicleCalendarBlock.create({ data })
}
export async function deleteCalendarBlock(id: string, vehicleId: string) {
return prisma.vehicleCalendarBlock.deleteMany({ where: { id, vehicleId } })
}
export async function findMaintenanceLogs(vehicleId: string) {
return prisma.maintenanceLog.findMany({ where: { vehicleId }, orderBy: { performedAt: 'desc' } })
}
export async function createMaintenanceLog(data: any) {
return prisma.maintenanceLog.create({ data })
}
@@ -0,0 +1,140 @@
import { Router } from 'express'
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
import { requireTenant } from '../../middleware/requireTenant'
import { requireSubscription } from '../../middleware/requireSubscription'
import { requireRole } from '../../middleware/requireRole'
import { parseBody, parseQuery, parseParams } from '../../http/validate'
import { ok, created } from '../../http/respond'
import { imageUpload, assertImageFiles } from '../../http/upload'
import * as service from './vehicle.service'
import {
vehicleSchema, listQuerySchema, availabilityQuerySchema, calendarQuerySchema,
calendarBlockSchema, maintenanceLogSchema, publishSchema,
idParamSchema, photoIdxSchema, blockIdParamSchema,
} from './vehicle.schemas'
const router = Router()
router.use(requireCompanyAuth, requireTenant, requireSubscription)
router.get('/', async (req, res, next) => {
try {
const query = parseQuery(listQuerySchema, req)
const result = await service.listVehicles(req.companyId, query)
res.json(result)
} catch (err) { next(err) }
})
router.post('/', requireRole('MANAGER'), async (req, res, next) => {
try {
const body = parseBody(vehicleSchema, req)
const vehicle = await service.createVehicle(body, req.companyId)
created(res, vehicle)
} catch (err) { next(err) }
})
router.get('/:id', async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const vehicle = await service.getVehicle(id, req.companyId)
ok(res, vehicle)
} catch (err) { next(err) }
})
router.patch('/:id', requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const body = parseBody(vehicleSchema.partial(), req)
const vehicle = await service.updateVehicle(id, req.companyId, body)
ok(res, vehicle)
} catch (err) { next(err) }
})
router.delete('/:id', requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
await service.deleteVehicle(id, req.companyId)
ok(res, { success: true })
} catch (err) { next(err) }
})
router.post('/:id/photos', requireRole('MANAGER'), imageUpload.array('photos', 10), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const files = req.files as Express.Multer.File[]
assertImageFiles(files)
const updated = await service.uploadPhotos(id, req.companyId, files)
ok(res, updated)
} catch (err) { next(err) }
})
router.delete('/:id/photos/:idx', requireRole('MANAGER'), async (req, res, next) => {
try {
const { id, idx } = parseParams(photoIdxSchema, req)
const updated = await service.deletePhoto(id, req.companyId, parseInt(idx, 10))
ok(res, updated)
} catch (err) { next(err) }
})
router.patch('/:id/publish', requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { isPublished } = parseBody(publishSchema, req)
await service.setPublished(id, req.companyId, isPublished)
ok(res, { success: true, isPublished })
} catch (err) { next(err) }
})
router.get('/:id/availability', async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { startDate, endDate } = parseQuery(availabilityQuerySchema, req)
const result = await service.checkAvailability(id, req.companyId, startDate, endDate)
ok(res, result)
} catch (err) { next(err) }
})
router.get('/:id/calendar', async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { year, month } = parseQuery(calendarQuerySchema, req)
const events = await service.getCalendar(id, req.companyId, year, month)
ok(res, events)
} catch (err) { next(err) }
})
router.post('/:id/calendar/blocks', requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const body = parseBody(calendarBlockSchema, req)
const block = await service.createCalendarBlock(id, req.companyId, body)
created(res, block)
} catch (err) { next(err) }
})
router.delete('/:id/calendar/blocks/:blockId', requireRole('MANAGER'), async (req, res, next) => {
try {
const { id, blockId } = parseParams(blockIdParamSchema, req)
await service.deleteCalendarBlock(id, req.companyId, blockId)
ok(res, { success: true })
} catch (err) { next(err) }
})
router.get('/:id/maintenance', async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const logs = await service.getMaintenanceLogs(id)
ok(res, logs)
} catch (err) { next(err) }
})
router.post('/:id/maintenance', requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const body = parseBody(maintenanceLogSchema, req)
const log = await service.createMaintenanceLog(id, req.companyId, body)
created(res, log)
} catch (err) { next(err) }
})
export default router
@@ -0,0 +1,61 @@
import { z } from 'zod'
export const vehicleSchema = z.object({
make: z.string().min(1),
model: z.string().min(1),
year: z.number().int().min(1990).max(new Date().getFullYear() + 1),
color: z.string().default(''),
licensePlate: z.string().min(1),
vin: z.string().optional(),
category: z.enum(['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK']),
seats: z.number().int().min(1).max(20).default(5),
transmission: z.enum(['AUTOMATIC', 'MANUAL']).default('AUTOMATIC'),
fuelType: z.enum(['GASOLINE', 'DIESEL', 'ELECTRIC', 'HYBRID']).default('GASOLINE'),
features: z.array(z.string()).default([]),
dailyRate: z.number().int().min(0),
mileage: z.number().int().optional(),
notes: z.string().optional(),
isPublished: z.boolean().default(true),
status: z.enum(['AVAILABLE', 'RENTED', 'MAINTENANCE', 'OUT_OF_SERVICE']).optional(),
})
export const listQuerySchema = z.object({
status: z.string().optional(),
category: z.string().optional(),
published: z.string().optional(),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
})
export const availabilityQuerySchema = z.object({
startDate: z.string(),
endDate: z.string(),
})
export const calendarQuerySchema = z.object({
year: z.coerce.number().int(),
month: z.coerce.number().int().min(1).max(12),
})
export const calendarBlockSchema = z.object({
startDate: z.string().datetime({ offset: true }).or(z.string().regex(/^\d{4}-\d{2}-\d{2}$/)),
endDate: z.string().datetime({ offset: true }).or(z.string().regex(/^\d{4}-\d{2}-\d{2}$/)),
reason: z.string().optional(),
type: z.enum(['MANUAL', 'MAINTENANCE']).default('MANUAL'),
})
export const maintenanceLogSchema = z.object({
type: z.string().min(1),
description: z.string().optional(),
cost: z.number().int().optional(),
mileage: z.number().int().optional(),
performedAt: z.string().datetime(),
nextDueAt: z.string().datetime().optional(),
nextDueMileage: z.number().int().optional(),
})
export const publishSchema = z.object({ isPublished: z.boolean() })
export const idParamSchema = z.object({ id: z.string() })
export const photoIdxSchema = z.object({ id: z.string(), idx: z.string() })
export const blockIdParamSchema = z.object({ id: z.string(), blockId: z.string() })
@@ -0,0 +1,126 @@
import { uploadImage } from '../../lib/storage'
import { NotFoundError, ValidationError } from '../../http/errors'
import { presentVehicle, presentVehicleList } from './vehicle.presenter'
import * as repo from './vehicle.repo'
export async function listVehicles(companyId: string, query: { status?: string; category?: string; published?: string; page?: number; pageSize?: number }) {
const page = query.page ?? 1
const pageSize = query.pageSize ?? 20
const { status, category, published } = query
const where: any = { companyId }
if (status) where.status = status
if (category) where.category = category
if (published !== undefined) where.isPublished = published === 'true'
const [vehicles, total] = await repo.findMany(where, (page - 1) * pageSize, pageSize)
return presentVehicleList(vehicles.map(presentVehicle), { total, page, pageSize, totalPages: Math.ceil(total / pageSize) })
}
export async function getVehicle(id: string, companyId: string) {
const vehicle = await repo.findById(id, companyId)
if (!vehicle) throw new NotFoundError('Vehicle not found')
return presentVehicle(vehicle)
}
export async function createVehicle(data: any, companyId: string) {
return presentVehicle(await repo.create({ ...data, companyId }))
}
export async function updateVehicle(id: string, companyId: string, data: any) {
const patch = { ...data }
if (patch.status === 'MAINTENANCE' || patch.status === 'OUT_OF_SERVICE') {
patch.isPublished = false
} else if (patch.status === 'AVAILABLE' || patch.status === 'RENTED') {
patch.isPublished = true
}
const existing = await repo.findFirst(id, companyId)
if (!existing) throw new NotFoundError('Vehicle not found')
return presentVehicle(await repo.updateById(id, patch))
}
export async function deleteVehicle(id: string, companyId: string) {
return repo.softDelete(id, companyId)
}
export async function uploadPhotos(id: string, companyId: string, files: Express.Multer.File[]) {
const vehicle = await repo.findById(id, companyId)
if (!vehicle) throw new NotFoundError('Vehicle not found')
const urls = await Promise.all(files.map((f) => uploadImage(f.buffer, `companies/${companyId}/vehicles`)))
return presentVehicle(await repo.updateById(id, { photos: [...vehicle.photos, ...urls] }))
}
export async function deletePhoto(id: string, companyId: string, idx: number) {
const vehicle = await repo.findById(id, companyId)
if (!vehicle) throw new NotFoundError('Vehicle not found')
const photos = vehicle.photos.filter((_: string, i: number) => i !== idx)
return presentVehicle(await repo.updateById(id, { photos }))
}
export async function setPublished(id: string, companyId: string, isPublished: boolean) {
await repo.setPublished(id, companyId, isPublished)
}
export async function checkAvailability(id: string, companyId: string, startDate: string, endDate: string) {
const start = new Date(startDate)
const end = new Date(endDate)
const [conflicts, blocks] = await Promise.all([
repo.findReservationConflicts(id, companyId, start, end),
repo.findCalendarBlockConflicts(id, start, end),
])
return { available: conflicts.length === 0 && blocks.length === 0, conflicts, blocks }
}
export async function getCalendar(id: string, companyId: string, year: number, month: number) {
const vehicle = await repo.findById(id, companyId)
if (!vehicle) throw new NotFoundError('Vehicle not found')
const rangeStart = new Date(year, month - 1, 1)
const rangeEnd = new Date(year, month, 1)
const [reservations, blocks] = await repo.findCalendarEvents(id, companyId, rangeStart, rangeEnd)
return [
...reservations.map((r: any) => ({
id: r.id,
type: 'RESERVATION' as const,
startDate: r.startDate,
endDate: r.endDate,
status: r.status,
label: r.customer ? `${r.customer.firstName} ${r.customer.lastName}` : 'Reserved',
})),
...blocks.map((b: any) => ({
id: b.id,
type: b.type === 'MAINTENANCE' ? ('MAINTENANCE' as const) : ('BLOCK' as const),
startDate: b.startDate,
endDate: b.endDate,
status: null,
label: b.reason ?? (b.type === 'MAINTENANCE' ? 'Maintenance' : 'Blocked'),
})),
]
}
export async function createCalendarBlock(id: string, companyId: string, data: { startDate: string; endDate: string; reason?: string; type?: 'MANUAL' | 'MAINTENANCE' }) {
const start = new Date(data.startDate)
const end = new Date(data.endDate)
if (end <= start) throw new ValidationError('endDate must be after startDate')
const vehicle = await repo.findById(id, companyId)
if (!vehicle) throw new NotFoundError('Vehicle not found')
return repo.createCalendarBlock({ vehicleId: id, startDate: start, endDate: end, reason: data.reason, type: data.type ?? 'MANUAL' })
}
export async function deleteCalendarBlock(vehicleId: string, companyId: string, blockId: string) {
const vehicle = await repo.findById(vehicleId, companyId)
if (!vehicle) throw new NotFoundError('Vehicle not found')
await repo.deleteCalendarBlock(blockId, vehicleId)
}
export async function getMaintenanceLogs(id: string) {
return repo.findMaintenanceLogs(id)
}
export async function createMaintenanceLog(id: string, companyId: string, data: any) {
const vehicle = await repo.findById(id, companyId)
if (!vehicle) throw new NotFoundError('Vehicle not found')
return repo.createMaintenanceLog({
...data,
vehicleId: id,
performedAt: new Date(data.performedAt),
nextDueAt: data.nextDueAt ? new Date(data.nextDueAt) : undefined,
})
}
@@ -0,0 +1,125 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import * as repo from './vehicle.repo'
import * as service from './vehicle.service'
vi.mock('./vehicle.repo')
vi.mock('../../lib/storage', () => ({
uploadImage: vi.fn().mockResolvedValue('http://localhost:4000/storage/vehicles/test.jpg'),
}))
const mockVehicle = {
id: 'veh_1',
companyId: 'comp_1',
make: 'Toyota',
model: 'Camry',
year: 2022,
licensePlate: 'ABC-123',
status: 'AVAILABLE',
isPublished: true,
photos: [],
dailyRate: 500,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('vehicle.service', () => {
describe('createVehicle', () => {
it('creates a vehicle with companyId', async () => {
vi.mocked(repo.create).mockResolvedValue(mockVehicle as any)
const result = await service.createVehicle({ make: 'Toyota', model: 'Camry', year: 2022, licensePlate: 'ABC-123', dailyRate: 500 }, 'comp_1')
expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({ companyId: 'comp_1' }))
expect(result).toEqual(mockVehicle)
})
})
describe('updateVehicle', () => {
it('throws NotFoundError when vehicle does not belong to company', async () => {
vi.mocked(repo.findFirst).mockResolvedValue(null)
await expect(service.updateVehicle('veh_1', 'comp_1', { make: 'Honda' })).rejects.toThrow('Vehicle not found')
})
it('sets isPublished=false when status is MAINTENANCE', async () => {
vi.mocked(repo.findFirst).mockResolvedValue(mockVehicle as any)
vi.mocked(repo.updateById).mockResolvedValue({ ...mockVehicle, status: 'MAINTENANCE', isPublished: false } as any)
await service.updateVehicle('veh_1', 'comp_1', { status: 'MAINTENANCE' })
expect(repo.updateById).toHaveBeenCalledWith('veh_1', expect.objectContaining({ isPublished: false }))
})
it('sets isPublished=true when status is AVAILABLE', async () => {
vi.mocked(repo.findFirst).mockResolvedValue({ ...mockVehicle, status: 'MAINTENANCE' } as any)
vi.mocked(repo.updateById).mockResolvedValue({ ...mockVehicle, status: 'AVAILABLE', isPublished: true } as any)
await service.updateVehicle('veh_1', 'comp_1', { status: 'AVAILABLE' })
expect(repo.updateById).toHaveBeenCalledWith('veh_1', expect.objectContaining({ isPublished: true }))
})
})
describe('uploadPhotos', () => {
it('appends new photo URLs to existing photos', async () => {
const vehicleWithPhotos = { ...mockVehicle, photos: ['http://localhost:4000/storage/vehicles/existing.jpg'] }
vi.mocked(repo.findById).mockResolvedValue(vehicleWithPhotos as any)
vi.mocked(repo.updateById).mockResolvedValue({ ...vehicleWithPhotos, photos: [...vehicleWithPhotos.photos, 'http://localhost:4000/storage/vehicles/test.jpg'] } as any)
await service.uploadPhotos('veh_1', 'comp_1', [{ buffer: Buffer.from('') } as any])
expect(repo.updateById).toHaveBeenCalledWith('veh_1', {
photos: ['http://localhost:4000/storage/vehicles/existing.jpg', 'http://localhost:4000/storage/vehicles/test.jpg'],
})
})
})
describe('deletePhoto', () => {
it('removes photo at the given index', async () => {
const photos = ['url_0', 'url_1', 'url_2']
vi.mocked(repo.findById).mockResolvedValue({ ...mockVehicle, photos } as any)
vi.mocked(repo.updateById).mockResolvedValue({ ...mockVehicle, photos: ['url_0', 'url_2'] } as any)
await service.deletePhoto('veh_1', 'comp_1', 1)
expect(repo.updateById).toHaveBeenCalledWith('veh_1', { photos: ['url_0', 'url_2'] })
})
})
describe('checkAvailability', () => {
it('returns available=true when no conflicts', async () => {
vi.mocked(repo.findReservationConflicts).mockResolvedValue([])
vi.mocked(repo.findCalendarBlockConflicts).mockResolvedValue([])
const result = await service.checkAvailability('veh_1', 'comp_1', '2025-06-01T00:00:00Z', '2025-06-07T00:00:00Z')
expect(result.available).toBe(true)
expect(result.conflicts).toHaveLength(0)
expect(result.blocks).toHaveLength(0)
})
it('returns available=false when reservation conflict exists', async () => {
vi.mocked(repo.findReservationConflicts).mockResolvedValue([{ id: 'res_1', startDate: new Date(), endDate: new Date(), status: 'CONFIRMED' }] as any)
vi.mocked(repo.findCalendarBlockConflicts).mockResolvedValue([])
const result = await service.checkAvailability('veh_1', 'comp_1', '2025-06-01T00:00:00Z', '2025-06-07T00:00:00Z')
expect(result.available).toBe(false)
expect(result.conflicts).toHaveLength(1)
})
it('returns available=false when calendar block conflict exists', async () => {
vi.mocked(repo.findReservationConflicts).mockResolvedValue([])
vi.mocked(repo.findCalendarBlockConflicts).mockResolvedValue([{ id: 'block_1', startDate: new Date(), endDate: new Date(), type: 'MANUAL', reason: null }] as any)
const result = await service.checkAvailability('veh_1', 'comp_1', '2025-06-01T00:00:00Z', '2025-06-07T00:00:00Z')
expect(result.available).toBe(false)
expect(result.blocks).toHaveLength(1)
})
})
describe('createCalendarBlock', () => {
it('throws ValidationError when endDate is before startDate', async () => {
await expect(
service.createCalendarBlock('veh_1', 'comp_1', { startDate: '2025-06-07T00:00:00Z', endDate: '2025-06-01T00:00:00Z' }),
).rejects.toThrow('endDate must be after startDate')
})
it('creates a calendar block', async () => {
vi.mocked(repo.findById).mockResolvedValue(mockVehicle as any)
vi.mocked(repo.createCalendarBlock).mockResolvedValue({ id: 'block_1' } as any)
const result = await service.createCalendarBlock('veh_1', 'comp_1', {
startDate: '2025-06-01T00:00:00Z',
endDate: '2025-06-07T00:00:00Z',
type: 'MANUAL',
})
expect(result).toEqual({ id: 'block_1' })
})
})
})

Some files were not shown because too many files have changed in this diff Show More