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
+124 -11
View File
@@ -1,28 +1,141 @@
stages: stages:
- test
- build - build
- deploy - deploy
variables: variables:
DOCKER_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA DOCKER_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
DOCKER_IMAGE_LATEST: $CI_REGISTRY_IMAGE:latest
DOCKERFILE_PATH: Dockerfile.production
# Required: disable TLS so the docker client can reach the dind daemon
DOCKER_TLS_CERTDIR: ""
build: # ====================================
# TEST STAGE
# Runs on every push and merge request
# ====================================
unit_tests:
stage: test
image: node:20-bookworm
before_script:
- npm ci
- npm run db:generate
script:
- npm run type-check
- npm run test:api
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"
integration_tests:
stage: test
image: node:20-bookworm
services:
- name: postgres:16-alpine
alias: postgres
variables:
POSTGRES_DB: rentaldrivego_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
- name: redis:7-alpine
alias: redis
variables:
# Used by db:deploy (Prisma migrations) and any code reading DATABASE_URL directly
DATABASE_URL: "postgresql://postgres:password@postgres:5432/rentaldrivego_test"
REDIS_URL: "redis://redis:6379"
NODE_ENV: test
before_script:
- npm ci
- npm run db:generate
# Overwrite the local .env.test so vitest integration config gets CI service hostnames
# (the file uses postgres/redis aliases, not localhost)
- |
cat > apps/api/.env.test << 'EOF'
DATABASE_URL=postgresql://postgres:password@postgres:5432/rentaldrivego_test
REDIS_URL=redis://redis:6379
JWT_SECRET=test-secret
JWT_EXPIRY=8h
NODE_ENV=test
FILE_STORAGE_ROOT=/tmp/rentaldrivego-test-storage
EOF
- npm run db:deploy
script:
- npm run test:api:integration
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"
# ====================================
# BUILD STAGE
# ====================================
build_image:
stage: build stage: build
image: docker:24 image: docker:24
services: services:
- docker:dind - name: docker:24-dind
script: alias: docker
before_script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker build -t $DOCKER_IMAGE . script:
- echo "--- Building Docker Image ---"
# NEXT_PUBLIC_* vars are inlined into Next.js bundles at build time — must be passed as ARGs
- |
docker build \
--build-arg NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \
--build-arg NEXT_PUBLIC_MARKETPLACE_URL=$NEXT_PUBLIC_MARKETPLACE_URL \
--build-arg NEXT_PUBLIC_DASHBOARD_URL=$NEXT_PUBLIC_DASHBOARD_URL \
--build-arg NEXT_PUBLIC_ADMIN_URL=$NEXT_PUBLIC_ADMIN_URL \
-t $DOCKER_IMAGE \
-t $DOCKER_IMAGE_LATEST \
-f $DOCKERFILE_PATH .
- echo "--- Pushing to Registry ---"
- docker push $DOCKER_IMAGE - docker push $DOCKER_IMAGE
only: - docker push $DOCKER_IMAGE_LATEST
- main rules:
- if: $CI_COMMIT_BRANCH == "main"
deploy: # ====================================
# DEPLOY STAGE
# ====================================
deploy_to_vps:
stage: deploy stage: deploy
image: alpine:latest
needs:
- build_image
before_script: before_script:
- apk add --no-cache openssh-client - apk add --no-cache openssh-client
# Load the SSH private key stored in the CI variable SSH_PRIVATE_KEY
- eval $(ssh-agent -s)
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
- mkdir -p ~/.ssh && chmod 700 ~/.ssh
# Scan and trust the VPS host key (avoids manual known_hosts management)
- ssh-keyscan -H $VPS_IP >> ~/.ssh/known_hosts
- chmod 644 ~/.ssh/known_hosts
script: script:
- ssh $VPS_USER@$VPS_IP "docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY" - echo "--- Deploying $DOCKER_IMAGE to VPS ---"
- ssh $VPS_USER@$VPS_IP "docker pull $DOCKER_IMAGE" - |
- ssh $VPS_USER@$VPS_IP "docker stop myapp || true" ssh $VPS_USER@$VPS_IP "
- ssh $VPS_USER@$VPS_IP "docker run -d --name myapp -p 80:3000 $DOCKER_IMAGE" set -e
cd /opt/rentaldrivego
echo '-- Pulling latest code --'
git pull
echo '-- Logging into registry --'
docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
echo '-- Pulling pre-built image --'
docker pull $DOCKER_IMAGE
echo '-- Restarting services --'
APP_VERSION=$CI_COMMIT_SHORT_SHA \
docker compose -p rentaldrivego-prod \
--env-file .env.docker.production \
-f docker-compose.production.yml \
up -d --no-build
echo '-- Pruning old images --'
docker image prune -f
"
rules:
- if: $CI_COMMIT_BRANCH == "main"
BIN
View File
Binary file not shown.
+65
View File
@@ -103,6 +103,7 @@ The test container runs:
- `npm run db:generate` - `npm run db:generate`
- `npm run type-check` - `npm run type-check`
- `npm run build` - `npm run build`
- `npm run test:api:integration`
### Production ### Production
@@ -240,6 +241,70 @@ npm run docker:prod:logs
npm run docker:prod:logs:api npm run docker:prod:logs:api
``` ```
#### Backup production data
Create a timestamped backup directory under `./backups`:
```bash
npm run docker:prod:backup
```
Or choose a different parent directory:
```bash
bash scripts/docker-prod-backup.sh /srv/rentaldrivego-backups
```
Each backup contains:
- `postgres.dump` — logical PostgreSQL backup in custom format
- `api-uploads.tar.gz` — uploaded files from `/var/lib/rentaldrivego/storage`
- `traefik-letsencrypt.tar.gz` — Traefik ACME certificate state, when available
- `volumes/*.tar.gz` — raw Docker volume archives for the default production volumes
- `manifest.txt` — basic metadata
By default, the backup also archives these named production volumes when they exist:
- `${DOCKER_PROD_PROJECT_NAME:-rentaldrivego-prod}_api_uploads`
- `${DOCKER_PROD_PROJECT_NAME:-rentaldrivego-prod}_pgmanage_prod_data`
- `${DOCKER_PROD_PROJECT_NAME:-rentaldrivego-prod}_postgres_prod_data`
- `${DOCKER_PROD_PROJECT_NAME:-rentaldrivego-prod}_redis_prod_data`
To include extra named volumes from the same server, pass them as additional arguments:
```bash
bash scripts/docker-prod-backup.sh /srv/rentaldrivego-backups \
gitlab-l3gq_gitlab-config \
gitlab-l3gq_gitlab-data \
gitlab-l3gq_gitlab-logs \
pgmanage_data \
traefik-gcjk_portainer_data \
traefik-gcjk_traefik-letsencrypt
```
You can also provide extra volume names through `DOCKER_EXTRA_BACKUP_VOLUMES`.
#### Restore production data
Restore is destructive: it overwrites the production database, uploaded files, and, when present in the backup, Traefik ACME state.
```bash
bash scripts/docker-prod-restore.sh ./backups/rentaldrivego-prod-YYYYMMDDTHHMMSSZ --yes
```
The restore script:
1. Stops public app services
2. Restores the PostgreSQL dump
3. Replaces uploaded files
4. Restores Traefik ACME state if the archive exists
5. Restores raw volume archives from `volumes/` except the ones already covered by the database and upload restore steps
6. Starts Traefik and the production stack again
If the backup contains raw archives for volumes used by other stacks such as GitLab or Portainer, stop those containers before running restore. The script will refuse to overwrite a volume that is currently attached to a running container.
Before running restore on a live server, take a fresh backup first.
#### Stop the stack #### Stop the stack
```bash ```bash
+1 -1
View File
@@ -12,4 +12,4 @@ RUN npm install
ENV NODE_ENV=test ENV NODE_ENV=test
CMD ["sh", "-c", "npm run db:generate && npm run type-check && npm run build"] CMD ["sh", "-c", "npm run db:deploy && npm run db:generate && npm run type-check && npm run build && npm run test:api:integration"]
+6
View File
@@ -0,0 +1,6 @@
DATABASE_URL=postgresql://postgres:password@localhost:5432/rentaldrivego_test
REDIS_URL=redis://localhost:6379
JWT_SECRET=test-secret
JWT_EXPIRY=8h
NODE_ENV=test
FILE_STORAGE_ROOT=/tmp/rentaldrivego-test-storage
+9 -2
View File
@@ -9,7 +9,11 @@
"build": "tsc", "build": "tsc",
"prestart": "npm run build --workspace @rentaldrivego/types", "prestart": "npm run build --workspace @rentaldrivego/types",
"start": "node dist/index.js", "start": "node dist/index.js",
"type-check": "tsc --noEmit" "type-check": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:integration": "vitest run --config vitest.integration.config.ts",
"test:integration:watch": "vitest --config vitest.integration.config.ts"
}, },
"dependencies": { "dependencies": {
"@react-pdf/renderer": "^3.4.3", "@react-pdf/renderer": "^3.4.3",
@@ -50,7 +54,10 @@
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"@types/react": "^18.3.1", "@types/react": "^18.3.1",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@types/supertest": "^6.0.2",
"supertest": "^7.0.0",
"ts-node-dev": "^2.0.0", "ts-node-dev": "^2.0.0",
"typescript": "^5.4.0" "typescript": "^5.4.0",
"vitest": "^1.6.0"
} }
} }
+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 http from 'http'
import { Server as SocketIOServer } from 'socket.io' import { Server as SocketIOServer } from 'socket.io'
import cron from 'node-cron' import cron from 'node-cron'
@@ -9,72 +5,12 @@ import jwt from 'jsonwebtoken'
import { z } from 'zod' import { z } from 'zod'
import { redis } from './lib/redis' import { redis } from './lib/redis'
import { prisma } from './lib/prisma' import { prisma } from './lib/prisma'
import { getStorageRoot } from './lib/storage' import { assertStorageConfiguration } from './lib/storage'
import { authLimiter, apiLimiter, publicLimiter, adminLimiter } from './middleware/rateLimiter' import { createApp, corsOrigins } from './app'
// ─── Routes ─────────────────────────────────────────────────── const app = createApp()
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 server = http.createServer(app) const server = http.createServer(app)
assertStorageConfiguration()
// 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' },
]
// ─── Socket.io ──────────────────────────────────────────────── // ─── Socket.io ────────────────────────────────────────────────
const io = new SocketIOServer(server, { 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 ─────────────────────────────────────────── // ─── Scheduled jobs ───────────────────────────────────────────
// Daily: flag expiring/expired licenses // 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 path from 'path'
import crypto from 'crypto' 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 { export function getStorageRoot(): string {
return process.env.FILE_STORAGE_ROOT return process.env.FILE_STORAGE_ROOT
@@ -10,8 +16,37 @@ export function getStorageRoot(): string {
: DEFAULT_STORAGE_ROOT : 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() 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 }) fs.mkdirSync(storageRoot, { recursive: true })
return storageRoot return storageRoot
} }
@@ -20,6 +55,25 @@ function getApiBase(): string {
return (process.env.API_URL ?? 'http://localhost:4000').replace(/\/$/, '') 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( export async function uploadImage(
buffer: Buffer, buffer: Buffer,
folder: string, folder: string,
@@ -38,12 +92,24 @@ export async function uploadImage(
return `${getApiBase()}/storage/${folder}/${filename}` return `${getApiBase()}/storage/${folder}/${filename}`
} }
export async function deleteImage(imageUrl: string): Promise<void> { export function resolveStoredFilePath(imageUrl: string): string | null {
const storageRoot = getStorageRoot() const relative = getStorageRelativePath(imageUrl)
const marker = '/storage/' if (!relative) return null
const idx = imageUrl.indexOf(marker)
if (idx === -1) return const roots = [assertStorageConfiguration(), ...getLegacyStorageRoots()]
const relative = imageUrl.slice(idx + marker.length) for (const root of roots) {
const filePath = path.join(storageRoot, relative) const filePath = path.join(root, relative)
if (fs.existsSync(filePath)) fs.unlinkSync(filePath) 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 })
}
+22 -17
View File
@@ -2,6 +2,7 @@ import { Request, Response, NextFunction } from 'express'
import jwt from 'jsonwebtoken' import jwt from 'jsonwebtoken'
import { prisma } from '../lib/prisma' import { prisma } from '../lib/prisma'
import { AdminRole } from '@rentaldrivego/database' import { AdminRole } from '@rentaldrivego/database'
import { sendUnauthorized, sendForbidden } from './authHelpers'
const ROLE_RANK: Record<AdminRole, number> = { const ROLE_RANK: Record<AdminRole, number> = {
SUPER_ADMIN: 5, SUPER_ADMIN: 5,
@@ -11,48 +12,52 @@ const ROLE_RANK: Record<AdminRole, number> = {
VIEWER: 1, 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) { export async function requireAdminAuth(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization const authHeader = req.headers.authorization
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
if (!token) { if (!token) return sendUnauthorized(res, 'unauthenticated', 'Admin authentication required')
return res.status(401).json({ error: 'unauthenticated', message: 'Admin authentication required', statusCode: 401 })
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 admin token')
} }
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
if (payload.type !== 'admin') { if (payload.type !== 'admin') {
return res.status(401).json({ error: 'invalid_token', message: 'Invalid token type', statusCode: 401 }) return sendUnauthorized(res, 'invalid_token', 'Invalid token type')
} }
const admin = await prisma.adminUser.findUnique({ where: { id: payload.sub } }) const admin = await prisma.adminUser.findUnique({ where: { id: payload.sub } })
if (!admin || !admin.isActive) { if (!admin || !admin.isActive) {
return res.status(401).json({ error: 'unauthenticated', message: 'Admin account not found or deactivated', statusCode: 401 }) return sendUnauthorized(res, 'unauthenticated', 'Admin account not found or deactivated')
} }
req.admin = admin req.admin = admin
next() next()
} catch {
return res.status(401).json({ error: 'invalid_token', message: 'Invalid or expired admin token', statusCode: 401 })
}
} }
/**
* Requires the authenticated admin to have at least `minimumRole`.
* Must be applied after `requireAdminAuth`.
*/
export function requireAdminRole(minimumRole: AdminRole) { export function requireAdminRole(minimumRole: AdminRole) {
return (req: Request, res: Response, next: NextFunction) => { return (req: Request, res: Response, next: NextFunction) => {
const admin = req.admin const admin = req.admin
if (!admin) { if (!admin) return sendUnauthorized(res, 'unauthenticated', 'Admin authentication required')
return res.status(401).json({ error: 'unauthenticated', message: 'Admin authentication required', statusCode: 401 })
}
const rank = ROLE_RANK[admin.role] ?? 0 const rank = ROLE_RANK[admin.role] ?? 0
const required = ROLE_RANK[minimumRole] ?? 99 const required = ROLE_RANK[minimumRole] ?? 99
if (rank < required) { if (rank < required) {
return res.status(403).json({ return sendForbidden(res, 'forbidden', `This action requires the ${minimumRole} role or higher`)
error: 'forbidden',
message: `This action requires the ${minimumRole} role or higher`,
statusCode: 403,
})
} }
next() next()
+32 -23
View File
@@ -1,45 +1,54 @@
import { Request, Response, NextFunction } from 'express' import { Request, Response, NextFunction } from 'express'
import jwt from 'jsonwebtoken' import jwt from 'jsonwebtoken'
import { prisma } from '../lib/prisma' import { prisma } from '../lib/prisma'
import { getCookie, sendUnauthorized } from './authHelpers'
export async function requireCompanyAuth(req: Request, res: Response, next: NextFunction) { /**
* 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 authHeader = req.headers.authorization
const sessionToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
const cookieToken = allowCookie ? getCookie(req, 'employee_token') : null
const token = bearerToken ?? cookieToken
if (!sessionToken) { if (!token) return sendUnauthorized(res, 'unauthenticated', 'Authentication required')
return res.status(401).json({
error: 'unauthenticated', let payload: { sub: string; type: string }
message: 'Authentication required', try {
statusCode: 401, 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')
} }
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({ const employee = await prisma.employee.findUnique({
where: { id: payload.sub }, where: { id: payload.sub },
include: { company: true }, include: { company: true },
}) })
if (!employee || !employee.isActive) { if (!employee || !employee.isActive) {
return res.status(401).json({ return sendUnauthorized(res, 'unauthenticated', 'Employee account not found or inactive')
error: 'unauthenticated',
message: 'Employee account not found or inactive',
statusCode: 401,
})
} }
req.employee = employee req.employee = employee
req.company = employee.company req.company = employee.company
req.companyId = employee.companyId req.companyId = employee.companyId
return next() next()
} }
} catch {
return res.status(401).json({ export async function requireCompanyAuth(req: Request, res: Response, next: NextFunction) {
error: 'invalid_token', return authenticateCompanyRequest(req, res, next)
message: 'Invalid or expired session token',
statusCode: 401,
})
} }
export async function requireCompanyDocumentAuth(req: Request, res: Response, next: NextFunction) {
return authenticateCompanyRequest(req, res, next, true)
} }
+26 -15
View File
@@ -1,46 +1,57 @@
import { Request, Response, NextFunction } from 'express' import { Request, Response, NextFunction } from 'express'
import jwt from 'jsonwebtoken' import jwt from 'jsonwebtoken'
import { prisma } from '../lib/prisma' 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) { export async function requireRenterAuth(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization const authHeader = req.headers.authorization
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
if (!token) { if (!token) return sendUnauthorized(res, 'unauthenticated', 'Renter authentication required')
return res.status(401).json({ error: 'unauthenticated', message: 'Renter authentication required', statusCode: 401 })
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 token')
} }
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
if (payload.type !== 'renter') { if (payload.type !== 'renter') {
return res.status(401).json({ error: 'invalid_token', message: 'Invalid token type', statusCode: 401 }) return sendUnauthorized(res, 'invalid_token', 'Invalid token type')
} }
const renter = await prisma.renter.findUnique({ where: { id: payload.sub } }) const renter = await prisma.renter.findUnique({ where: { id: payload.sub } })
if (!renter || !renter.isActive) { if (!renter || !renter.isActive) {
return res.status(401).json({ error: 'unauthenticated', message: 'Renter account not found or inactive', statusCode: 401 }) return sendUnauthorized(res, 'unauthenticated', 'Renter account not found or inactive')
} }
req.renterId = renter.id req.renterId = renter.id
next() next()
} catch {
return res.status(401).json({ error: 'invalid_token', message: 'Invalid or expired token', statusCode: 401 })
}
} }
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 authHeader = req.headers.authorization
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
if (!token) return next() if (!token) return next()
try { try {
const payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string } const payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
if (payload.type === 'renter') { if (payload.type === 'renter') req.renterId = payload.sub
req.renterId = payload.sub
}
} catch { } catch {
// Optional — ignore invalid tokens // Optional — silently ignore invalid tokens
} }
next() next()
+7 -7
View File
@@ -1,5 +1,6 @@
import { Request, Response, NextFunction } from 'express' import { Request, Response, NextFunction } from 'express'
import { EmployeeRole } from '@rentaldrivego/database' import { EmployeeRole } from '@rentaldrivego/database'
import { sendUnauthorized, sendForbidden } from './authHelpers'
const ROLE_RANK: Record<EmployeeRole, number> = { const ROLE_RANK: Record<EmployeeRole, number> = {
OWNER: 3, OWNER: 3,
@@ -7,21 +8,20 @@ const ROLE_RANK: Record<EmployeeRole, number> = {
AGENT: 1, AGENT: 1,
} }
/**
* Requires the authenticated employee to have at least `minimumRole`.
* Must be applied after `requireCompanyAuth`.
*/
export function requireRole(minimumRole: EmployeeRole) { export function requireRole(minimumRole: EmployeeRole) {
return (req: Request, res: Response, next: NextFunction) => { return (req: Request, res: Response, next: NextFunction) => {
const employee = req.employee const employee = req.employee
if (!employee) { if (!employee) return sendUnauthorized(res, 'unauthenticated', 'Authentication required')
return res.status(401).json({ error: 'unauthenticated', message: 'Authentication required', statusCode: 401 })
}
const employeeRank = ROLE_RANK[employee.role] ?? 0 const employeeRank = ROLE_RANK[employee.role] ?? 0
const requiredRank = ROLE_RANK[minimumRole] ?? 99 const requiredRank = ROLE_RANK[minimumRole] ?? 99
if (employeeRank < requiredRank) { if (employeeRank < requiredRank) {
return res.status(403).json({ return sendForbidden(res, 'forbidden', `This action requires the ${minimumRole} role or higher`, {
error: 'forbidden',
message: `This action requires the ${minimumRole} role or higher`,
statusCode: 403,
requiredRole: minimumRole, requiredRole: minimumRole,
yourRole: employee.role, yourRole: employee.role,
}) })
+14 -9
View File
@@ -1,23 +1,28 @@
import { Request, Response, NextFunction } from 'express' import { Request, Response, NextFunction } from 'express'
import { sendUnauthorized, sendPaymentRequired } from './authHelpers'
const BLOCKED_STATUSES = ['SUSPENDED', 'PENDING'] 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) { export function requireSubscription(req: Request, res: Response, next: NextFunction) {
const company = req.company const company = req.company
if (!company) { if (!company) return sendUnauthorized(res, 'unauthenticated', 'No company context')
return res.status(401).json({ error: 'unauthenticated', message: 'No company context', statusCode: 401 })
}
if (BLOCKED_STATUSES.includes(company.status)) { if (BLOCKED_STATUSES.includes(company.status)) {
return res.status(402).json({ return sendPaymentRequired(
error: 'subscription_' + company.status.toLowerCase(), res,
message: `subscription_${company.status.toLowerCase()}`,
company.status === 'SUSPENDED' company.status === 'SUSPENDED'
? 'Your account has been suspended. Please contact support or renew your subscription.' ? 'Your account has been suspended. Please contact support or renew your subscription.'
: 'Your account is pending activation. Please complete your subscription setup.', : 'Your account is pending activation. Please complete your subscription setup.',
statusCode: 402, { billingUrl: `${process.env.NEXT_PUBLIC_DASHBOARD_URL}/billing` },
billingUrl: `${process.env.NEXT_PUBLIC_DASHBOARD_URL}/billing`, )
})
} }
next() next()
+11 -10
View File
@@ -1,22 +1,23 @@
import { Request, Response, NextFunction } from 'express' import { Request, Response, NextFunction } from 'express'
import { prisma } from '../lib/prisma' 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) { export async function requireTenant(req: Request, res: Response, next: NextFunction) {
if (!req.companyId) { if (!req.companyId) {
return res.status(401).json({ return sendUnauthorized(res, 'unauthenticated', 'Tenant context missing — requireCompanyAuth must run first')
error: 'unauthenticated',
message: 'Tenant context missing — requireCompanyAuth must run first',
statusCode: 401,
})
} }
const company = await prisma.company.findUnique({ where: { id: req.companyId } }) const company = await prisma.company.findUnique({ where: { id: req.companyId } })
if (!company) { if (!company) {
return res.status(401).json({ return sendUnauthorized(res, 'company_not_found', 'Company not found')
error: 'company_not_found',
message: 'Company not found',
statusCode: 401,
})
} }
req.company = company 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 }
}

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