Compare commits
75 Commits
dc72970ff1
...
dashboard
| Author | SHA1 | Date | |
|---|---|---|---|
| d359458e3d | |||
| f8d8f050f8 | |||
| 831cd8c7af | |||
| 2a79ac8e63 | |||
| b220807d70 | |||
| 2de00868ad | |||
| 2959285425 | |||
| c27f0909df | |||
| 511ab4d03b | |||
| 00d01bdaf4 | |||
| 68bf9ca610 | |||
| f330efb1ad | |||
| e3f40410e9 | |||
| c21916559f | |||
| 7ff2dbb139 | |||
| ad5f5ebab7 | |||
| a947e48c49 | |||
| 6913c298ad | |||
| b45f84d62b | |||
| 781512399b | |||
| 56c3bcf666 | |||
| 9c11f3660d | |||
| 7c1bd2d0c0 | |||
| 1fd1ddf3f2 | |||
| 0394f0ea2a | |||
| 2993dd5b57 | |||
| e93b988146 | |||
| ea2bd215f6 | |||
| 13b54f07de | |||
| 75a1d39050 | |||
| 0dcbe18c54 | |||
| 8ef61d7005 | |||
| 56984c4ea7 | |||
| 330fc11791 | |||
| 0ddf67c754 | |||
| bcfe518af2 | |||
| 30631504f1 | |||
| 757ad41c9b | |||
| 97d5ecfcf0 | |||
| 63f8df1e38 | |||
| e2b564aea5 | |||
| 184a4bac8b | |||
| 9703de974a | |||
| 5dfd5b1814 | |||
| 13d0512048 | |||
| b0c78ad8a2 | |||
| d8b7b92cb1 | |||
| fb77e91730 | |||
| 9ad92765c1 | |||
| f22e0d45e1 | |||
| a752a399c2 | |||
| e1e2f55c93 | |||
| 85d1aad956 | |||
| 26ab64e473 | |||
| 1e100cf367 | |||
| 3d6607e6c8 | |||
| ae10f5272f | |||
| a8472ddeed | |||
| 6de0690b76 | |||
| 0c9f3f8635 | |||
| 8aab968e09 | |||
| bffda95a7c | |||
| ad88fe0148 | |||
| df83c9876d | |||
| abb3a18345 | |||
| a486f891cf | |||
| 9dd4a3d087 | |||
| 7f86435769 | |||
| e6460de4d0 | |||
| 59b58d02ea | |||
| a48285ecc5 | |||
| 7392b094e0 | |||
| 4d82cf9f1a | |||
| f51d6e4634 | |||
| dce4fdc77e |
+2
-3
@@ -11,17 +11,16 @@ API_PORT=4000
|
|||||||
API_URL=http://localhost:4000
|
API_URL=http://localhost:4000
|
||||||
API_INTERNAL_URL=http://api:4000/api/v1
|
API_INTERNAL_URL=http://api:4000/api/v1
|
||||||
NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1
|
NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1
|
||||||
NEXT_PUBLIC_MARKETPLACE_URL=http://localhost:3000
|
|
||||||
NEXT_PUBLIC_DASHBOARD_URL=http://localhost:3000/dashboard
|
NEXT_PUBLIC_DASHBOARD_URL=http://localhost:3000/dashboard
|
||||||
NEXT_PUBLIC_ADMIN_URL=http://localhost:3000/admin
|
NEXT_PUBLIC_ADMIN_URL=http://localhost:3000/admin
|
||||||
DASHBOARD_URL=http://localhost:3000/dashboard
|
DASHBOARD_URL=http://localhost:3000/dashboard
|
||||||
ADMIN_URL=http://localhost:3000/admin
|
ADMIN_URL=http://localhost:3000/admin
|
||||||
DASHBOARD_INTERNAL_URL=http://host.docker.internal:3001
|
DASHBOARD_INTERNAL_URL=http://host.docker.internal:3001
|
||||||
ADMIN_INTERNAL_URL=http://host.docker.internal:3002
|
ADMIN_INTERNAL_URL=http://host.docker.internal:3002
|
||||||
# Asset prefix for the dashboard/admin dev servers so browsers load JS chunks from the
|
# Asset prefixes for apps served through the homepage dev proxy.
|
||||||
# correct port rather than through the marketplace proxy (which doesn't have those chunks).
|
|
||||||
DASHBOARD_ASSET_PREFIX=http://localhost:3001/dashboard
|
DASHBOARD_ASSET_PREFIX=http://localhost:3001/dashboard
|
||||||
ADMIN_ASSET_PREFIX=http://localhost:3002/admin
|
ADMIN_ASSET_PREFIX=http://localhost:3002/admin
|
||||||
|
CARPLACE_ASSET_PREFIX=http://localhost:3004/carplace
|
||||||
JWT_SECRET=placeholder
|
JWT_SECRET=placeholder
|
||||||
JWT_EXPIRY=8h
|
JWT_EXPIRY=8h
|
||||||
RENTER_JWT_EXPIRY=7d
|
RENTER_JWT_EXPIRY=7d
|
||||||
|
|||||||
@@ -1,43 +1,40 @@
|
|||||||
# ── Traefik domains — used in docker-compose labels ──────────────────────────
|
# ── Traefik domains — used in docker-compose labels ──────────────────────────
|
||||||
ACME_EMAIL=ops@example.com
|
ACME_EMAIL=rentaldrivego@gmail.com
|
||||||
API_DOMAIN=api.rentaldrivego.ma
|
API_DOMAIN=api.rentaldrivego.ma
|
||||||
PUBLIC_SITE_DOMAIN=rentaldrivego.ma
|
PUBLIC_SITE_DOMAIN=rentaldrivego.ma
|
||||||
PGMANAGE_DOMAIN=pgmanage.rentaldrivego.ma
|
PGMANAGE_DOMAIN=pgmanage.rentaldrivego.ma
|
||||||
PORTAINER_DOMAIN=portainer.rentaldrivego.ma
|
PORTAINER_DOMAIN=portainer.rentaldrivego.ma
|
||||||
REGISTRY_DOMAIN=registry.rentaldrivego.ma
|
|
||||||
REGISTRY_UPSTREAM_URL=http://10.0.0.10:5000
|
|
||||||
|
|
||||||
# ── Optional prebuilt image source ────────────────────────────────────────────
|
# ── Optional prebuilt image source ────────────────────────────────────────────
|
||||||
# Used for pull-based deployments. CI injects APP_IMAGE and APP_VERSION
|
# Used for production deployments. CI injects IMAGE_TAG automatically during
|
||||||
# automatically during registry-backed deploys, so these can stay commented out.
|
# registry-backed deploys; set it manually when running docker compose directly.
|
||||||
|
IMAGE_TAG=latest
|
||||||
|
# APP_IMAGE and APP_VERSION are still accepted by helper scripts for compatibility.
|
||||||
# APP_IMAGE=registry.example.com/rentaldrivego/car_management_system
|
# APP_IMAGE=registry.example.com/rentaldrivego/car_management_system
|
||||||
# APP_VERSION=latest
|
# APP_VERSION=latest
|
||||||
REGISTRY_HOST=registry.rentaldrivego.ma
|
|
||||||
REGISTRY_USER=rentaldrivego_registry
|
|
||||||
REGISTRY_PASSWORD=placeholder
|
|
||||||
REGISTRY_IMAGE=registry.rentaldrivego.ma/rentaldrivego/car_management_system
|
|
||||||
REGISTRY_HTTP_SECRET=placeholder
|
|
||||||
|
|
||||||
# ── Database ──────────────────────────────────────────────────────────────────
|
# ── Database ──────────────────────────────────────────────────────────────────
|
||||||
# Full connection URL (used when DATABASE_URL_FROM_POSTGRES=false)
|
# Full connection URL (used when DATABASE_URL_FROM_POSTGRES=false)
|
||||||
DATABASE_URL=postgresql://placeholder:placeholder@placeholder:5432/placeholder
|
|
||||||
# Build the URL from individual vars (set to true to use POSTGRES_* vars below)
|
# Build the URL from individual vars (set to true to use POSTGRES_* vars below)
|
||||||
|
DATABASE_URL=postgresql://dbadmin:PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK@localhost:5432/rentaldrivego
|
||||||
DATABASE_URL_FROM_POSTGRES=true
|
DATABASE_URL_FROM_POSTGRES=true
|
||||||
POSTGRES_HOST=postgres
|
POSTGRES_HOST=postgres
|
||||||
POSTGRES_PORT=5432
|
POSTGRES_PORT=5432
|
||||||
POSTGRES_DB=rentaldrivego
|
POSTGRES_DB=rentaldrivego
|
||||||
POSTGRES_USER=postgres
|
POSTGRES_USER=dbadmin
|
||||||
POSTGRES_PASSWORD=placeholder
|
POSTGRES_PASSWORD=PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK
|
||||||
|
|
||||||
|
|
||||||
# ── Cache ─────────────────────────────────────────────────────────────────────
|
# ── Cache ─────────────────────────────────────────────────────────────────────
|
||||||
REDIS_URL=redis://redis:6379
|
REDIS_PASSWORD=PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK
|
||||||
|
REDIS_URL=redis://:PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK@redis:6379
|
||||||
|
|
||||||
# ── API ───────────────────────────────────────────────────────────────────────
|
# ── API ───────────────────────────────────────────────────────────────────────
|
||||||
API_PORT=4000
|
API_PORT=4000
|
||||||
# SSR server-side calls go via the internal Docker network
|
# SSR server-side calls go via the internal Docker network
|
||||||
API_INTERNAL_URL=http://api:4000/api/v1
|
API_INTERNAL_URL=http://api:4000/api/v1
|
||||||
# Next.js rewrites between the marketplace and internal apps also use Docker-network URLs
|
|
||||||
DASHBOARD_INTERNAL_URL=http://dashboard:3001
|
DASHBOARD_INTERNAL_URL=http://dashboard:3001
|
||||||
ADMIN_INTERNAL_URL=http://admin:3002
|
ADMIN_INTERNAL_URL=http://admin:3002
|
||||||
# Public-facing API URL visible to browsers — MUST be your real domain, not localhost
|
# Public-facing API URL visible to browsers — MUST be your real domain, not localhost
|
||||||
@@ -47,11 +44,15 @@ NEXT_PUBLIC_API_URL=https://api.rentaldrivego.ma/api/v1
|
|||||||
|
|
||||||
|
|
||||||
# ── Frontend public URLs ──────────────────────────────────────────────────────
|
# ── Frontend public URLs ──────────────────────────────────────────────────────
|
||||||
# Marketplace sits at the domain root (Traefik routes /dashboard and /admin to their apps)
|
# SITE_ORIGIN is the canonical public origin for robots.txt, sitemap, and metadata.
|
||||||
NEXT_PUBLIC_MARKETING_URL=https://rentaldrivego.ma
|
# Required for production builds. Must be an absolute https:// URL with no trailing slash.
|
||||||
NEXT_PUBLIC_MARKETPLACE_URL=https://rentaldrivego.ma
|
SITE_ORIGIN=https://rentaldrivego.ma
|
||||||
|
|
||||||
|
# Carplace public routes sit under /carplace on the public site domain.
|
||||||
|
NEXT_PUBLIC_CARPLACE_URL=https://rentaldrivego.ma/carplace
|
||||||
NEXT_PUBLIC_DASHBOARD_URL=https://rentaldrivego.ma/dashboard
|
NEXT_PUBLIC_DASHBOARD_URL=https://rentaldrivego.ma/dashboard
|
||||||
NEXT_PUBLIC_ADMIN_URL=https://rentaldrivego.ma/admin
|
NEXT_PUBLIC_ADMIN_URL=https://rentaldrivego.ma/admin
|
||||||
|
NEXT_PUBLIC_HOMEPAGE_URL=https://rentaldrivego.ma
|
||||||
NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=rentaldrivego.ma
|
NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=rentaldrivego.ma
|
||||||
DASHBOARD_URL=https://rentaldrivego.ma/dashboard
|
DASHBOARD_URL=https://rentaldrivego.ma/dashboard
|
||||||
ADMIN_URL=https://rentaldrivego.ma/admin
|
ADMIN_URL=https://rentaldrivego.ma/admin
|
||||||
@@ -62,7 +63,7 @@ ADMIN_URL=https://rentaldrivego.ma/admin
|
|||||||
CORS_ORIGINS=https://rentaldrivego.ma,https://www.rentaldrivego.ma
|
CORS_ORIGINS=https://rentaldrivego.ma,https://www.rentaldrivego.ma
|
||||||
|
|
||||||
# ── Auth ──────────────────────────────────────────────────────────────────────
|
# ── Auth ──────────────────────────────────────────────────────────────────────
|
||||||
JWT_SECRET=placeholder
|
JWT_SECRET=eb9ab3eb5d6648bdb82cbef29afde498c58f089829a037dfea6cb5e98ef2aae0
|
||||||
JWT_EXPIRY=8h
|
JWT_EXPIRY=8h
|
||||||
RENTER_JWT_EXPIRY=7d
|
RENTER_JWT_EXPIRY=7d
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
@@ -74,11 +75,12 @@ EMAIL_FROM_NAME=RentalDriveGo
|
|||||||
# Option A — Resend
|
# Option A — Resend
|
||||||
#RESEND_API_KEY=C8qPDuFwsv5l@KsGhL/V
|
#RESEND_API_KEY=C8qPDuFwsv5l@KsGhL/V
|
||||||
# Option B — SMTP (Gmail)
|
# Option B — SMTP (Gmail)
|
||||||
|
# SMTP fallback (only used if Resend fails or is unconfigured)
|
||||||
MAIL_HOST=smtp.gmail.com
|
MAIL_HOST=smtp.gmail.com
|
||||||
MAIL_PORT=587
|
MAIL_PORT=587
|
||||||
MAIL_SCHEME=smtp
|
MAIL_SCHEME=smtp
|
||||||
MAIL_USERNAME=rentaldrivego@gmail.com
|
MAIL_USERNAME=rentaldrivego@gmail.com
|
||||||
MAIL_PASSWORD=placeholder
|
MAIL_PASSWORD=kfahihfzbcvkczew
|
||||||
MAIL_FROM_ADDRESS=rentaldrivego@gmail.com
|
MAIL_FROM_ADDRESS=rentaldrivego@gmail.com
|
||||||
MAIL_FROM_NAME=RentalDriveGo
|
MAIL_FROM_NAME=RentalDriveGo
|
||||||
MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com
|
MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Traefik domains
|
# Traefik domains
|
||||||
ACME_EMAIL=ops@example.com
|
ACME_EMAIL=rentaldrivego@gmail.com
|
||||||
API_DOMAIN=api.example.com
|
API_DOMAIN=api.example.com
|
||||||
PUBLIC_SITE_DOMAIN=example.com
|
PUBLIC_SITE_DOMAIN=example.com
|
||||||
PGMANAGE_DOMAIN=pgmanage.example.com
|
PGMANAGE_DOMAIN=pgmanage.example.com
|
||||||
@@ -7,6 +7,9 @@ PORTAINER_DOMAIN=portainer.example.com
|
|||||||
REGISTRY_DOMAIN=registry.example.com
|
REGISTRY_DOMAIN=registry.example.com
|
||||||
REGISTRY_UPSTREAM_URL=http://10.0.0.10:5000
|
REGISTRY_UPSTREAM_URL=http://10.0.0.10:5000
|
||||||
|
|
||||||
|
# Image tag
|
||||||
|
IMAGE_TAG=latest
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
DATABASE_URL_FROM_POSTGRES=true
|
DATABASE_URL_FROM_POSTGRES=true
|
||||||
POSTGRES_HOST=postgres
|
POSTGRES_HOST=postgres
|
||||||
@@ -17,6 +20,7 @@ POSTGRES_PASSWORD=placeholder
|
|||||||
DATABASE_URL=postgresql://placeholder:placeholder@placeholder:5432/placeholder
|
DATABASE_URL=postgresql://placeholder:placeholder@placeholder:5432/placeholder
|
||||||
|
|
||||||
# Cache
|
# Cache
|
||||||
|
REDIS_PASSWORD=placeholder
|
||||||
REDIS_URL=redis://redis:6379
|
REDIS_URL=redis://redis:6379
|
||||||
|
|
||||||
# API
|
# API
|
||||||
@@ -26,8 +30,8 @@ API_URL=https://api.example.com
|
|||||||
NEXT_PUBLIC_API_URL=https://api.example.com/api/v1
|
NEXT_PUBLIC_API_URL=https://api.example.com/api/v1
|
||||||
|
|
||||||
# Frontend public URLs
|
# Frontend public URLs
|
||||||
NEXT_PUBLIC_MARKETING_URL=https://example.com
|
NEXT_PUBLIC_HOMEPAGE_URL=https://example.com
|
||||||
NEXT_PUBLIC_MARKETPLACE_URL=https://example.com
|
NEXT_PUBLIC_STOREFRONT_URL=https://example.com
|
||||||
NEXT_PUBLIC_DASHBOARD_URL=https://example.com/dashboard
|
NEXT_PUBLIC_DASHBOARD_URL=https://example.com/dashboard
|
||||||
NEXT_PUBLIC_ADMIN_URL=https://example.com/admin
|
NEXT_PUBLIC_ADMIN_URL=https://example.com/admin
|
||||||
NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=example.com
|
NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=example.com
|
||||||
|
|||||||
+34
-6
@@ -4,7 +4,13 @@
|
|||||||
# ═══════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
# ─── Database ──────────────────────────────────────────────────
|
# ─── Database ──────────────────────────────────────────────────
|
||||||
DATABASE_URL=postgresql://placeholder:placeholder@placeholder:5432/placeholder
|
DATABASE_URL=postgresql://dbadmin:PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK@localhost:5432/rentaldrivego
|
||||||
|
DATABASE_URL_FROM_POSTGRES=true
|
||||||
|
POSTGRES_HOST=postgres
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
POSTGRES_DB=rentaldrivego
|
||||||
|
POSTGRES_USER=dbadmin
|
||||||
|
POSTGRES_PASSWORD=PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK
|
||||||
|
|
||||||
# ─── API ───────────────────────────────────────────────────────
|
# ─── API ───────────────────────────────────────────────────────
|
||||||
API_PORT=4000
|
API_PORT=4000
|
||||||
@@ -12,7 +18,7 @@ API_URL=http://localhost:4000
|
|||||||
NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1
|
NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1
|
||||||
|
|
||||||
# ─── JWT (Renter auth + Admin auth) ───────────────────────────
|
# ─── JWT (Renter auth + Admin auth) ───────────────────────────
|
||||||
JWT_SECRET=placeholder
|
JWT_SECRET=8bf96de20297c2c295a60e4040e937a7eb8ec7d7d2b83e79a1fc98463322a97c
|
||||||
JWT_EXPIRY=8h
|
JWT_EXPIRY=8h
|
||||||
RENTER_JWT_EXPIRY=7d
|
RENTER_JWT_EXPIRY=7d
|
||||||
|
|
||||||
@@ -71,22 +77,44 @@ NEXT_PUBLIC_FIREBASE_APP_ID=1:123456789:web:abc123
|
|||||||
# MAIL_* SMTP variables are intentionally omitted here.
|
# MAIL_* SMTP variables are intentionally omitted here.
|
||||||
|
|
||||||
# ─── Redis (Real-time / Socket.io) ────────────────────────────
|
# ─── Redis (Real-time / Socket.io) ────────────────────────────
|
||||||
|
REDIS_PASSWORD=replace-with-production-redis-password
|
||||||
REDIS_URL=redis://localhost:6379
|
REDIS_URL=redis://localhost:6379
|
||||||
|
|
||||||
# ─── App URLs ──────────────────────────────────────────────────
|
# ─── App URLs ──────────────────────────────────────────────────
|
||||||
NEXT_PUBLIC_MARKETING_URL=http://localhost:3000
|
# SITE_ORIGIN is the canonical public origin for robots.txt, sitemap, and metadata.
|
||||||
|
# Required for production builds. Must be an absolute https:// URL with no trailing slash.
|
||||||
|
# SITE_ORIGIN=https://your-production-domain.example
|
||||||
|
|
||||||
|
|
||||||
NEXT_PUBLIC_DASHBOARD_URL=http://localhost:3000/dashboard
|
NEXT_PUBLIC_DASHBOARD_URL=http://localhost:3000/dashboard
|
||||||
NEXT_PUBLIC_ADMIN_URL=http://localhost:3000/admin
|
NEXT_PUBLIC_ADMIN_URL=http://localhost:3000/admin
|
||||||
NEXT_PUBLIC_MARKETPLACE_URL=http://localhost:3000
|
NEXT_PUBLIC_STOREFRONT_URL=http://localhost:3000/storefront
|
||||||
|
NEXT_PUBLIC_HOMEPAGE_URL=http://localhost:3000
|
||||||
# Public site is subdomain-based; use this for local dev:
|
# Public site is subdomain-based; use this for local dev:
|
||||||
NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=localhost:3003
|
NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=localhost:3003
|
||||||
|
API_DOMAIN=api.rentaldrivego.ma
|
||||||
|
PUBLIC_SITE_DOMAIN=rentaldrivego.ma
|
||||||
DASHBOARD_URL=http://localhost:3000/dashboard
|
DASHBOARD_URL=http://localhost:3000/dashboard
|
||||||
|
|
||||||
# ─── Admin seed (first SUPER_ADMIN created on db:seed) ────────
|
# ─── Admin seed (first SUPER_ADMIN created on db:seed) ────────
|
||||||
ADMIN_SEED_EMAIL=admin@rentaldrivego.ma
|
ADMIN_SEED_EMAIL=admin@rentaldrivego.ma
|
||||||
ADMIN_SEED_PASSWORD=placeholder
|
ADMIN_SEED_PASSWORD=PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK
|
||||||
ADMIN_SEED_FIRST_NAME=Super
|
ADMIN_SEED_FIRST_NAME=Super
|
||||||
ADMIN_SEED_LAST_NAME=Admin
|
ADMIN_SEED_LAST_NAME=Admin
|
||||||
|
|
||||||
|
# SMTP fallback (only used if Resend fails or is unconfigured)
|
||||||
|
MAIL_HOST=smtp.gmail.com
|
||||||
|
MAIL_PORT=587
|
||||||
|
MAIL_SCHEME=smtp
|
||||||
|
MAIL_USERNAME=rentaldrivego@gmail.com
|
||||||
|
MAIL_PASSWORD=kfahihfzbcvkczew
|
||||||
|
MAIL_FROM_ADDRESS=rentaldrivego@gmail.com
|
||||||
|
MAIL_FROM_NAME=RentalDriveGo
|
||||||
|
MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com
|
||||||
|
MAIL_REPLY_TO_NAME=RentalDriveGo
|
||||||
|
|
||||||
|
|
||||||
|
IMAGE_TAG=13d0512048d86e9fe93e9395459d04663c2b7eb0
|
||||||
|
|
||||||
# ─── Misc ──────────────────────────────────────────────────────
|
# ─── Misc ──────────────────────────────────────────────────────
|
||||||
NODE_ENV=development
|
NODE_ENV=production
|
||||||
|
|||||||
@@ -11,10 +11,12 @@ concurrency:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
NODE_VERSION: "20"
|
NODE_VERSION: "20"
|
||||||
# Gitea built-in container registry (HTTPS on port 443)
|
# TODO: Remove after installing internal CA certificate on the runner
|
||||||
|
GIT_SSL_NO_VERIFY: "true"
|
||||||
REGISTRY_HOST: 192.168.3.80
|
REGISTRY_HOST: 192.168.3.80
|
||||||
|
DEPLOY_REGISTRY_HOST: 10.0.0.4
|
||||||
DOCKERFILE_PATH: Dockerfile.production
|
DOCKERFILE_PATH: Dockerfile.production
|
||||||
# VPS deployment target
|
DOCKER_PLATFORM: linux/amd64
|
||||||
DEPLOY_ROOT: /opt/rentaldrivego
|
DEPLOY_ROOT: /opt/rentaldrivego
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -25,17 +27,60 @@ jobs:
|
|||||||
image_repository: ${{ steps.image-meta.outputs.repository }}
|
image_repository: ${{ steps.image-meta.outputs.repository }}
|
||||||
docker_image: ${{ steps.image-meta.outputs.full }}
|
docker_image: ${{ steps.image-meta.outputs.full }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Check out repository
|
||||||
env:
|
shell: bash
|
||||||
GIT_SSL_NO_VERIFY: "true"
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" ]; then
|
||||||
|
REPOSITORY_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
|
||||||
|
|
||||||
|
git init .
|
||||||
|
git remote add origin "$REPOSITORY_URL"
|
||||||
|
if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then
|
||||||
|
AUTH_HEADER="$(printf '%s:%s' "${{ gitea.actor }}" "${{ secrets.GITHUB_TOKEN }}" | base64 | tr -d '\n')"
|
||||||
|
git -c http.extraheader="Authorization: Basic ${AUTH_HEADER}" \
|
||||||
|
fetch --depth 1 origin "${{ gitea.ref }}"
|
||||||
|
else
|
||||||
|
git fetch --depth 1 origin "${{ gitea.ref }}"
|
||||||
|
fi
|
||||||
|
git checkout --detach "${{ gitea.sha }}" || git checkout --detach FETCH_HEAD
|
||||||
|
fi
|
||||||
|
|
||||||
|
git rev-parse --short HEAD
|
||||||
|
|
||||||
|
- name: Ensure Docker CLI is available
|
||||||
|
run: |
|
||||||
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
|
echo "::error::Docker CLI is not available in this Gitea runner image."
|
||||||
|
echo "::error::Configure the runner label used by this job to an image that already includes Docker CLI, for example ubuntu-latest:docker://catthehacker/ubuntu:act-latest."
|
||||||
|
echo "::error::The current runner maps ubuntu-latest to node:20-bookworm, and this job cannot install docker.io because the job container has no external DNS."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
docker --version
|
||||||
|
docker info >/dev/null
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
docker buildx version
|
||||||
|
printf '[registry."%s"]\n insecure = true\n' "$REGISTRY_HOST" > /tmp/buildkitd.toml
|
||||||
|
if ! docker buildx inspect rentaldrivego-builder >/dev/null 2>&1; then
|
||||||
|
docker buildx create \
|
||||||
|
--name rentaldrivego-builder \
|
||||||
|
--driver docker-container \
|
||||||
|
--config /tmp/buildkitd.toml \
|
||||||
|
--use
|
||||||
|
else
|
||||||
|
docker buildx use rentaldrivego-builder
|
||||||
|
fi
|
||||||
|
docker buildx inspect --bootstrap
|
||||||
|
|
||||||
- name: Docker image metadata
|
- name: Docker image metadata
|
||||||
id: image-meta
|
id: image-meta
|
||||||
run: |
|
run: |
|
||||||
# Gitea container registry path: host/owner/repo
|
|
||||||
REPO="${{ gitea.repository }}"
|
REPO="${{ gitea.repository }}"
|
||||||
TAG="${{ gitea.sha }}"
|
TAG="${{ gitea.sha }}"
|
||||||
echo "repository=${REPO}" >> "$GITHUB_OUTPUT"
|
echo "repository=${REPO}" >> "$GITHUB_OUTPUT"
|
||||||
@@ -45,43 +90,82 @@ jobs:
|
|||||||
- name: Check Docker registry credentials
|
- name: Check Docker registry credentials
|
||||||
id: registry-check
|
id: registry-check
|
||||||
run: |
|
run: |
|
||||||
if [ -n "${{ secrets.REGISTRY_TOKEN }}" ]; then
|
if [ -n "${{ secrets.REGISTRY_USERNAME }}" ] && [ -n "${{ secrets.REGISTRY_PASSWORD }}" ]; then
|
||||||
echo "available=true" >> "$GITHUB_OUTPUT"
|
echo "available=true" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
|
echo "::warning::REGISTRY_USERNAME or REGISTRY_PASSWORD secrets not set — push will be skipped"
|
||||||
echo "available=false" >> "$GITHUB_OUTPUT"
|
echo "available=false" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Install Docker CLI
|
- name: Validate public URLs
|
||||||
if: steps.registry-check.outputs.available == 'true'
|
shell: bash
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
|
||||||
|
NEXT_PUBLIC_HOMEPAGE_URL: ${{ secrets.NEXT_PUBLIC_HOMEPAGE_URL }}
|
||||||
|
NEXT_PUBLIC_CARPLACE_URL: ${{ secrets.NEXT_PUBLIC_CARPLACE_URL }}
|
||||||
|
NEXT_PUBLIC_DASHBOARD_URL: ${{ secrets.NEXT_PUBLIC_DASHBOARD_URL }}
|
||||||
|
NEXT_PUBLIC_ADMIN_URL: ${{ secrets.NEXT_PUBLIC_ADMIN_URL }}
|
||||||
|
SITE_ORIGIN: ${{ secrets.SITE_ORIGIN }}
|
||||||
run: |
|
run: |
|
||||||
apt-get update -qq
|
for variable in \
|
||||||
apt-get install -y -qq docker.io
|
NEXT_PUBLIC_API_URL \
|
||||||
|
NEXT_PUBLIC_HOMEPAGE_URL \
|
||||||
|
NEXT_PUBLIC_CARPLACE_URL \
|
||||||
|
NEXT_PUBLIC_DASHBOARD_URL \
|
||||||
|
NEXT_PUBLIC_ADMIN_URL \
|
||||||
|
SITE_ORIGIN
|
||||||
|
do
|
||||||
|
value="${!variable}"
|
||||||
|
if [ -z "$value" ]; then
|
||||||
|
echo "::error::$variable is missing — add it to Gitea Actions secrets"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
case "$value" in
|
||||||
|
http://*|https://*) ;;
|
||||||
|
*)
|
||||||
|
echo "::error::$variable must be an absolute URL (got: $value)"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
- name: Log in to Gitea Container Registry
|
- name: Log in to Gitea Container Registry
|
||||||
if: steps.registry-check.outputs.available == 'true'
|
if: steps.registry-check.outputs.available == 'true'
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
echo "${{ secrets.REGISTRY_TOKEN }}" | \
|
printf '%s' "${{ secrets.REGISTRY_PASSWORD }}" | \
|
||||||
docker login "$REGISTRY_HOST" \
|
docker login "$REGISTRY_HOST" \
|
||||||
-u "${{ gitea.actor }}" \
|
--username "${{ secrets.REGISTRY_USERNAME }}" \
|
||||||
--password-stdin
|
--password-stdin
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
shell: bash
|
||||||
with:
|
env:
|
||||||
context: .
|
BUILDKIT_NO_CLIENT_TOKEN: "true"
|
||||||
file: ${{ env.DOCKERFILE_PATH }}
|
run: |
|
||||||
push: ${{ steps.registry-check.outputs.available == 'true' }}
|
set -euo pipefail
|
||||||
tags: |
|
|
||||||
${{ steps.image-meta.outputs.full }}
|
OUTPUT_ARG="--load"
|
||||||
${{ steps.image-meta.outputs.latest }}
|
if [ "${{ steps.registry-check.outputs.available }}" = "true" ]; then
|
||||||
build-args: |
|
OUTPUT_ARG="--push"
|
||||||
API_INTERNAL_URL=http://api:4000/api/v1
|
fi
|
||||||
DASHBOARD_INTERNAL_URL=http://dashboard:3001
|
|
||||||
ADMIN_INTERNAL_URL=http://admin:3002
|
docker buildx build \
|
||||||
NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}
|
--file "$DOCKERFILE_PATH" \
|
||||||
NEXT_PUBLIC_MARKETPLACE_URL=${{ secrets.NEXT_PUBLIC_MARKETPLACE_URL }}
|
--platform "$DOCKER_PLATFORM" \
|
||||||
NEXT_PUBLIC_DASHBOARD_URL=${{ secrets.NEXT_PUBLIC_DASHBOARD_URL }}
|
--tag "${{ steps.image-meta.outputs.full }}" \
|
||||||
NEXT_PUBLIC_ADMIN_URL=${{ secrets.NEXT_PUBLIC_ADMIN_URL }}
|
--tag "${{ steps.image-meta.outputs.latest }}" \
|
||||||
|
--build-arg API_INTERNAL_URL=http://api:4000/api/v1 \
|
||||||
|
--build-arg DASHBOARD_INTERNAL_URL=http://dashboard:3001 \
|
||||||
|
--build-arg ADMIN_INTERNAL_URL=http://admin:3002 \
|
||||||
|
--build-arg NEXT_PUBLIC_API_URL="${{ secrets.NEXT_PUBLIC_API_URL }}" \
|
||||||
|
--build-arg NEXT_PUBLIC_HOMEPAGE_URL="${{ secrets.NEXT_PUBLIC_HOMEPAGE_URL }}" \
|
||||||
|
--build-arg NEXT_PUBLIC_CARPLACE_URL="${{ secrets.NEXT_PUBLIC_CARPLACE_URL }}" \
|
||||||
|
--build-arg NEXT_PUBLIC_DASHBOARD_URL="${{ secrets.NEXT_PUBLIC_DASHBOARD_URL }}" \
|
||||||
|
--build-arg NEXT_PUBLIC_ADMIN_URL="${{ secrets.NEXT_PUBLIC_ADMIN_URL }}" \
|
||||||
|
--build-arg SITE_ORIGIN="${{ secrets.SITE_ORIGIN }}" \
|
||||||
|
"$OUTPUT_ARG" \
|
||||||
|
.
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
name: Deploy to VPS
|
name: Deploy to VPS
|
||||||
@@ -91,9 +175,27 @@ jobs:
|
|||||||
DOCKER_IMAGE: ${{ needs.build-image.outputs.docker_image }}
|
DOCKER_IMAGE: ${{ needs.build-image.outputs.docker_image }}
|
||||||
IMAGE_REPOSITORY: ${{ needs.build-image.outputs.image_repository }}
|
IMAGE_REPOSITORY: ${{ needs.build-image.outputs.image_repository }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Check out repository
|
||||||
env:
|
shell: bash
|
||||||
GIT_SSL_NO_VERIFY: "true"
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" ]; then
|
||||||
|
REPOSITORY_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
|
||||||
|
|
||||||
|
git init .
|
||||||
|
git remote add origin "$REPOSITORY_URL"
|
||||||
|
if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then
|
||||||
|
AUTH_HEADER="$(printf '%s:%s' "${{ gitea.actor }}" "${{ secrets.GITHUB_TOKEN }}" | base64 | tr -d '\n')"
|
||||||
|
git -c http.extraheader="Authorization: Basic ${AUTH_HEADER}" \
|
||||||
|
fetch --depth 1 origin "${{ gitea.ref }}"
|
||||||
|
else
|
||||||
|
git fetch --depth 1 origin "${{ gitea.ref }}"
|
||||||
|
fi
|
||||||
|
git checkout --detach "${{ gitea.sha }}" || git checkout --detach FETCH_HEAD
|
||||||
|
fi
|
||||||
|
|
||||||
|
git rev-parse --short HEAD
|
||||||
|
|
||||||
- name: Check deploy credentials
|
- name: Check deploy credentials
|
||||||
id: check-creds
|
id: check-creds
|
||||||
@@ -110,7 +212,12 @@ jobs:
|
|||||||
- name: Install SSH client
|
- name: Install SSH client
|
||||||
if: steps.check-creds.outputs.skip == 'false'
|
if: steps.check-creds.outputs.skip == 'false'
|
||||||
run: |
|
run: |
|
||||||
apt-get update -qq && apt-get install -y -qq openssh-client
|
if ! command -v ssh >/dev/null 2>&1 || ! command -v scp >/dev/null 2>&1; then
|
||||||
|
echo "::error::OpenSSH client tools are not available in this Gitea runner image."
|
||||||
|
echo "::error::Use a runner image that already includes ssh and scp; installing packages from apt is not reliable because the job container has no external DNS."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ssh -V
|
||||||
|
|
||||||
- name: Set up SSH key
|
- name: Set up SSH key
|
||||||
if: steps.check-creds.outputs.skip == 'false'
|
if: steps.check-creds.outputs.skip == 'false'
|
||||||
@@ -143,14 +250,15 @@ jobs:
|
|||||||
- name: Run deploy script on VPS
|
- name: Run deploy script on VPS
|
||||||
if: steps.check-creds.outputs.skip == 'false'
|
if: steps.check-creds.outputs.skip == 'false'
|
||||||
run: |
|
run: |
|
||||||
REGISTRY_PASSWORD_B64="$(printf '%s' "${{ secrets.REGISTRY_TOKEN }}" | base64 | tr -d '\n')"
|
REGISTRY_PASSWORD_B64="$(printf '%s' "${{ secrets.REGISTRY_PASSWORD }}" | base64 | tr -d '\n')"
|
||||||
ssh "${{ secrets.VPS_USER }}@${{ secrets.VPS_IP }}" "
|
ssh "${{ secrets.VPS_USER }}@${{ secrets.VPS_IP }}" "
|
||||||
set -e
|
set -e
|
||||||
cd '$DEPLOY_ROOT'
|
cd '$DEPLOY_ROOT'
|
||||||
APP_IMAGE='$IMAGE_REPOSITORY' \
|
APP_IMAGE='$DEPLOY_REGISTRY_HOST/$IMAGE_REPOSITORY' \
|
||||||
APP_VERSION='${{ gitea.sha }}' \
|
APP_VERSION='${{ gitea.sha }}' \
|
||||||
REGISTRY_HOST='$REGISTRY_HOST' \
|
IMAGE_TAG='${{ gitea.sha }}' \
|
||||||
REGISTRY_USER='${{ gitea.actor }}' \
|
REGISTRY_HOST='$DEPLOY_REGISTRY_HOST' \
|
||||||
|
REGISTRY_USER='${{ secrets.REGISTRY_USERNAME }}' \
|
||||||
REGISTRY_PASSWORD=\$(printf '%s' '$REGISTRY_PASSWORD_B64' | base64 -d) \
|
REGISTRY_PASSWORD=\$(printf '%s' '$REGISTRY_PASSWORD_B64' | base64 -d) \
|
||||||
bash scripts/docker-prod-deploy.sh
|
bash scripts/docker-prod-deploy.sh
|
||||||
"
|
"
|
||||||
|
|||||||
+357
-34
@@ -12,64 +12,346 @@ concurrency:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
NODE_VERSION: "20"
|
NODE_VERSION: "20"
|
||||||
|
# TODO: Remove after installing internal CA certificate on the runner
|
||||||
GIT_SSL_NO_VERIFY: "true"
|
GIT_SSL_NO_VERIFY: "true"
|
||||||
|
NPM_CONFIG_FETCH_RETRIES: "5"
|
||||||
|
NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: "20000"
|
||||||
|
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: "120000"
|
||||||
|
NPM_CONFIG_INCLUDE: "optional"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
type-check:
|
||||||
|
name: Type Check (all packages)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" ]; then
|
||||||
|
REPOSITORY_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
|
||||||
|
git init .
|
||||||
|
git remote add origin "$REPOSITORY_URL"
|
||||||
|
if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then
|
||||||
|
AUTH_HEADER="$(printf '%s:%s' "${{ gitea.actor }}" "${{ secrets.GITHUB_TOKEN }}" | base64 | tr -d '\n')"
|
||||||
|
git -c http.extraheader="Authorization: Basic ${AUTH_HEADER}" fetch --depth 1 origin "${{ gitea.ref }}"
|
||||||
|
else
|
||||||
|
git fetch --depth 1 origin "${{ gitea.ref }}"
|
||||||
|
fi
|
||||||
|
git checkout --detach "${{ gitea.sha }}" || git checkout --detach FETCH_HEAD
|
||||||
|
fi
|
||||||
|
- name: Check Node runtime
|
||||||
|
run: |
|
||||||
|
node --version
|
||||||
|
npm --version
|
||||||
|
- run: corepack enable && corepack prepare npm@10.5.0 --activate
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
npm ci --include=optional && break
|
||||||
|
if [ "$attempt" = "3" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
npm cache verify || true
|
||||||
|
sleep "$((attempt * 10))"
|
||||||
|
done
|
||||||
|
- name: Repair Rollup optional dependency on Linux ARM64
|
||||||
|
run: |
|
||||||
|
ARCH="$(uname -m)"
|
||||||
|
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
|
||||||
|
ROLLUP_VERSION="$(node -p "require('./node_modules/rollup/package.json').version")"
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
|
||||||
|
if [ "$attempt" = "3" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
npm cache verify || true
|
||||||
|
sleep "$((attempt * 10))"
|
||||||
|
done
|
||||||
|
node -e "require('@rollup/rollup-linux-arm64-gnu')"
|
||||||
|
fi
|
||||||
|
- run: npm run db:generate
|
||||||
|
- run: npm run type-check
|
||||||
|
|
||||||
api-tests:
|
api-tests:
|
||||||
name: API Unit Tests
|
name: API Unit Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: type-check
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Check out repository
|
||||||
- uses: actions/setup-node@v4
|
shell: bash
|
||||||
with:
|
run: |
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
set -euo pipefail
|
||||||
cache: "npm"
|
|
||||||
- run: npm ci
|
if [ -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" ]; then
|
||||||
|
REPOSITORY_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
|
||||||
|
git init .
|
||||||
|
git remote add origin "$REPOSITORY_URL"
|
||||||
|
if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then
|
||||||
|
AUTH_HEADER="$(printf '%s:%s' "${{ gitea.actor }}" "${{ secrets.GITHUB_TOKEN }}" | base64 | tr -d '\n')"
|
||||||
|
git -c http.extraheader="Authorization: Basic ${AUTH_HEADER}" fetch --depth 1 origin "${{ gitea.ref }}"
|
||||||
|
else
|
||||||
|
git fetch --depth 1 origin "${{ gitea.ref }}"
|
||||||
|
fi
|
||||||
|
git checkout --detach "${{ gitea.sha }}" || git checkout --detach FETCH_HEAD
|
||||||
|
fi
|
||||||
|
- name: Check Node runtime
|
||||||
|
run: |
|
||||||
|
node --version
|
||||||
|
npm --version
|
||||||
|
- run: corepack enable && corepack prepare npm@10.5.0 --activate
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
npm ci --include=optional && break
|
||||||
|
if [ "$attempt" = "3" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
npm cache verify || true
|
||||||
|
sleep "$((attempt * 10))"
|
||||||
|
done
|
||||||
|
- name: Repair Rollup optional dependency on Linux ARM64
|
||||||
|
run: |
|
||||||
|
ARCH="$(uname -m)"
|
||||||
|
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
|
||||||
|
ROLLUP_VERSION="$(node -p "require('./node_modules/rollup/package.json').version")"
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
|
||||||
|
if [ "$attempt" = "3" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
npm cache verify || true
|
||||||
|
sleep "$((attempt * 10))"
|
||||||
|
done
|
||||||
|
node -e "require('@rollup/rollup-linux-arm64-gnu')"
|
||||||
|
fi
|
||||||
- run: npm run db:generate
|
- run: npm run db:generate
|
||||||
- run: npm run type-check
|
|
||||||
- run: npm run test:api
|
- run: npm run test:api
|
||||||
|
|
||||||
marketplace-tests:
|
homepage-tests:
|
||||||
name: Marketplace Unit Tests
|
name: Homepage Unit Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: type-check
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Check out repository
|
||||||
- uses: actions/setup-node@v4
|
shell: bash
|
||||||
with:
|
run: |
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
set -euo pipefail
|
||||||
cache: "npm"
|
|
||||||
- run: npm ci
|
if [ -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" ]; then
|
||||||
|
REPOSITORY_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
|
||||||
|
git init .
|
||||||
|
git remote add origin "$REPOSITORY_URL"
|
||||||
|
if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then
|
||||||
|
AUTH_HEADER="$(printf '%s:%s' "${{ gitea.actor }}" "${{ secrets.GITHUB_TOKEN }}" | base64 | tr -d '\n')"
|
||||||
|
git -c http.extraheader="Authorization: Basic ${AUTH_HEADER}" fetch --depth 1 origin "${{ gitea.ref }}"
|
||||||
|
else
|
||||||
|
git fetch --depth 1 origin "${{ gitea.ref }}"
|
||||||
|
fi
|
||||||
|
git checkout --detach "${{ gitea.sha }}" || git checkout --detach FETCH_HEAD
|
||||||
|
fi
|
||||||
|
- name: Check Node runtime
|
||||||
|
run: |
|
||||||
|
node --version
|
||||||
|
npm --version
|
||||||
|
- run: corepack enable && corepack prepare npm@10.5.0 --activate
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
npm ci --include=optional && break
|
||||||
|
if [ "$attempt" = "3" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
npm cache verify || true
|
||||||
|
sleep "$((attempt * 10))"
|
||||||
|
done
|
||||||
|
- name: Repair Rollup optional dependency on Linux ARM64
|
||||||
|
run: |
|
||||||
|
ARCH="$(uname -m)"
|
||||||
|
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
|
||||||
|
ROLLUP_VERSION="$(node -p "require('./node_modules/rollup/package.json').version")"
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
|
||||||
|
if [ "$attempt" = "3" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
npm cache verify || true
|
||||||
|
sleep "$((attempt * 10))"
|
||||||
|
done
|
||||||
|
node -e "require('@rollup/rollup-linux-arm64-gnu')"
|
||||||
|
fi
|
||||||
- run: npm run build --workspace @rentaldrivego/types
|
- run: npm run build --workspace @rentaldrivego/types
|
||||||
- run: npm run test:marketplace
|
- run: npm run test:homepage
|
||||||
|
|
||||||
|
carplace-tests:
|
||||||
|
name: Carplace Unit Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: type-check
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" ]; then
|
||||||
|
REPOSITORY_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
|
||||||
|
git init .
|
||||||
|
git remote add origin "$REPOSITORY_URL"
|
||||||
|
if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then
|
||||||
|
AUTH_HEADER="$(printf '%s:%s' "${{ gitea.actor }}" "${{ secrets.GITHUB_TOKEN }}" | base64 | tr -d '\n')"
|
||||||
|
git -c http.extraheader="Authorization: Basic ${AUTH_HEADER}" fetch --depth 1 origin "${{ gitea.ref }}"
|
||||||
|
else
|
||||||
|
git fetch --depth 1 origin "${{ gitea.ref }}"
|
||||||
|
fi
|
||||||
|
git checkout --detach "${{ gitea.sha }}" || git checkout --detach FETCH_HEAD
|
||||||
|
fi
|
||||||
|
- name: Check Node runtime
|
||||||
|
run: |
|
||||||
|
node --version
|
||||||
|
npm --version
|
||||||
|
- run: corepack enable && corepack prepare npm@10.5.0 --activate
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
npm ci --include=optional && break
|
||||||
|
if [ "$attempt" = "3" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
npm cache verify || true
|
||||||
|
sleep "$((attempt * 10))"
|
||||||
|
done
|
||||||
|
- name: Repair Rollup optional dependency on Linux ARM64
|
||||||
|
run: |
|
||||||
|
ARCH="$(uname -m)"
|
||||||
|
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
|
||||||
|
ROLLUP_VERSION="$(node -p "require('./node_modules/rollup/package.json').version")"
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
|
||||||
|
if [ "$attempt" = "3" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
npm cache verify || true
|
||||||
|
sleep "$((attempt * 10))"
|
||||||
|
done
|
||||||
|
node -e "require('@rollup/rollup-linux-arm64-gnu')"
|
||||||
|
fi
|
||||||
|
- run: npm run build --workspace @rentaldrivego/types
|
||||||
|
- run: npm run test:carplace
|
||||||
|
|
||||||
admin-tests:
|
admin-tests:
|
||||||
name: Admin Unit Tests
|
name: Admin Unit Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: type-check
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Check out repository
|
||||||
- uses: actions/setup-node@v4
|
shell: bash
|
||||||
with:
|
run: |
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
set -euo pipefail
|
||||||
cache: "npm"
|
|
||||||
- run: npm ci
|
if [ -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" ]; then
|
||||||
|
REPOSITORY_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
|
||||||
|
git init .
|
||||||
|
git remote add origin "$REPOSITORY_URL"
|
||||||
|
if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then
|
||||||
|
AUTH_HEADER="$(printf '%s:%s' "${{ gitea.actor }}" "${{ secrets.GITHUB_TOKEN }}" | base64 | tr -d '\n')"
|
||||||
|
git -c http.extraheader="Authorization: Basic ${AUTH_HEADER}" fetch --depth 1 origin "${{ gitea.ref }}"
|
||||||
|
else
|
||||||
|
git fetch --depth 1 origin "${{ gitea.ref }}"
|
||||||
|
fi
|
||||||
|
git checkout --detach "${{ gitea.sha }}" || git checkout --detach FETCH_HEAD
|
||||||
|
fi
|
||||||
|
- name: Check Node runtime
|
||||||
|
run: |
|
||||||
|
node --version
|
||||||
|
npm --version
|
||||||
|
- run: corepack enable && corepack prepare npm@10.5.0 --activate
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
npm ci --include=optional && break
|
||||||
|
if [ "$attempt" = "3" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
npm cache verify || true
|
||||||
|
sleep "$((attempt * 10))"
|
||||||
|
done
|
||||||
|
- name: Repair Rollup optional dependency on Linux ARM64
|
||||||
|
run: |
|
||||||
|
ARCH="$(uname -m)"
|
||||||
|
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
|
||||||
|
ROLLUP_VERSION="$(node -p "require('./node_modules/rollup/package.json').version")"
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
|
||||||
|
if [ "$attempt" = "3" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
npm cache verify || true
|
||||||
|
sleep "$((attempt * 10))"
|
||||||
|
done
|
||||||
|
node -e "require('@rollup/rollup-linux-arm64-gnu')"
|
||||||
|
fi
|
||||||
- run: npm run test:admin
|
- run: npm run test:admin
|
||||||
|
|
||||||
dashboard-tests:
|
dashboard-tests:
|
||||||
name: Dashboard Unit Tests
|
name: Dashboard Unit Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: type-check
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Check out repository
|
||||||
- uses: actions/setup-node@v4
|
shell: bash
|
||||||
with:
|
run: |
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
set -euo pipefail
|
||||||
cache: "npm"
|
|
||||||
- run: npm ci
|
if [ -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" ]; then
|
||||||
|
REPOSITORY_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
|
||||||
|
git init .
|
||||||
|
git remote add origin "$REPOSITORY_URL"
|
||||||
|
if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then
|
||||||
|
AUTH_HEADER="$(printf '%s:%s' "${{ gitea.actor }}" "${{ secrets.GITHUB_TOKEN }}" | base64 | tr -d '\n')"
|
||||||
|
git -c http.extraheader="Authorization: Basic ${AUTH_HEADER}" fetch --depth 1 origin "${{ gitea.ref }}"
|
||||||
|
else
|
||||||
|
git fetch --depth 1 origin "${{ gitea.ref }}"
|
||||||
|
fi
|
||||||
|
git checkout --detach "${{ gitea.sha }}" || git checkout --detach FETCH_HEAD
|
||||||
|
fi
|
||||||
|
- name: Check Node runtime
|
||||||
|
run: |
|
||||||
|
node --version
|
||||||
|
npm --version
|
||||||
|
- run: corepack enable && corepack prepare npm@10.5.0 --activate
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
npm ci --include=optional && break
|
||||||
|
if [ "$attempt" = "3" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
npm cache verify || true
|
||||||
|
sleep "$((attempt * 10))"
|
||||||
|
done
|
||||||
|
- name: Repair Rollup optional dependency on Linux ARM64
|
||||||
|
run: |
|
||||||
|
ARCH="$(uname -m)"
|
||||||
|
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
|
||||||
|
ROLLUP_VERSION="$(node -p "require('./node_modules/rollup/package.json').version")"
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
|
||||||
|
if [ "$attempt" = "3" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
npm cache verify || true
|
||||||
|
sleep "$((attempt * 10))"
|
||||||
|
done
|
||||||
|
node -e "require('@rollup/rollup-linux-arm64-gnu')"
|
||||||
|
fi
|
||||||
- run: npm run build --workspace @rentaldrivego/types
|
- run: npm run build --workspace @rentaldrivego/types
|
||||||
- run: npm run test:dashboard
|
- run: npm run test:dashboard
|
||||||
|
|
||||||
integration-tests:
|
integration-tests:
|
||||||
name: API Integration Tests
|
name: API Integration Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: type-check
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
@@ -97,12 +379,53 @@ jobs:
|
|||||||
JWT_EXPIRY: 8h
|
JWT_EXPIRY: 8h
|
||||||
FILE_STORAGE_ROOT: /tmp/rentaldrivego-test-storage
|
FILE_STORAGE_ROOT: /tmp/rentaldrivego-test-storage
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Check out repository
|
||||||
- uses: actions/setup-node@v4
|
shell: bash
|
||||||
with:
|
run: |
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
set -euo pipefail
|
||||||
cache: "npm"
|
|
||||||
- run: npm ci
|
if [ -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" ]; then
|
||||||
|
REPOSITORY_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
|
||||||
|
git init .
|
||||||
|
git remote add origin "$REPOSITORY_URL"
|
||||||
|
if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then
|
||||||
|
AUTH_HEADER="$(printf '%s:%s' "${{ gitea.actor }}" "${{ secrets.GITHUB_TOKEN }}" | base64 | tr -d '\n')"
|
||||||
|
git -c http.extraheader="Authorization: Basic ${AUTH_HEADER}" fetch --depth 1 origin "${{ gitea.ref }}"
|
||||||
|
else
|
||||||
|
git fetch --depth 1 origin "${{ gitea.ref }}"
|
||||||
|
fi
|
||||||
|
git checkout --detach "${{ gitea.sha }}" || git checkout --detach FETCH_HEAD
|
||||||
|
fi
|
||||||
|
- name: Check Node runtime
|
||||||
|
run: |
|
||||||
|
node --version
|
||||||
|
npm --version
|
||||||
|
- run: corepack enable && corepack prepare npm@10.5.0 --activate
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
npm ci --include=optional && break
|
||||||
|
if [ "$attempt" = "3" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
npm cache verify || true
|
||||||
|
sleep "$((attempt * 10))"
|
||||||
|
done
|
||||||
|
- name: Repair Rollup optional dependency on Linux ARM64
|
||||||
|
run: |
|
||||||
|
ARCH="$(uname -m)"
|
||||||
|
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
|
||||||
|
ROLLUP_VERSION="$(node -p "require('./node_modules/rollup/package.json').version")"
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
|
||||||
|
if [ "$attempt" = "3" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
npm cache verify || true
|
||||||
|
sleep "$((attempt * 10))"
|
||||||
|
done
|
||||||
|
node -e "require('@rollup/rollup-linux-arm64-gnu')"
|
||||||
|
fi
|
||||||
- run: npm run db:generate
|
- run: npm run db:generate
|
||||||
- run: npm run db:deploy
|
- run: npm run db:deploy
|
||||||
- run: npx prisma db push --schema packages/database/prisma/schema.prisma
|
- run: npx prisma db push --schema packages/database/prisma/schema.prisma
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ apps/api/storage/
|
|||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
.env.docker.production
|
||||||
|
production/.env.docker.production
|
||||||
|
|
||||||
# Turbo
|
# Turbo
|
||||||
.turbo
|
.turbo
|
||||||
|
|||||||
+2
-2
@@ -28,14 +28,14 @@ api_tests:
|
|||||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||||
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
|
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
|
||||||
|
|
||||||
marketplace_tests:
|
storefront_tests:
|
||||||
stage: test
|
stage: test
|
||||||
image: node:20-bookworm
|
image: node:20-bookworm
|
||||||
before_script:
|
before_script:
|
||||||
- npm ci
|
- npm ci
|
||||||
- npm run build --workspace @rentaldrivego/types
|
- npm run build --workspace @rentaldrivego/types
|
||||||
script:
|
script:
|
||||||
- npm run test:marketplace
|
- npm run test:storefront
|
||||||
rules:
|
rules:
|
||||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||||
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
|
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
|
||||||
|
|||||||
+15
-5
@@ -17,18 +17,26 @@ ARG ADMIN_INTERNAL_URL=http://admin:3002
|
|||||||
|
|
||||||
# NEXT_PUBLIC_* vars must be present at build time — they are inlined into JS bundles
|
# NEXT_PUBLIC_* vars must be present at build time — they are inlined into JS bundles
|
||||||
ARG NEXT_PUBLIC_API_URL
|
ARG NEXT_PUBLIC_API_URL
|
||||||
ARG NEXT_PUBLIC_MARKETPLACE_URL
|
ARG NEXT_PUBLIC_HOMEPAGE_URL
|
||||||
|
ARG NEXT_PUBLIC_CARPLACE_URL
|
||||||
ARG NEXT_PUBLIC_DASHBOARD_URL
|
ARG NEXT_PUBLIC_DASHBOARD_URL
|
||||||
ARG NEXT_PUBLIC_ADMIN_URL
|
ARG NEXT_PUBLIC_ADMIN_URL
|
||||||
|
|
||||||
|
# SITE_ORIGIN is the canonical public origin for robots.txt, sitemap, and metadata.
|
||||||
|
# Required in production — must be an absolute https:// URL with no path, query, or fragment.
|
||||||
|
ARG SITE_ORIGIN
|
||||||
|
|
||||||
ENV API_INTERNAL_URL=$API_INTERNAL_URL \
|
ENV API_INTERNAL_URL=$API_INTERNAL_URL \
|
||||||
DASHBOARD_INTERNAL_URL=$DASHBOARD_INTERNAL_URL \
|
DASHBOARD_INTERNAL_URL=$DASHBOARD_INTERNAL_URL \
|
||||||
ADMIN_INTERNAL_URL=$ADMIN_INTERNAL_URL \
|
ADMIN_INTERNAL_URL=$ADMIN_INTERNAL_URL \
|
||||||
NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \
|
NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \
|
||||||
NEXT_PUBLIC_MARKETPLACE_URL=$NEXT_PUBLIC_MARKETPLACE_URL \
|
NEXT_PUBLIC_HOMEPAGE_URL=$NEXT_PUBLIC_HOMEPAGE_URL \
|
||||||
|
NEXT_PUBLIC_CARPLACE_URL=$NEXT_PUBLIC_CARPLACE_URL \
|
||||||
NEXT_PUBLIC_DASHBOARD_URL=$NEXT_PUBLIC_DASHBOARD_URL \
|
NEXT_PUBLIC_DASHBOARD_URL=$NEXT_PUBLIC_DASHBOARD_URL \
|
||||||
NEXT_PUBLIC_ADMIN_URL=$NEXT_PUBLIC_ADMIN_URL
|
NEXT_PUBLIC_ADMIN_URL=$NEXT_PUBLIC_ADMIN_URL \
|
||||||
|
SITE_ORIGIN=$SITE_ORIGIN
|
||||||
|
|
||||||
RUN npm install
|
RUN npm ci --include=optional
|
||||||
RUN npm run db:generate
|
RUN npm run db:generate
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
@@ -36,7 +44,8 @@ FROM node:20-bookworm AS runner
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production \
|
||||||
|
NPM_CONFIG_UPDATE_NOTIFIER=false
|
||||||
|
|
||||||
RUN corepack enable && corepack prepare npm@10.5.0 --activate \
|
RUN corepack enable && corepack prepare npm@10.5.0 --activate \
|
||||||
&& groupadd --system --gid 10001 app \
|
&& groupadd --system --gid 10001 app \
|
||||||
@@ -48,6 +57,7 @@ COPY --from=builder --chown=app:app /app/package.json /app/package-lock.json /ap
|
|||||||
COPY --from=builder --chown=app:app /app/node_modules ./node_modules
|
COPY --from=builder --chown=app:app /app/node_modules ./node_modules
|
||||||
COPY --from=builder --chown=app:app /app/apps ./apps
|
COPY --from=builder --chown=app:app /app/apps ./apps
|
||||||
COPY --from=builder --chown=app:app /app/packages ./packages
|
COPY --from=builder --chown=app:app /app/packages ./packages
|
||||||
|
COPY --from=builder --chown=app:app /app/config ./config
|
||||||
COPY --chown=root:root docker/entrypoint.production.sh /usr/local/bin/rdg-entrypoint.sh
|
COPY --chown=root:root docker/entrypoint.production.sh /usr/local/bin/rdg-entrypoint.sh
|
||||||
|
|
||||||
RUN chmod 755 /usr/local/bin/rdg-entrypoint.sh
|
RUN chmod 755 /usr/local/bin/rdg-entrypoint.sh
|
||||||
|
|||||||
@@ -49,13 +49,13 @@
|
|||||||
"sha256": "49e03206558248eb5de2b9a8463be6e0b43e01df518bc0b625fe2953a18185c6"
|
"sha256": "49e03206558248eb5de2b9a8463be6e0b43e01df518bc0b625fe2953a18185c6"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "storefront/src/components/MarketplaceFooter.tsx",
|
"path": "storefront/src/components/StorefrontFooter.tsx",
|
||||||
"status": "modified",
|
"status": "modified",
|
||||||
"bytes": 4632,
|
"bytes": 4632,
|
||||||
"sha256": "169d04dcf0976d2637ef5371ca295e9c362bd089d8d6ec313b4e8b4131d5bdd3"
|
"sha256": "169d04dcf0976d2637ef5371ca295e9c362bd089d8d6ec313b4e8b4131d5bdd3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "storefront/src/components/MarketplaceHeader.tsx",
|
"path": "storefront/src/components/StorefrontHeader.tsx",
|
||||||
"status": "modified",
|
"status": "modified",
|
||||||
"bytes": 10438,
|
"bytes": 10438,
|
||||||
"sha256": "25afc57ff9893f255eddf19580e84b510e6032764daaa5727e2e8d669dca4213"
|
"sha256": "25afc57ff9893f255eddf19580e84b510e6032764daaa5727e2e8d669dca4213"
|
||||||
|
|||||||
@@ -60,15 +60,15 @@ Changed files:
|
|||||||
|
|
||||||
- `apps/api/src/app.ts`
|
- `apps/api/src/app.ts`
|
||||||
- `apps/dashboard/src/middleware.ts`
|
- `apps/dashboard/src/middleware.ts`
|
||||||
- `apps/marketplace/src/middleware.ts`
|
- `apps/storefront/src/middleware.ts`
|
||||||
- `apps/admin/src/middleware.ts`
|
- `apps/admin/src/middleware.ts`
|
||||||
- `apps/dashboard/src/middleware.test.ts`
|
- `apps/dashboard/src/middleware.test.ts`
|
||||||
- `apps/marketplace/src/middleware.test.ts`
|
- `apps/storefront/src/middleware.test.ts`
|
||||||
|
|
||||||
What changed:
|
What changed:
|
||||||
|
|
||||||
- API now rejects requests containing `x-middleware-subrequest` before route handling.
|
- API now rejects requests containing `x-middleware-subrequest` before route handling.
|
||||||
- Dashboard, marketplace, and admin Next middleware now reject the same header at the app layer.
|
- Dashboard, storefront, and admin Next middleware now reject the same header at the app layer.
|
||||||
- Added/updated middleware tests for the rejection path.
|
- Added/updated middleware tests for the rejection path.
|
||||||
|
|
||||||
Security effect:
|
Security effect:
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http:
|
|||||||
const apiUrl = new URL(apiOrigin)
|
const apiUrl = new URL(apiOrigin)
|
||||||
const ADMIN_BASE_PATH = '/admin'
|
const ADMIN_BASE_PATH = '/admin'
|
||||||
|
|
||||||
// In Docker dev the admin app runs on port 3002 while the marketplace proxy
|
// In Docker dev the admin app runs on port 3002 while the Carplace proxy
|
||||||
// serves it from port 3000. When ADMIN_ASSET_PREFIX is set, Next emits
|
// serves it from port 3000. When ADMIN_ASSET_PREFIX is set, Next emits
|
||||||
// absolute chunk URLs so /admin pages load their JS/CSS and HMR directly from
|
// absolute chunk URLs so /admin pages load their JS/CSS and HMR directly from
|
||||||
// port 3002, bypassing the proxy (which can't upgrade WebSocket connections).
|
// port 3002, bypassing the proxy (which can't upgrade WebSocket connections).
|
||||||
|
|||||||
+10
-6
@@ -18,20 +18,24 @@
|
|||||||
"@rentaldrivego/types": "*",
|
"@rentaldrivego/types": "*",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.19",
|
||||||
"dayjs": "^1.11.11",
|
"dayjs": "^1.11.11",
|
||||||
|
"firebase-admin": "^10.3.0",
|
||||||
"lucide-react": "^0.376.0",
|
"lucide-react": "^0.376.0",
|
||||||
"next": "^16.2.7",
|
"next": "^16.2.9",
|
||||||
|
"node-cron": "4.5.0",
|
||||||
|
"nodemailer": "9.0.1",
|
||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.38",
|
||||||
"react": "^18.3.1",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^19.2.0",
|
||||||
"recharts": "^2.12.7",
|
"recharts": "^2.12.7",
|
||||||
"tailwindcss": "^3.4.3",
|
"tailwindcss": "^3.4.3",
|
||||||
|
"turbo": "2.10.0",
|
||||||
"zod": "^3.23.0"
|
"zod": "^3.23.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.12.0",
|
"@types/node": "^20.12.0",
|
||||||
"@types/react": "^18.3.1",
|
"@types/react": "^19.2.0",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^19.2.0",
|
||||||
"typescript": "^5.4.0",
|
"typescript": "^5.4.0",
|
||||||
"vitest": "^1.6.0"
|
"vitest": "^2.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export default function AdminUsersPage() {
|
|||||||
const [showModal, setShowModal] = useState(false)
|
const [showModal, setShowModal] = useState(false)
|
||||||
const [editingAdminId, setEditingAdminId] = useState<string | null>(null)
|
const [editingAdminId, setEditingAdminId] = useState<string | null>(null)
|
||||||
const [form, setForm] = useState(EMPTY_FORM)
|
const [form, setForm] = useState(EMPTY_FORM)
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
async function fetchAdmins() {
|
async function fetchAdmins() {
|
||||||
@@ -54,6 +55,7 @@ export default function AdminUsersPage() {
|
|||||||
function openCreateModal() {
|
function openCreateModal() {
|
||||||
setEditingAdminId(null)
|
setEditingAdminId(null)
|
||||||
setForm(EMPTY_FORM)
|
setForm(EMPTY_FORM)
|
||||||
|
setShowPassword(false)
|
||||||
setError(null)
|
setError(null)
|
||||||
setShowModal(true)
|
setShowModal(true)
|
||||||
}
|
}
|
||||||
@@ -75,6 +77,7 @@ export default function AdminUsersPage() {
|
|||||||
function closeModal() {
|
function closeModal() {
|
||||||
setShowModal(false)
|
setShowModal(false)
|
||||||
setEditingAdminId(null)
|
setEditingAdminId(null)
|
||||||
|
setShowPassword(false)
|
||||||
setForm(EMPTY_FORM)
|
setForm(EMPTY_FORM)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,14 +235,33 @@ export default function AdminUsersPage() {
|
|||||||
<label className="block text-xs font-medium text-zinc-400 mb-1">
|
<label className="block text-xs font-medium text-zinc-400 mb-1">
|
||||||
Password {editingAdminId ? <span className="text-zinc-500">(leave blank to keep current)</span> : null}
|
Password {editingAdminId ? <span className="text-zinc-500">(leave blank to keep current)</span> : null}
|
||||||
</label>
|
</label>
|
||||||
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
type="password"
|
type={showPassword ? 'text' : 'password'}
|
||||||
required={!editingAdminId}
|
required={!editingAdminId}
|
||||||
minLength={editingAdminId ? undefined : 8}
|
minLength={editingAdminId ? undefined : 8}
|
||||||
className="w-full px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
className="w-full px-3 py-2 pr-10 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||||
value={form.password}
|
value={form.password}
|
||||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword((v) => !v)}
|
||||||
|
className="absolute inset-y-0 right-3 flex items-center text-zinc-500 hover:text-zinc-200"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-zinc-400 mb-1">Role</label>
|
<label className="block text-xs font-medium text-zinc-400 mb-1">Role</label>
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ interface CompanyDetail {
|
|||||||
whatsappNumber: string | null
|
whatsappNumber: string | null
|
||||||
defaultLocale: string
|
defaultLocale: string
|
||||||
defaultCurrency: string
|
defaultCurrency: string
|
||||||
isListedOnMarketplace: boolean
|
isListedOnCarplace: boolean
|
||||||
} | null
|
} | null
|
||||||
contractSettings: {
|
contractSettings: {
|
||||||
legalName: string | null
|
legalName: string | null
|
||||||
@@ -171,7 +171,7 @@ interface FormState {
|
|||||||
whatsappNumber: string
|
whatsappNumber: string
|
||||||
defaultLocale: string
|
defaultLocale: string
|
||||||
defaultCurrency: string
|
defaultCurrency: string
|
||||||
isListedOnMarketplace: boolean
|
isListedOnCarplace: boolean
|
||||||
}
|
}
|
||||||
contractSettings: {
|
contractSettings: {
|
||||||
legalName: string
|
legalName: string
|
||||||
@@ -277,7 +277,7 @@ function createFormState(company: CompanyDetail): FormState {
|
|||||||
whatsappNumber: company.brand?.whatsappNumber ?? '',
|
whatsappNumber: company.brand?.whatsappNumber ?? '',
|
||||||
defaultLocale: company.brand?.defaultLocale ?? 'en',
|
defaultLocale: company.brand?.defaultLocale ?? 'en',
|
||||||
defaultCurrency: company.brand?.defaultCurrency ?? 'MAD',
|
defaultCurrency: company.brand?.defaultCurrency ?? 'MAD',
|
||||||
isListedOnMarketplace: company.brand?.isListedOnMarketplace ?? true,
|
isListedOnCarplace: company.brand?.isListedOnCarplace ?? true,
|
||||||
},
|
},
|
||||||
contractSettings: {
|
contractSettings: {
|
||||||
legalName: company.contractSettings?.legalName ?? '',
|
legalName: company.contractSettings?.legalName ?? '',
|
||||||
@@ -420,7 +420,7 @@ export default function AdminCompanyDetailPage() {
|
|||||||
whatsappNumber: toNullable(form.brand.whatsappNumber),
|
whatsappNumber: toNullable(form.brand.whatsappNumber),
|
||||||
defaultLocale: form.brand.defaultLocale,
|
defaultLocale: form.brand.defaultLocale,
|
||||||
defaultCurrency: form.brand.defaultCurrency,
|
defaultCurrency: form.brand.defaultCurrency,
|
||||||
isListedOnMarketplace: form.brand.isListedOnMarketplace,
|
isListedOnCarplace: form.brand.isListedOnCarplace,
|
||||||
},
|
},
|
||||||
contractSettings: {
|
contractSettings: {
|
||||||
legalName: toNullable(form.contractSettings.legalName),
|
legalName: toNullable(form.contractSettings.legalName),
|
||||||
@@ -723,8 +723,8 @@ export default function AdminCompanyDetailPage() {
|
|||||||
<input className={INPUT_CLASS} value={form.brand.publicAddress} onChange={(e) => updateSection('brand', { publicAddress: e.target.value })} />
|
<input className={INPUT_CLASS} value={form.brand.publicAddress} onChange={(e) => updateSection('brand', { publicAddress: e.target.value })} />
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-3 pt-7 text-sm text-zinc-300">
|
<label className="flex items-center gap-3 pt-7 text-sm text-zinc-300">
|
||||||
<input type="checkbox" checked={form.brand.isListedOnMarketplace} onChange={(e) => updateSection('brand', { isListedOnMarketplace: e.target.checked })} />
|
<input type="checkbox" checked={form.brand.isListedOnCarplace} onChange={(e) => updateSection('brand', { isListedOnCarplace: e.target.checked })} />
|
||||||
Listed on marketplace
|
Listed on Carplace
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export default function AdminDashboardPage() {
|
|||||||
brand: 'RentalDriveGo',
|
brand: 'RentalDriveGo',
|
||||||
eyebrow: 'Operations command',
|
eyebrow: 'Operations command',
|
||||||
platformOverview: 'RentalDriveGo admin dashboard',
|
platformOverview: 'RentalDriveGo admin dashboard',
|
||||||
subtitle: 'Monitor marketplace health, subscription coverage, renter activity, and operator actions from one control surface.',
|
subtitle: 'Monitor Carplace health, subscription coverage, renter activity, and operator actions from one control surface.',
|
||||||
liveSignal: 'Live platform signal',
|
liveSignal: 'Live platform signal',
|
||||||
readiness: 'Operational readiness',
|
readiness: 'Operational readiness',
|
||||||
readinessBody: 'Core admin workflows are connected and ready for platform support.',
|
readinessBody: 'Core admin workflows are connected and ready for platform support.',
|
||||||
@@ -36,14 +36,14 @@ export default function AdminDashboardPage() {
|
|||||||
['Renters', 'Support renter trust workflows, blocking, and account recovery.', 'View all'],
|
['Renters', 'Support renter trust workflows, blocking, and account recovery.', 'View all'],
|
||||||
['Audit logs', 'Trace every sensitive admin action across RentalDriveGo.', 'View logs'],
|
['Audit logs', 'Trace every sensitive admin action across RentalDriveGo.', 'View logs'],
|
||||||
],
|
],
|
||||||
health: ['Tenant accounts', 'Marketplace identity', 'Admin audit trail'],
|
health: ['Tenant accounts', 'Carplace identity', 'Admin audit trail'],
|
||||||
},
|
},
|
||||||
fr: {
|
fr: {
|
||||||
kpis: ['Entreprises totales', 'Entreprises actives', 'Locataires totaux', 'Réservations totales'],
|
kpis: ['Entreprises totales', 'Entreprises actives', 'Locataires totaux', 'Réservations totales'],
|
||||||
brand: 'RentalDriveGo',
|
brand: 'RentalDriveGo',
|
||||||
eyebrow: 'Centre des opérations',
|
eyebrow: 'Centre des opérations',
|
||||||
platformOverview: 'Tableau de bord admin RentalDriveGo',
|
platformOverview: 'Tableau de bord admin RentalDriveGo',
|
||||||
subtitle: 'Suivez la santé de la marketplace, les abonnements, l’activité des locataires et les actions opérateur depuis une même interface.',
|
subtitle: 'Suivez la santé de la Carplace, les abonnements, l’activité des locataires et les actions opérateur depuis une même interface.',
|
||||||
liveSignal: 'Signal plateforme en direct',
|
liveSignal: 'Signal plateforme en direct',
|
||||||
readiness: 'Disponibilité opérationnelle',
|
readiness: 'Disponibilité opérationnelle',
|
||||||
readinessBody: 'Les principaux workflows admin sont connectés et prêts pour le support plateforme.',
|
readinessBody: 'Les principaux workflows admin sont connectés et prêts pour le support plateforme.',
|
||||||
@@ -55,7 +55,7 @@ export default function AdminDashboardPage() {
|
|||||||
['Locataires', 'Gérer les workflows de confiance, de blocage et de récupération.', 'Voir tout'],
|
['Locataires', 'Gérer les workflows de confiance, de blocage et de récupération.', 'Voir tout'],
|
||||||
['Journaux d’audit', 'Tracer chaque action admin sensible dans RentalDriveGo.', 'Voir les journaux'],
|
['Journaux d’audit', 'Tracer chaque action admin sensible dans RentalDriveGo.', 'Voir les journaux'],
|
||||||
],
|
],
|
||||||
health: ['Comptes locataires', 'Identité marketplace', 'Piste d’audit admin'],
|
health: ['Comptes locataires', 'Identité Carplace', 'Piste d’audit admin'],
|
||||||
},
|
},
|
||||||
ar: {
|
ar: {
|
||||||
kpis: ['إجمالي الشركات', 'الشركات النشطة', 'إجمالي المستأجرين', 'إجمالي الحجوزات'],
|
kpis: ['إجمالي الشركات', 'الشركات النشطة', 'إجمالي المستأجرين', 'إجمالي الحجوزات'],
|
||||||
|
|||||||
@@ -3,12 +3,12 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import {
|
import {
|
||||||
cloneMarketplaceHomepageContent,
|
cloneCarplaceHomepageContent,
|
||||||
resolveMarketplaceHomepageSections,
|
resolveCarplaceHomepageSections,
|
||||||
type MarketplaceHomepageConfig,
|
type CarplaceHomepageConfig,
|
||||||
type MarketplaceHomepageContent,
|
type CarplaceHomepageContent,
|
||||||
type MarketplaceHomepageSectionType,
|
type CarplaceHomepageSectionType,
|
||||||
type MarketplaceLanguage,
|
type CarplaceLanguage,
|
||||||
} from '@rentaldrivego/types'
|
} from '@rentaldrivego/types'
|
||||||
import { useAdminI18n } from '@/components/I18nProvider'
|
import { useAdminI18n } from '@/components/I18nProvider'
|
||||||
import { ADMIN_API_BASE } from '@/lib/api'
|
import { ADMIN_API_BASE } from '@/lib/api'
|
||||||
@@ -35,15 +35,15 @@ const STATUS_COLORS: Record<string, string> = {
|
|||||||
const INPUT_CLASS = 'w-full rounded-xl border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-emerald-500'
|
const INPUT_CLASS = 'w-full rounded-xl border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-emerald-500'
|
||||||
const LABEL_CLASS = 'mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500'
|
const LABEL_CLASS = 'mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500'
|
||||||
|
|
||||||
function cloneHomepageContent(content: MarketplaceHomepageConfig) {
|
function cloneHomepageContent(content: CarplaceHomepageConfig) {
|
||||||
const cloned = JSON.parse(JSON.stringify(content)) as MarketplaceHomepageConfig
|
const cloned = JSON.parse(JSON.stringify(content)) as CarplaceHomepageConfig
|
||||||
;(['en', 'fr', 'ar'] as MarketplaceLanguage[]).forEach((language) => {
|
;(['en', 'fr', 'ar'] as CarplaceLanguage[]).forEach((language) => {
|
||||||
cloned[language].sections = resolveMarketplaceHomepageSections(cloned[language].sections)
|
cloned[language].sections = resolveCarplaceHomepageSections(cloned[language].sections)
|
||||||
})
|
})
|
||||||
return cloned
|
return cloned
|
||||||
}
|
}
|
||||||
|
|
||||||
const HOMEPAGE_SECTIONS: MarketplaceHomepageSectionType[] = [
|
const HOMEPAGE_SECTIONS: CarplaceHomepageSectionType[] = [
|
||||||
'hero',
|
'hero',
|
||||||
'surface',
|
'surface',
|
||||||
'pillars',
|
'pillars',
|
||||||
@@ -58,12 +58,12 @@ export default function AdminSiteConfigPage() {
|
|||||||
const copy = {
|
const copy = {
|
||||||
en: {
|
en: {
|
||||||
title: 'Site configuration',
|
title: 'Site configuration',
|
||||||
description: 'Edit the main marketplace homepage here, or jump into a company to manage its branded public homepage and menu.',
|
description: 'Edit the main Carplace homepage here, or jump into a company to manage its branded public homepage and menu.',
|
||||||
homepageTitle: 'Main website homepage',
|
homepageTitle: 'Main website homepage',
|
||||||
homepageDescription: 'This controls the marketplace homepage shown on the main RentalDriveGo website.',
|
homepageDescription: 'This controls the Carplace homepage shown on the main RentalDriveGo website.',
|
||||||
saveHomepage: 'Save homepage',
|
saveHomepage: 'Save homepage',
|
||||||
savingHomepage: 'Saving…',
|
savingHomepage: 'Saving…',
|
||||||
homepageSaved: 'Marketplace homepage saved.',
|
homepageSaved: 'Carplace homepage saved.',
|
||||||
search: 'Search by company or slug…',
|
search: 'Search by company or slug…',
|
||||||
loading: 'Loading…',
|
loading: 'Loading…',
|
||||||
empty: 'No companies found',
|
empty: 'No companies found',
|
||||||
@@ -86,7 +86,7 @@ export default function AdminSiteConfigPage() {
|
|||||||
previewTitle: 'Live preview',
|
previewTitle: 'Live preview',
|
||||||
previewDescription: 'Draft changes appear here before you save them.',
|
previewDescription: 'Draft changes appear here before you save them.',
|
||||||
homepageSections: 'Homepage sections',
|
homepageSections: 'Homepage sections',
|
||||||
homepageSectionsDescription: 'Add or remove blocks from the main marketplace homepage.',
|
homepageSectionsDescription: 'Add or remove blocks from the main Carplace homepage.',
|
||||||
addSection: 'Add',
|
addSection: 'Add',
|
||||||
removeSection: 'Remove',
|
removeSection: 'Remove',
|
||||||
expand: 'Expand',
|
expand: 'Expand',
|
||||||
@@ -94,12 +94,12 @@ export default function AdminSiteConfigPage() {
|
|||||||
},
|
},
|
||||||
fr: {
|
fr: {
|
||||||
title: 'Configuration du site',
|
title: 'Configuration du site',
|
||||||
description: 'Modifiez ici la page d’accueil principale de la marketplace, ou ouvrez une entreprise pour gérer sa page publique et son menu.',
|
description: 'Modifiez ici la page d’accueil principale de la Carplace, ou ouvrez une entreprise pour gérer sa page publique et son menu.',
|
||||||
homepageTitle: 'Page d’accueil du site principal',
|
homepageTitle: 'Page d’accueil du site principal',
|
||||||
homepageDescription: 'Cette section contrôle la page d’accueil marketplace affichée sur le site principal de RentalDriveGo.',
|
homepageDescription: 'Cette section contrôle la page d’accueil Carplace affichée sur le site principal de RentalDriveGo.',
|
||||||
saveHomepage: 'Enregistrer la page d’accueil',
|
saveHomepage: 'Enregistrer la page d’accueil',
|
||||||
savingHomepage: 'Enregistrement…',
|
savingHomepage: 'Enregistrement…',
|
||||||
homepageSaved: 'Page d’accueil marketplace enregistrée.',
|
homepageSaved: 'Page d’accueil Carplace enregistrée.',
|
||||||
search: 'Rechercher par entreprise ou slug…',
|
search: 'Rechercher par entreprise ou slug…',
|
||||||
loading: 'Chargement…',
|
loading: 'Chargement…',
|
||||||
empty: 'Aucune entreprise trouvée',
|
empty: 'Aucune entreprise trouvée',
|
||||||
@@ -112,7 +112,7 @@ export default function AdminSiteConfigPage() {
|
|||||||
companiesTitle: 'Sites de marque des entreprises',
|
companiesTitle: 'Sites de marque des entreprises',
|
||||||
languageLabel: 'Langue',
|
languageLabel: 'Langue',
|
||||||
hero: 'Bloc principal',
|
hero: 'Bloc principal',
|
||||||
surface: 'Bloc marketplace',
|
surface: 'Bloc Carplace',
|
||||||
companySection: 'Bloc opérateur',
|
companySection: 'Bloc opérateur',
|
||||||
renterSection: 'Bloc client',
|
renterSection: 'Bloc client',
|
||||||
valueSection: 'Blocs de valeur',
|
valueSection: 'Blocs de valeur',
|
||||||
@@ -122,7 +122,7 @@ export default function AdminSiteConfigPage() {
|
|||||||
previewTitle: 'Aperçu en direct',
|
previewTitle: 'Aperçu en direct',
|
||||||
previewDescription: 'Les brouillons apparaissent ici avant l’enregistrement.',
|
previewDescription: 'Les brouillons apparaissent ici avant l’enregistrement.',
|
||||||
homepageSections: 'Sections de la page d’accueil',
|
homepageSections: 'Sections de la page d’accueil',
|
||||||
homepageSectionsDescription: 'Ajoutez ou retirez des blocs de la page d’accueil marketplace principale.',
|
homepageSectionsDescription: 'Ajoutez ou retirez des blocs de la page d’accueil Carplace principale.',
|
||||||
addSection: 'Ajouter',
|
addSection: 'Ajouter',
|
||||||
removeSection: 'Retirer',
|
removeSection: 'Retirer',
|
||||||
expand: 'Ouvrir',
|
expand: 'Ouvrir',
|
||||||
@@ -171,8 +171,8 @@ export default function AdminSiteConfigPage() {
|
|||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [homepage, setHomepage] = useState<MarketplaceHomepageConfig>(cloneMarketplaceHomepageContent())
|
const [homepage, setHomepage] = useState<CarplaceHomepageConfig>(cloneCarplaceHomepageContent())
|
||||||
const [homepageLanguage, setHomepageLanguage] = useState<MarketplaceLanguage>(language)
|
const [homepageLanguage, setHomepageLanguage] = useState<CarplaceLanguage>(language)
|
||||||
const [homepageSaving, setHomepageSaving] = useState(false)
|
const [homepageSaving, setHomepageSaving] = useState(false)
|
||||||
const [homepageMessage, setHomepageMessage] = useState<string | null>(null)
|
const [homepageMessage, setHomepageMessage] = useState<string | null>(null)
|
||||||
const [homepageExpanded, setHomepageExpanded] = useState(false)
|
const [homepageExpanded, setHomepageExpanded] = useState(false)
|
||||||
@@ -189,7 +189,7 @@ export default function AdminSiteConfigPage() {
|
|||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
}),
|
}),
|
||||||
fetch(`${ADMIN_API_BASE}/admin/site-config/marketplace-homepage`, {
|
fetch(`${ADMIN_API_BASE}/admin/site-config/carplace-homepage`, {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
}),
|
}),
|
||||||
@@ -202,7 +202,7 @@ export default function AdminSiteConfigPage() {
|
|||||||
|
|
||||||
const homepageJson = await homepageRes.json()
|
const homepageJson = await homepageRes.json()
|
||||||
if (homepageRes.ok && homepageJson?.data) {
|
if (homepageRes.ok && homepageJson?.data) {
|
||||||
setHomepage(cloneHomepageContent(homepageJson.data as MarketplaceHomepageConfig))
|
setHomepage(cloneHomepageContent(homepageJson.data as CarplaceHomepageConfig))
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
@@ -225,7 +225,7 @@ export default function AdminSiteConfigPage() {
|
|||||||
)
|
)
|
||||||
}, [search, companies])
|
}, [search, companies])
|
||||||
|
|
||||||
function updateHomepageContent(patch: Partial<MarketplaceHomepageContent>) {
|
function updateHomepageContent(patch: Partial<CarplaceHomepageContent>) {
|
||||||
setHomepage((current) => ({
|
setHomepage((current) => ({
|
||||||
...current,
|
...current,
|
||||||
[homepageLanguage]: {
|
[homepageLanguage]: {
|
||||||
@@ -257,16 +257,16 @@ export default function AdminSiteConfigPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getActiveSections() {
|
function getActiveSections() {
|
||||||
return resolveMarketplaceHomepageSections(activeContent.sections)
|
return resolveCarplaceHomepageSections(activeContent.sections)
|
||||||
}
|
}
|
||||||
|
|
||||||
function addHomepageSection(section: MarketplaceHomepageSectionType) {
|
function addHomepageSection(section: CarplaceHomepageSectionType) {
|
||||||
const sections = getActiveSections()
|
const sections = getActiveSections()
|
||||||
if (sections.includes(section)) return
|
if (sections.includes(section)) return
|
||||||
updateHomepageContent({ sections: [...sections, section] })
|
updateHomepageContent({ sections: [...sections, section] })
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeHomepageSection(section: MarketplaceHomepageSectionType) {
|
function removeHomepageSection(section: CarplaceHomepageSectionType) {
|
||||||
const sections = getActiveSections().filter((item) => item !== section)
|
const sections = getActiveSections().filter((item) => item !== section)
|
||||||
if (sections.length === 0) return
|
if (sections.length === 0) return
|
||||||
updateHomepageContent({ sections })
|
updateHomepageContent({ sections })
|
||||||
@@ -276,7 +276,7 @@ export default function AdminSiteConfigPage() {
|
|||||||
setHomepageSaving(true)
|
setHomepageSaving(true)
|
||||||
setHomepageMessage(null)
|
setHomepageMessage(null)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${ADMIN_API_BASE}/admin/site-config/marketplace-homepage`, {
|
const res = await fetch(`${ADMIN_API_BASE}/admin/site-config/carplace-homepage`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -286,7 +286,7 @@ export default function AdminSiteConfigPage() {
|
|||||||
})
|
})
|
||||||
const json = await res.json()
|
const json = await res.json()
|
||||||
if (!res.ok) throw new Error(json?.message ?? 'Failed to save homepage')
|
if (!res.ok) throw new Error(json?.message ?? 'Failed to save homepage')
|
||||||
setHomepage(cloneHomepageContent(json.data as MarketplaceHomepageConfig))
|
setHomepage(cloneHomepageContent(json.data as CarplaceHomepageConfig))
|
||||||
setHomepageMessage(copy.homepageSaved)
|
setHomepageMessage(copy.homepageSaved)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
@@ -298,7 +298,7 @@ export default function AdminSiteConfigPage() {
|
|||||||
const activeContent = homepage[homepageLanguage]
|
const activeContent = homepage[homepageLanguage]
|
||||||
const activeSections = getActiveSections()
|
const activeSections = getActiveSections()
|
||||||
const availableSections = HOMEPAGE_SECTIONS.filter((section) => !activeSections.includes(section))
|
const availableSections = HOMEPAGE_SECTIONS.filter((section) => !activeSections.includes(section))
|
||||||
const sectionLabels: Record<MarketplaceHomepageSectionType, string> = {
|
const sectionLabels: Record<CarplaceHomepageSectionType, string> = {
|
||||||
hero: copy.hero,
|
hero: copy.hero,
|
||||||
surface: copy.surface,
|
surface: copy.surface,
|
||||||
pillars: copy.valueSection,
|
pillars: copy.valueSection,
|
||||||
@@ -337,7 +337,7 @@ export default function AdminSiteConfigPage() {
|
|||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">{copy.languageLabel}</span>
|
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">{copy.languageLabel}</span>
|
||||||
{(['en', 'fr', 'ar'] as MarketplaceLanguage[]).map((value) => (
|
{(['en', 'fr', 'ar'] as CarplaceLanguage[]).map((value) => (
|
||||||
<button
|
<button
|
||||||
key={value}
|
key={value}
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ function AdminResetPasswordContent() {
|
|||||||
const token = searchParams.get('token')
|
const token = searchParams.get('token')
|
||||||
|
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
const [confirm, setConfirm] = useState('')
|
const [confirm, setConfirm] = useState('')
|
||||||
|
const [showConfirm, setShowConfirm] = useState(false)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [done, setDone] = useState(false)
|
const [done, setDone] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -95,27 +97,65 @@ function AdminResetPasswordContent() {
|
|||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-sm font-medium text-zinc-300">New password</label>
|
<label className="mb-1.5 block text-sm font-medium text-zinc-300">New password</label>
|
||||||
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
type="password"
|
type={showPassword ? 'text' : 'password'}
|
||||||
required
|
required
|
||||||
minLength={8}
|
minLength={8}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-900 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-[#07101e]/80 dark:text-stone-100"
|
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 pr-10 text-sm text-stone-900 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-[#07101e]/80 dark:text-stone-100"
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword((v) => !v)}
|
||||||
|
className="absolute inset-y-0 right-3 flex items-center text-stone-400 hover:text-stone-700 dark:text-stone-500 dark:hover:text-stone-200"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-sm font-medium text-zinc-300">Confirm password</label>
|
<label className="mb-1.5 block text-sm font-medium text-zinc-300">Confirm password</label>
|
||||||
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
type="password"
|
type={showConfirm ? 'text' : 'password'}
|
||||||
required
|
required
|
||||||
minLength={8}
|
minLength={8}
|
||||||
value={confirm}
|
value={confirm}
|
||||||
onChange={(e) => setConfirm(e.target.value)}
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-900 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-[#07101e]/80 dark:text-stone-100"
|
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 pr-10 text-sm text-stone-900 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-[#07101e]/80 dark:text-stone-100"
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowConfirm((v) => !v)}
|
||||||
|
className="absolute inset-y-0 right-3 flex items-center text-stone-400 hover:text-stone-700 dark:text-stone-500 dark:hover:text-stone-200"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showConfirm ? (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ vi.mock('@/components/PublicFooter', () => ({ default: function MockAdminFooter(
|
|||||||
|
|
||||||
import PublicShell from './PublicShell'
|
import PublicShell from './PublicShell'
|
||||||
|
|
||||||
function childTypes(node: React.ReactElement): string[] {
|
type WithChildren = { children?: React.ReactNode }
|
||||||
|
|
||||||
|
function childTypes(node: React.ReactElement<WithChildren>): string[] {
|
||||||
return React.Children.toArray(node.props.children).filter(isValidElement).map((child) => {
|
return React.Children.toArray(node.props.children).filter(isValidElement).map((child) => {
|
||||||
const type = child.type as any
|
const type = child.type as any
|
||||||
if (typeof type === 'string') return type
|
if (typeof type === 'string') return type
|
||||||
|
|||||||
@@ -31,21 +31,22 @@
|
|||||||
"dayjs": "^1.11.11",
|
"dayjs": "^1.11.11",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"express-rate-limit": "^8.5.1",
|
"express-rate-limit": "^8.5.1",
|
||||||
"firebase-admin": "^12.1.0",
|
"firebase-admin": "^10.3.0",
|
||||||
"helmet": "^7.1.0",
|
"helmet": "^7.1.0",
|
||||||
"ioredis": "^5.3.2",
|
"ioredis": "^5.3.2",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"multer": "^2.1.1",
|
"multer": "^2.1.1",
|
||||||
"node-cron": "^3.0.3",
|
"next": "16.2.9",
|
||||||
"nodemailer": "^8.0.10",
|
"node-cron": "^4.5.0",
|
||||||
|
"nodemailer": "^9.0.1",
|
||||||
"otplib": "^12.0.1",
|
"otplib": "^12.0.1",
|
||||||
"qrcode": "^1.5.3",
|
"qrcode": "^1.5.3",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
|
||||||
"resend": "^3.2.0",
|
"resend": "^3.2.0",
|
||||||
"socket.io": "^4.7.5",
|
"socket.io": "^4.7.5",
|
||||||
"swagger-ui-express": "^5.0.1",
|
"swagger-ui-express": "^5.0.1",
|
||||||
|
"turbo": "2.10.0",
|
||||||
"twilio": "^5.1.0",
|
"twilio": "^5.1.0",
|
||||||
"zod": "^3.23.0",
|
"zod": "^3.23.0",
|
||||||
"zod-to-json-schema": "^3.25.2"
|
"zod-to-json-schema": "^3.25.2"
|
||||||
@@ -61,13 +62,11 @@
|
|||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
"@types/nodemailer": "^6.4.17",
|
"@types/nodemailer": "^6.4.17",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
"@types/react": "^18.3.1",
|
|
||||||
"@types/react-dom": "^18.3.0",
|
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"@types/swagger-ui-express": "^4.1.8",
|
"@types/swagger-ui-express": "^4.1.8",
|
||||||
"supertest": "^7.0.0",
|
"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"
|
"vitest": "^2.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-4
@@ -24,11 +24,12 @@ import subscriptionsRouter, {
|
|||||||
subscriptionWebhookRouter,
|
subscriptionWebhookRouter,
|
||||||
} from './modules/subscriptions/subscription.routes'
|
} from './modules/subscriptions/subscription.routes'
|
||||||
import paymentsRouter from './modules/payments/payment.routes'
|
import paymentsRouter from './modules/payments/payment.routes'
|
||||||
|
import billingRouter from './modules/billing/billing.routes'
|
||||||
import customersRouter from './modules/customers/customer.routes'
|
import customersRouter from './modules/customers/customer.routes'
|
||||||
import vehiclesRouter from './modules/vehicles/vehicle.routes'
|
import vehiclesRouter from './modules/vehicles/vehicle.routes'
|
||||||
import companiesRouter from './modules/companies/company.routes'
|
import companiesRouter from './modules/companies/company.routes'
|
||||||
import reservationsRouter from './modules/reservations/reservation.routes'
|
import reservationsRouter from './modules/reservations/reservation.routes'
|
||||||
import marketplaceRouter from './modules/marketplace/marketplace.routes'
|
import carplaceRouter from './modules/carplace/carplace.routes'
|
||||||
import siteRouter from './modules/site/site.routes'
|
import siteRouter from './modules/site/site.routes'
|
||||||
import reviewsRouter from './modules/reviews/review.routes'
|
import reviewsRouter from './modules/reviews/review.routes'
|
||||||
import complaintsRouter from './modules/complaints/complaint.routes'
|
import complaintsRouter from './modules/complaints/complaint.routes'
|
||||||
@@ -95,8 +96,10 @@ const routeDocs = [
|
|||||||
{ method: 'GET', path: `${v1}/analytics/dashboard`, description: 'Dashboard analytics' },
|
{ method: 'GET', path: `${v1}/analytics/dashboard`, description: 'Dashboard analytics' },
|
||||||
{ method: 'GET', path: `${v1}/analytics/report`, description: 'Analytics report' },
|
{ method: 'GET', path: `${v1}/analytics/report`, description: 'Analytics report' },
|
||||||
{ method: 'GET', path: `${v1}/notifications/company`, description: 'Company notifications' },
|
{ method: 'GET', path: `${v1}/notifications/company`, description: 'Company notifications' },
|
||||||
{ method: 'GET', path: `${v1}/marketplace/cities`, description: 'Marketplace cities' },
|
{ method: 'GET', path: `${v1}/carplace/home`, description: 'Carplace home' },
|
||||||
{ method: 'GET', path: `${v1}/marketplace/search`, description: 'Marketplace search' },
|
{ method: 'GET', path: `${v1}/carplace/search`, description: 'Carplace paginated vehicle search' },
|
||||||
|
{ method: 'POST', path: `${v1}/carplace/quotes`, description: 'Carplace availability and price estimate' },
|
||||||
|
{ method: 'POST', path: `${v1}/carplace/reservations`, description: 'Create Carplace reservation request' },
|
||||||
{ method: 'GET', path: `${v1}/site/:slug/brand`, description: 'Public site brand config' },
|
{ 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}/companies/me`, description: 'Current company profile' },
|
||||||
{ method: 'GET', path: `${v1}/subscriptions/plans`, description: 'Subscription plans' },
|
{ method: 'GET', path: `${v1}/subscriptions/plans`, description: 'Subscription plans' },
|
||||||
@@ -187,7 +190,7 @@ export function createApp() {
|
|||||||
app.use(`${v1}/admin/auth`, authLimiter)
|
app.use(`${v1}/admin/auth`, authLimiter)
|
||||||
app.use(`${v1}/admin`, adminLimiter, adminRouter)
|
app.use(`${v1}/admin`, adminLimiter, adminRouter)
|
||||||
|
|
||||||
app.use(`${v1}/marketplace`, publicLimiter, marketplaceRouter)
|
app.use(`${v1}/carplace`, publicLimiter, carplaceRouter)
|
||||||
app.use(`${v1}/site`, publicLimiter, siteRouter)
|
app.use(`${v1}/site`, publicLimiter, siteRouter)
|
||||||
app.use(`${v1}/subscriptions`, subscriptionPublicRouter)
|
app.use(`${v1}/subscriptions`, subscriptionPublicRouter)
|
||||||
app.use(`${v1}/subscriptions`, subscriptionWebhookRouter)
|
app.use(`${v1}/subscriptions`, subscriptionWebhookRouter)
|
||||||
@@ -202,6 +205,7 @@ export function createApp() {
|
|||||||
app.use(`${v1}/companies`, apiLimiter, companiesRouter)
|
app.use(`${v1}/companies`, apiLimiter, companiesRouter)
|
||||||
app.use(`${v1}/subscriptions`, apiLimiter, subscriptionsRouter)
|
app.use(`${v1}/subscriptions`, apiLimiter, subscriptionsRouter)
|
||||||
app.use(`${v1}/payments`, apiLimiter, paymentsRouter)
|
app.use(`${v1}/payments`, apiLimiter, paymentsRouter)
|
||||||
|
app.use(`${v1}/billing`, apiLimiter, billingRouter)
|
||||||
app.use(`${v1}/reviews`, apiLimiter, reviewsRouter)
|
app.use(`${v1}/reviews`, apiLimiter, reviewsRouter)
|
||||||
app.use(`${v1}/complaints`, apiLimiter, complaintsRouter)
|
app.use(`${v1}/complaints`, apiLimiter, complaintsRouter)
|
||||||
app.use(`${v1}/licenses`, publicLimiter, licenseValidationRouter)
|
app.use(`${v1}/licenses`, publicLimiter, licenseValidationRouter)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"marketplaceHomepage": {
|
"carplaceHomepage": {
|
||||||
"en": {
|
"en": {
|
||||||
"sections": [
|
"sections": [
|
||||||
"hero",
|
"hero",
|
||||||
@@ -13,13 +13,13 @@
|
|||||||
"closing"
|
"closing"
|
||||||
],
|
],
|
||||||
"heroKicker": "RentalDriveGo",
|
"heroKicker": "RentalDriveGo",
|
||||||
"heroTitle": "Marketplace discovery with a sharper front door.",
|
"heroTitle": "Carplace discovery with a sharper front door.",
|
||||||
"heroBody": "Rental companies run private operations, renters browse a shared marketplace, and every booking ends on the company’s own branded checkout.",
|
"heroBody": "Rental companies run private operations, renters browse a shared Carplace, and every booking ends on the company’s own branded checkout.",
|
||||||
"startTrial": "Start free trial",
|
"startTrial": "Start free trial",
|
||||||
"exploreVehicles": "Explore vehicles",
|
"exploreVehicles": "Explore vehicles",
|
||||||
"surfaceLabel": "Marketplace surface",
|
"surfaceLabel": "Carplace surface",
|
||||||
"surfaceTitle": "Designed for two audiences at once.",
|
"surfaceTitle": "Designed for two audiences at once.",
|
||||||
"surfaceBody": "Operators need control, renters need confidence. The marketplace should show both without feeling like a template.",
|
"surfaceBody": "Operators need control, renters need confidence. The Carplace should show both without feeling like a template.",
|
||||||
"liveLabel": "Live network",
|
"liveLabel": "Live network",
|
||||||
"trustedFleets": "Trusted fleets",
|
"trustedFleets": "Trusted fleets",
|
||||||
"brandedFlows": "Branded booking flows",
|
"brandedFlows": "Branded booking flows",
|
||||||
@@ -29,11 +29,11 @@
|
|||||||
"companyBody": "Publish inventory once, decide what appears publicly, and keep contracts, payments, and reporting isolated per company.",
|
"companyBody": "Publish inventory once, decide what appears publicly, and keep contracts, payments, and reporting isolated per company.",
|
||||||
"renterKicker": "Renter experience",
|
"renterKicker": "Renter experience",
|
||||||
"renterTitle": "Let renters compare quickly, then hand them off to the right company site.",
|
"renterTitle": "Let renters compare quickly, then hand them off to the right company site.",
|
||||||
"renterBody": "The marketplace works as a discovery engine, not a dead-end aggregator. Search here, reserve there, and pay direct.",
|
"renterBody": "The Carplace works as a discovery engine, not a dead-end aggregator. Search here, reserve there, and pay direct.",
|
||||||
"pillars": [
|
"pillars": [
|
||||||
{
|
{
|
||||||
"title": "Unified publishing",
|
"title": "Unified publishing",
|
||||||
"body": "Vehicle photos, pricing, and offers flow from one admin workflow into marketplace discovery and branded booking pages."
|
"body": "Vehicle photos, pricing, and offers flow from one admin workflow into Carplace discovery and branded booking pages."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Qualified discovery",
|
"title": "Qualified discovery",
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
"metrics": [
|
"metrics": [
|
||||||
{
|
{
|
||||||
"value": "01",
|
"value": "01",
|
||||||
"label": "Shared marketplace visibility"
|
"label": "Shared Carplace visibility"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"value": "02",
|
"value": "02",
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
"featureLabel": "What the product covers",
|
"featureLabel": "What the product covers",
|
||||||
"features": [
|
"features": [
|
||||||
"Fleet management with multi-photo uploads",
|
"Fleet management with multi-photo uploads",
|
||||||
"Marketplace offers and redirect booking flow",
|
"Carplace offers and redirect booking flow",
|
||||||
"Branded public booking site per company",
|
"Branded public booking site per company",
|
||||||
"Customer CRM, analytics, and billing controls"
|
"Customer CRM, analytics, and billing controls"
|
||||||
],
|
],
|
||||||
@@ -71,12 +71,12 @@
|
|||||||
{
|
{
|
||||||
"number": "1",
|
"number": "1",
|
||||||
"title": "Create Account",
|
"title": "Create Account",
|
||||||
"description": "Sign up for your company workspace and choose your pricing plan for the 90-day free trial."
|
"description": "Sign up for your company workspace and choose your pricing plan for the 30-day free trial."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"number": "2",
|
"number": "2",
|
||||||
"title": "Add Your Fleet",
|
"title": "Add Your Fleet",
|
||||||
"description": "Upload vehicle photos, set prices, and create offers visible on the marketplace."
|
"description": "Upload vehicle photos, set prices, and create offers visible on the Carplace."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"number": "3",
|
"number": "3",
|
||||||
@@ -89,12 +89,12 @@
|
|||||||
{
|
{
|
||||||
"step": "1",
|
"step": "1",
|
||||||
"title": "Create your company workspace",
|
"title": "Create your company workspace",
|
||||||
"body": "Pick a plan, launch your 90-day trial, and verify the owner account."
|
"body": "Pick a plan, launch your 30-day trial, and verify the owner account."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"step": "2",
|
"step": "2",
|
||||||
"title": "Publish vehicles and offers",
|
"title": "Publish vehicles and offers",
|
||||||
"body": "Upload photos once and control what appears on the marketplace and branded site."
|
"body": "Upload photos once and control what appears on the Carplace and branded site."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"step": "3",
|
"step": "3",
|
||||||
@@ -104,8 +104,8 @@
|
|||||||
],
|
],
|
||||||
"stepLabel": "Step",
|
"stepLabel": "Step",
|
||||||
"readyKicker": "Ready to launch",
|
"readyKicker": "Ready to launch",
|
||||||
"readyTitle": "The marketplace homepage should explain the product in seconds.",
|
"readyTitle": "The Carplace homepage should explain the product in seconds.",
|
||||||
"readyBody": "Use the marketplace to attract demand, then move renters into a branded experience that keeps pricing, payments, and trust tied to your company.",
|
"readyBody": "Use the Carplace to attract demand, then move renters into a branded experience that keeps pricing, payments, and trust tied to your company.",
|
||||||
"viewPricing": "View pricing",
|
"viewPricing": "View pricing",
|
||||||
"createWorkspace": "Create workspace"
|
"createWorkspace": "Create workspace"
|
||||||
},
|
},
|
||||||
@@ -122,13 +122,13 @@
|
|||||||
"closing"
|
"closing"
|
||||||
],
|
],
|
||||||
"heroKicker": "RentalDriveGo",
|
"heroKicker": "RentalDriveGo",
|
||||||
"heroTitle": "Une vitrine marketplace plus claire et plus percutante.",
|
"heroTitle": "Une vitrine Carplace plus claire et plus percutante.",
|
||||||
"heroBody": "Les entreprises de location gèrent leurs opérations en privé, les clients parcourent une marketplace commune et chaque réservation se finalise sur le parcours de paiement aux couleurs de l'entreprise.",
|
"heroBody": "Les entreprises de location gèrent leurs opérations en privé, les clients parcourent une Carplace commune et chaque réservation se finalise sur le parcours de paiement aux couleurs de l'entreprise.",
|
||||||
"startTrial": "Commencer l’essai gratuit",
|
"startTrial": "Commencer l’essai gratuit",
|
||||||
"exploreVehicles": "Explorer les véhicules",
|
"exploreVehicles": "Explorer les véhicules",
|
||||||
"surfaceLabel": "Vitrine marketplace",
|
"surfaceLabel": "Vitrine Carplace",
|
||||||
"surfaceTitle": "Pensée pour deux audiences à la fois.",
|
"surfaceTitle": "Pensée pour deux audiences à la fois.",
|
||||||
"surfaceBody": "Les opérateurs veulent du contrôle, les clients veulent de la confiance. La marketplace doit montrer les deux sans ressembler à un modèle générique.",
|
"surfaceBody": "Les opérateurs veulent du contrôle, les clients veulent de la confiance. La Carplace doit montrer les deux sans ressembler à un modèle générique.",
|
||||||
"liveLabel": "Réseau actif",
|
"liveLabel": "Réseau actif",
|
||||||
"trustedFleets": "Flottes fiables",
|
"trustedFleets": "Flottes fiables",
|
||||||
"brandedFlows": "Parcours de marque",
|
"brandedFlows": "Parcours de marque",
|
||||||
@@ -138,11 +138,11 @@
|
|||||||
"companyBody": "Publiez une seule fois, choisissez ce qui apparaît publiquement, et gardez contrats, paiements et rapports isolés par entreprise.",
|
"companyBody": "Publiez une seule fois, choisissez ce qui apparaît publiquement, et gardez contrats, paiements et rapports isolés par entreprise.",
|
||||||
"renterKicker": "Expérience client",
|
"renterKicker": "Expérience client",
|
||||||
"renterTitle": "Laissez les clients comparer rapidement, puis redirigez-les vers le bon site d’entreprise.",
|
"renterTitle": "Laissez les clients comparer rapidement, puis redirigez-les vers le bon site d’entreprise.",
|
||||||
"renterBody": "La marketplace sert de moteur de découverte, pas d’agrégateur sans suite. Recherche ici, réservation là-bas, paiement direct.",
|
"renterBody": "La Carplace sert de moteur de découverte, pas d’agrégateur sans suite. Recherche ici, réservation là-bas, paiement direct.",
|
||||||
"pillars": [
|
"pillars": [
|
||||||
{
|
{
|
||||||
"title": "Publication unifiée",
|
"title": "Publication unifiée",
|
||||||
"body": "Les photos, tarifs et offres circulent depuis un seul flux d’administration vers la découverte marketplace et les pages de réservation de marque."
|
"body": "Les photos, tarifs et offres circulent depuis un seul flux d’administration vers la découverte Carplace et les pages de réservation de marque."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Découverte qualifiée",
|
"title": "Découverte qualifiée",
|
||||||
@@ -156,7 +156,7 @@
|
|||||||
"metrics": [
|
"metrics": [
|
||||||
{
|
{
|
||||||
"value": "01",
|
"value": "01",
|
||||||
"label": "Visibilité marketplace partagée"
|
"label": "Visibilité Carplace partagée"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"value": "02",
|
"value": "02",
|
||||||
@@ -170,7 +170,7 @@
|
|||||||
"featureLabel": "Ce que couvre le produit",
|
"featureLabel": "Ce que couvre le produit",
|
||||||
"features": [
|
"features": [
|
||||||
"Gestion de flotte avec téléversement de plusieurs photos",
|
"Gestion de flotte avec téléversement de plusieurs photos",
|
||||||
"Offres marketplace et redirection vers la réservation",
|
"Offres Carplace et redirection vers la réservation",
|
||||||
"Site public de réservation par entreprise",
|
"Site public de réservation par entreprise",
|
||||||
"CRM client, analytics et contrôle de facturation"
|
"CRM client, analytics et contrôle de facturation"
|
||||||
],
|
],
|
||||||
@@ -180,12 +180,12 @@
|
|||||||
{
|
{
|
||||||
"number": "1",
|
"number": "1",
|
||||||
"title": "Créer un compte",
|
"title": "Créer un compte",
|
||||||
"description": "Inscrivez-vous à votre espace entreprise et choisissez votre forfait pour l'essai gratuit de 90 jours."
|
"description": "Inscrivez-vous à votre espace entreprise et choisissez votre forfait pour l'essai gratuit de 30 jours."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"number": "2",
|
"number": "2",
|
||||||
"title": "Ajouter votre flotte",
|
"title": "Ajouter votre flotte",
|
||||||
"description": "Téléversez les photos des véhicules, fixez les tarifs et créez des offres visibles sur la marketplace."
|
"description": "Téléversez les photos des véhicules, fixez les tarifs et créez des offres visibles sur la Carplace."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"number": "3",
|
"number": "3",
|
||||||
@@ -198,12 +198,12 @@
|
|||||||
{
|
{
|
||||||
"step": "1",
|
"step": "1",
|
||||||
"title": "Créez votre espace entreprise",
|
"title": "Créez votre espace entreprise",
|
||||||
"body": "Choisissez un plan, lancez votre essai de 90 jours et vérifiez le compte propriétaire."
|
"body": "Choisissez un plan, lancez votre essai de 30 jours et vérifiez le compte propriétaire."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"step": "2",
|
"step": "2",
|
||||||
"title": "Publiez véhicules et offres",
|
"title": "Publiez véhicules et offres",
|
||||||
"body": "Téléversez une seule fois et contrôlez ce qui apparaît sur la marketplace et le site de marque."
|
"body": "Téléversez une seule fois et contrôlez ce qui apparaît sur la Carplace et le site de marque."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"step": "3",
|
"step": "3",
|
||||||
@@ -213,8 +213,8 @@
|
|||||||
],
|
],
|
||||||
"stepLabel": "Étape",
|
"stepLabel": "Étape",
|
||||||
"readyKicker": "Prêt à démarrer",
|
"readyKicker": "Prêt à démarrer",
|
||||||
"readyTitle": "La page d’accueil marketplace doit présenter le produit en quelques secondes.",
|
"readyTitle": "La page d’accueil Carplace doit présenter le produit en quelques secondes.",
|
||||||
"readyBody": "Utilisez la marketplace pour capter la demande, puis orientez les clients vers une expérience de marque où les prix, les paiements et la confiance restent attachés à votre entreprise.",
|
"readyBody": "Utilisez la Carplace pour capter la demande, puis orientez les clients vers une expérience de marque où les prix, les paiements et la confiance restent attachés à votre entreprise.",
|
||||||
"viewPricing": "Voir les tarifs",
|
"viewPricing": "Voir les tarifs",
|
||||||
"createWorkspace": "Créer l’espace"
|
"createWorkspace": "Créer l’espace"
|
||||||
},
|
},
|
||||||
@@ -289,7 +289,7 @@
|
|||||||
{
|
{
|
||||||
"number": "1",
|
"number": "1",
|
||||||
"title": "إنشاء حساب",
|
"title": "إنشاء حساب",
|
||||||
"description": "قم بالتسجيل للحصول على مساحة عمل شركتك واختر خطتك للتجربة المجانية لمدة 90 يوماً."
|
"description": "قم بالتسجيل للحصول على مساحة عمل شركتك واختر خطتك للتجربة المجانية لمدة 30 يوماً."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"number": "2",
|
"number": "2",
|
||||||
|
|||||||
@@ -74,6 +74,22 @@ describe('errorMiddleware', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.each(['P2021', 'P2022'])('normalizes Prisma schema mismatch errors for %s', (code) => {
|
||||||
|
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||||
|
|
||||||
|
const res = handle({ code })
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(503)
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'database_schema_mismatch',
|
||||||
|
message: 'Database schema is out of date. Run database migrations and retry.',
|
||||||
|
statusCode: 503,
|
||||||
|
requestId: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
spy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
it('preserves AppError metadata in the response body', () => {
|
it('preserves AppError metadata in the response body', () => {
|
||||||
const res = handle(new AppError('Plan required', 402, 'payment_required', { requiredPlan: 'PRO' }))
|
const res = handle(new AppError('Plan required', 402, 'payment_required', { requiredPlan: 'PRO' }))
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,15 @@ export function errorMiddleware(err: any, req: Request, res: Response, _next: Ne
|
|||||||
return res.status(409).json(withRequestId(req, { error: 'conflict', message: 'A resource with this value already exists', statusCode: 409 }))
|
return res.status(409).json(withRequestId(req, { error: 'conflict', message: 'A resource with this value already exists', statusCode: 409 }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (err.code === 'P2021' || err.code === 'P2022') {
|
||||||
|
console.error('[API Error] Database schema mismatch', { requestId: req.requestId, err })
|
||||||
|
return res.status(503).json(withRequestId(req, {
|
||||||
|
error: 'database_schema_mismatch',
|
||||||
|
message: 'Database schema is out of date. Run database migrations and retry.',
|
||||||
|
statusCode: 503,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
if (err instanceof AppError) {
|
if (err instanceof AppError) {
|
||||||
if (err.statusCode >= 500) console.error('[API Error]', { requestId: req.requestId, err })
|
if (err.statusCode >= 500) console.error('[API Error]', { requestId: req.requestId, err })
|
||||||
return res.status(err.statusCode).json(withRequestId(req, {
|
return res.status(err.statusCode).json(withRequestId(req, {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import {
|
import {
|
||||||
formatDate,
|
formatDate,
|
||||||
marketplaceReservationEmail,
|
carplaceReservationEmail,
|
||||||
resetPasswordEmail,
|
resetPasswordEmail,
|
||||||
signupEmail,
|
signupEmail,
|
||||||
} from './emailTranslations'
|
} from './emailTranslations'
|
||||||
@@ -40,8 +40,8 @@ describe('emailTranslations', () => {
|
|||||||
expect(html).toContain('45')
|
expect(html).toContain('45')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('includes optional contact phone in marketplace reservation HTML when present', () => {
|
it('includes optional contact phone in Carplace reservation HTML when present', () => {
|
||||||
const html = marketplaceReservationEmail.html({
|
const html = carplaceReservationEmail.html({
|
||||||
firstName: 'Yassine',
|
firstName: 'Yassine',
|
||||||
vehicleYear: 2024,
|
vehicleYear: 2024,
|
||||||
vehicleMake: 'Dacia',
|
vehicleMake: 'Dacia',
|
||||||
|
|||||||
@@ -117,9 +117,9 @@ export const resetPasswordEmail = {
|
|||||||
}, lang),
|
}, lang),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Marketplace reservation request ─────────────────────────────────────────
|
// ─── Carplace reservation request ─────────────────────────────────────────
|
||||||
|
|
||||||
export const marketplaceReservationEmail = {
|
export const carplaceReservationEmail = {
|
||||||
subject: (vehicleName: string, lang: Lang) => t({
|
subject: (vehicleName: string, lang: Lang) => t({
|
||||||
en: `Reservation Request Received — ${vehicleName}`,
|
en: `Reservation Request Received — ${vehicleName}`,
|
||||||
fr: `Demande de réservation reçue — ${vehicleName}`,
|
fr: `Demande de réservation reçue — ${vehicleName}`,
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export const apiLimiter = rateLimit({
|
|||||||
message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 },
|
message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 },
|
||||||
})
|
})
|
||||||
|
|
||||||
// Limiter for public marketplace and site endpoints (no auth)
|
// Limiter for public carplace and site endpoints (no auth)
|
||||||
export const publicLimiter = rateLimit({
|
export const publicLimiter = rateLimit({
|
||||||
windowMs: 60 * 1000,
|
windowMs: 60 * 1000,
|
||||||
max: 60,
|
max: 60,
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import type { NextFunction, Request, Response } from 'express'
|
import type { NextFunction, Request, Response } from 'express'
|
||||||
|
|
||||||
|
vi.mock('../lib/prisma', () => ({
|
||||||
|
prisma: {
|
||||||
|
subscription: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { prisma } from '../lib/prisma'
|
||||||
import { requireSubscription } from './requireSubscription'
|
import { requireSubscription } from './requireSubscription'
|
||||||
|
|
||||||
function responseStub() {
|
function responseStub() {
|
||||||
@@ -12,44 +21,46 @@ function responseStub() {
|
|||||||
|
|
||||||
describe('requireSubscription middleware', () => {
|
describe('requireSubscription middleware', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
process.env.NEXT_PUBLIC_DASHBOARD_URL = 'https://dashboard.example.test'
|
process.env.NEXT_PUBLIC_DASHBOARD_URL = 'https://dashboard.example.test'
|
||||||
})
|
})
|
||||||
|
|
||||||
it('requires tenant/company context first', () => {
|
it('requires tenant/company context first', async () => {
|
||||||
const req = {} as Request
|
const req = {} as Request
|
||||||
const res = responseStub()
|
const res = responseStub()
|
||||||
const next = vi.fn() as NextFunction
|
const next = vi.fn() as NextFunction
|
||||||
|
|
||||||
requireSubscription(req, res, next)
|
await requireSubscription(req, res, next)
|
||||||
|
|
||||||
expect(res.status).toHaveBeenCalledWith(401)
|
expect(res.status).toHaveBeenCalledWith(401)
|
||||||
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'No company context', statusCode: 401 })
|
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'No company context', statusCode: 401 })
|
||||||
expect(next).not.toHaveBeenCalled()
|
expect(next).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('blocks suspended companies with a billing URL', () => {
|
it('blocks suspended companies with a subscription recovery URL', async () => {
|
||||||
const req = { company: { status: 'SUSPENDED' } } as Request
|
const req = { company: { status: 'SUSPENDED' } } as Request
|
||||||
const res = responseStub()
|
const res = responseStub()
|
||||||
const next = vi.fn() as NextFunction
|
const next = vi.fn() as NextFunction
|
||||||
|
|
||||||
requireSubscription(req, res, next)
|
await requireSubscription(req, res, next)
|
||||||
|
|
||||||
expect(res.status).toHaveBeenCalledWith(402)
|
expect(res.status).toHaveBeenCalledWith(402)
|
||||||
expect(res.json).toHaveBeenCalledWith({
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
error: 'subscription_suspended',
|
error: 'subscription_suspended',
|
||||||
message: 'Your account has been suspended. Please contact support or renew your subscription.',
|
message: 'Your account has been suspended. Please contact support or renew your subscription.',
|
||||||
statusCode: 402,
|
statusCode: 402,
|
||||||
billingUrl: 'https://dashboard.example.test/billing',
|
billingUrl: 'https://dashboard.example.test/subscription',
|
||||||
})
|
})
|
||||||
expect(next).not.toHaveBeenCalled()
|
expect(next).not.toHaveBeenCalled()
|
||||||
|
expect(prisma.subscription.findUnique).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('blocks pending companies with setup guidance', () => {
|
it('blocks pending companies with setup guidance', async () => {
|
||||||
const req = { company: { status: 'PENDING' } } as Request
|
const req = { company: { status: 'PENDING' } } as Request
|
||||||
const res = responseStub()
|
const res = responseStub()
|
||||||
const next = vi.fn() as NextFunction
|
const next = vi.fn() as NextFunction
|
||||||
|
|
||||||
requireSubscription(req, res, next)
|
await requireSubscription(req, res, next)
|
||||||
|
|
||||||
expect(res.status).toHaveBeenCalledWith(402)
|
expect(res.status).toHaveBeenCalledWith(402)
|
||||||
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
|
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
@@ -59,14 +70,55 @@ describe('requireSubscription middleware', () => {
|
|||||||
expect(next).not.toHaveBeenCalled()
|
expect(next).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('allows active companies through', () => {
|
it('blocks expired trial subscriptions with a subscription recovery URL', async () => {
|
||||||
const req = { company: { status: 'ACTIVE' } } as Request
|
vi.mocked(prisma.subscription.findUnique).mockResolvedValue({ status: 'EXPIRED' } as never)
|
||||||
|
|
||||||
|
const req = { company: { id: 'company_1', status: 'TRIALING' } } as Request
|
||||||
const res = responseStub()
|
const res = responseStub()
|
||||||
const next = vi.fn() as NextFunction
|
const next = vi.fn() as NextFunction
|
||||||
|
|
||||||
requireSubscription(req, res, next)
|
await requireSubscription(req, res, next)
|
||||||
|
|
||||||
|
expect(prisma.subscription.findUnique).toHaveBeenCalledWith({
|
||||||
|
where: { companyId: 'company_1' },
|
||||||
|
select: { status: true },
|
||||||
|
})
|
||||||
|
expect(res.status).toHaveBeenCalledWith(402)
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'subscription_required',
|
||||||
|
message: 'Your subscription has ended. Please reactivate to continue.',
|
||||||
|
statusCode: 402,
|
||||||
|
billingUrl: 'https://dashboard.example.test/subscription',
|
||||||
|
subscriptionStatus: 'EXPIRED',
|
||||||
|
accessLevel: 'none',
|
||||||
|
})
|
||||||
|
expect(next).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows active companies with active subscriptions through', async () => {
|
||||||
|
vi.mocked(prisma.subscription.findUnique).mockResolvedValue({ status: 'ACTIVE' } as never)
|
||||||
|
|
||||||
|
const req = { company: { id: 'company_1', status: 'ACTIVE' } } as Request
|
||||||
|
const res = responseStub()
|
||||||
|
const next = vi.fn() as NextFunction
|
||||||
|
|
||||||
|
await requireSubscription(req, res, next)
|
||||||
|
|
||||||
expect(next).toHaveBeenCalledTimes(1)
|
expect(next).toHaveBeenCalledTimes(1)
|
||||||
expect(res.status).not.toHaveBeenCalled()
|
expect(res.status).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('passes subscription lookup failures to Express error handling', async () => {
|
||||||
|
const error = new Error('database unavailable')
|
||||||
|
vi.mocked(prisma.subscription.findUnique).mockRejectedValue(error as never)
|
||||||
|
|
||||||
|
const req = { company: { id: 'company_1', status: 'ACTIVE' } } as Request
|
||||||
|
const res = responseStub()
|
||||||
|
const next = vi.fn() as NextFunction
|
||||||
|
|
||||||
|
await requireSubscription(req, res, next)
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalledWith(error)
|
||||||
|
expect(res.status).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Request, Response, NextFunction } from 'express'
|
import { Request, Response, NextFunction } from 'express'
|
||||||
|
import { prisma } from '../lib/prisma'
|
||||||
|
import { getAccessLevel, hasAnyAccess } from '../modules/subscriptions/subscription.policy'
|
||||||
import { sendUnauthorized, sendPaymentRequired } from './authHelpers'
|
import { sendUnauthorized, sendPaymentRequired } from './authHelpers'
|
||||||
|
|
||||||
const BLOCKED_STATUSES = ['SUSPENDED', 'PENDING']
|
const BLOCKED_STATUSES = ['SUSPENDED', 'PENDING']
|
||||||
@@ -8,9 +10,10 @@ const BLOCKED_STATUSES = ['SUSPENDED', 'PENDING']
|
|||||||
* Must be applied after `requireTenant`.
|
* Must be applied after `requireTenant`.
|
||||||
*
|
*
|
||||||
* Guarantees on success:
|
* Guarantees on success:
|
||||||
* req.company.status is not SUSPENDED or PENDING
|
* req.company.status is not SUSPENDED or PENDING, and subscription access is not none
|
||||||
*/
|
*/
|
||||||
export function requireSubscription(req: Request, res: Response, next: NextFunction) {
|
export async function requireSubscription(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
const company = req.company
|
const company = req.company
|
||||||
if (!company) return sendUnauthorized(res, 'unauthenticated', 'No company context')
|
if (!company) return sendUnauthorized(res, 'unauthenticated', 'No company context')
|
||||||
|
|
||||||
@@ -21,9 +24,31 @@ export function requireSubscription(req: Request, res: Response, next: NextFunct
|
|||||||
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.',
|
||||||
{ billingUrl: `${process.env.NEXT_PUBLIC_DASHBOARD_URL}/billing` },
|
{ billingUrl: `${process.env.NEXT_PUBLIC_DASHBOARD_URL}/subscription` },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = await prisma.subscription.findUnique({
|
||||||
|
where: { companyId: company.id },
|
||||||
|
select: { status: true },
|
||||||
|
})
|
||||||
|
const subscriptionStatus = subscription?.status ?? 'EXPIRED'
|
||||||
|
|
||||||
|
if (!hasAnyAccess(subscriptionStatus)) {
|
||||||
|
return sendPaymentRequired(
|
||||||
|
res,
|
||||||
|
'subscription_required',
|
||||||
|
'Your subscription has ended. Please reactivate to continue.',
|
||||||
|
{
|
||||||
|
billingUrl: `${process.env.NEXT_PUBLIC_DASHBOARD_URL}/subscription`,
|
||||||
|
subscriptionStatus,
|
||||||
|
accessLevel: getAccessLevel(subscriptionStatus),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
next()
|
next()
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -559,16 +559,16 @@ router.post('/subscriptions/:subscriptionId/cancel', requireAdminAuth, requireAd
|
|||||||
|
|
||||||
// ─── Site config ───────────────────────────────────────────────
|
// ─── Site config ───────────────────────────────────────────────
|
||||||
|
|
||||||
router.get('/site-config/marketplace-homepage', requireAdminAuth, async (req, res, next) => {
|
router.get('/site-config/carplace-homepage', requireAdminAuth, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
ok(res, await service.getMarketplaceHomepage())
|
ok(res, await service.getCarplaceHomepage())
|
||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.patch('/site-config/marketplace-homepage', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
|
router.patch('/site-config/carplace-homepage', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { homepage } = parseBody(homepageUpdateSchema, req)
|
const { homepage } = parseBody(homepageUpdateSchema, req)
|
||||||
ok(res, await service.updateMarketplaceHomepage(homepage, req.admin.id, req.ip))
|
ok(res, await service.updateCarplaceHomepage(homepage, req.admin.id, req.ip))
|
||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import type { MarketplaceHomepageContent, MarketplaceHomepageHowItWorksStep, MarketplaceHomepageMetric, MarketplaceHomepagePillar, MarketplaceHomepageSectionType, MarketplaceHomepageStep, MarketplaceHomepageTestimonial } from '@rentaldrivego/types'
|
import type {
|
||||||
|
CarplaceHomepageContent,
|
||||||
|
CarplaceHomepageHowItWorksStep,
|
||||||
|
CarplaceHomepageMetric,
|
||||||
|
CarplaceHomepagePillar,
|
||||||
|
CarplaceHomepageSectionType,
|
||||||
|
CarplaceHomepageStep,
|
||||||
|
CarplaceHomepageTestimonial,
|
||||||
|
} from '@rentaldrivego/types'
|
||||||
|
|
||||||
export const loginSchema = z.object({
|
export const loginSchema = z.object({
|
||||||
email: z.string().email().max(255).trim().toLowerCase(),
|
email: z.string().email().max(255).trim().toLowerCase(),
|
||||||
@@ -154,7 +162,7 @@ export const adminCompanyUpdateSchema = z.object({
|
|||||||
publicCity: nullableString, publicCountry: nullableString, websiteUrl: nullableUrl,
|
publicCity: nullableString, publicCountry: nullableString, websiteUrl: nullableUrl,
|
||||||
whatsappNumber: nullableString, defaultLocale: z.string().min(2).optional(),
|
whatsappNumber: nullableString, defaultLocale: z.string().min(2).optional(),
|
||||||
defaultCurrency: z.literal('MAD').optional(),
|
defaultCurrency: z.literal('MAD').optional(),
|
||||||
isListedOnMarketplace: z.boolean().optional(),
|
isListedOnCarplace: z.boolean().optional(),
|
||||||
homePageConfig: z.any().optional(),
|
homePageConfig: z.any().optional(),
|
||||||
menuConfig: z.any().optional(),
|
menuConfig: z.any().optional(),
|
||||||
}).optional(),
|
}).optional(),
|
||||||
@@ -175,35 +183,35 @@ export const adminCompanyUpdateSchema = z.object({
|
|||||||
}).optional(),
|
}).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const marketplaceMetricSchema: z.ZodType<MarketplaceHomepageMetric> = z.object({ value: z.string().min(1), label: z.string().min(1) })
|
const carplaceMetricSchema: z.ZodType<CarplaceHomepageMetric> = 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 carplacePillarSchema: z.ZodType<CarplaceHomepagePillar> = 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 carplaceStepSchema: z.ZodType<CarplaceHomepageStep> = z.object({ step: z.string().min(1), title: z.string().min(1), body: z.string().min(1) })
|
||||||
const marketplaceHowItWorksStepSchema: z.ZodType<MarketplaceHomepageHowItWorksStep> = z.object({ number: z.string().min(1), title: z.string().min(1), description: z.string().min(1) })
|
const carplaceHowItWorksStepSchema: z.ZodType<CarplaceHomepageHowItWorksStep> = z.object({ number: z.string().min(1), title: z.string().min(1), description: z.string().min(1) })
|
||||||
const marketplaceTestimonialSchema: z.ZodType<MarketplaceHomepageTestimonial> = z.object({ quote: z.string().min(1), author: z.string().min(1), role: z.string().min(1), company: z.string().optional() })
|
const carplaceTestimonialSchema: z.ZodType<CarplaceHomepageTestimonial> = z.object({ quote: z.string().min(1), author: z.string().min(1), role: z.string().min(1), company: z.string().optional() })
|
||||||
const marketplaceSectionSchema: z.ZodType<MarketplaceHomepageSectionType> = z.enum(['hero', 'surface', 'pillars', 'audiences', 'features', 'howitworks', 'steps', 'testimonials', 'closing'])
|
const carplaceSectionSchema: z.ZodType<CarplaceHomepageSectionType> = z.enum(['hero', 'surface', 'pillars', 'audiences', 'features', 'howitworks', 'steps', 'testimonials', 'closing'])
|
||||||
|
|
||||||
const marketplaceHomepageContentSchema: z.ZodType<MarketplaceHomepageContent> = z.object({
|
const carplaceHomepageContentSchema: z.ZodType<CarplaceHomepageContent> = z.object({
|
||||||
sections: z.array(marketplaceSectionSchema).min(1),
|
sections: z.array(carplaceSectionSchema).min(1),
|
||||||
heroKicker: z.string().min(1), heroTitle: z.string().min(1), heroBody: z.string().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),
|
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),
|
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),
|
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),
|
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),
|
renterKicker: z.string().min(1), renterTitle: z.string().min(1), renterBody: z.string().min(1),
|
||||||
pillars: z.array(marketplacePillarSchema).length(3),
|
pillars: z.array(carplacePillarSchema).length(3),
|
||||||
metrics: z.array(marketplaceMetricSchema).length(3),
|
metrics: z.array(carplaceMetricSchema).length(3),
|
||||||
featureLabel: z.string().min(1), features: z.array(z.string().min(1)).min(1),
|
featureLabel: z.string().min(1), features: z.array(z.string().min(1)).min(1),
|
||||||
howitworksKicker: z.string().min(1), howitworksTitle: z.string().min(1), howitworksSteps: z.array(marketplaceHowItWorksStepSchema).min(1),
|
howitworksKicker: z.string().min(1), howitworksTitle: z.string().min(1), howitworksSteps: z.array(carplaceHowItWorksStepSchema).min(1),
|
||||||
stepsTitle: z.string().min(1), steps: z.array(marketplaceStepSchema).length(3), stepLabel: z.string().min(1),
|
stepsTitle: z.string().min(1), steps: z.array(carplaceStepSchema).length(3), stepLabel: z.string().min(1),
|
||||||
testimonialsKicker: z.string().min(1), testimonialsTitle: z.string().min(1), testimonials: z.array(marketplaceTestimonialSchema).min(1),
|
testimonialsKicker: z.string().min(1), testimonialsTitle: z.string().min(1), testimonials: z.array(carplaceTestimonialSchema).min(1),
|
||||||
readyKicker: z.string().min(1), readyTitle: z.string().min(1), readyBody: 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),
|
viewPricing: z.string().min(1), createWorkspace: z.string().min(1),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const marketplaceHomepageConfigSchema = z.object({
|
export const carplaceHomepageConfigSchema = z.object({
|
||||||
en: marketplaceHomepageContentSchema,
|
en: carplaceHomepageContentSchema,
|
||||||
fr: marketplaceHomepageContentSchema,
|
fr: carplaceHomepageContentSchema,
|
||||||
ar: marketplaceHomepageContentSchema,
|
ar: carplaceHomepageContentSchema,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const idParamSchema = z.object({
|
export const idParamSchema = z.object({
|
||||||
@@ -300,7 +308,7 @@ export const billingRefundSchema = z.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const homepageUpdateSchema = z.object({
|
export const homepageUpdateSchema = z.object({
|
||||||
homepage: marketplaceHomepageConfigSchema,
|
homepage: carplaceHomepageConfigSchema,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const pricingUpdateSchema = z.object({
|
export const pricingUpdateSchema = z.object({
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import crypto from 'crypto'
|
|||||||
import { authenticator } from 'otplib'
|
import { authenticator } from 'otplib'
|
||||||
import { signActorToken } from '../../security/tokens'
|
import { signActorToken } from '../../security/tokens'
|
||||||
import qrcode from 'qrcode'
|
import qrcode from 'qrcode'
|
||||||
import { getMarketplaceHomepageContent, saveMarketplaceHomepageContent } from '../../services/platformContentService'
|
import { getCarplaceHomepageContent, saveCarplaceHomepageContent } from '../../services/platformContentService'
|
||||||
import { sendTransactionalEmail } from '../../services/notificationService'
|
import { sendTransactionalEmail } from '../../services/notificationService'
|
||||||
import * as presenter from './admin.presenter'
|
import * as presenter from './admin.presenter'
|
||||||
import * as repo from './admin.repo'
|
import * as repo from './admin.repo'
|
||||||
@@ -402,16 +402,16 @@ export function issueBillingRefund(
|
|||||||
return billingService.issueRefund(invoiceId, data, adminId, ip)
|
return billingService.issueRefund(invoiceId, data, adminId, ip)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMarketplaceHomepage() {
|
export function getCarplaceHomepage() {
|
||||||
return getMarketplaceHomepageContent()
|
return getCarplaceHomepageContent()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateMarketplaceHomepage(homepage: any, adminId: string, ip?: string) {
|
export async function updateCarplaceHomepage(homepage: any, adminId: string, ip?: string) {
|
||||||
const saved = await saveMarketplaceHomepageContent(homepage)
|
const saved = await saveCarplaceHomepageContent(homepage)
|
||||||
await repo.createAuditLog({
|
await repo.createAuditLog({
|
||||||
adminUserId: adminId,
|
adminUserId: adminId,
|
||||||
action: 'UPDATE',
|
action: 'UPDATE',
|
||||||
resource: 'MarketplaceHomepage',
|
resource: 'CarplaceHomepage',
|
||||||
after: toAuditJson(saved),
|
after: toAuditJson(saved),
|
||||||
ipAddress: ip,
|
ipAddress: ip,
|
||||||
userAgent: undefined,
|
userAgent: undefined,
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ describe('analytics.service', () => {
|
|||||||
},
|
},
|
||||||
] as never)
|
] as never)
|
||||||
vi.mocked(prisma.reservation.groupBy).mockResolvedValue([
|
vi.mocked(prisma.reservation.groupBy).mockResolvedValue([
|
||||||
{ source: 'MARKETPLACE', _count: { id: 3 }, _sum: { totalAmount: 3600 } },
|
{ source: 'CARPLACE', _count: { id: 3 }, _sum: { totalAmount: 3600 } },
|
||||||
{ source: 'DIRECT', _count: { id: 2 }, _sum: { totalAmount: null } },
|
{ source: 'DIRECT', _count: { id: 2 }, _sum: { totalAmount: null } },
|
||||||
] as never)
|
] as never)
|
||||||
vi.mocked(prisma.subscription.findUnique).mockResolvedValue({
|
vi.mocked(prisma.subscription.findUnique).mockResolvedValue({
|
||||||
@@ -115,7 +115,7 @@ describe('analytics.service', () => {
|
|||||||
totalAmount: 1200,
|
totalAmount: 1200,
|
||||||
})])
|
})])
|
||||||
expect(result.sourceBreakdown).toEqual([
|
expect(result.sourceBreakdown).toEqual([
|
||||||
{ source: 'MARKETPLACE', count: 3, revenue: 3600 },
|
{ source: 'CARPLACE', count: 3, revenue: 3600 },
|
||||||
{ source: 'DIRECT', count: 2, revenue: 0 },
|
{ source: 'DIRECT', count: 2, revenue: 0 },
|
||||||
])
|
])
|
||||||
expect(result.subscription).toEqual({
|
expect(result.subscription).toEqual({
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export async function startAccount(body: AccountStartInput) {
|
|||||||
|
|
||||||
const result = await prisma.$transaction(async (tx: any) => {
|
const result = await prisma.$transaction(async (tx: any) => {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const trialEndAt = new Date(now.getTime() + 90 * 24 * 60 * 60 * 1000)
|
const trialEndAt = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000)
|
||||||
|
|
||||||
const company = await tx.company.create({
|
const company = await tx.company.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -97,23 +97,26 @@ export async function startAccount(body: AccountStartInput) {
|
|||||||
)
|
)
|
||||||
const verifyUrl = `${dashboardUrl}/verify-email?token=${encodeURIComponent(verificationToken)}`
|
const verifyUrl = `${dashboardUrl}/verify-email?token=${encodeURIComponent(verificationToken)}`
|
||||||
|
|
||||||
const emailSubjects: Record<string, string> = {
|
const emailSubjects: Record<'en' | 'fr' | 'ar', string> = {
|
||||||
en: 'Verify your email — RentalDriveGo',
|
en: 'Verify your email — RentalDriveGo',
|
||||||
fr: 'Vérifiez votre email — RentalDriveGo',
|
fr: 'Vérifiez votre email — RentalDriveGo',
|
||||||
ar: 'تحقق من بريدك الإلكتروني — RentalDriveGo',
|
ar: 'تحقق من بريدك الإلكتروني — RentalDriveGo',
|
||||||
}
|
}
|
||||||
const emailBodies: Record<string, string> = {
|
const emailBodies: Record<'en' | 'fr' | 'ar', string> = {
|
||||||
en: `<p>Welcome to RentalDriveGo!</p><p>Click the link below to verify your email and activate your account:</p><p><a href="${verifyUrl}">Verify Email</a></p><p>If you did not create this account, you can safely ignore this email.</p>`,
|
en: `<p>Welcome to RentalDriveGo!</p><p>Click the link below to verify your email and activate your account:</p><p><a href="${verifyUrl}">Verify Email</a></p><p>If you did not create this account, you can safely ignore this email.</p>`,
|
||||||
fr: `<p>Bienvenue sur RentalDriveGo !</p><p>Cliquez sur le lien ci-dessous pour vérifier votre email et activer votre compte :</p><p><a href="${verifyUrl}">Vérifier l'email</a></p><p>Si vous n'avez pas créé ce compte, ignorez cet email.</p>`,
|
fr: `<p>Bienvenue sur RentalDriveGo !</p><p>Cliquez sur le lien ci-dessous pour vérifier votre email et activer votre compte :</p><p><a href="${verifyUrl}">Vérifier l'email</a></p><p>Si vous n'avez pas créé ce compte, ignorez cet email.</p>`,
|
||||||
ar: `<p>مرحباً بك في RentalDriveGo!</p><p>انقر على الرابط أدناه للتحقق من بريدك الإلكتروني وتفعيل حسابك:</p><p><a href="${verifyUrl}">تحقق من البريد الإلكتروني</a></p><p>إذا لم تقم بإنشاء هذا الحساب، يمكنك تجاهل هذا البريد الإلكتروني.</p>`,
|
ar: `<p>مرحباً بك في RentalDriveGo!</p><p>انقر على الرابط أدناه للتحقق من بريدك الإلكتروني وتفعيل حسابك:</p><p><a href="${verifyUrl}">تحقق من البريد الإلكتروني</a></p><p>إذا لم تقم بإنشاء هذا الحساب، يمكنك تجاهل هذا البريد الإلكتروني.</p>`,
|
||||||
}
|
}
|
||||||
const emailTexts: Record<string, string> = {
|
const emailTexts: Record<'en' | 'fr' | 'ar', string> = {
|
||||||
en: `Welcome to RentalDriveGo!\n\nVerify your email by visiting:\n${verifyUrl}\n\nIf you did not create this account, you can safely ignore this email.`,
|
en: `Welcome to RentalDriveGo!\n\nVerify your email by visiting:\n${verifyUrl}\n\nIf you did not create this account, you can safely ignore this email.`,
|
||||||
fr: `Bienvenue sur RentalDriveGo !\n\nVérifiez votre email en visitant :\n${verifyUrl}\n\nSi vous n'avez pas créé ce compte, ignorez cet email.`,
|
fr: `Bienvenue sur RentalDriveGo !\n\nVérifiez votre email en visitant :\n${verifyUrl}\n\nSi vous n'avez pas créé ce compte, ignorez cet email.`,
|
||||||
ar: `مرحباً بك في RentalDriveGo!\n\nتحقق من بريدك الإلكتروني بزيارة:\n${verifyUrl}\n\nإذا لم تقم بإنشاء هذا الحساب، يمكنك تجاهل هذا البريد الإلكتروني.`,
|
ar: `مرحباً بك في RentalDriveGo!\n\nتحقق من بريدك الإلكتروني بزيارة:\n${verifyUrl}\n\nإذا لم تقم بإنشاء هذا الحساب، يمكنك تجاهل هذا البريد الإلكتروني.`,
|
||||||
}
|
}
|
||||||
|
|
||||||
const lang = (body.preferredLanguage === 'ar' || body.preferredLanguage === 'fr') ? body.preferredLanguage : 'en'
|
const lang: 'en' | 'fr' | 'ar' =
|
||||||
|
body.preferredLanguage === 'ar' || body.preferredLanguage === 'fr'
|
||||||
|
? body.preferredLanguage
|
||||||
|
: 'en'
|
||||||
|
|
||||||
await sendTransactionalEmail({
|
await sendTransactionalEmail({
|
||||||
to: body.email,
|
to: body.email,
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ describe('auth.company.repo.createCompanySignup', () => {
|
|||||||
companyId: 'company_1',
|
companyId: 'company_1',
|
||||||
clerkUserId: 'local_owner_company_1',
|
clerkUserId: 'local_owner_company_1',
|
||||||
email: 'owner@example.com',
|
email: 'owner@example.com',
|
||||||
|
emailVerified: baseInput.now,
|
||||||
role: 'OWNER',
|
role: 'OWNER',
|
||||||
preferredLanguage: 'fr',
|
preferredLanguage: 'fr',
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ export async function createCompanySignup(db: DbClient, input: CompanySignupInpu
|
|||||||
email: input.ownerEmail,
|
email: input.ownerEmail,
|
||||||
phone: input.companyPhone,
|
phone: input.companyPhone,
|
||||||
passwordHash: input.passwordHash,
|
passwordHash: input.passwordHash,
|
||||||
|
emailVerified: input.now,
|
||||||
role: 'OWNER',
|
role: 'OWNER',
|
||||||
preferredLanguage: input.preferredLanguage,
|
preferredLanguage: input.preferredLanguage,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { companySignupSchema } from './auth.company.schemas'
|
|||||||
import type { output } from 'zod'
|
import type { output } from 'zod'
|
||||||
|
|
||||||
type CompanySignupInput = output<typeof companySignupSchema>
|
type CompanySignupInput = output<typeof companySignupSchema>
|
||||||
const TRIAL_PERIOD_DAYS = 90
|
const TRIAL_PERIOD_DAYS = 30
|
||||||
|
|
||||||
function slugify(value: string) {
|
function slugify(value: string) {
|
||||||
return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 50) || 'company'
|
return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 50) || 'company'
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ const employee = {
|
|||||||
preferredLanguage: 'fr',
|
preferredLanguage: 'fr',
|
||||||
companyId: 'company_1',
|
companyId: 'company_1',
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
emailVerified: true,
|
||||||
|
emailVerificationToken: null,
|
||||||
passwordHash: 'hash_old',
|
passwordHash: 'hash_old',
|
||||||
company: { name: 'Atlas Cars', slug: 'atlas' },
|
company: { name: 'Atlas Cars', slug: 'atlas' },
|
||||||
}
|
}
|
||||||
@@ -73,6 +75,38 @@ describe('auth.employee.service edge behavior', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('rejects login for employees with a pending email verification token', async () => {
|
||||||
|
vi.mocked(repo.findEmployeeWithCompanyByEmail).mockResolvedValue({
|
||||||
|
...employee,
|
||||||
|
emailVerified: null,
|
||||||
|
emailVerificationToken: 'verify-token',
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
await expect(service.login({ email: 'agent@example.test', password: 'correct-password' })).rejects.toMatchObject({
|
||||||
|
statusCode: 401,
|
||||||
|
error: 'email_not_verified',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('logs in legacy full-signup owners that were created without a verification token', async () => {
|
||||||
|
vi.mocked(repo.findEmployeeWithCompanyByEmail).mockResolvedValue({
|
||||||
|
...employee,
|
||||||
|
role: 'OWNER',
|
||||||
|
emailVerified: null,
|
||||||
|
emailVerificationToken: null,
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
const result = await service.login({ email: 'agent@example.test', password: 'correct-password' })
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
token: expect.any(String),
|
||||||
|
employee: {
|
||||||
|
id: 'employee_1',
|
||||||
|
role: 'OWNER',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('distinguishes employees without passwords from wrong credentials', async () => {
|
it('distinguishes employees without passwords from wrong credentials', async () => {
|
||||||
vi.mocked(repo.findEmployeeWithCompanyByEmail).mockResolvedValue({ ...employee, passwordHash: null } as never)
|
vi.mocked(repo.findEmployeeWithCompanyByEmail).mockResolvedValue({ ...employee, passwordHash: null } as never)
|
||||||
|
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export async function login(body: EmployeeLoginInput) {
|
|||||||
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')
|
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')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!employee.emailVerified) {
|
if (!employee.emailVerified && employee.emailVerificationToken) {
|
||||||
throw new AppError('Please verify your email address before signing in. Check your inbox for the verification link.', 401, 'email_not_verified')
|
throw new AppError('Please verify your email address before signing in. Check your inbox for the verification link.', 401, 'email_not_verified')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,8 +211,12 @@ export async function resendVerification(email: string) {
|
|||||||
)
|
)
|
||||||
const verifyUrl = `${dashboardUrl}/verify-email?token=${encodeURIComponent(verificationToken)}`
|
const verifyUrl = `${dashboardUrl}/verify-email?token=${encodeURIComponent(verificationToken)}`
|
||||||
|
|
||||||
const lang = (employee.preferredLanguage === 'ar' || employee.preferredLanguage === 'fr') ? employee.preferredLanguage : 'en'
|
const lang: 'en' | 'fr' | 'ar' =
|
||||||
const emailSubjects: Record<string, string> = {
|
employee.preferredLanguage === 'ar' || employee.preferredLanguage === 'fr'
|
||||||
|
? employee.preferredLanguage
|
||||||
|
: 'en'
|
||||||
|
|
||||||
|
const emailSubjects: Record<'en' | 'fr' | 'ar', string> = {
|
||||||
en: 'Verify your email — RentalDriveGo',
|
en: 'Verify your email — RentalDriveGo',
|
||||||
fr: 'Vérifiez votre email — RentalDriveGo',
|
fr: 'Vérifiez votre email — RentalDriveGo',
|
||||||
ar: 'تحقق من بريدك الإلكتروني — RentalDriveGo',
|
ar: 'تحقق من بريدك الإلكتروني — RentalDriveGo',
|
||||||
|
|||||||
@@ -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 { parseBody, parseParams, parseQuery } from '../../http/validate'
|
||||||
|
import { ok } from '../../http/respond'
|
||||||
|
import * as service from './billing.service'
|
||||||
|
import {
|
||||||
|
billingListQuerySchema,
|
||||||
|
billingSummaryQuerySchema,
|
||||||
|
invoiceParamSchema,
|
||||||
|
manualBillingPaymentSchema,
|
||||||
|
} from './billing.schemas'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
router.use(requireCompanyAuth, requireTenant, requireSubscription, requireRole('OWNER'))
|
||||||
|
|
||||||
|
router.get('/summary', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const query = parseQuery(billingSummaryQuerySchema, req)
|
||||||
|
ok(res, await service.getSummary(req.companyId, query))
|
||||||
|
} catch (err) { next(err) }
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get('/invoices', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const query = parseQuery(billingListQuerySchema, req)
|
||||||
|
ok(res, await service.listInvoices(req.companyId, query))
|
||||||
|
} catch (err) { next(err) }
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get('/invoices/:invoiceId', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { invoiceId } = parseParams(invoiceParamSchema, req)
|
||||||
|
ok(res, await service.getInvoice(req.companyId, invoiceId))
|
||||||
|
} catch (err) { next(err) }
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/invoices/:invoiceId/payments/manual', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { invoiceId } = parseParams(invoiceParamSchema, req)
|
||||||
|
const body = parseBody(manualBillingPaymentSchema, req)
|
||||||
|
ok(res, await service.recordManualPayment(req.companyId, req.employee.id, invoiceId, body))
|
||||||
|
} catch (err) { next(err) }
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { billingListQuerySchema } from './billing.schemas'
|
||||||
|
|
||||||
|
describe('billing.schemas billingListQuerySchema', () => {
|
||||||
|
it('parses explicit outstandingOnly query strings as booleans', () => {
|
||||||
|
expect(billingListQuerySchema.parse({ outstandingOnly: 'false' }).outstandingOnly).toBe(false)
|
||||||
|
expect(billingListQuerySchema.parse({ outstandingOnly: 'true' }).outstandingOnly).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults outstandingOnly to false', () => {
|
||||||
|
expect(billingListQuerySchema.parse({}).outstandingOnly).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const queryBooleanSchema = z.preprocess((value) => {
|
||||||
|
if (value === 'true') return true
|
||||||
|
if (value === 'false') return false
|
||||||
|
return value
|
||||||
|
}, z.boolean())
|
||||||
|
|
||||||
|
export const billingListQuerySchema = z.object({
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
pageSize: z.coerce.number().int().positive().max(100).default(25),
|
||||||
|
search: z.string().trim().max(120).optional().default(''),
|
||||||
|
paymentStatus: z.enum(['ALL', 'UNPAID', 'PARTIAL', 'PAID']).optional().default('ALL'),
|
||||||
|
outstandingOnly: queryBooleanSchema.optional().default(false),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const billingSummaryQuerySchema = z.object({
|
||||||
|
search: z.string().trim().max(120).optional().default(''),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const invoiceParamSchema = z.object({
|
||||||
|
invoiceId: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const manualBillingPaymentSchema = z.object({
|
||||||
|
amountMinor: z.number().int().positive(),
|
||||||
|
currency: z.literal('MAD').default('MAD'),
|
||||||
|
type: z.enum(['CHARGE', 'DEPOSIT']),
|
||||||
|
method: z.enum(['CASH', 'CHECK', 'BANK_TRANSFER', 'CARD', 'PAYPAL', 'OTHER']),
|
||||||
|
receivedAt: z.string().datetime().optional(),
|
||||||
|
reference: z.string().trim().max(120).optional(),
|
||||||
|
note: z.string().trim().max(1000).optional(),
|
||||||
|
idempotencyKey: z.string().uuid(),
|
||||||
|
})
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { buildBillingInvoice } from './billing.service'
|
||||||
|
|
||||||
|
const baseReservation = {
|
||||||
|
id: 'reservation_1',
|
||||||
|
invoiceNumber: 'INV-001',
|
||||||
|
contractNumber: 'CON-001',
|
||||||
|
status: 'CONFIRMED',
|
||||||
|
paymentStatus: 'UNPAID',
|
||||||
|
startDate: new Date('2026-06-01T10:00:00.000Z'),
|
||||||
|
endDate: new Date('2026-06-05T10:00:00.000Z'),
|
||||||
|
totalAmount: 100000,
|
||||||
|
depositAmount: 20000,
|
||||||
|
customer: { firstName: 'Nora', lastName: 'Driver', email: 'nora@example.com' },
|
||||||
|
vehicle: { make: 'Dacia', model: 'Duster', licensePlate: '123-A-6' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function payment(overrides: Partial<any>) {
|
||||||
|
return {
|
||||||
|
id: `payment_${Math.random()}`,
|
||||||
|
reservationId: 'reservation_1',
|
||||||
|
amount: 10000,
|
||||||
|
currency: 'MAD',
|
||||||
|
status: 'SUCCEEDED',
|
||||||
|
type: 'CHARGE',
|
||||||
|
paymentProvider: 'MANUAL',
|
||||||
|
paymentMethod: 'CASH',
|
||||||
|
paidAt: new Date('2026-06-01T12:00:00.000Z'),
|
||||||
|
createdAt: new Date('2026-06-01T12:00:00.000Z'),
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('billing.service buildBillingInvoice', () => {
|
||||||
|
it('keeps invoice charges and security deposits in separate balances', () => {
|
||||||
|
const invoice = buildBillingInvoice({
|
||||||
|
...baseReservation,
|
||||||
|
rentalPayments: [
|
||||||
|
payment({ amount: 100000, type: 'CHARGE' }),
|
||||||
|
payment({ amount: 5000, type: 'DEPOSIT' }),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(invoice.invoicePaid).toBe(100000)
|
||||||
|
expect(invoice.invoiceBalanceDue).toBe(0)
|
||||||
|
expect(invoice.depositCollected).toBe(5000)
|
||||||
|
expect(invoice.depositOutstanding).toBe(15000)
|
||||||
|
expect(invoice.paymentStatus).toBe('PAID')
|
||||||
|
expect(invoice.depositStatus).toBe('PARTIALLY_COLLECTED')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not count failed or pending payments as collected', () => {
|
||||||
|
const invoice = buildBillingInvoice({
|
||||||
|
...baseReservation,
|
||||||
|
rentalPayments: [
|
||||||
|
payment({ amount: 30000, type: 'CHARGE', status: 'PENDING' }),
|
||||||
|
payment({ amount: 20000, type: 'DEPOSIT', status: 'FAILED' }),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(invoice.invoicePaid).toBe(0)
|
||||||
|
expect(invoice.invoiceBalanceDue).toBe(100000)
|
||||||
|
expect(invoice.depositCollected).toBe(0)
|
||||||
|
expect(invoice.depositOutstanding).toBe(20000)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
import { ConflictError, NotFoundError, ValidationError } from '../../http/errors'
|
||||||
|
import { prisma } from '../../lib/prisma'
|
||||||
|
|
||||||
|
type BillingPayment = {
|
||||||
|
id: string
|
||||||
|
reservationId: string
|
||||||
|
amount: number
|
||||||
|
currency: string
|
||||||
|
status: string
|
||||||
|
type: string
|
||||||
|
paymentProvider: string
|
||||||
|
paymentMethod: string | null
|
||||||
|
reference?: string | null
|
||||||
|
note?: string | null
|
||||||
|
receivedAt?: Date | null
|
||||||
|
paidAt: Date | null
|
||||||
|
createdAt: Date
|
||||||
|
recordedByEmployee?: { firstName: string; lastName: string; email: string } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type BillingReservation = {
|
||||||
|
id: string
|
||||||
|
invoiceNumber: string | null
|
||||||
|
contractNumber: string | null
|
||||||
|
status: string
|
||||||
|
paymentStatus: string
|
||||||
|
startDate: Date
|
||||||
|
endDate: Date
|
||||||
|
totalAmount: number
|
||||||
|
depositAmount: number
|
||||||
|
customer: { firstName: string; lastName: string; email: string }
|
||||||
|
vehicle: { make: string; model: string; licensePlate: string }
|
||||||
|
rentalPayments: BillingPayment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListQuery = {
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
search: string
|
||||||
|
paymentStatus: 'ALL' | 'UNPAID' | 'PARTIAL' | 'PAID'
|
||||||
|
outstandingOnly: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type SummaryQuery = {
|
||||||
|
search: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ManualPaymentInput = {
|
||||||
|
amountMinor: number
|
||||||
|
currency: 'MAD'
|
||||||
|
type: 'CHARGE' | 'DEPOSIT'
|
||||||
|
method: 'CASH' | 'CHECK' | 'BANK_TRANSFER' | 'CARD' | 'PAYPAL' | 'OTHER'
|
||||||
|
receivedAt?: string
|
||||||
|
reference?: string
|
||||||
|
note?: string
|
||||||
|
idempotencyKey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const BILLING_CURRENCY = 'MAD'
|
||||||
|
const COLLECTED_STATUS = new Set(['SUCCEEDED'])
|
||||||
|
|
||||||
|
function sumCollected(payments: BillingPayment[], type: 'CHARGE' | 'DEPOSIT') {
|
||||||
|
return payments.reduce((total, payment) => {
|
||||||
|
if (payment.type !== type || !COLLECTED_STATUS.has(payment.status)) return total
|
||||||
|
return total + payment.amount
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function derivePaymentStatus(invoiceBalanceDue: number, invoicePaid: number) {
|
||||||
|
if (invoiceBalanceDue <= 0) return 'PAID'
|
||||||
|
if (invoicePaid > 0) return 'PARTIAL'
|
||||||
|
return 'UNPAID'
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveDepositStatus(depositRequired: number, depositCollected: number) {
|
||||||
|
if (depositRequired <= 0) return 'NOT_REQUIRED'
|
||||||
|
if (depositCollected <= 0) return 'OUTSTANDING'
|
||||||
|
if (depositCollected < depositRequired) return 'PARTIALLY_COLLECTED'
|
||||||
|
return 'HELD'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBillingInvoice(reservation: BillingReservation) {
|
||||||
|
const invoiceTotal = reservation.totalAmount
|
||||||
|
const invoicePaid = sumCollected(reservation.rentalPayments, 'CHARGE')
|
||||||
|
const invoiceRefunded = 0
|
||||||
|
const invoiceBalanceDue = Math.max(invoiceTotal - invoicePaid, 0)
|
||||||
|
const depositRequired = reservation.depositAmount
|
||||||
|
const depositCollected = sumCollected(reservation.rentalPayments, 'DEPOSIT')
|
||||||
|
const depositRefunded = 0
|
||||||
|
const depositHeld = Math.max(depositCollected - depositRefunded, 0)
|
||||||
|
const depositOutstanding = Math.max(depositRequired - depositCollected, 0)
|
||||||
|
|
||||||
|
const payments = [...reservation.rentalPayments]
|
||||||
|
.sort((a, b) => new Date(b.paidAt ?? b.receivedAt ?? b.createdAt).getTime() - new Date(a.paidAt ?? a.receivedAt ?? a.createdAt).getTime())
|
||||||
|
.map((payment) => ({
|
||||||
|
id: payment.id,
|
||||||
|
reservationId: payment.reservationId,
|
||||||
|
amountMinor: payment.amount,
|
||||||
|
currency: payment.currency,
|
||||||
|
type: payment.type,
|
||||||
|
channel: payment.paymentProvider === 'MANUAL' ? 'OFFLINE' : 'ONLINE',
|
||||||
|
provider: payment.paymentProvider,
|
||||||
|
method: payment.paymentMethod,
|
||||||
|
status: payment.status,
|
||||||
|
reference: payment.reference ?? null,
|
||||||
|
note: payment.note ?? null,
|
||||||
|
receivedAt: payment.receivedAt?.toISOString() ?? payment.paidAt?.toISOString() ?? payment.createdAt.toISOString(),
|
||||||
|
paidAt: payment.paidAt?.toISOString() ?? null,
|
||||||
|
createdAt: payment.createdAt.toISOString(),
|
||||||
|
recordedBy: payment.recordedByEmployee
|
||||||
|
? {
|
||||||
|
name: `${payment.recordedByEmployee.firstName} ${payment.recordedByEmployee.lastName}`,
|
||||||
|
email: payment.recordedByEmployee.email,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
refundedAmountMinor: 0,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: reservation.id,
|
||||||
|
reservationId: reservation.id,
|
||||||
|
invoiceNumber: reservation.invoiceNumber,
|
||||||
|
contractNumber: reservation.contractNumber,
|
||||||
|
customer: reservation.customer,
|
||||||
|
vehicle: reservation.vehicle,
|
||||||
|
rentalPeriod: {
|
||||||
|
startDate: reservation.startDate.toISOString(),
|
||||||
|
endDate: reservation.endDate.toISOString(),
|
||||||
|
},
|
||||||
|
currency: BILLING_CURRENCY,
|
||||||
|
status: reservation.status,
|
||||||
|
paymentStatus: derivePaymentStatus(invoiceBalanceDue, invoicePaid),
|
||||||
|
issuedAt: null,
|
||||||
|
dueAt: reservation.startDate.toISOString(),
|
||||||
|
subtotal: invoiceTotal,
|
||||||
|
taxTotal: 0,
|
||||||
|
discountTotal: 0,
|
||||||
|
adjustmentTotal: 0,
|
||||||
|
invoiceTotal,
|
||||||
|
invoicePaid,
|
||||||
|
invoiceRefunded,
|
||||||
|
invoiceBalanceDue,
|
||||||
|
depositRequired,
|
||||||
|
depositCollected,
|
||||||
|
depositRefunded,
|
||||||
|
depositHeld,
|
||||||
|
depositOutstanding,
|
||||||
|
depositStatus: deriveDepositStatus(depositRequired, depositCollected),
|
||||||
|
paymentCount: payments.length,
|
||||||
|
latestPayment: payments[0] ?? null,
|
||||||
|
payments,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReservationWhere(companyId: string, search = '') {
|
||||||
|
const trimmedSearch = search.trim()
|
||||||
|
const where: any = { companyId }
|
||||||
|
|
||||||
|
if (trimmedSearch) {
|
||||||
|
where.OR = [
|
||||||
|
{ invoiceNumber: { contains: trimmedSearch, mode: 'insensitive' } },
|
||||||
|
{ contractNumber: { contains: trimmedSearch, mode: 'insensitive' } },
|
||||||
|
{ customer: { firstName: { contains: trimmedSearch, mode: 'insensitive' } } },
|
||||||
|
{ customer: { lastName: { contains: trimmedSearch, mode: 'insensitive' } } },
|
||||||
|
{ customer: { email: { contains: trimmedSearch, mode: 'insensitive' } } },
|
||||||
|
{ vehicle: { make: { contains: trimmedSearch, mode: 'insensitive' } } },
|
||||||
|
{ vehicle: { model: { contains: trimmedSearch, mode: 'insensitive' } } },
|
||||||
|
{ vehicle: { licensePlate: { contains: trimmedSearch, mode: 'insensitive' } } },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return where
|
||||||
|
}
|
||||||
|
|
||||||
|
const reservationInclude = {
|
||||||
|
customer: true,
|
||||||
|
vehicle: true,
|
||||||
|
rentalPayments: {
|
||||||
|
include: {
|
||||||
|
recordedByEmployee: {
|
||||||
|
select: { firstName: true, lastName: true, email: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export async function listInvoices(companyId: string, query: ListQuery) {
|
||||||
|
const where = buildReservationWhere(companyId, query.search)
|
||||||
|
const reservations = await prisma.reservation.findMany({
|
||||||
|
where,
|
||||||
|
include: reservationInclude as any,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
let items = reservations.map((reservation) => buildBillingInvoice(reservation as any))
|
||||||
|
if (query.paymentStatus !== 'ALL') {
|
||||||
|
items = items.filter((invoice) => invoice.paymentStatus === query.paymentStatus)
|
||||||
|
}
|
||||||
|
if (query.outstandingOnly) {
|
||||||
|
items = items.filter((invoice) => invoice.invoiceBalanceDue > 0 || invoice.depositOutstanding > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalRecords = items.length
|
||||||
|
const pagedItems = items.slice((query.page - 1) * query.pageSize, query.page * query.pageSize)
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: pagedItems,
|
||||||
|
page: query.page,
|
||||||
|
pageSize: query.pageSize,
|
||||||
|
totalItems: totalRecords,
|
||||||
|
totalPages: Math.max(Math.ceil(totalRecords / query.pageSize), 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSummary(companyId: string, query: SummaryQuery) {
|
||||||
|
const reservations = await prisma.reservation.findMany({
|
||||||
|
where: buildReservationWhere(companyId, query.search),
|
||||||
|
include: reservationInclude as any,
|
||||||
|
})
|
||||||
|
|
||||||
|
const totals = reservations.reduce(
|
||||||
|
(acc, reservation) => {
|
||||||
|
const invoice = buildBillingInvoice(reservation as any)
|
||||||
|
acc.totalInvoiced += invoice.invoiceTotal
|
||||||
|
acc.totalCollected += invoice.invoicePaid
|
||||||
|
acc.totalRefunded += invoice.invoiceRefunded
|
||||||
|
acc.totalOutstanding += invoice.invoiceBalanceDue
|
||||||
|
acc.depositsHeld += invoice.depositHeld
|
||||||
|
if (invoice.invoiceBalanceDue > 0) acc.openInvoiceCount += 1
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{
|
||||||
|
currency: BILLING_CURRENCY,
|
||||||
|
totalInvoiced: 0,
|
||||||
|
totalCollected: 0,
|
||||||
|
totalRefunded: 0,
|
||||||
|
totalOutstanding: 0,
|
||||||
|
depositsHeld: 0,
|
||||||
|
openInvoiceCount: 0,
|
||||||
|
overdueInvoiceCount: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return totals
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getInvoice(companyId: string, invoiceId: string) {
|
||||||
|
const reservation = await prisma.reservation.findFirst({
|
||||||
|
where: { id: invoiceId, companyId },
|
||||||
|
include: reservationInclude as any,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!reservation) throw new NotFoundError('Billing invoice not found')
|
||||||
|
return buildBillingInvoice(reservation as any)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recordManualPayment(companyId: string, employeeId: string, invoiceId: string, body: ManualPaymentInput) {
|
||||||
|
const result = await prisma.$transaction(async (tx: any) => {
|
||||||
|
const existingPayment = await tx.rentalPayment.findFirst({
|
||||||
|
where: { companyId, idempotencyKey: body.idempotencyKey },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existingPayment) {
|
||||||
|
return existingPayment
|
||||||
|
}
|
||||||
|
|
||||||
|
const reservation = await tx.reservation.findFirst({
|
||||||
|
where: { id: invoiceId, companyId },
|
||||||
|
include: reservationInclude as any,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!reservation) throw new NotFoundError('Billing invoice not found')
|
||||||
|
|
||||||
|
const invoice = buildBillingInvoice(reservation)
|
||||||
|
if (body.currency !== invoice.currency) {
|
||||||
|
throw new ValidationError('Payment currency must match the invoice currency')
|
||||||
|
}
|
||||||
|
|
||||||
|
const permittedAmount = body.type === 'DEPOSIT' ? invoice.depositOutstanding : invoice.invoiceBalanceDue
|
||||||
|
if (permittedAmount <= 0) {
|
||||||
|
throw new ConflictError(body.type === 'DEPOSIT' ? 'Security deposit is already fully collected' : 'Invoice is already fully paid')
|
||||||
|
}
|
||||||
|
if (body.amountMinor > permittedAmount) {
|
||||||
|
throw new ValidationError(body.type === 'DEPOSIT' ? 'Payment amount exceeds deposit outstanding' : 'Payment amount exceeds invoice balance due')
|
||||||
|
}
|
||||||
|
|
||||||
|
const receivedAt = body.receivedAt ? new Date(body.receivedAt) : new Date()
|
||||||
|
const payment = await tx.rentalPayment.create({
|
||||||
|
data: {
|
||||||
|
companyId,
|
||||||
|
reservationId: reservation.id,
|
||||||
|
amount: body.amountMinor,
|
||||||
|
currency: body.currency,
|
||||||
|
status: 'SUCCEEDED',
|
||||||
|
type: body.type,
|
||||||
|
paymentProvider: 'MANUAL',
|
||||||
|
paymentMethod: body.method,
|
||||||
|
reference: body.reference,
|
||||||
|
note: body.note,
|
||||||
|
receivedAt,
|
||||||
|
paidAt: receivedAt,
|
||||||
|
recordedByEmployeeId: employeeId,
|
||||||
|
idempotencyKey: body.idempotencyKey,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (body.type === 'CHARGE') {
|
||||||
|
const paidAmount = invoice.invoicePaid + body.amountMinor
|
||||||
|
await tx.reservation.update({
|
||||||
|
where: { id: reservation.id },
|
||||||
|
data: {
|
||||||
|
paidAmount,
|
||||||
|
paymentStatus: paidAmount >= invoice.invoiceTotal ? 'PAID' : 'PARTIAL',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return payment
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { presentVehicleWithAvailability } from './marketplace.presenter'
|
import { presentVehicleWithAvailability } from './carplace.presenter'
|
||||||
|
|
||||||
describe('marketplace.presenter', () => {
|
describe('carplace.presenter', () => {
|
||||||
it('adds public availability fields without mutating the original vehicle shape', () => {
|
it('adds public availability fields without mutating the original vehicle shape', () => {
|
||||||
const nextAvailableAt = new Date('2026-08-01T10:00:00.000Z')
|
const nextAvailableAt = new Date('2026-08-01T10:00:00.000Z')
|
||||||
const vehicle = { id: 'vehicle_1', make: 'Dacia', model: 'Logan', dailyRate: 25000 }
|
const vehicle = { id: 'vehicle_1', make: 'Dacia', model: 'Logan', dailyRate: 25000 }
|
||||||
+15
-12
@@ -11,9 +11,9 @@ const prismaMock = vi.hoisted(() => ({
|
|||||||
|
|
||||||
vi.mock('../../lib/prisma', () => ({ prisma: prismaMock }))
|
vi.mock('../../lib/prisma', () => ({ prisma: prismaMock }))
|
||||||
|
|
||||||
import * as repo from './marketplace.repo'
|
import * as repo from './carplace.repo'
|
||||||
|
|
||||||
describe('marketplace.repo public query and write boundaries', () => {
|
describe('carplace.repo public query and write boundaries', () => {
|
||||||
it('finds public offers using active/public/current-window filters and featured ordering', async () => {
|
it('finds public offers using active/public/current-window filters and featured ordering', async () => {
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
vi.setSystemTime(new Date('2026-06-09T10:00:00.000Z'))
|
vi.setSystemTime(new Date('2026-06-09T10:00:00.000Z'))
|
||||||
@@ -33,38 +33,41 @@ describe('marketplace.repo public query and write boundaries', () => {
|
|||||||
vi.useRealTimers()
|
vi.useRealTimers()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('lists marketplace cities only from active listed companies with a public city', async () => {
|
it('lists carplace cities only from active listed companies with a public city', async () => {
|
||||||
await repo.findCitiesFromCompanies()
|
await repo.findCitiesFromCompanies()
|
||||||
|
|
||||||
expect(prismaMock.company.findMany).toHaveBeenCalledWith({
|
expect(prismaMock.company.findMany).toHaveBeenCalledWith({
|
||||||
where: {
|
where: {
|
||||||
status: { in: ['ACTIVE', 'TRIALING'] },
|
status: { in: ['ACTIVE', 'TRIALING'] },
|
||||||
brand: { isListedOnMarketplace: true, publicCity: { not: null } },
|
brand: { isListedOnCarplace: true, publicCity: { not: null } },
|
||||||
},
|
},
|
||||||
select: { brand: { select: { publicCity: true } } },
|
select: { brand: { select: { publicCity: true } } },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('requires published vehicles and an active company when loading marketplace vehicle details', async () => {
|
it('requires published vehicles and an active company when loading carplace vehicle details', async () => {
|
||||||
await repo.findVehicleForMarketplace('vehicle_1', 'atlas')
|
await repo.findVehicleForCarplace('vehicle_1', 'atlas')
|
||||||
|
|
||||||
expect(prismaMock.vehicle.findFirst).toHaveBeenCalledWith({
|
expect(prismaMock.vehicle.findFirst).toHaveBeenCalledWith({
|
||||||
where: {
|
where: {
|
||||||
id: 'vehicle_1',
|
id: 'vehicle_1',
|
||||||
isPublished: true,
|
isPublished: true,
|
||||||
company: { slug: 'atlas', status: { in: ['ACTIVE', 'TRIALING'] } },
|
company: {
|
||||||
|
OR: [{ slug: 'atlas' }, { brand: { subdomain: 'atlas' } }],
|
||||||
|
status: { in: ['ACTIVE', 'TRIALING'] },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
include: { company: { include: { brand: true } } },
|
include: { company: { include: { brand: true } } },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('patches marketplace customer address fields without deleting existing address metadata', async () => {
|
it('patches carplace customer address fields without deleting existing address metadata', async () => {
|
||||||
prismaMock.customer.findUnique.mockResolvedValueOnce({
|
prismaMock.customer.findUnique.mockResolvedValueOnce({
|
||||||
id: 'customer_1',
|
id: 'customer_1',
|
||||||
address: { fullAddress: 'Old address', loyaltyNote: 'keep', internationalLicenseNumber: 'INT-1' },
|
address: { fullAddress: 'Old address', loyaltyNote: 'keep', internationalLicenseNumber: 'INT-1' },
|
||||||
})
|
})
|
||||||
|
|
||||||
await repo.upsertMarketplaceCustomer('company_1', {
|
await repo.upsertCarplaceCustomer('company_1', {
|
||||||
email: 'renter@example.test',
|
email: 'renter@example.test',
|
||||||
firstName: 'Nora',
|
firstName: 'Nora',
|
||||||
lastName: 'Renter',
|
lastName: 'Renter',
|
||||||
@@ -86,11 +89,11 @@ describe('marketplace.repo public query and write boundaries', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('creates marketplace reservations as draft marketplace-sourced records', async () => {
|
it('creates carplace reservations as draft carplace-sourced records', async () => {
|
||||||
const startDate = new Date('2026-07-01T10:00:00.000Z')
|
const startDate = new Date('2026-07-01T10:00:00.000Z')
|
||||||
const endDate = new Date('2026-07-05T10:00:00.000Z')
|
const endDate = new Date('2026-07-05T10:00:00.000Z')
|
||||||
|
|
||||||
await repo.createMarketplaceReservation({
|
await repo.createCarplaceReservation({
|
||||||
companyId: 'company_1',
|
companyId: 'company_1',
|
||||||
vehicleId: 'vehicle_1',
|
vehicleId: 'vehicle_1',
|
||||||
customerId: 'customer_1',
|
customerId: 'customer_1',
|
||||||
@@ -108,7 +111,7 @@ describe('marketplace.repo public query and write boundaries', () => {
|
|||||||
companyId: 'company_1',
|
companyId: 'company_1',
|
||||||
vehicleId: 'vehicle_1',
|
vehicleId: 'vehicle_1',
|
||||||
customerId: 'customer_1',
|
customerId: 'customer_1',
|
||||||
source: 'MARKETPLACE',
|
source: 'CARPLACE',
|
||||||
status: 'DRAFT',
|
status: 'DRAFT',
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
+16
-16
@@ -13,7 +13,7 @@ export async function findCitiesFromCompanies() {
|
|||||||
return prisma.company.findMany({
|
return prisma.company.findMany({
|
||||||
where: {
|
where: {
|
||||||
status: { in: ['ACTIVE', 'TRIALING'] },
|
status: { in: ['ACTIVE', 'TRIALING'] },
|
||||||
brand: { isListedOnMarketplace: true, publicCity: { not: null } },
|
brand: { isListedOnCarplace: true, publicCity: { not: null } },
|
||||||
},
|
},
|
||||||
select: { brand: { select: { publicCity: true } } },
|
select: { brand: { select: { publicCity: true } } },
|
||||||
})
|
})
|
||||||
@@ -23,7 +23,7 @@ export async function findListedCompanies(where: any, skip: number, take: number
|
|||||||
return prisma.company.findMany({
|
return prisma.company.findMany({
|
||||||
where,
|
where,
|
||||||
include: {
|
include: {
|
||||||
brand: { select: { displayName: true, logoUrl: true, subdomain: true, publicCity: true, publicCountry: true, marketplaceRating: true, primaryColor: true } },
|
brand: { select: { displayName: true, logoUrl: true, subdomain: true, publicCity: true, publicCountry: true, carplaceRating: true, primaryColor: true } },
|
||||||
_count: { select: { vehicles: { where: { isPublished: true, status: 'AVAILABLE' } } } },
|
_count: { select: { vehicles: { where: { isPublished: true, status: 'AVAILABLE' } } } },
|
||||||
},
|
},
|
||||||
skip,
|
skip,
|
||||||
@@ -35,31 +35,31 @@ export async function findPublishedVehicles(where: any) {
|
|||||||
return prisma.vehicle.findMany({
|
return prisma.vehicle.findMany({
|
||||||
where,
|
where,
|
||||||
include: {
|
include: {
|
||||||
company: { include: { brand: { select: { displayName: true, logoUrl: true, subdomain: true, marketplaceRating: true, primaryColor: true } } } },
|
company: { include: { brand: { select: { displayName: true, logoUrl: true, subdomain: true, carplaceRating: true, primaryColor: true } } } },
|
||||||
},
|
},
|
||||||
orderBy: { dailyRate: 'asc' },
|
orderBy: { dailyRate: 'asc' },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function findVehicleForMarketplace(vehicleId: string, companySlug: string) {
|
export async function findVehicleForCarplace(vehicleId: string, companySlug: string) {
|
||||||
return prisma.vehicle.findFirst({
|
return prisma.vehicle.findFirst({
|
||||||
where: { id: vehicleId, isPublished: true, company: { slug: companySlug, status: { in: ['ACTIVE', 'TRIALING'] } } },
|
where: { id: vehicleId, isPublished: true, company: { status: { in: ['ACTIVE', 'TRIALING'] }, OR: [{ slug: companySlug }, { brand: { subdomain: companySlug } }] } },
|
||||||
include: { company: { include: { brand: true } } },
|
include: { company: { include: { brand: true } } },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function findVehicleForMarketplaceById(vehicleId: string) {
|
export async function findVehicleForCarplaceById(vehicleId: string) {
|
||||||
return prisma.vehicle.findFirst({
|
return prisma.vehicle.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: vehicleId,
|
id: vehicleId,
|
||||||
isPublished: true,
|
isPublished: true,
|
||||||
company: { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnMarketplace: true } },
|
company: { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnCarplace: true } },
|
||||||
},
|
},
|
||||||
include: { company: { include: { brand: true } } },
|
include: { company: { include: { brand: true } } },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function upsertMarketplaceCustomer(companyId: string, data: {
|
export async function upsertCarplaceCustomer(companyId: string, data: {
|
||||||
email: string
|
email: string
|
||||||
firstName: string
|
firstName: string
|
||||||
lastName: string
|
lastName: string
|
||||||
@@ -115,17 +115,17 @@ export async function upsertMarketplaceCustomer(companyId: string, data: {
|
|||||||
return prisma.customer.update({ where: { id: existing.id }, data: payload })
|
return prisma.customer.update({ where: { id: existing.id }, data: payload })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createMarketplaceReservation(data: {
|
export async function createCarplaceReservation(data: {
|
||||||
companyId: string; vehicleId: string; customerId: string
|
companyId: string; vehicleId: string; customerId: string
|
||||||
startDate: Date; endDate: Date; pickupLocation?: string | null; returnLocation?: string | null
|
startDate: Date; endDate: Date; pickupLocation?: string | null; returnLocation?: string | null
|
||||||
dailyRate: number; totalDays: number; totalAmount: number; notes?: string; bookingReference?: string
|
dailyRate: number; totalDays: number; totalAmount: number; notes?: string; bookingReference?: string
|
||||||
}) {
|
}) {
|
||||||
return prisma.reservation.create({
|
return prisma.reservation.create({
|
||||||
data: { ...data, source: 'MARKETPLACE', status: 'DRAFT' },
|
data: { ...data, source: 'CARPLACE', status: 'DRAFT' },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createMarketplaceFunnelEvent(data: {
|
export async function createCarplaceFunnelEvent(data: {
|
||||||
eventName: string
|
eventName: string
|
||||||
companySlug: string
|
companySlug: string
|
||||||
vehicleId: string
|
vehicleId: string
|
||||||
@@ -138,12 +138,12 @@ export async function createMarketplaceFunnelEvent(data: {
|
|||||||
where: {
|
where: {
|
||||||
id: data.vehicleId,
|
id: data.vehicleId,
|
||||||
isPublished: true,
|
isPublished: true,
|
||||||
company: { slug: data.companySlug, status: { in: ['ACTIVE', 'TRIALING'] } },
|
company: { status: { in: ['ACTIVE', 'TRIALING'] }, OR: [{ slug: data.companySlug }, { brand: { subdomain: data.companySlug } }] },
|
||||||
},
|
},
|
||||||
select: { companyId: true },
|
select: { companyId: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
return prisma.marketplaceFunnelEvent.create({
|
return prisma.carplaceFunnelEvent.create({
|
||||||
data: {
|
data: {
|
||||||
eventName: data.eventName,
|
eventName: data.eventName,
|
||||||
companyId: vehicle?.companyId ?? null,
|
companyId: vehicle?.companyId ?? null,
|
||||||
@@ -159,7 +159,7 @@ export async function createMarketplaceFunnelEvent(data: {
|
|||||||
|
|
||||||
export async function findCompanyPage(slug: string) {
|
export async function findCompanyPage(slug: string) {
|
||||||
return prisma.company.findFirst({
|
return prisma.company.findFirst({
|
||||||
where: { slug, status: { in: ['ACTIVE', 'TRIALING'] } },
|
where: { status: { in: ['ACTIVE', 'TRIALING'] }, OR: [{ slug }, { brand: { subdomain: slug } }] },
|
||||||
include: {
|
include: {
|
||||||
brand: true,
|
brand: true,
|
||||||
vehicles: { where: { isPublished: true, status: 'AVAILABLE' }, orderBy: { createdAt: 'desc' } },
|
vehicles: { where: { isPublished: true, status: 'AVAILABLE' }, orderBy: { createdAt: 'desc' } },
|
||||||
@@ -169,7 +169,7 @@ export async function findCompanyPage(slug: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function findCompanyBySlug(slug: string) {
|
export async function findCompanyBySlug(slug: string) {
|
||||||
return prisma.company.findFirstOrThrow({ where: { slug, status: { in: ['ACTIVE', 'TRIALING'] } } })
|
return prisma.company.findFirstOrThrow({ where: { status: { in: ['ACTIVE', 'TRIALING'] }, OR: [{ slug }, { brand: { subdomain: slug } }] } })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function findCompanyReviews(companyId: string) {
|
export async function findCompanyReviews(companyId: string) {
|
||||||
@@ -189,7 +189,7 @@ export async function findCompanyVehicles(companyId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function findVehicleById(slug: string, vehicleId: string) {
|
export async function findVehicleById(slug: string, vehicleId: string) {
|
||||||
const company = await prisma.company.findFirstOrThrow({ where: { slug, status: { in: ['ACTIVE', 'TRIALING'] } } })
|
const company = await prisma.company.findFirstOrThrow({ where: { status: { in: ['ACTIVE', 'TRIALING'] }, OR: [{ slug }, { brand: { subdomain: slug } }] } })
|
||||||
return prisma.vehicle.findFirstOrThrow({
|
return prisma.vehicle.findFirstOrThrow({
|
||||||
where: { id: vehicleId, companyId: company.id, isPublished: true, status: 'AVAILABLE' },
|
where: { id: vehicleId, companyId: company.id, isPublished: true, status: 'AVAILABLE' },
|
||||||
include: { company: { include: { brand: true } } },
|
include: { company: { include: { brand: true } } },
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
import { Router } from 'express'
|
||||||
|
import { optionalRenterAuth } from '../../middleware/requireRenterAuth'
|
||||||
|
import { parseBody, parseParams, parseQuery } from '../../http/validate'
|
||||||
|
import { created, ok } from '../../http/respond'
|
||||||
|
import { isDatabaseUnavailableError } from '../../lib/isDatabaseUnavailable'
|
||||||
|
import * as service from './carplace.service'
|
||||||
|
import {
|
||||||
|
carplaceQuoteSchema,
|
||||||
|
companiesQuerySchema,
|
||||||
|
carplaceFunnelEventSchema,
|
||||||
|
offerCodeParamSchema,
|
||||||
|
reviewBodySchema,
|
||||||
|
reviewTokenSchema,
|
||||||
|
paginationSchema,
|
||||||
|
searchSchema,
|
||||||
|
slugParamSchema,
|
||||||
|
carplaceReservationSchema,
|
||||||
|
vehicleAvailabilityQuerySchema,
|
||||||
|
vehicleParamSchema,
|
||||||
|
} from './carplace.schemas'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
router.use(optionalRenterAuth)
|
||||||
|
|
||||||
|
const idempotencyCache = new Map<string, { expiresAt: number; fingerprint: string; result: unknown }>()
|
||||||
|
const IDEMPOTENCY_TTL_MS = 15 * 60 * 1000
|
||||||
|
|
||||||
|
function cleanupIdempotencyCache(now = Date.now()) {
|
||||||
|
for (const [key, value] of idempotencyCache) if (value.expiresAt <= now) idempotencyCache.delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/home', async (_req, res, next) => {
|
||||||
|
try {
|
||||||
|
const [cities, offers, companies, search] = await Promise.all([
|
||||||
|
service.getCities(),
|
||||||
|
service.getPublicOffers(),
|
||||||
|
service.getListedCompanies({ page: 1, pageSize: 8 }),
|
||||||
|
service.searchVehiclesPage({ page: 1, pageSize: 9 }),
|
||||||
|
])
|
||||||
|
ok(res, { cities, offers: offers.slice(0, 6), companies, vehicles: search.items })
|
||||||
|
} catch (error) {
|
||||||
|
if (isDatabaseUnavailableError(error)) return ok(res, { cities: [], offers: [], companies: [], vehicles: [] })
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get('/cities', async (_req, res, next) => {
|
||||||
|
try { ok(res, await service.getCities()) }
|
||||||
|
catch (error) { if (isDatabaseUnavailableError(error)) return ok(res, []); next(error) }
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get('/offers', async (_req, res, next) => {
|
||||||
|
try { ok(res, await service.getPublicOffers()) }
|
||||||
|
catch (error) { if (isDatabaseUnavailableError(error)) return ok(res, []); next(error) }
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get('/companies', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const filters = parseQuery(companiesQuerySchema, req)
|
||||||
|
const pagination = parseQuery(paginationSchema, req)
|
||||||
|
ok(res, await service.getListedCompanies({ ...filters, ...pagination }))
|
||||||
|
} catch (error) { if (isDatabaseUnavailableError(error)) return ok(res, []); next(error) }
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get('/search', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const filters = parseQuery(searchSchema, req)
|
||||||
|
const pagination = parseQuery(paginationSchema, req)
|
||||||
|
ok(res, await service.searchVehiclesPage({ ...filters, ...pagination }))
|
||||||
|
} catch (error) {
|
||||||
|
if (isDatabaseUnavailableError(error)) return ok(res, { items: [], pagination: { page: 1, pageSize: 20, totalItems: 0, totalPages: 0 }, facets: { categories: [], transmissions: [], makes: [], price: { min: null, max: null } } })
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/quotes', async (req, res, next) => {
|
||||||
|
try { ok(res, await service.createCarplaceQuote(parseBody(carplaceQuoteSchema, req))) }
|
||||||
|
catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/reservations', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
cleanupIdempotencyCache()
|
||||||
|
const body = parseBody(carplaceReservationSchema, req)
|
||||||
|
const key = body.idempotencyKey ?? req.header('Idempotency-Key')?.trim()
|
||||||
|
const fingerprint = createHash('sha256').update(JSON.stringify(body)).digest('hex')
|
||||||
|
if (key) {
|
||||||
|
const cached = idempotencyCache.get(key)
|
||||||
|
if (cached && cached.expiresAt > Date.now()) {
|
||||||
|
if (cached.fingerprint !== fingerprint) return res.status(409).json({ error: 'idempotency_conflict', message: 'This idempotency key was already used with a different request', statusCode: 409 })
|
||||||
|
return ok(res, cached.result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const result = await service.createCarplaceReservation(body)
|
||||||
|
if (key) idempotencyCache.set(key, { expiresAt: Date.now() + IDEMPOTENCY_TTL_MS, fingerprint, result })
|
||||||
|
created(res, result)
|
||||||
|
} catch (error) {
|
||||||
|
if (isDatabaseUnavailableError(error)) return res.status(503).json({ error: 'database_unavailable', message: 'Service temporarily unavailable', statusCode: 503 })
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/events', async (req, res, next) => {
|
||||||
|
try { ok(res, await service.trackCarplaceFunnelEvent(parseBody(carplaceFunnelEventSchema, req), req.renterId)) }
|
||||||
|
catch (error) { if (isDatabaseUnavailableError(error)) return ok(res, { success: false }); next(error) }
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get('/review/:token', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { token } = parseParams(reviewTokenSchema, req)
|
||||||
|
ok(res, await service.getReviewContext(token))
|
||||||
|
} catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/review/:token', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { token } = parseParams(reviewTokenSchema, req)
|
||||||
|
created(res, await service.submitReview(token, parseBody(reviewBodySchema, req)))
|
||||||
|
} catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/offers/:code/validate', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { code } = parseParams(offerCodeParamSchema, req)
|
||||||
|
ok(res, await service.validateOfferCode(code))
|
||||||
|
} catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get('/:slug', async (req, res, next) => {
|
||||||
|
try { const { slug } = parseParams(slugParamSchema, req); ok(res, await service.getCompanyPage(slug)) }
|
||||||
|
catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get('/:slug/reviews', async (req, res, next) => {
|
||||||
|
try { const { slug } = parseParams(slugParamSchema, req); ok(res, await service.getCompanyReviews(slug)) }
|
||||||
|
catch (error) { if (isDatabaseUnavailableError(error)) return ok(res, []); next(error) }
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
router.get('/:slug/vehicles', async (req, res, next) => {
|
||||||
|
try { const { slug } = parseParams(slugParamSchema, req); ok(res, await service.getCompanyVehicles(slug)) }
|
||||||
|
catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get('/:slug/offers', async (req, res, next) => {
|
||||||
|
try { const { slug } = parseParams(slugParamSchema, req); ok(res, await service.getCompanyOffers(slug)) }
|
||||||
|
catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get('/:slug/vehicles/:id', async (req, res, next) => {
|
||||||
|
try { const { slug, id } = parseParams(vehicleParamSchema, req); ok(res, await service.getVehicleDetail(slug, id)) }
|
||||||
|
catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get('/:slug/vehicles/:id/availability', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { slug, id } = parseParams(vehicleParamSchema, req)
|
||||||
|
const range = parseQuery(vehicleAvailabilityQuerySchema, req)
|
||||||
|
ok(res, await service.getCarplaceVehicleAvailability(slug, id, range))
|
||||||
|
} catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
+6
-6
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { marketplaceReservationSchema, paginationSchema, reviewBodySchema, searchSchema } from './marketplace.schemas'
|
import { carplaceReservationSchema, paginationSchema, reviewBodySchema, searchSchema } from './carplace.schemas'
|
||||||
|
|
||||||
describe('marketplace.schemas', () => {
|
describe('carplace.schemas', () => {
|
||||||
it('coerces pagination and caps untrusted public page sizes', () => {
|
it('coerces pagination and caps untrusted public page sizes', () => {
|
||||||
expect(paginationSchema.parse({ page: '2', pageSize: '40' })).toEqual({ page: 2, pageSize: 40 })
|
expect(paginationSchema.parse({ page: '2', pageSize: '40' })).toEqual({ page: 2, pageSize: 40 })
|
||||||
expect(paginationSchema.parse({})).toEqual({ page: 1, pageSize: 20 })
|
expect(paginationSchema.parse({})).toEqual({ page: 1, pageSize: 20 })
|
||||||
@@ -18,8 +18,8 @@ describe('marketplace.schemas', () => {
|
|||||||
expect(() => searchSchema.parse({ maxPrice: '-1' })).toThrow()
|
expect(() => searchSchema.parse({ maxPrice: '-1' })).toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('defaults marketplace reservation language while keeping date and email validation strict', () => {
|
it('defaults carplace reservation language while keeping date and email validation strict', () => {
|
||||||
const parsed = marketplaceReservationSchema.parse({
|
const parsed = carplaceReservationSchema.parse({
|
||||||
vehicleId: 'ckvvehicle000000000000001',
|
vehicleId: 'ckvvehicle000000000000001',
|
||||||
companySlug: 'atlas-cars',
|
companySlug: 'atlas-cars',
|
||||||
firstName: 'Aya',
|
firstName: 'Aya',
|
||||||
@@ -30,8 +30,8 @@ describe('marketplace.schemas', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
expect(parsed.language).toBe('fr')
|
expect(parsed.language).toBe('fr')
|
||||||
expect(() => marketplaceReservationSchema.parse({ ...parsed, email: 'bad-email' })).toThrow()
|
expect(() => carplaceReservationSchema.parse({ ...parsed, email: 'bad-email' })).toThrow()
|
||||||
expect(() => marketplaceReservationSchema.parse({ ...parsed, startDate: '2026-07-01' })).toThrow()
|
expect(() => carplaceReservationSchema.parse({ ...parsed, startDate: '2026-07-01' })).toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('validates review ratings as bounded integers', () => {
|
it('validates review ratings as bounded integers', () => {
|
||||||
+15
-2
@@ -29,7 +29,17 @@ export const vehicleAvailabilityQuerySchema = z.object({
|
|||||||
endDate: z.string().datetime(),
|
endDate: z.string().datetime(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const marketplaceReservationSchema = z.object({
|
|
||||||
|
export const carplaceQuoteSchema = z.object({
|
||||||
|
vehicleId: z.string().cuid(),
|
||||||
|
startDate: z.string().datetime(),
|
||||||
|
endDate: z.string().datetime(),
|
||||||
|
pickupLocation: z.string().trim().max(100).optional(),
|
||||||
|
returnLocation: z.string().trim().max(100).optional(),
|
||||||
|
promoCode: z.string().trim().max(100).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const carplaceReservationSchema = z.object({
|
||||||
vehicleId: z.string().cuid(),
|
vehicleId: z.string().cuid(),
|
||||||
companySlug: z.string().trim().max(100).optional(),
|
companySlug: z.string().trim().max(100).optional(),
|
||||||
firstName: z.string().min(1).max(100),
|
firstName: z.string().min(1).max(100),
|
||||||
@@ -37,6 +47,7 @@ export const marketplaceReservationSchema = z.object({
|
|||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
// Contact info — collected at reservation time
|
// Contact info — collected at reservation time
|
||||||
phone: z.string().min(1).max(30).optional(),
|
phone: z.string().min(1).max(30).optional(),
|
||||||
|
driverAge: z.coerce.number().int().min(18).max(100).optional(),
|
||||||
// Identity & license — optional at reservation, collected at pickup
|
// Identity & license — optional at reservation, collected at pickup
|
||||||
dateOfBirth: z.string().datetime().optional(),
|
dateOfBirth: z.string().datetime().optional(),
|
||||||
nationality: z.string().min(1).max(100).optional(),
|
nationality: z.string().min(1).max(100).optional(),
|
||||||
@@ -52,11 +63,13 @@ export const marketplaceReservationSchema = z.object({
|
|||||||
endDate: z.string().datetime(),
|
endDate: z.string().datetime(),
|
||||||
pickupLocation: z.string().trim().max(100).optional(),
|
pickupLocation: z.string().trim().max(100).optional(),
|
||||||
returnLocation: z.string().trim().max(100).optional(),
|
returnLocation: z.string().trim().max(100).optional(),
|
||||||
|
promoCode: z.string().trim().max(100).optional(),
|
||||||
notes: z.string().max(500).optional(),
|
notes: z.string().max(500).optional(),
|
||||||
language: z.enum(['en', 'fr', 'ar']).default('fr'),
|
language: z.enum(['en', 'fr', 'ar']).default('fr'),
|
||||||
|
idempotencyKey: z.string().uuid().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const marketplaceFunnelEventSchema = z.object({
|
export const carplaceFunnelEventSchema = z.object({
|
||||||
eventName: z.enum([
|
eventName: z.enum([
|
||||||
'booking_form_viewed',
|
'booking_form_viewed',
|
||||||
'trip_dates_selected',
|
'trip_dates_selected',
|
||||||
+5
-5
@@ -1,6 +1,6 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
vi.mock('./marketplace.repo', () => ({
|
vi.mock('./carplace.repo', () => ({
|
||||||
findCompanyPage: vi.fn(),
|
findCompanyPage: vi.fn(),
|
||||||
findCompanyBySlug: vi.fn(),
|
findCompanyBySlug: vi.fn(),
|
||||||
findCompanyReviews: vi.fn(),
|
findCompanyReviews: vi.fn(),
|
||||||
@@ -12,16 +12,16 @@ vi.mock('./marketplace.repo', () => ({
|
|||||||
vi.mock('../../services/vehicleAvailabilityService', () => ({ getVehicleAvailabilitySummary: vi.fn() }))
|
vi.mock('../../services/vehicleAvailabilityService', () => ({ getVehicleAvailabilitySummary: vi.fn() }))
|
||||||
vi.mock('../../services/notificationService', () => ({ sendNotification: vi.fn(), sendTransactionalEmail: vi.fn() }))
|
vi.mock('../../services/notificationService', () => ({ sendNotification: vi.fn(), sendTransactionalEmail: vi.fn() }))
|
||||||
vi.mock('../../lib/emailTranslations', () => ({
|
vi.mock('../../lib/emailTranslations', () => ({
|
||||||
marketplaceReservationEmail: { subject: vi.fn(), html: vi.fn(), text: vi.fn() },
|
carplaceReservationEmail: { subject: vi.fn(), html: vi.fn(), text: vi.fn() },
|
||||||
}))
|
}))
|
||||||
|
|
||||||
import * as repo from './marketplace.repo'
|
import * as repo from './carplace.repo'
|
||||||
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
|
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
|
||||||
import { getCompanyOffers, getCompanyPage, getCompanyReviews, getCompanyVehicles, getVehicleDetail, validateOfferCode } from './marketplace.service'
|
import { getCompanyOffers, getCompanyPage, getCompanyReviews, getCompanyVehicles, getVehicleDetail, validateOfferCode } from './carplace.service'
|
||||||
|
|
||||||
beforeEach(() => vi.clearAllMocks())
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
describe('marketplace.service public page edges', () => {
|
describe('carplace.service public page edges', () => {
|
||||||
it('enriches company page vehicles with availability without changing company metadata', async () => {
|
it('enriches company page vehicles with availability without changing company metadata', async () => {
|
||||||
const nextAvailableAt = new Date('2026-07-14T09:00:00.000Z')
|
const nextAvailableAt = new Date('2026-07-14T09:00:00.000Z')
|
||||||
vi.mocked(repo.findCompanyPage).mockResolvedValue({
|
vi.mocked(repo.findCompanyPage).mockResolvedValue({
|
||||||
+187
-38
@@ -1,8 +1,8 @@
|
|||||||
import { AppError, NotFoundError, ConflictError } from '../../http/errors'
|
import { AppError, NotFoundError, ConflictError } from '../../http/errors'
|
||||||
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
|
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
|
||||||
import { sendNotification, sendTransactionalEmail } from '../../services/notificationService'
|
import { sendNotification, sendTransactionalEmail } from '../../services/notificationService'
|
||||||
import { marketplaceReservationEmail, type Lang } from '../../lib/emailTranslations'
|
import { carplaceReservationEmail, type Lang } from '../../lib/emailTranslations'
|
||||||
import * as repo from './marketplace.repo'
|
import * as repo from './carplace.repo'
|
||||||
|
|
||||||
function normalizeLocation(value?: string | null) {
|
function normalizeLocation(value?: string | null) {
|
||||||
const normalized = value?.trim()
|
const normalized = value?.trim()
|
||||||
@@ -21,6 +21,28 @@ function includesLocation(values: string[], target: string) {
|
|||||||
return values.some((value) => value.toLocaleLowerCase() === key)
|
return values.some((value) => value.toLocaleLowerCase() === key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function calculateOfferDiscount(
|
||||||
|
offer: { type?: string; discountValue?: number; specialRate?: number | null; maxRedemptions?: number | null; redemptionCount?: number | null },
|
||||||
|
baseSubtotal: number,
|
||||||
|
dailyRate: number,
|
||||||
|
rentalDays: number,
|
||||||
|
) {
|
||||||
|
if (offer.maxRedemptions != null && (offer.redemptionCount ?? 0) >= offer.maxRedemptions) {
|
||||||
|
throw new AppError('Offer code has reached its redemption limit', 409, 'offer_exhausted')
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = Number(offer.discountValue ?? 0)
|
||||||
|
let discount = 0
|
||||||
|
if (offer.type === 'PERCENTAGE') discount = Math.round(baseSubtotal * (value / 100))
|
||||||
|
else if (offer.type === 'FIXED_AMOUNT') discount = value
|
||||||
|
else if (offer.type === 'FREE_DAY') discount = dailyRate * value
|
||||||
|
else if (offer.type === 'SPECIAL_RATE') {
|
||||||
|
const specialRate = Number(offer.specialRate ?? value)
|
||||||
|
discount = Math.max(0, baseSubtotal - (specialRate * rentalDays))
|
||||||
|
}
|
||||||
|
return Math.min(baseSubtotal, Math.max(0, Math.round(discount)))
|
||||||
|
}
|
||||||
|
|
||||||
function matchesVehicleLocationRules(
|
function matchesVehicleLocationRules(
|
||||||
vehicle: { pickupLocations?: string[]; dropoffLocations?: string[]; allowDifferentDropoff?: boolean },
|
vehicle: { pickupLocations?: string[]; dropoffLocations?: string[]; allowDifferentDropoff?: boolean },
|
||||||
params: { pickupLocation?: string; dropoffLocation?: string; dropoffMode?: 'same' | 'different' },
|
params: { pickupLocation?: string; dropoffLocation?: string; dropoffMode?: 'same' | 'different' },
|
||||||
@@ -65,7 +87,7 @@ export async function getListedCompanies(params: {
|
|||||||
}) {
|
}) {
|
||||||
const page = params.page ?? 1
|
const page = params.page ?? 1
|
||||||
const pageSize = params.pageSize ?? 20
|
const pageSize = params.pageSize ?? 20
|
||||||
const where: any = { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnMarketplace: true } }
|
const where: any = { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnCarplace: true } }
|
||||||
if (params.city) where.brand = { ...where.brand, publicCity: { contains: params.city, mode: 'insensitive' } }
|
if (params.city) where.brand = { ...where.brand, publicCity: { contains: params.city, mode: 'insensitive' } }
|
||||||
if (params.hasOffer === 'true') {
|
if (params.hasOffer === 'true') {
|
||||||
where.offers = { some: { isPublic: true, isActive: true, validUntil: { gte: new Date() } } }
|
where.offers = { some: { isPublic: true, isActive: true, validUntil: { gte: new Date() } } }
|
||||||
@@ -78,13 +100,23 @@ export async function searchVehicles(params: {
|
|||||||
startDate?: string; endDate?: string; category?: string
|
startDate?: string; endDate?: string; category?: string
|
||||||
maxPrice?: number; transmission?: string; make?: string; model?: string
|
maxPrice?: number; transmission?: string; make?: string; model?: string
|
||||||
page?: number; pageSize?: number
|
page?: number; pageSize?: number
|
||||||
|
}) {
|
||||||
|
const result = await searchVehiclesPage(params)
|
||||||
|
return result.items
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchVehiclesPage(params: {
|
||||||
|
city?: string; pickupLocation?: string; dropoffLocation?: string; dropoffMode?: 'same' | 'different'
|
||||||
|
startDate?: string; endDate?: string; category?: string
|
||||||
|
maxPrice?: number; transmission?: string; make?: string; model?: string
|
||||||
|
page?: number; pageSize?: number
|
||||||
}) {
|
}) {
|
||||||
const page = params.page ?? 1
|
const page = params.page ?? 1
|
||||||
const pageSize = params.pageSize ?? 20
|
const pageSize = params.pageSize ?? 20
|
||||||
const where: any = {
|
const where: any = {
|
||||||
isPublished: true,
|
isPublished: true,
|
||||||
status: 'AVAILABLE',
|
status: 'AVAILABLE',
|
||||||
company: { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnMarketplace: true } },
|
company: { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnCarplace: true } },
|
||||||
}
|
}
|
||||||
if (params.category) where.category = params.category
|
if (params.category) where.category = params.category
|
||||||
if (params.maxPrice !== undefined) where.dailyRate = { lte: params.maxPrice }
|
if (params.maxPrice !== undefined) where.dailyRate = { lte: params.maxPrice }
|
||||||
@@ -92,40 +124,83 @@ export async function searchVehicles(params: {
|
|||||||
if (params.make) where.make = { contains: params.make, mode: 'insensitive' }
|
if (params.make) where.make = { contains: params.make, mode: 'insensitive' }
|
||||||
if (params.model) where.model = { contains: params.model, mode: 'insensitive' }
|
if (params.model) where.model = { contains: params.model, mode: 'insensitive' }
|
||||||
if (params.city) {
|
if (params.city) {
|
||||||
where.company = { ...where.company, brand: { isListedOnMarketplace: true, publicCity: { contains: params.city, mode: 'insensitive' } } }
|
where.company = { ...where.company, brand: { isListedOnCarplace: true, publicCity: { contains: params.city, mode: 'insensitive' } } }
|
||||||
|
}
|
||||||
|
|
||||||
|
let range: { startDate: Date; endDate: Date } | undefined
|
||||||
|
if (params.startDate || params.endDate) {
|
||||||
|
if (!params.startDate || !params.endDate) throw new AppError('Both start and end dates are required', 400, 'invalid_dates')
|
||||||
|
const startDate = new Date(params.startDate)
|
||||||
|
const endDate = new Date(params.endDate)
|
||||||
|
if (!Number.isFinite(startDate.getTime()) || !Number.isFinite(endDate.getTime()) || endDate <= startDate) {
|
||||||
|
throw new AppError('End date must be after start date', 400, 'invalid_dates')
|
||||||
|
}
|
||||||
|
range = { startDate, endDate }
|
||||||
}
|
}
|
||||||
|
|
||||||
const vehicles = await repo.findPublishedVehicles(where) as any[]
|
const vehicles = await repo.findPublishedVehicles(where) as any[]
|
||||||
const locationFiltered = vehicles.filter((vehicle: any) => matchesVehicleLocationRules(vehicle, params))
|
const locationFiltered = vehicles.filter((vehicle: any) => matchesVehicleLocationRules(vehicle, params))
|
||||||
const pagedVehicles = locationFiltered.slice((page - 1) * pageSize, page * pageSize)
|
const availabilityByVehicle = new Map<string, any>()
|
||||||
|
|
||||||
|
let availableVehicles = locationFiltered
|
||||||
|
if (range) {
|
||||||
const availability = await Promise.all(
|
const availability = await Promise.all(
|
||||||
pagedVehicles.map(async (v: any) => {
|
locationFiltered.map(async (vehicle: any) => {
|
||||||
const a = await getVehicleAvailabilitySummary(v.id, {
|
const result = await getVehicleAvailabilitySummary(vehicle.id, { companyId: vehicle.companyId, range })
|
||||||
companyId: v.companyId,
|
availabilityByVehicle.set(vehicle.id, result)
|
||||||
range: params.startDate && params.endDate
|
return result.available ? vehicle : null
|
||||||
? { startDate: new Date(params.startDate), endDate: new Date(params.endDate) }
|
|
||||||
: undefined,
|
|
||||||
})
|
|
||||||
return [v.id, a] as const
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const availMap = new Map<string, any>(availability)
|
availableVehicles = availability.filter((vehicle): vehicle is any => Boolean(vehicle))
|
||||||
|
|
||||||
return pagedVehicles.map((v: any) => ({
|
|
||||||
...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 getMarketplaceVehicleAvailability(
|
const pageItems = availableVehicles.slice((page - 1) * pageSize, page * pageSize)
|
||||||
|
const items = pageItems.map((vehicle: any) => {
|
||||||
|
const availability = availabilityByVehicle.get(vehicle.id)
|
||||||
|
return {
|
||||||
|
...vehicle,
|
||||||
|
availability: availability?.available ?? true,
|
||||||
|
availabilityStatus: availability?.status ?? 'AVAILABLE',
|
||||||
|
nextAvailableAt: availability?.nextAvailableAt ?? null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const countValues = (key: 'category' | 'transmission' | 'make') => {
|
||||||
|
const counts = new Map<string, number>()
|
||||||
|
for (const vehicle of availableVehicles) {
|
||||||
|
const value = typeof vehicle[key] === 'string' ? vehicle[key].trim() : ''
|
||||||
|
if (value) counts.set(value, (counts.get(value) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
return Array.from(counts, ([value, count]) => ({ value, count })).sort((a, b) => a.value.localeCompare(b.value))
|
||||||
|
}
|
||||||
|
const prices = availableVehicles.map((vehicle) => Number(vehicle.dailyRate)).filter(Number.isFinite)
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
totalItems: availableVehicles.length,
|
||||||
|
totalPages: Math.ceil(availableVehicles.length / pageSize),
|
||||||
|
},
|
||||||
|
facets: {
|
||||||
|
categories: countValues('category'),
|
||||||
|
transmissions: countValues('transmission'),
|
||||||
|
makes: countValues('make'),
|
||||||
|
price: {
|
||||||
|
min: prices.length ? Math.min(...prices) : null,
|
||||||
|
max: prices.length ? Math.max(...prices) : null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCarplaceVehicleAvailability(
|
||||||
slug: string,
|
slug: string,
|
||||||
vehicleId: string,
|
vehicleId: string,
|
||||||
params: { startDate: string; endDate: string },
|
params: { startDate: string; endDate: string },
|
||||||
) {
|
) {
|
||||||
const vehicle = await repo.findVehicleForMarketplace(vehicleId, slug)
|
const vehicle = await repo.findVehicleForCarplace(vehicleId, slug)
|
||||||
if (!vehicle) throw new NotFoundError('Vehicle not found')
|
if (!vehicle) throw new NotFoundError('Vehicle not found')
|
||||||
|
|
||||||
const startDate = new Date(params.startDate)
|
const startDate = new Date(params.startDate)
|
||||||
@@ -148,20 +223,85 @@ export async function getMarketplaceVehicleAvailability(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createCarplaceQuote(body: {
|
||||||
|
vehicleId: string
|
||||||
|
startDate: string
|
||||||
|
endDate: string
|
||||||
|
pickupLocation?: string
|
||||||
|
returnLocation?: string
|
||||||
|
promoCode?: string
|
||||||
|
}) {
|
||||||
|
const startDate = new Date(body.startDate)
|
||||||
|
const endDate = new Date(body.endDate)
|
||||||
|
if (!Number.isFinite(startDate.getTime()) || !Number.isFinite(endDate.getTime()) || endDate <= startDate) {
|
||||||
|
throw new AppError('End date must be after start date', 400, 'invalid_dates')
|
||||||
|
}
|
||||||
|
|
||||||
|
const vehicle = await repo.findVehicleForCarplaceById(body.vehicleId)
|
||||||
|
if (!vehicle) throw new NotFoundError('Vehicle not found')
|
||||||
|
|
||||||
|
const pickupLocation = normalizeLocation(body.pickupLocation)
|
||||||
|
const returnLocation = normalizeLocation(body.returnLocation) ?? pickupLocation
|
||||||
|
const pickupLocations = normalizeLocationList(vehicle.pickupLocations)
|
||||||
|
const dropoffLocations = normalizeLocationList(vehicle.dropoffLocations)
|
||||||
|
|
||||||
|
if (pickupLocation && pickupLocations.length > 0 && !includesLocation(pickupLocations, pickupLocation)) {
|
||||||
|
throw new AppError('Selected pickup location is not available for this vehicle', 400, 'invalid_pickup_location')
|
||||||
|
}
|
||||||
|
if (pickupLocation && returnLocation && pickupLocation.toLocaleLowerCase() !== returnLocation.toLocaleLowerCase()) {
|
||||||
|
if (!vehicle.allowDifferentDropoff) throw new AppError('This vehicle must be returned to the same pickup location', 400, 'same_dropoff_required')
|
||||||
|
if (dropoffLocations.length > 0 && !includesLocation(dropoffLocations, returnLocation)) {
|
||||||
|
throw new AppError('Selected return location is not available for this vehicle', 400, 'invalid_return_location')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const availability = await getVehicleAvailabilitySummary(vehicle.id, {
|
||||||
|
companyId: vehicle.companyId,
|
||||||
|
range: { startDate, endDate },
|
||||||
|
})
|
||||||
|
const rentalDays = Math.max(1, Math.ceil((endDate.getTime() - startDate.getTime()) / 86_400_000))
|
||||||
|
const baseSubtotal = vehicle.dailyRate * rentalDays
|
||||||
|
let discountAmount = 0
|
||||||
|
const discounts: Array<{ label: string; amount: number }> = []
|
||||||
|
|
||||||
|
if (body.promoCode) {
|
||||||
|
const offer = await repo.findOfferByCode(body.promoCode.trim())
|
||||||
|
if (!offer || offer.companyId !== vehicle.companyId) throw new AppError('Offer code is invalid or expired', 400, 'offer_invalid')
|
||||||
|
discountAmount = calculateOfferDiscount(offer, baseSubtotal, vehicle.dailyRate, rentalDays)
|
||||||
|
discounts.push({ label: offer.title, amount: discountAmount })
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
available: availability.available,
|
||||||
|
availabilityStatus: availability.status,
|
||||||
|
nextAvailableAt: availability.nextAvailableAt?.toISOString() ?? null,
|
||||||
|
currency: 'MAD' as const,
|
||||||
|
rentalDays,
|
||||||
|
dailyRate: vehicle.dailyRate,
|
||||||
|
baseSubtotal,
|
||||||
|
discounts,
|
||||||
|
fees: [],
|
||||||
|
taxes: [],
|
||||||
|
estimatedTotal: Math.max(0, baseSubtotal - discountAmount),
|
||||||
|
securityDeposit: null,
|
||||||
|
finalPriceRequiresCompanyConfirmation: true as const,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function generateBookingReference() {
|
function generateBookingReference() {
|
||||||
const year = new Date().getUTCFullYear()
|
const year = new Date().getUTCFullYear()
|
||||||
const random = Math.random().toString(36).slice(2, 8).toUpperCase()
|
const random = Math.random().toString(36).slice(2, 8).toUpperCase()
|
||||||
return `BK-${year}-${random}`
|
return `BK-${year}-${random}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createMarketplaceReservation(body: {
|
export async function createCarplaceReservation(body: {
|
||||||
vehicleId: string; companySlug?: string; firstName: string; lastName: string
|
vehicleId: string; companySlug?: string; firstName: string; lastName: string
|
||||||
email: string; phone?: string; dateOfBirth?: string; nationality?: string
|
email: string; phone?: string; driverAge?: number; dateOfBirth?: string; nationality?: string
|
||||||
identityDocumentNumber?: string; fullAddress?: string
|
identityDocumentNumber?: string; fullAddress?: string
|
||||||
driverLicense?: string; licenseExpiry?: string; licenseIssuedAt?: string
|
driverLicense?: string; licenseExpiry?: string; licenseIssuedAt?: string
|
||||||
licenseCountry?: string; licenseCategory?: string; internationalLicenseNumber?: string
|
licenseCountry?: string; licenseCategory?: string; internationalLicenseNumber?: string
|
||||||
startDate: string; endDate: string
|
startDate: string; endDate: string
|
||||||
pickupLocation?: string; returnLocation?: string; notes?: string; language?: string
|
pickupLocation?: string; returnLocation?: string; promoCode?: string; notes?: string; language?: string
|
||||||
}) {
|
}) {
|
||||||
const startDate = new Date(body.startDate)
|
const startDate = new Date(body.startDate)
|
||||||
const endDate = new Date(body.endDate)
|
const endDate = new Date(body.endDate)
|
||||||
@@ -170,7 +310,7 @@ export async function createMarketplaceReservation(body: {
|
|||||||
throw new AppError('End date must be after start date', 400, 'invalid_dates')
|
throw new AppError('End date must be after start date', 400, 'invalid_dates')
|
||||||
}
|
}
|
||||||
|
|
||||||
const vehicle = await repo.findVehicleForMarketplaceById(body.vehicleId)
|
const vehicle = await repo.findVehicleForCarplaceById(body.vehicleId)
|
||||||
if (!vehicle) throw new NotFoundError('Vehicle not found')
|
if (!vehicle) throw new NotFoundError('Vehicle not found')
|
||||||
|
|
||||||
const pickupLocation = normalizeLocation(body.pickupLocation)
|
const pickupLocation = normalizeLocation(body.pickupLocation)
|
||||||
@@ -198,7 +338,7 @@ export async function createMarketplaceReservation(body: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const customer = await repo.upsertMarketplaceCustomer(vehicle.companyId, {
|
const customer = await repo.upsertCarplaceCustomer(vehicle.companyId, {
|
||||||
email: body.email,
|
email: body.email,
|
||||||
firstName: body.firstName,
|
firstName: body.firstName,
|
||||||
lastName: body.lastName,
|
lastName: body.lastName,
|
||||||
@@ -216,9 +356,18 @@ export async function createMarketplaceReservation(body: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const totalDays = Math.max(1, Math.ceil((endDate.getTime() - startDate.getTime()) / 86_400_000))
|
const totalDays = Math.max(1, Math.ceil((endDate.getTime() - startDate.getTime()) / 86_400_000))
|
||||||
const totalAmount = vehicle.dailyRate * totalDays
|
const baseSubtotal = vehicle.dailyRate * totalDays
|
||||||
|
let discountAmount = 0
|
||||||
|
if (body.promoCode) {
|
||||||
|
const offer = await repo.findOfferByCode(body.promoCode.trim())
|
||||||
|
if (!offer || offer.companyId !== vehicle.companyId) throw new AppError('Offer code is invalid or expired', 400, 'offer_invalid')
|
||||||
|
discountAmount = calculateOfferDiscount(offer, baseSubtotal, vehicle.dailyRate, totalDays)
|
||||||
|
}
|
||||||
|
const totalAmount = Math.max(0, baseSubtotal - discountAmount)
|
||||||
|
|
||||||
const reservation = await repo.createMarketplaceReservation({
|
const reservationNotes = [body.notes?.trim(), body.driverAge ? `Driver age: ${body.driverAge}` : null].filter(Boolean).join('\n').slice(0, 500) || undefined
|
||||||
|
|
||||||
|
const reservation = await repo.createCarplaceReservation({
|
||||||
companyId: vehicle.companyId,
|
companyId: vehicle.companyId,
|
||||||
vehicleId: body.vehicleId,
|
vehicleId: body.vehicleId,
|
||||||
customerId: customer.id,
|
customerId: customer.id,
|
||||||
@@ -229,7 +378,7 @@ export async function createMarketplaceReservation(body: {
|
|||||||
dailyRate: vehicle.dailyRate,
|
dailyRate: vehicle.dailyRate,
|
||||||
totalDays,
|
totalDays,
|
||||||
totalAmount,
|
totalAmount,
|
||||||
notes: body.notes,
|
notes: reservationNotes,
|
||||||
bookingReference: generateBookingReference(),
|
bookingReference: generateBookingReference(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -245,14 +394,14 @@ export async function createMarketplaceReservation(body: {
|
|||||||
|
|
||||||
sendTransactionalEmail({
|
sendTransactionalEmail({
|
||||||
to: body.email,
|
to: body.email,
|
||||||
subject: marketplaceReservationEmail.subject(`${vehicle.make} ${vehicle.model}`, lang),
|
subject: carplaceReservationEmail.subject(`${vehicle.make} ${vehicle.model}`, lang),
|
||||||
html: marketplaceReservationEmail.html(emailOpts, lang),
|
html: carplaceReservationEmail.html(emailOpts, lang),
|
||||||
text: marketplaceReservationEmail.text(emailOpts, lang),
|
text: carplaceReservationEmail.text(emailOpts, lang),
|
||||||
}).catch(() => null)
|
}).catch(() => null)
|
||||||
|
|
||||||
sendNotification({
|
sendNotification({
|
||||||
type: 'NEW_BOOKING',
|
type: 'NEW_BOOKING',
|
||||||
title: 'New Marketplace Reservation Request',
|
title: 'New Carplace Reservation Request',
|
||||||
body: `${body.firstName} ${body.lastName} requested ${vehicle.make} ${vehicle.model} from ${startDate.toISOString().slice(0, 10)} to ${endDate.toISOString().slice(0, 10)}.`,
|
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,
|
companyId: vehicle.companyId,
|
||||||
channels: ['IN_APP'],
|
channels: ['IN_APP'],
|
||||||
@@ -270,7 +419,7 @@ export async function createMarketplaceReservation(body: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function trackMarketplaceFunnelEvent(body: {
|
export async function trackCarplaceFunnelEvent(body: {
|
||||||
eventName: string
|
eventName: string
|
||||||
companySlug: string
|
companySlug: string
|
||||||
vehicleId: string
|
vehicleId: string
|
||||||
@@ -278,7 +427,7 @@ export async function trackMarketplaceFunnelEvent(body: {
|
|||||||
path?: string
|
path?: string
|
||||||
metadata?: Record<string, string | number | boolean | null>
|
metadata?: Record<string, string | number | boolean | null>
|
||||||
}, renterId?: string) {
|
}, renterId?: string) {
|
||||||
await repo.createMarketplaceFunnelEvent({
|
await repo.createCarplaceFunnelEvent({
|
||||||
...body,
|
...body,
|
||||||
renterId: renterId ?? null,
|
renterId: renterId ?? null,
|
||||||
})
|
})
|
||||||
+108
-26
@@ -1,15 +1,15 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import { NotFoundError, ConflictError, AppError } from '../../http/errors'
|
import { NotFoundError, ConflictError, AppError } from '../../http/errors'
|
||||||
|
|
||||||
vi.mock('./marketplace.repo', () => ({
|
vi.mock('./carplace.repo', () => ({
|
||||||
findPublicOffers: vi.fn(),
|
findPublicOffers: vi.fn(),
|
||||||
findCitiesFromCompanies: vi.fn(),
|
findCitiesFromCompanies: vi.fn(),
|
||||||
findListedCompanies: vi.fn(),
|
findListedCompanies: vi.fn(),
|
||||||
findPublishedVehicles: vi.fn(),
|
findPublishedVehicles: vi.fn(),
|
||||||
findVehicleForMarketplace: vi.fn(),
|
findVehicleForCarplace: vi.fn(),
|
||||||
findVehicleForMarketplaceById: vi.fn(),
|
findVehicleForCarplaceById: vi.fn(),
|
||||||
upsertMarketplaceCustomer: vi.fn(),
|
upsertCarplaceCustomer: vi.fn(),
|
||||||
createMarketplaceReservation: vi.fn(),
|
createCarplaceReservation: vi.fn(),
|
||||||
findCompanyPage: vi.fn(),
|
findCompanyPage: vi.fn(),
|
||||||
findCompanyBySlug: vi.fn(),
|
findCompanyBySlug: vi.fn(),
|
||||||
findCompanyReviews: vi.fn(),
|
findCompanyReviews: vi.fn(),
|
||||||
@@ -33,19 +33,19 @@ vi.mock('../../services/notificationService', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('../../lib/emailTranslations', () => ({
|
vi.mock('../../lib/emailTranslations', () => ({
|
||||||
marketplaceReservationEmail: {
|
carplaceReservationEmail: {
|
||||||
subject: vi.fn(() => 'Subject'),
|
subject: vi.fn(() => 'Subject'),
|
||||||
html: vi.fn(() => '<html>'),
|
html: vi.fn(() => '<html>'),
|
||||||
text: vi.fn(() => 'text'),
|
text: vi.fn(() => 'text'),
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
import * as repo from './marketplace.repo'
|
import * as repo from './carplace.repo'
|
||||||
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
|
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
|
||||||
import {
|
import {
|
||||||
getCities, searchVehicles, createMarketplaceReservation,
|
getCities, searchVehicles, createCarplaceQuote, createCarplaceReservation,
|
||||||
getReviewContext, submitReview, validateOfferCode,
|
getReviewContext, submitReview, validateOfferCode,
|
||||||
} from './marketplace.service'
|
} from './carplace.service'
|
||||||
|
|
||||||
const SLUG = 'test-company'
|
const SLUG = 'test-company'
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ describe('getCities', () => {
|
|||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
describe('searchVehicles', () => {
|
describe('searchVehicles', () => {
|
||||||
it('returns vehicles enriched with availability data', async () => {
|
it('returns published vehicles with an available Carplace status when no dates are selected', async () => {
|
||||||
vi.mocked(repo.findPublishedVehicles).mockResolvedValue([makeVehicle()] as any)
|
vi.mocked(repo.findPublishedVehicles).mockResolvedValue([makeVehicle()] as any)
|
||||||
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
|
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@ describe('searchVehicles', () => {
|
|||||||
expect(result[0].availabilityStatus).toBe('AVAILABLE')
|
expect(result[0].availabilityStatus).toBe('AVAILABLE')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('passes date range to availability service when provided', async () => {
|
it('passes the date range to availability and removes unavailable vehicles before pagination', async () => {
|
||||||
vi.mocked(repo.findPublishedVehicles).mockResolvedValue([makeVehicle()] as any)
|
vi.mocked(repo.findPublishedVehicles).mockResolvedValue([makeVehicle()] as any)
|
||||||
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: false, status: 'RESERVED', nextAvailableAt: new Date('2025-07-01'), blockingReason: 'RESERVATION' })
|
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: false, status: 'RESERVED', nextAvailableAt: new Date('2025-07-01'), blockingReason: 'RESERVATION' })
|
||||||
|
|
||||||
@@ -101,7 +101,33 @@ describe('searchVehicles', () => {
|
|||||||
companyId: 'co-1',
|
companyId: 'co-1',
|
||||||
range: { startDate: new Date('2025-06-01T00:00:00.000Z'), endDate: new Date('2025-06-04T00:00:00.000Z') },
|
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)
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
it('paginates after date-specific availability filtering', async () => {
|
||||||
|
vi.mocked(repo.findPublishedVehicles).mockResolvedValue([
|
||||||
|
makeVehicle({ id: 'v-1' }),
|
||||||
|
makeVehicle({ id: 'v-2' }),
|
||||||
|
makeVehicle({ id: 'v-3' }),
|
||||||
|
] as any)
|
||||||
|
vi.mocked(getVehicleAvailabilitySummary).mockImplementation(async (vehicleId) => ({
|
||||||
|
available: vehicleId !== 'v-1',
|
||||||
|
status: vehicleId === 'v-1' ? 'RESERVED' : 'AVAILABLE',
|
||||||
|
nextAvailableAt: null,
|
||||||
|
blockingReason: vehicleId === 'v-1' ? 'RESERVATION' : null,
|
||||||
|
}) as any)
|
||||||
|
|
||||||
|
const result = await searchVehicles({
|
||||||
|
startDate: '2026-07-10T10:00:00.000Z',
|
||||||
|
endDate: '2026-07-12T10:00:00.000Z',
|
||||||
|
page: 1,
|
||||||
|
pageSize: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].id).toBe('v-2')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('filters out vehicles that do not support different drop-off when requested', async () => {
|
it('filters out vehicles that do not support different drop-off when requested', async () => {
|
||||||
@@ -119,7 +145,49 @@ describe('searchVehicles', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
describe('createMarketplaceReservation', () => {
|
describe('createCarplaceQuote', () => {
|
||||||
|
const body = {
|
||||||
|
vehicleId: 'v-1',
|
||||||
|
startDate: '2026-07-10T10:00:00.000Z',
|
||||||
|
endDate: '2026-07-13T10:00:00.000Z',
|
||||||
|
pickupLocation: 'Casablanca',
|
||||||
|
returnLocation: 'Casablanca',
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns an availability-aware three-day estimate', async () => {
|
||||||
|
vi.mocked(repo.findVehicleForCarplaceById).mockResolvedValue(makeVehicle() as any)
|
||||||
|
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
|
||||||
|
|
||||||
|
const result = await createCarplaceQuote(body)
|
||||||
|
|
||||||
|
expect(result.rentalDays).toBe(3)
|
||||||
|
expect(result.baseSubtotal).toBe(30000)
|
||||||
|
expect(result.estimatedTotal).toBe(30000)
|
||||||
|
expect(result.finalPriceRequiresCompanyConfirmation).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calculates fixed and free-day promotions instead of assuming every offer is a percentage', async () => {
|
||||||
|
vi.mocked(repo.findVehicleForCarplaceById).mockResolvedValue(makeVehicle() as any)
|
||||||
|
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
|
||||||
|
vi.mocked(repo.findOfferByCode).mockResolvedValue({ companyId: 'co-1', type: 'FREE_DAY', discountValue: 1, title: 'One free day', maxRedemptions: null, redemptionCount: 0 } as any)
|
||||||
|
|
||||||
|
const result = await createCarplaceQuote({ ...body, promoCode: 'FREE1' })
|
||||||
|
|
||||||
|
expect(result.discounts).toEqual([{ label: 'One free day', amount: 10000 }])
|
||||||
|
expect(result.estimatedTotal).toBe(20000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a promotion owned by a different rental company', async () => {
|
||||||
|
vi.mocked(repo.findVehicleForCarplaceById).mockResolvedValue(makeVehicle() as any)
|
||||||
|
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
|
||||||
|
vi.mocked(repo.findOfferByCode).mockResolvedValue({ companyId: 'other-company', type: 'PERCENTAGE', discountValue: 10, title: 'Wrong company' } as any)
|
||||||
|
|
||||||
|
await expect(createCarplaceQuote({ ...body, promoCode: 'WRONG' })).rejects.toMatchObject({ error: 'offer_invalid' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
describe('createCarplaceReservation', () => {
|
||||||
const baseBody = {
|
const baseBody = {
|
||||||
vehicleId: 'v-1', companySlug: SLUG,
|
vehicleId: 'v-1', companySlug: SLUG,
|
||||||
firstName: 'Ali', lastName: 'Ben', email: 'ali@test.com',
|
firstName: 'Ali', lastName: 'Ben', email: 'ali@test.com',
|
||||||
@@ -137,49 +205,63 @@ describe('createMarketplaceReservation', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
it('creates reservation and returns reservationId', async () => {
|
it('creates reservation and returns reservationId', async () => {
|
||||||
vi.mocked(repo.findVehicleForMarketplaceById).mockResolvedValue(makeVehicle() as any)
|
vi.mocked(repo.findVehicleForCarplaceById).mockResolvedValue(makeVehicle() as any)
|
||||||
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
|
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.upsertCarplaceCustomer).mockResolvedValue({ id: 'c-1' } as any)
|
||||||
vi.mocked(repo.createMarketplaceReservation).mockResolvedValue({ id: 'r-1', bookingReference: 'BK-2026-ABC123' } as any)
|
vi.mocked(repo.createCarplaceReservation).mockResolvedValue({ id: 'r-1', bookingReference: 'BK-2026-ABC123' } as any)
|
||||||
|
|
||||||
const result = await createMarketplaceReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Casablanca' })
|
const result = await createCarplaceReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Casablanca' })
|
||||||
expect(result.reservationId).toBe('r-1')
|
expect(result.reservationId).toBe('r-1')
|
||||||
expect(result.bookingReference).toBe('BK-2026-ABC123')
|
expect(result.bookingReference).toBe('BK-2026-ABC123')
|
||||||
expect(repo.upsertMarketplaceCustomer).toHaveBeenCalledWith('co-1', expect.objectContaining({
|
expect(repo.upsertCarplaceCustomer).toHaveBeenCalledWith('co-1', expect.objectContaining({
|
||||||
identityDocumentNumber: 'CIN123456',
|
identityDocumentNumber: 'CIN123456',
|
||||||
fullAddress: '123 Avenue Hassan II, Casablanca',
|
fullAddress: '123 Avenue Hassan II, Casablanca',
|
||||||
driverLicense: 'DL-123456',
|
driverLicense: 'DL-123456',
|
||||||
}))
|
}))
|
||||||
expect(repo.createMarketplaceReservation).toHaveBeenCalledWith(expect.objectContaining({
|
expect(repo.createCarplaceReservation).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
pickupLocation: 'Casablanca',
|
pickupLocation: 'Casablanca',
|
||||||
returnLocation: 'Casablanca',
|
returnLocation: 'Casablanca',
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
it('throws NotFoundError when vehicle not found', async () => {
|
it('throws NotFoundError when vehicle not found', async () => {
|
||||||
vi.mocked(repo.findVehicleForMarketplaceById).mockResolvedValue(null)
|
vi.mocked(repo.findVehicleForCarplaceById).mockResolvedValue(null)
|
||||||
|
|
||||||
await expect(createMarketplaceReservation(baseBody)).rejects.toBeInstanceOf(NotFoundError)
|
await expect(createCarplaceReservation(baseBody)).rejects.toBeInstanceOf(NotFoundError)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('throws AppError when vehicle is unavailable', async () => {
|
it('throws AppError when vehicle is unavailable', async () => {
|
||||||
vi.mocked(repo.findVehicleForMarketplaceById).mockResolvedValue(makeVehicle() as any)
|
vi.mocked(repo.findVehicleForCarplaceById).mockResolvedValue(makeVehicle() as any)
|
||||||
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: false, status: 'RESERVED', nextAvailableAt: null, blockingReason: 'RESERVATION' })
|
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: false, status: 'RESERVED', nextAvailableAt: null, blockingReason: 'RESERVATION' })
|
||||||
|
|
||||||
await expect(createMarketplaceReservation(baseBody)).rejects.toMatchObject({ error: 'unavailable' })
|
await expect(createCarplaceReservation(baseBody)).rejects.toMatchObject({ error: 'unavailable' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects different return locations when the vehicle requires same drop-off', async () => {
|
it('rejects different return locations when the vehicle requires same drop-off', async () => {
|
||||||
vi.mocked(repo.findVehicleForMarketplaceById).mockResolvedValue(makeVehicle() as any)
|
vi.mocked(repo.findVehicleForCarplaceById).mockResolvedValue(makeVehicle() as any)
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
createMarketplaceReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Rabat' }),
|
createCarplaceReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Rabat' }),
|
||||||
).rejects.toMatchObject({ error: 'same_dropoff_required' })
|
).rejects.toMatchObject({ error: 'same_dropoff_required' })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('stores the same discounted total shown by the Carplace quote', async () => {
|
||||||
|
vi.mocked(repo.findVehicleForCarplaceById).mockResolvedValue(makeVehicle() as any)
|
||||||
|
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
|
||||||
|
vi.mocked(repo.findOfferByCode).mockResolvedValue({ companyId: 'co-1', type: 'PERCENTAGE', discountValue: 10, title: 'Ten percent', maxRedemptions: null, redemptionCount: 0 } as any)
|
||||||
|
vi.mocked(repo.upsertCarplaceCustomer).mockResolvedValue({ id: 'c-1' } as any)
|
||||||
|
vi.mocked(repo.createCarplaceReservation).mockResolvedValue({ id: 'r-2', bookingReference: 'BK-2026-PROMO1' } as any)
|
||||||
|
|
||||||
|
await createCarplaceReservation({ ...baseBody, promoCode: 'SAVE10' })
|
||||||
|
|
||||||
|
expect(repo.createCarplaceReservation).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
totalAmount: 27000,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
it('throws AppError for invalid date range', async () => {
|
it('throws AppError for invalid date range', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
createMarketplaceReservation({ ...baseBody, startDate: '2025-06-04T00:00:00.000Z', endDate: '2025-06-01T00:00:00.000Z' }),
|
createCarplaceReservation({ ...baseBody, startDate: '2025-06-04T00:00:00.000Z', endDate: '2025-06-01T00:00:00.000Z' }),
|
||||||
).rejects.toMatchObject({ error: 'invalid_dates' })
|
).rejects.toMatchObject({ error: 'invalid_dates' })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -14,7 +14,7 @@ const fullBrand = {
|
|||||||
amanpaySecretKey: 'super-secret-key',
|
amanpaySecretKey: 'super-secret-key',
|
||||||
paypalEmail: 'paypal@example.com',
|
paypalEmail: 'paypal@example.com',
|
||||||
paypalMerchantId: 'paypal-merchant-xyz',
|
paypalMerchantId: 'paypal-merchant-xyz',
|
||||||
isListedOnMarketplace: true,
|
isListedOnCarplace: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('presentBrand', () => {
|
describe('presentBrand', () => {
|
||||||
@@ -84,7 +84,7 @@ describe('presentBrand', () => {
|
|||||||
primaryColor: '#1A56DB',
|
primaryColor: '#1A56DB',
|
||||||
defaultLocale: 'en',
|
defaultLocale: 'en',
|
||||||
defaultCurrency: 'MAD',
|
defaultCurrency: 'MAD',
|
||||||
isListedOnMarketplace: true,
|
isListedOnCarplace: true,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ import { parseBody, parseParams } from '../../http/validate'
|
|||||||
import { ok, created, noContent } from '../../http/respond'
|
import { ok, created, noContent } from '../../http/respond'
|
||||||
import { imageUpload, assertImageFile } from '../../http/upload'
|
import { imageUpload, assertImageFile } from '../../http/upload'
|
||||||
import * as service from './company.service'
|
import * as service from './company.service'
|
||||||
|
import {
|
||||||
|
normalizeSettingsLocale,
|
||||||
|
requireSettingsFeature,
|
||||||
|
resolveSettingsEntitlements,
|
||||||
|
resolveSettingsMenu,
|
||||||
|
} from './settingsEntitlements'
|
||||||
import {
|
import {
|
||||||
companySchema, brandSchema, contractSettingsSchema,
|
companySchema, brandSchema, contractSettingsSchema,
|
||||||
insurancePolicySchema, pricingRuleSchema, accountingSettingsSchema,
|
insurancePolicySchema, pricingRuleSchema, accountingSettingsSchema,
|
||||||
@@ -40,7 +46,7 @@ router.get('/me/brand', async (req, res, next) => {
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.patch('/me/brand', requireRole('OWNER'), async (req, res, next) => {
|
router.patch('/me/brand', requireRole('OWNER'), requireSettingsFeature('settings.branding_basic'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const body = parseBody(brandSchema, req)
|
const body = parseBody(brandSchema, req)
|
||||||
const brand = await service.updateBrand(req.companyId, body, req.company.name, req.company.slug)
|
const brand = await service.updateBrand(req.companyId, body, req.company.name, req.company.slug)
|
||||||
@@ -48,7 +54,7 @@ router.patch('/me/brand', requireRole('OWNER'), async (req, res, next) => {
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/me/brand/logo', requireRole('OWNER'), imageUpload.single('file'), async (req, res, next) => {
|
router.post('/me/brand/logo', requireRole('OWNER'), requireSettingsFeature('settings.branding_basic'), imageUpload.single('file'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
assertImageFile(req.file, 'logo')
|
assertImageFile(req.file, 'logo')
|
||||||
const brand = await service.uploadLogo(req.companyId, req.company.name, req.company.slug, req.file.buffer)
|
const brand = await service.uploadLogo(req.companyId, req.company.name, req.company.slug, req.file.buffer)
|
||||||
@@ -56,7 +62,7 @@ router.post('/me/brand/logo', requireRole('OWNER'), imageUpload.single('file'),
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/me/brand/hero', requireRole('OWNER'), imageUpload.single('file'), async (req, res, next) => {
|
router.post('/me/brand/hero', requireRole('OWNER'), requireSettingsFeature('settings.branding_hero'), imageUpload.single('file'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
assertImageFile(req.file, 'hero image')
|
assertImageFile(req.file, 'hero image')
|
||||||
const brand = await service.uploadHeroImage(req.companyId, req.company.name, req.company.slug, req.file.buffer)
|
const brand = await service.uploadHeroImage(req.companyId, req.company.name, req.company.slug, req.file.buffer)
|
||||||
@@ -64,6 +70,18 @@ router.post('/me/brand/hero', requireRole('OWNER'), imageUpload.single('file'),
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.get('/me/settings-menu', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
ok(res, await resolveSettingsMenu(req.companyId, normalizeSettingsLocale(req.query.locale as string | undefined)))
|
||||||
|
} catch (err) { next(err) }
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get('/me/settings-entitlements', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
ok(res, await resolveSettingsEntitlements(req.companyId))
|
||||||
|
} catch (err) { next(err) }
|
||||||
|
})
|
||||||
|
|
||||||
router.post('/me/brand/subdomain/check', requireRole('OWNER'), async (req, res, next) => {
|
router.post('/me/brand/subdomain/check', requireRole('OWNER'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { subdomain } = parseBody(subdomainSchema, req)
|
const { subdomain } = parseBody(subdomainSchema, req)
|
||||||
@@ -101,7 +119,7 @@ router.get('/me/contract-settings', async (req, res, next) => {
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.patch('/me/contract-settings', requireRole('MANAGER'), async (req, res, next) => {
|
router.patch('/me/contract-settings', requireRole('MANAGER'), requireSettingsFeature('settings.rental_policies_basic'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const body = parseBody(contractSettingsSchema, req)
|
const body = parseBody(contractSettingsSchema, req)
|
||||||
const settings = await service.updateContractSettings(req.companyId, body)
|
const settings = await service.updateContractSettings(req.companyId, body)
|
||||||
@@ -116,7 +134,7 @@ router.get('/me/insurance-policies', async (req, res, next) => {
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/me/insurance-policies', requireRole('MANAGER'), async (req, res, next) => {
|
router.post('/me/insurance-policies', requireRole('MANAGER'), requireSettingsFeature('settings.insurance_policies'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const body = parseBody(insurancePolicySchema, req)
|
const body = parseBody(insurancePolicySchema, req)
|
||||||
const policy = await service.createInsurancePolicy(req.companyId, body)
|
const policy = await service.createInsurancePolicy(req.companyId, body)
|
||||||
@@ -124,7 +142,7 @@ router.post('/me/insurance-policies', requireRole('MANAGER'), async (req, res, n
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.patch('/me/insurance-policies/:id', requireRole('MANAGER'), async (req, res, next) => {
|
router.patch('/me/insurance-policies/:id', requireRole('MANAGER'), requireSettingsFeature('settings.insurance_policies'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = parseParams(idParamSchema, req)
|
const { id } = parseParams(idParamSchema, req)
|
||||||
const body = parseBody(insurancePolicySchema.partial(), req)
|
const body = parseBody(insurancePolicySchema.partial(), req)
|
||||||
@@ -133,7 +151,7 @@ router.patch('/me/insurance-policies/:id', requireRole('MANAGER'), async (req, r
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.delete('/me/insurance-policies/:id', requireRole('MANAGER'), async (req, res, next) => {
|
router.delete('/me/insurance-policies/:id', requireRole('MANAGER'), requireSettingsFeature('settings.insurance_policies'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = parseParams(idParamSchema, req)
|
const { id } = parseParams(idParamSchema, req)
|
||||||
await service.deleteInsurancePolicy(id, req.companyId)
|
await service.deleteInsurancePolicy(id, req.companyId)
|
||||||
@@ -148,7 +166,7 @@ router.get('/me/pricing-rules', async (req, res, next) => {
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/me/pricing-rules', requireRole('MANAGER'), async (req, res, next) => {
|
router.post('/me/pricing-rules', requireRole('MANAGER'), requireSettingsFeature('settings.pricing_rules'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const body = parseBody(pricingRuleSchema, req)
|
const body = parseBody(pricingRuleSchema, req)
|
||||||
const rule = await service.createPricingRule(req.companyId, body)
|
const rule = await service.createPricingRule(req.companyId, body)
|
||||||
@@ -156,7 +174,7 @@ router.post('/me/pricing-rules', requireRole('MANAGER'), async (req, res, next)
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.patch('/me/pricing-rules/:id', requireRole('MANAGER'), async (req, res, next) => {
|
router.patch('/me/pricing-rules/:id', requireRole('MANAGER'), requireSettingsFeature('settings.pricing_rules'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = parseParams(idParamSchema, req)
|
const { id } = parseParams(idParamSchema, req)
|
||||||
const body = parseBody(pricingRuleSchema.partial(), req)
|
const body = parseBody(pricingRuleSchema.partial(), req)
|
||||||
@@ -165,7 +183,7 @@ router.patch('/me/pricing-rules/:id', requireRole('MANAGER'), async (req, res, n
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.delete('/me/pricing-rules/:id', requireCompanyPolicy('deletePricingRule'), async (req, res, next) => {
|
router.delete('/me/pricing-rules/:id', requireCompanyPolicy('deletePricingRule'), requireSettingsFeature('settings.pricing_rules'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = parseParams(idParamSchema, req)
|
const { id } = parseParams(idParamSchema, req)
|
||||||
await service.deletePricingRule(id, req.companyId)
|
await service.deletePricingRule(id, req.companyId)
|
||||||
@@ -180,7 +198,7 @@ router.get('/me/accounting-settings', async (req, res, next) => {
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.patch('/me/accounting-settings', requireCompanyPolicy('updateAccountingSettings'), async (req, res, next) => {
|
router.patch('/me/accounting-settings', requireCompanyPolicy('updateAccountingSettings'), requireSettingsFeature('settings.accounting_defaults'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const body = parseBody(accountingSettingsSchema, req)
|
const body = parseBody(accountingSettingsSchema, req)
|
||||||
const settings = await service.updateAccountingSettings(req.companyId, body)
|
const settings = await service.updateAccountingSettings(req.companyId, body)
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const brandSchema = z.object({
|
|||||||
amanpaySecretKey: z.string().optional(),
|
amanpaySecretKey: z.string().optional(),
|
||||||
paypalEmail: optionalEmailField(),
|
paypalEmail: optionalEmailField(),
|
||||||
paypalMerchantId: z.string().optional(),
|
paypalMerchantId: z.string().optional(),
|
||||||
isListedOnMarketplace: z.boolean().optional(),
|
isListedOnCarplace: z.boolean().optional(),
|
||||||
homePageConfig: z.object({
|
homePageConfig: z.object({
|
||||||
heroTitle: z.union([z.string(), z.null()]).optional(),
|
heroTitle: z.union([z.string(), z.null()]).optional(),
|
||||||
heroDescription: z.union([z.string(), z.null()]).optional(),
|
heroDescription: z.union([z.string(), z.null()]).optional(),
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
vi.mock('../../lib/storage', () => ({ uploadImage: vi.fn().mockResolvedValue('/storage/companies/company_1/brand/logo.jpg') }))
|
vi.mock('../../lib/storage', () => ({ uploadImage: vi.fn().mockResolvedValue('/storage/companies/company_1/brand/logo.jpg') }))
|
||||||
|
vi.mock('./settingsEntitlements', () => ({
|
||||||
|
resolveSettingsEntitlements: vi.fn(),
|
||||||
|
}))
|
||||||
vi.mock('./company.repo', () => ({
|
vi.mock('./company.repo', () => ({
|
||||||
findCompany: vi.fn(),
|
findCompany: vi.fn(),
|
||||||
updateCompany: vi.fn(),
|
updateCompany: vi.fn(),
|
||||||
@@ -28,8 +31,43 @@ vi.mock('./company.repo', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const repo = await import('./company.repo')
|
const repo = await import('./company.repo')
|
||||||
|
const entitlements = await import('./settingsEntitlements')
|
||||||
const service = await import('./company.service')
|
const service = await import('./company.service')
|
||||||
|
|
||||||
|
const editableSettingsFeatures = [
|
||||||
|
'settings.company_profile',
|
||||||
|
'settings.public_contact',
|
||||||
|
'settings.locale_currency',
|
||||||
|
'settings.carplace_listing',
|
||||||
|
'settings.branding_basic',
|
||||||
|
'settings.branding_custom',
|
||||||
|
'settings.branding_hero',
|
||||||
|
'settings.renter_payments',
|
||||||
|
'settings.rental_policies_basic',
|
||||||
|
'settings.additional_driver_fees',
|
||||||
|
'settings.insurance_policies',
|
||||||
|
'settings.pricing_rules',
|
||||||
|
'settings.accounting_defaults',
|
||||||
|
'settings.accounting_scheduled_delivery',
|
||||||
|
'settings.accounting_advanced_formats',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
function buildEditableEntitlements() {
|
||||||
|
return {
|
||||||
|
plan: 'PRO',
|
||||||
|
subscriptionStatus: 'ACTIVE',
|
||||||
|
currentPeriodEnd: null,
|
||||||
|
trialEndAt: null,
|
||||||
|
accessLevel: 'full',
|
||||||
|
features: Object.fromEntries(editableSettingsFeatures.map((featureKey) => [featureKey, {
|
||||||
|
available: true,
|
||||||
|
editable: true,
|
||||||
|
requiredPlan: null,
|
||||||
|
reason: null,
|
||||||
|
}])),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const currentBrand = {
|
const currentBrand = {
|
||||||
id: 'brand_1',
|
id: 'brand_1',
|
||||||
companyId: 'company_1',
|
companyId: 'company_1',
|
||||||
@@ -44,6 +82,7 @@ const currentBrand = {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
vi.mocked(entitlements.resolveSettingsEntitlements).mockResolvedValue(buildEditableEntitlements() as any)
|
||||||
delete process.env.NEXT_PUBLIC_CUSTOM_DOMAIN_TARGET
|
delete process.env.NEXT_PUBLIC_CUSTOM_DOMAIN_TARGET
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { uploadImage } from '../../lib/storage'
|
import { uploadImage } from '../../lib/storage'
|
||||||
import { ConflictError, NotFoundError } from '../../http/errors'
|
import { ConflictError, ForbiddenError, NotFoundError } from '../../http/errors'
|
||||||
import { presentCompany, presentBrand } from './company.presenter'
|
import { presentCompany, presentBrand } from './company.presenter'
|
||||||
import * as repo from './company.repo'
|
import * as repo from './company.repo'
|
||||||
|
import { resolveSettingsEntitlements, SettingsFeatureKey } from './settingsEntitlements'
|
||||||
|
|
||||||
function buildPaymentMethodsEnabled(input: {
|
function buildPaymentMethodsEnabled(input: {
|
||||||
amanpayMerchantId?: string | null
|
amanpayMerchantId?: string | null
|
||||||
@@ -27,6 +28,12 @@ export async function getBrand(companyId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateBrand(companyId: string, body: any, companyName: string, companySlug: string) {
|
export async function updateBrand(companyId: string, body: any, companyName: string, companySlug: string) {
|
||||||
|
await assertSettingsFeature(companyId, 'settings.branding_basic')
|
||||||
|
if (body.primaryColor || body.accentColor) await assertSettingsFeature(companyId, 'settings.branding_custom')
|
||||||
|
if (body.amanpayMerchantId || body.amanpaySecretKey || body.paypalEmail || body.paypalMerchantId) {
|
||||||
|
await assertSettingsFeature(companyId, 'settings.renter_payments')
|
||||||
|
}
|
||||||
|
|
||||||
const current = await repo.findBrand(companyId)
|
const current = await repo.findBrand(companyId)
|
||||||
const paymentMethodsEnabled = buildPaymentMethodsEnabled({
|
const paymentMethodsEnabled = buildPaymentMethodsEnabled({
|
||||||
amanpayMerchantId: body.amanpayMerchantId ?? current?.amanpayMerchantId,
|
amanpayMerchantId: body.amanpayMerchantId ?? current?.amanpayMerchantId,
|
||||||
@@ -41,6 +48,7 @@ export async function updateBrand(companyId: string, body: any, companyName: str
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadLogo(companyId: string, companyName: string, companySlug: string, file: Buffer) {
|
export async function uploadLogo(companyId: string, companyName: string, companySlug: string, file: Buffer) {
|
||||||
|
await assertSettingsFeature(companyId, 'settings.branding_basic')
|
||||||
const url = await uploadImage(file, `companies/${companyId}/brand`, 'logo')
|
const url = await uploadImage(file, `companies/${companyId}/brand`, 'logo')
|
||||||
return presentBrand(await repo.upsertBrand(
|
return presentBrand(await repo.upsertBrand(
|
||||||
companyId,
|
companyId,
|
||||||
@@ -50,6 +58,7 @@ export async function uploadLogo(companyId: string, companyName: string, company
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadHeroImage(companyId: string, companyName: string, companySlug: string, file: Buffer) {
|
export async function uploadHeroImage(companyId: string, companyName: string, companySlug: string, file: Buffer) {
|
||||||
|
await assertSettingsFeature(companyId, 'settings.branding_hero')
|
||||||
const url = await uploadImage(file, `companies/${companyId}/brand`, 'hero')
|
const url = await uploadImage(file, `companies/${companyId}/brand`, 'hero')
|
||||||
return presentBrand(await repo.upsertBrand(
|
return presentBrand(await repo.upsertBrand(
|
||||||
companyId,
|
companyId,
|
||||||
@@ -95,6 +104,14 @@ export async function getContractSettings(companyId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateContractSettings(companyId: string, data: any) {
|
export async function updateContractSettings(companyId: string, data: any) {
|
||||||
|
await assertSettingsFeature(companyId, 'settings.rental_policies_basic')
|
||||||
|
if (
|
||||||
|
data.additionalDriverCharge ||
|
||||||
|
data.additionalDriverDailyRate !== undefined ||
|
||||||
|
data.additionalDriverFlatRate !== undefined
|
||||||
|
) {
|
||||||
|
await assertSettingsFeature(companyId, 'settings.additional_driver_fees')
|
||||||
|
}
|
||||||
return repo.upsertContractSettings(companyId, data)
|
return repo.upsertContractSettings(companyId, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,16 +120,19 @@ export async function getInsurancePolicies(companyId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createInsurancePolicy(companyId: string, data: any) {
|
export async function createInsurancePolicy(companyId: string, data: any) {
|
||||||
|
await assertSettingsFeature(companyId, 'settings.insurance_policies')
|
||||||
return repo.createInsurancePolicy(companyId, data)
|
return repo.createInsurancePolicy(companyId, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateInsurancePolicy(id: string, companyId: string, data: any) {
|
export async function updateInsurancePolicy(id: string, companyId: string, data: any) {
|
||||||
|
await assertSettingsFeature(companyId, 'settings.insurance_policies')
|
||||||
const result = await repo.updateInsurancePolicy(id, companyId, data)
|
const result = await repo.updateInsurancePolicy(id, companyId, data)
|
||||||
if (result.count === 0) throw new NotFoundError('Insurance policy not found')
|
if (result.count === 0) throw new NotFoundError('Insurance policy not found')
|
||||||
return repo.findInsurancePolicyOrThrow(id)
|
return repo.findInsurancePolicyOrThrow(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteInsurancePolicy(id: string, companyId: string) {
|
export async function deleteInsurancePolicy(id: string, companyId: string) {
|
||||||
|
await assertSettingsFeature(companyId, 'settings.insurance_policies')
|
||||||
const result = await repo.deleteInsurancePolicy(id, companyId)
|
const result = await repo.deleteInsurancePolicy(id, companyId)
|
||||||
if (result.count === 0) throw new NotFoundError('Insurance policy not found')
|
if (result.count === 0) throw new NotFoundError('Insurance policy not found')
|
||||||
}
|
}
|
||||||
@@ -122,16 +142,19 @@ export async function getPricingRules(companyId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createPricingRule(companyId: string, data: any) {
|
export async function createPricingRule(companyId: string, data: any) {
|
||||||
|
await assertSettingsFeature(companyId, 'settings.pricing_rules')
|
||||||
return repo.createPricingRule(companyId, data)
|
return repo.createPricingRule(companyId, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updatePricingRule(id: string, companyId: string, data: any) {
|
export async function updatePricingRule(id: string, companyId: string, data: any) {
|
||||||
|
await assertSettingsFeature(companyId, 'settings.pricing_rules')
|
||||||
const result = await repo.updatePricingRule(id, companyId, data)
|
const result = await repo.updatePricingRule(id, companyId, data)
|
||||||
if (result.count === 0) throw new NotFoundError('Pricing rule not found')
|
if (result.count === 0) throw new NotFoundError('Pricing rule not found')
|
||||||
return repo.findPricingRuleOrThrow(id)
|
return repo.findPricingRuleOrThrow(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deletePricingRule(id: string, companyId: string) {
|
export async function deletePricingRule(id: string, companyId: string) {
|
||||||
|
await assertSettingsFeature(companyId, 'settings.pricing_rules')
|
||||||
const result = await repo.deletePricingRule(id, companyId)
|
const result = await repo.deletePricingRule(id, companyId)
|
||||||
if (result.count === 0) throw new NotFoundError('Pricing rule not found')
|
if (result.count === 0) throw new NotFoundError('Pricing rule not found')
|
||||||
}
|
}
|
||||||
@@ -141,6 +164,9 @@ export async function getAccountingSettings(companyId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateAccountingSettings(companyId: string, data: any) {
|
export async function updateAccountingSettings(companyId: string, data: any) {
|
||||||
|
await assertSettingsFeature(companyId, 'settings.accounting_defaults')
|
||||||
|
if (data.autoSendReport) await assertSettingsFeature(companyId, 'settings.accounting_scheduled_delivery')
|
||||||
|
if (data.reportFormat === 'BOTH') await assertSettingsFeature(companyId, 'settings.accounting_advanced_formats')
|
||||||
return repo.upsertAccountingSettings(companyId, data)
|
return repo.upsertAccountingSettings(companyId, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,3 +177,10 @@ export async function getApiKey(companyId: string) {
|
|||||||
export async function regenerateApiKey(companyId: string) {
|
export async function regenerateApiKey(companyId: string) {
|
||||||
return repo.regenerateApiKey(companyId)
|
return repo.regenerateApiKey(companyId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function assertSettingsFeature(companyId: string, featureKey: SettingsFeatureKey) {
|
||||||
|
const entitlements = await resolveSettingsEntitlements(companyId)
|
||||||
|
if (!entitlements.features[featureKey]?.editable) {
|
||||||
|
throw new ForbiddenError('This settings feature is not available for your current subscription.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,51 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
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('./company.repo')
|
||||||
|
vi.mock('./settingsEntitlements', () => ({
|
||||||
|
resolveSettingsEntitlements: vi.fn(),
|
||||||
|
}))
|
||||||
vi.mock('../../lib/storage', () => ({
|
vi.mock('../../lib/storage', () => ({
|
||||||
uploadImage: vi.fn().mockResolvedValue('http://localhost:4000/storage/companies/comp_1/brand/logo.jpg'),
|
uploadImage: vi.fn().mockResolvedValue('http://localhost:4000/storage/companies/comp_1/brand/logo.jpg'),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const repo = await import('./company.repo')
|
||||||
|
const entitlements = await import('./settingsEntitlements')
|
||||||
|
const service = await import('./company.service')
|
||||||
|
|
||||||
|
const editableSettingsFeatures = [
|
||||||
|
'settings.company_profile',
|
||||||
|
'settings.public_contact',
|
||||||
|
'settings.locale_currency',
|
||||||
|
'settings.carplace_listing',
|
||||||
|
'settings.branding_basic',
|
||||||
|
'settings.branding_custom',
|
||||||
|
'settings.branding_hero',
|
||||||
|
'settings.renter_payments',
|
||||||
|
'settings.rental_policies_basic',
|
||||||
|
'settings.additional_driver_fees',
|
||||||
|
'settings.insurance_policies',
|
||||||
|
'settings.pricing_rules',
|
||||||
|
'settings.accounting_defaults',
|
||||||
|
'settings.accounting_scheduled_delivery',
|
||||||
|
'settings.accounting_advanced_formats',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
function buildEditableEntitlements() {
|
||||||
|
return {
|
||||||
|
plan: 'PRO',
|
||||||
|
subscriptionStatus: 'ACTIVE',
|
||||||
|
currentPeriodEnd: null,
|
||||||
|
trialEndAt: null,
|
||||||
|
accessLevel: 'full',
|
||||||
|
features: Object.fromEntries(editableSettingsFeatures.map((featureKey) => [featureKey, {
|
||||||
|
available: true,
|
||||||
|
editable: true,
|
||||||
|
requiredPlan: null,
|
||||||
|
reason: null,
|
||||||
|
}])),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const mockCompany = {
|
const mockCompany = {
|
||||||
id: 'comp_1',
|
id: 'comp_1',
|
||||||
name: 'Test Rentals',
|
name: 'Test Rentals',
|
||||||
@@ -33,6 +72,7 @@ const mockBrand = {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
vi.mocked(entitlements.resolveSettingsEntitlements).mockResolvedValue(buildEditableEntitlements() as any)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('company.service', () => {
|
describe('company.service', () => {
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('../../lib/prisma', () => ({
|
||||||
|
prisma: {
|
||||||
|
subscription: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { prisma } from '../../lib/prisma'
|
||||||
|
import { resolveSettingsEntitlements, resolveSettingsMenu } from './settingsEntitlements'
|
||||||
|
|
||||||
|
describe('settingsEntitlements', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps company profile available while locking premium STARTER capabilities', async () => {
|
||||||
|
vi.mocked(prisma.subscription.findUnique).mockResolvedValue({
|
||||||
|
plan: 'STARTER',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
currentPeriodEnd: null,
|
||||||
|
trialEndAt: null,
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
const result = await resolveSettingsEntitlements('company_1')
|
||||||
|
|
||||||
|
expect(result.features['settings.company_profile']).toMatchObject({ available: true, editable: true })
|
||||||
|
expect(result.features['settings.renter_payments']).toMatchObject({
|
||||||
|
available: false,
|
||||||
|
editable: false,
|
||||||
|
requiredPlan: 'GROWTH',
|
||||||
|
})
|
||||||
|
expect(result.features['settings.accounting_scheduled_delivery']).toMatchObject({
|
||||||
|
available: false,
|
||||||
|
requiredPlan: 'PRO',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns the approved seven settings sections with localized locked states', async () => {
|
||||||
|
vi.mocked(prisma.subscription.findUnique).mockResolvedValue({
|
||||||
|
plan: 'STARTER',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
currentPeriodEnd: null,
|
||||||
|
trialEndAt: null,
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
const result = await resolveSettingsMenu('company_1', 'fr')
|
||||||
|
|
||||||
|
expect(result.items).toHaveLength(7)
|
||||||
|
expect(result.items.map((item) => item.sectionKey)).toEqual([
|
||||||
|
'company',
|
||||||
|
'carplace',
|
||||||
|
'payments',
|
||||||
|
'rental-policies',
|
||||||
|
'insurance',
|
||||||
|
'pricing',
|
||||||
|
'accounting',
|
||||||
|
])
|
||||||
|
expect(result.items.find((item) => item.sectionKey === 'payments')).toMatchObject({
|
||||||
|
label: 'Méthodes de paiement',
|
||||||
|
state: 'LOCKED',
|
||||||
|
requiredPlan: 'GROWTH',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('makes premium features read-only during past-due access', async () => {
|
||||||
|
vi.mocked(prisma.subscription.findUnique).mockResolvedValue({
|
||||||
|
plan: 'PRO',
|
||||||
|
status: 'PAST_DUE',
|
||||||
|
currentPeriodEnd: null,
|
||||||
|
trialEndAt: null,
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
const result = await resolveSettingsEntitlements('company_1')
|
||||||
|
|
||||||
|
expect(result.features['settings.company_profile']).toMatchObject({ available: true, editable: true })
|
||||||
|
expect(result.features['settings.pricing_rules']).toMatchObject({ available: true, editable: false })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import { NextFunction, Request, Response } from 'express'
|
||||||
|
import { prisma } from '../../lib/prisma'
|
||||||
|
import { getAccessLevel } from '../subscriptions/subscription.policy'
|
||||||
|
import { sendForbidden, sendUnauthorized } from '../../middleware/authHelpers'
|
||||||
|
|
||||||
|
export type SettingsFeatureKey =
|
||||||
|
| 'settings.company_profile'
|
||||||
|
| 'settings.public_contact'
|
||||||
|
| 'settings.locale_currency'
|
||||||
|
| 'settings.carplace_listing'
|
||||||
|
| 'settings.branding_basic'
|
||||||
|
| 'settings.branding_custom'
|
||||||
|
| 'settings.branding_hero'
|
||||||
|
| 'settings.renter_payments'
|
||||||
|
| 'settings.rental_policies_basic'
|
||||||
|
| 'settings.additional_driver_fees'
|
||||||
|
| 'settings.insurance_policies'
|
||||||
|
| 'settings.pricing_rules'
|
||||||
|
| 'settings.accounting_defaults'
|
||||||
|
| 'settings.accounting_scheduled_delivery'
|
||||||
|
| 'settings.accounting_advanced_formats'
|
||||||
|
|
||||||
|
export type SettingsSectionKey =
|
||||||
|
| 'company'
|
||||||
|
| 'carplace'
|
||||||
|
| 'payments'
|
||||||
|
| 'rental-policies'
|
||||||
|
| 'insurance'
|
||||||
|
| 'pricing'
|
||||||
|
| 'accounting'
|
||||||
|
|
||||||
|
type Locale = 'en' | 'fr' | 'ar'
|
||||||
|
type Plan = 'STARTER' | 'GROWTH' | 'PRO'
|
||||||
|
type SubscriptionStatus =
|
||||||
|
| 'TRIALING'
|
||||||
|
| 'ACTIVE'
|
||||||
|
| 'PAYMENT_PENDING'
|
||||||
|
| 'PAST_DUE'
|
||||||
|
| 'SUSPENDED'
|
||||||
|
| 'CANCELLED'
|
||||||
|
| 'EXPIRED'
|
||||||
|
| 'PAUSED'
|
||||||
|
| 'UNPAID'
|
||||||
|
|
||||||
|
type MenuState = 'ENABLED' | 'LOCKED'
|
||||||
|
|
||||||
|
const ALL_PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO']
|
||||||
|
const GROWTH_PLUS: Plan[] = ['GROWTH', 'PRO']
|
||||||
|
const PRO_ONLY: Plan[] = ['PRO']
|
||||||
|
|
||||||
|
const SECTION_COPY: Record<Locale, Record<SettingsSectionKey, { label: string; description: string }>> = {
|
||||||
|
en: {
|
||||||
|
company: { label: 'Company Profile', description: 'Manage public company details and defaults.' },
|
||||||
|
carplace: { label: 'Branding and Carplace', description: 'Control Carplace listing, logo, colors, and media.' },
|
||||||
|
payments: { label: 'Payment Methods', description: 'Configure renter payment providers.' },
|
||||||
|
'rental-policies': { label: 'Rental Policies', description: 'Set fuel, damage, and additional-driver policies.' },
|
||||||
|
insurance: { label: 'Insurance Policies', description: 'Manage optional and required insurance products.' },
|
||||||
|
pricing: { label: 'Pricing Rules', description: 'Automate surcharges, discounts, and driver-based pricing.' },
|
||||||
|
accounting: { label: 'Accounting and Reports', description: 'Configure accounting defaults and report delivery.' },
|
||||||
|
},
|
||||||
|
fr: {
|
||||||
|
company: { label: 'Profil entreprise', description: 'Gérez les informations publiques et les valeurs par défaut.' },
|
||||||
|
carplace: { label: 'Marque et vitrine', description: 'Contrôlez la publication, le logo, les couleurs et les médias.' },
|
||||||
|
payments: { label: 'Méthodes de paiement', description: 'Configurez les prestataires de paiement des locataires.' },
|
||||||
|
'rental-policies': { label: 'Politiques de location', description: 'Définissez les règles carburant, dommages et conducteurs.' },
|
||||||
|
insurance: { label: 'Polices d’assurance', description: 'Gérez les assurances optionnelles et obligatoires.' },
|
||||||
|
pricing: { label: 'Règles tarifaires', description: 'Automatisez les suppléments, remises et règles conducteur.' },
|
||||||
|
accounting: { label: 'Comptabilité et rapports', description: 'Configurez les paramètres comptables et l’envoi des rapports.' },
|
||||||
|
},
|
||||||
|
ar: {
|
||||||
|
company: { label: 'ملف الشركة', description: 'إدارة بيانات الشركة العامة والإعدادات الافتراضية.' },
|
||||||
|
carplace: { label: 'العلامة والواجهة', description: 'التحكم في الظهور والشعار والألوان ووسائط الواجهة.' },
|
||||||
|
payments: { label: 'طرق الدفع', description: 'إعداد مزودي دفع المستأجرين.' },
|
||||||
|
'rental-policies': { label: 'سياسات الإيجار', description: 'ضبط سياسات الوقود والأضرار والسائق الإضافي.' },
|
||||||
|
insurance: { label: 'سياسات التأمين', description: 'إدارة منتجات التأمين الاختيارية والإلزامية.' },
|
||||||
|
pricing: { label: 'قواعد التسعير', description: 'أتمتة الرسوم والخصومات وقواعد السائق.' },
|
||||||
|
accounting: { label: 'المحاسبة والتقارير', description: 'إعداد الافتراضات المحاسبية وتسليم التقارير.' },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SETTINGS_MENU_CATALOG: Array<{
|
||||||
|
menuKey: SettingsSectionKey
|
||||||
|
sectionKey: SettingsSectionKey
|
||||||
|
iconKey: string
|
||||||
|
sortOrder: number
|
||||||
|
plans: Plan[]
|
||||||
|
requiredPlan: Plan | null
|
||||||
|
}> = [
|
||||||
|
{ menuKey: 'company', sectionKey: 'company', iconKey: 'building', sortOrder: 10, plans: ALL_PLANS, requiredPlan: null },
|
||||||
|
{ menuKey: 'carplace', sectionKey: 'carplace', iconKey: 'palette', sortOrder: 20, plans: ALL_PLANS, requiredPlan: null },
|
||||||
|
{ menuKey: 'payments', sectionKey: 'payments', iconKey: 'credit-card', sortOrder: 30, plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
|
||||||
|
{ menuKey: 'rental-policies', sectionKey: 'rental-policies', iconKey: 'file-text', sortOrder: 40, plans: ALL_PLANS, requiredPlan: null },
|
||||||
|
{ menuKey: 'insurance', sectionKey: 'insurance', iconKey: 'shield', sortOrder: 50, plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
|
||||||
|
{ menuKey: 'pricing', sectionKey: 'pricing', iconKey: 'tags', sortOrder: 60, plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
|
||||||
|
{ menuKey: 'accounting', sectionKey: 'accounting', iconKey: 'bar-chart', sortOrder: 70, plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const FEATURE_PLANS: Record<SettingsFeatureKey, { plans: Plan[]; requiredPlan: Plan | null }> = {
|
||||||
|
'settings.company_profile': { plans: ALL_PLANS, requiredPlan: null },
|
||||||
|
'settings.public_contact': { plans: ALL_PLANS, requiredPlan: null },
|
||||||
|
'settings.locale_currency': { plans: ALL_PLANS, requiredPlan: null },
|
||||||
|
'settings.carplace_listing': { plans: ALL_PLANS, requiredPlan: null },
|
||||||
|
'settings.branding_basic': { plans: ALL_PLANS, requiredPlan: null },
|
||||||
|
'settings.branding_custom': { plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
|
||||||
|
'settings.branding_hero': { plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
|
||||||
|
'settings.renter_payments': { plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
|
||||||
|
'settings.rental_policies_basic': { plans: ALL_PLANS, requiredPlan: null },
|
||||||
|
'settings.additional_driver_fees': { plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
|
||||||
|
'settings.insurance_policies': { plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
|
||||||
|
'settings.pricing_rules': { plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
|
||||||
|
'settings.accounting_defaults': { plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
|
||||||
|
'settings.accounting_scheduled_delivery': { plans: PRO_ONLY, requiredPlan: 'PRO' },
|
||||||
|
'settings.accounting_advanced_formats': { plans: PRO_ONLY, requiredPlan: 'PRO' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSettingsLocale(value?: string): Locale {
|
||||||
|
return value === 'fr' || value === 'ar' ? value : 'en'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSubscription(companyId: string) {
|
||||||
|
const subscriptionModel = (prisma as any).subscription
|
||||||
|
if (!subscriptionModel?.findUnique) {
|
||||||
|
return {
|
||||||
|
plan: 'GROWTH' as Plan,
|
||||||
|
subscriptionStatus: 'ACTIVE' as SubscriptionStatus,
|
||||||
|
currentPeriodEnd: null,
|
||||||
|
trialEndAt: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = await subscriptionModel.findUnique({ where: { companyId } })
|
||||||
|
return {
|
||||||
|
plan: subscription?.plan ?? 'STARTER',
|
||||||
|
subscriptionStatus: subscription?.status ?? 'EXPIRED',
|
||||||
|
currentPeriodEnd: subscription?.currentPeriodEnd?.toISOString() ?? null,
|
||||||
|
trialEndAt: subscription?.trialEndAt?.toISOString() ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusAllowsAvailability(status: SubscriptionStatus) {
|
||||||
|
return getAccessLevel(status) !== 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusAllowsEditing(status: SubscriptionStatus, featureKey: SettingsFeatureKey) {
|
||||||
|
const accessLevel = getAccessLevel(status)
|
||||||
|
if (accessLevel === 'full') return true
|
||||||
|
if (status === 'PAST_DUE') {
|
||||||
|
return [
|
||||||
|
'settings.company_profile',
|
||||||
|
'settings.public_contact',
|
||||||
|
'settings.locale_currency',
|
||||||
|
'settings.carplace_listing',
|
||||||
|
'settings.branding_basic',
|
||||||
|
'settings.rental_policies_basic',
|
||||||
|
].includes(featureKey)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveSettingsEntitlements(companyId: string) {
|
||||||
|
const subscription = await getSubscription(companyId)
|
||||||
|
const accessLevel = getAccessLevel(subscription.subscriptionStatus)
|
||||||
|
const features = Object.fromEntries(
|
||||||
|
Object.entries(FEATURE_PLANS).map(([featureKey, assignment]) => {
|
||||||
|
const assigned = assignment.plans.includes(subscription.plan)
|
||||||
|
const available = assigned && statusAllowsAvailability(subscription.subscriptionStatus)
|
||||||
|
const editable = available && statusAllowsEditing(subscription.subscriptionStatus, featureKey as SettingsFeatureKey)
|
||||||
|
return [featureKey, {
|
||||||
|
available,
|
||||||
|
editable,
|
||||||
|
requiredPlan: assigned ? null : assignment.requiredPlan,
|
||||||
|
reason: available ? null : assigned ? 'subscription_status_restricted' : 'plan_required',
|
||||||
|
}]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
plan: subscription.plan,
|
||||||
|
subscriptionStatus: subscription.subscriptionStatus,
|
||||||
|
currentPeriodEnd: subscription.currentPeriodEnd,
|
||||||
|
trialEndAt: subscription.trialEndAt,
|
||||||
|
accessLevel,
|
||||||
|
features,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveSettingsMenu(companyId: string, locale: Locale) {
|
||||||
|
const entitlements = await resolveSettingsEntitlements(companyId)
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
items: SETTINGS_MENU_CATALOG.map((item) => {
|
||||||
|
const assigned = item.plans.includes(entitlements.plan)
|
||||||
|
const coreAvailable = entitlements.accessLevel !== 'none' || item.sectionKey === 'company'
|
||||||
|
const state: MenuState = assigned && coreAvailable ? 'ENABLED' : 'LOCKED'
|
||||||
|
const copy = SECTION_COPY[locale][item.sectionKey]
|
||||||
|
return {
|
||||||
|
menuKey: item.menuKey,
|
||||||
|
sectionKey: item.sectionKey,
|
||||||
|
label: copy.label,
|
||||||
|
description: copy.description,
|
||||||
|
iconKey: item.iconKey,
|
||||||
|
sortOrder: item.sortOrder,
|
||||||
|
state,
|
||||||
|
requiredPlan: state === 'LOCKED' ? item.requiredPlan : null,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireSettingsFeature(featureKey: SettingsFeatureKey) {
|
||||||
|
return async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
if (!req.companyId) return sendUnauthorized(res, 'unauthenticated', 'Authentication required')
|
||||||
|
|
||||||
|
const entitlements = await resolveSettingsEntitlements(req.companyId)
|
||||||
|
const feature = entitlements.features[featureKey]
|
||||||
|
if (!feature?.editable) {
|
||||||
|
return sendForbidden(res, 'feature_unavailable', 'This settings feature is not available for your current subscription.', {
|
||||||
|
featureKey,
|
||||||
|
requiredPlan: feature?.requiredPlan ?? null,
|
||||||
|
subscriptionStatus: entitlements.subscriptionStatus,
|
||||||
|
accessLevel: entitlements.accessLevel,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
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,
|
|
||||||
vehicleAvailabilityQuerySchema, marketplaceFunnelEventSchema,
|
|
||||||
} 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.post('/events', async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const body = parseBody(marketplaceFunnelEventSchema, req)
|
|
||||||
ok(res, await service.trackMarketplaceFunnelEvent(body, req.renterId))
|
|
||||||
} catch (err) {
|
|
||||||
if (isDatabaseUnavailableError(err)) {
|
|
||||||
return res.status(503).json({ error: 'database_unavailable', message: 'Analytics events are 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/vehicles/:id/availability', async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const { slug, id } = parseParams(vehicleParamSchema, req)
|
|
||||||
const { startDate, endDate } = parseQuery(vehicleAvailabilityQuerySchema, req)
|
|
||||||
ok(res, await service.getMarketplaceVehicleAvailability(slug, id, { 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.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
|
|
||||||
@@ -198,4 +198,180 @@ describe('menu.service', () => {
|
|||||||
})
|
})
|
||||||
expect(result.items.find((item) => item.label === 'Reports')?.reasons.join(' ')).toContain('STARTER does not include this menu item')
|
expect(result.items.find((item) => item.label === 'Reports')?.reasons.join(' ')).toContain('STARTER does not include this menu item')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('returns the approved seven-item STARTER owner sidebar in order', async () => {
|
||||||
|
vi.mocked(prisma.employee.findUniqueOrThrow).mockResolvedValue({
|
||||||
|
id: 'employee_1',
|
||||||
|
role: 'OWNER',
|
||||||
|
isActive: true,
|
||||||
|
companyId: 'company_1',
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
vi.mocked(prisma.company.findUniqueOrThrow).mockResolvedValue({
|
||||||
|
id: 'company_1',
|
||||||
|
name: 'Atlas Cars',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
subscription: { plan: 'STARTER', status: 'ACTIVE' },
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
const item = (
|
||||||
|
systemKey: string,
|
||||||
|
label: string,
|
||||||
|
routeOrUrl: string,
|
||||||
|
icon: string,
|
||||||
|
displayOrder: number,
|
||||||
|
plans: string[],
|
||||||
|
) => ({
|
||||||
|
id: `item_${systemKey}`,
|
||||||
|
systemKey,
|
||||||
|
label,
|
||||||
|
itemType: 'INTERNAL_PAGE',
|
||||||
|
routeOrUrl,
|
||||||
|
icon,
|
||||||
|
parentId: null,
|
||||||
|
openInNewTab: false,
|
||||||
|
isRequired: displayOrder <= 70,
|
||||||
|
isActive: true,
|
||||||
|
displayOrder,
|
||||||
|
roleVisibilities: [{ role: 'OWNER' }],
|
||||||
|
subscriptionAssignments: plans.map((plan) => ({ plan, displayOrder, isActive: true })),
|
||||||
|
companyAssignments: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mocked(prisma.menuItem.findMany).mockResolvedValue([
|
||||||
|
item('dashboard', 'Dashboard', '/', 'LayoutDashboard', 10, ['STARTER', 'GROWTH', 'PRO']),
|
||||||
|
item('reservations', 'Reservations', '/reservations', 'Calendar', 20, ['STARTER', 'GROWTH', 'PRO']),
|
||||||
|
item('fleet', 'Fleet', '/fleet', 'Car', 30, ['STARTER', 'GROWTH', 'PRO']),
|
||||||
|
item('customers', 'Customers', '/customers', 'Users', 40, ['STARTER', 'GROWTH', 'PRO']),
|
||||||
|
item('reports', 'Reports', '/reports', 'BarChart2', 50, ['STARTER', 'GROWTH', 'PRO']),
|
||||||
|
item('billing', 'Billing', '/billing', 'CreditCard', 60, ['STARTER', 'GROWTH', 'PRO']),
|
||||||
|
item('settings', 'Settings', '/settings', 'Settings', 70, ['STARTER', 'GROWTH', 'PRO']),
|
||||||
|
item('online-reservations', 'Online Reservations', '/online-reservations', 'Globe', 80, ['GROWTH', 'PRO']),
|
||||||
|
item('offers', 'Offers', '/offers', 'Tag', 90, ['GROWTH', 'PRO']),
|
||||||
|
item('team', 'Team', '/team', 'UserPlus', 100, ['GROWTH', 'PRO']),
|
||||||
|
item('contracts', 'Contracts', '/contracts', 'FileText', 110, ['GROWTH', 'PRO']),
|
||||||
|
item('notifications', 'Notifications', '/notifications', 'Bell', 120, ['GROWTH', 'PRO']),
|
||||||
|
item('reviews', 'Reviews', '/reviews', 'Star', 130, ['PRO']),
|
||||||
|
item('complaints', 'Complaints', '/complaints', 'AlertTriangle', 140, ['PRO']),
|
||||||
|
{ ...item('subscription', 'Subscription', '/subscription', 'CreditCard', 999, []), isActive: false },
|
||||||
|
] as never)
|
||||||
|
|
||||||
|
const result = await getEmployeeMenu('employee_1')
|
||||||
|
|
||||||
|
expect(result.items.map((menuItem) => menuItem.systemKey)).toEqual([
|
||||||
|
'dashboard',
|
||||||
|
'reservations',
|
||||||
|
'fleet',
|
||||||
|
'customers',
|
||||||
|
'reports',
|
||||||
|
'billing',
|
||||||
|
'settings',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns no feature menu for expired trial subscriptions', async () => {
|
||||||
|
vi.mocked(prisma.employee.findUniqueOrThrow).mockResolvedValue({
|
||||||
|
id: 'employee_1',
|
||||||
|
role: 'OWNER',
|
||||||
|
isActive: true,
|
||||||
|
companyId: 'company_1',
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
vi.mocked(prisma.company.findUniqueOrThrow).mockResolvedValue({
|
||||||
|
id: 'company_1',
|
||||||
|
name: 'Atlas Cars',
|
||||||
|
status: 'TRIALING',
|
||||||
|
subscription: { plan: 'STARTER', status: 'EXPIRED' },
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
vi.mocked(prisma.menuItem.findMany).mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'item_dashboard',
|
||||||
|
systemKey: 'dashboard',
|
||||||
|
label: 'Dashboard',
|
||||||
|
itemType: 'INTERNAL_PAGE',
|
||||||
|
routeOrUrl: '/',
|
||||||
|
icon: 'LayoutDashboard',
|
||||||
|
parentId: null,
|
||||||
|
openInNewTab: false,
|
||||||
|
isRequired: true,
|
||||||
|
isActive: true,
|
||||||
|
displayOrder: 10,
|
||||||
|
roleVisibilities: [{ role: 'OWNER' }],
|
||||||
|
subscriptionAssignments: [{ plan: 'STARTER', displayOrder: 10, isActive: true }],
|
||||||
|
companyAssignments: [],
|
||||||
|
},
|
||||||
|
] as never)
|
||||||
|
|
||||||
|
const result = await getEmployeeMenu('employee_1')
|
||||||
|
|
||||||
|
expect(result.subscriptionStatus).toBe('EXPIRED')
|
||||||
|
expect(result.subscriptionAccessLevel).toBe('none')
|
||||||
|
expect(result.items).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the baseline feature menu when full-access entitlements only expose dashboard and subscription', async () => {
|
||||||
|
vi.mocked(prisma.employee.findUniqueOrThrow).mockResolvedValue({
|
||||||
|
id: 'employee_1',
|
||||||
|
role: 'OWNER',
|
||||||
|
isActive: true,
|
||||||
|
companyId: 'company_1',
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
vi.mocked(prisma.company.findUniqueOrThrow).mockResolvedValue({
|
||||||
|
id: 'company_1',
|
||||||
|
name: 'Atlas Cars',
|
||||||
|
status: 'TRIALING',
|
||||||
|
subscription: { plan: 'STARTER', status: 'TRIALING' },
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
vi.mocked(prisma.menuItem.findMany).mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'item_dashboard',
|
||||||
|
systemKey: 'dashboard',
|
||||||
|
label: 'Dashboard',
|
||||||
|
itemType: 'INTERNAL_PAGE',
|
||||||
|
routeOrUrl: '/',
|
||||||
|
icon: 'LayoutDashboard',
|
||||||
|
parentId: null,
|
||||||
|
openInNewTab: false,
|
||||||
|
isRequired: true,
|
||||||
|
isActive: true,
|
||||||
|
displayOrder: 10,
|
||||||
|
roleVisibilities: [{ role: 'OWNER' }],
|
||||||
|
subscriptionAssignments: [{ plan: 'STARTER', displayOrder: 10, isActive: true }],
|
||||||
|
companyAssignments: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'item_subscription',
|
||||||
|
systemKey: 'subscription',
|
||||||
|
label: 'Subscription',
|
||||||
|
itemType: 'INTERNAL_PAGE',
|
||||||
|
routeOrUrl: '/subscription',
|
||||||
|
icon: 'CreditCard',
|
||||||
|
parentId: null,
|
||||||
|
openInNewTab: false,
|
||||||
|
isRequired: false,
|
||||||
|
isActive: true,
|
||||||
|
displayOrder: 999,
|
||||||
|
roleVisibilities: [{ role: 'OWNER' }],
|
||||||
|
subscriptionAssignments: [],
|
||||||
|
companyAssignments: [],
|
||||||
|
},
|
||||||
|
] as never)
|
||||||
|
|
||||||
|
const result = await getEmployeeMenu('employee_1')
|
||||||
|
|
||||||
|
expect(result.subscriptionStatus).toBe('TRIALING')
|
||||||
|
expect(result.subscriptionAccessLevel).toBe('full')
|
||||||
|
expect(result.items.map((menuItem) => menuItem.systemKey)).toEqual([
|
||||||
|
'dashboard',
|
||||||
|
'reservations',
|
||||||
|
'fleet',
|
||||||
|
'customers',
|
||||||
|
'reports',
|
||||||
|
'billing',
|
||||||
|
'settings',
|
||||||
|
])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { AppError } from '../../http/errors'
|
import { AppError } from '../../http/errors'
|
||||||
import { prisma } from '../../lib/prisma'
|
import { prisma } from '../../lib/prisma'
|
||||||
|
import { getAccessLevel, hasFullAccess, type AccessLevel } from '../subscriptions/subscription.policy'
|
||||||
|
|
||||||
type EmployeeRole = 'OWNER' | 'MANAGER' | 'AGENT'
|
type EmployeeRole = 'OWNER' | 'MANAGER' | 'AGENT'
|
||||||
type Plan = 'STARTER' | 'GROWTH' | 'PRO'
|
type Plan = 'STARTER' | 'GROWTH' | 'PRO'
|
||||||
@@ -68,6 +69,17 @@ const MENU_ITEM_TYPES_WITHOUT_ROUTE = new Set([
|
|||||||
'SECTION_LABEL',
|
'SECTION_LABEL',
|
||||||
'DIVIDER',
|
'DIVIDER',
|
||||||
])
|
])
|
||||||
|
const ROLE_RANK: Record<EmployeeRole, number> = { OWNER: 3, MANAGER: 2, AGENT: 1 }
|
||||||
|
const BASELINE_EMPLOYEE_MENU_ITEMS = [
|
||||||
|
{ systemKey: 'dashboard', label: 'Dashboard', routeOrUrl: '/', icon: 'LayoutDashboard', displayOrder: 10, minRole: 'AGENT' },
|
||||||
|
{ systemKey: 'reservations', label: 'Reservations', routeOrUrl: '/reservations', icon: 'Calendar', displayOrder: 20, minRole: 'AGENT' },
|
||||||
|
{ systemKey: 'fleet', label: 'Fleet', routeOrUrl: '/fleet', icon: 'Car', displayOrder: 30, minRole: 'AGENT' },
|
||||||
|
{ systemKey: 'customers', label: 'Customers', routeOrUrl: '/customers', icon: 'Users', displayOrder: 40, minRole: 'AGENT' },
|
||||||
|
{ systemKey: 'reports', label: 'Reports', routeOrUrl: '/reports', icon: 'BarChart2', displayOrder: 50, minRole: 'MANAGER' },
|
||||||
|
{ systemKey: 'billing', label: 'Billing', routeOrUrl: '/billing', icon: 'CreditCard', displayOrder: 60, minRole: 'MANAGER' },
|
||||||
|
{ systemKey: 'settings', label: 'Settings', routeOrUrl: '/settings', icon: 'Settings', displayOrder: 70, minRole: 'OWNER' },
|
||||||
|
] as const
|
||||||
|
const MENU_RECOVERY_ROUTES = new Set(['/subscription', '/subscription/success', '/subscription/cancel'])
|
||||||
|
|
||||||
function normalizeNullableString(value?: string | null) {
|
function normalizeNullableString(value?: string | null) {
|
||||||
if (typeof value !== 'string') return null
|
if (typeof value !== 'string') return null
|
||||||
@@ -82,6 +94,10 @@ function sortByDisplayOrder<T extends { displayOrder: number; label?: string | n
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasMinRole(role: EmployeeRole, minRole: EmployeeRole): boolean {
|
||||||
|
return ROLE_RANK[role] >= ROLE_RANK[minRole]
|
||||||
|
}
|
||||||
|
|
||||||
function toPlanValue(plan: Plan) {
|
function toPlanValue(plan: Plan) {
|
||||||
return plan
|
return plan
|
||||||
}
|
}
|
||||||
@@ -466,23 +482,27 @@ async function getMenuEvaluationContext(companyId: string, role: EmployeeRole, e
|
|||||||
})
|
})
|
||||||
|
|
||||||
const currentPlan = company.subscription?.plan ?? null
|
const currentPlan = company.subscription?.plan ?? null
|
||||||
const currentPlanName = currentPlan as Plan | null
|
const subscriptionStatus = company.subscription?.status ?? 'EXPIRED'
|
||||||
|
const subscriptionAccessLevel = getAccessLevel(subscriptionStatus)
|
||||||
|
const subscriptionHasFullAccess = hasFullAccess(subscriptionStatus)
|
||||||
|
const currentPlanName = subscriptionHasFullAccess ? currentPlan as Plan | null : null
|
||||||
|
|
||||||
const evaluated: EvaluatedMenuItem[] = (menuItems as any[]).map((item: any) => {
|
const evaluated: EvaluatedMenuItem[] = (menuItems as any[]).map((item: any) => {
|
||||||
const defaultsToAllSubscriptions = item.subscriptionAssignments.length === 0
|
const defaultsToAllSubscriptions = item.subscriptionAssignments.length === 0
|
||||||
const subscriptionAssignment = item.subscriptionAssignments.find(
|
const subscriptionAssignment = item.subscriptionAssignments.find(
|
||||||
(assignment: any) => assignment.isActive && (!currentPlan || assignment.plan === currentPlan),
|
(assignment: any) => assignment.isActive && (!currentPlanName || assignment.plan === currentPlanName),
|
||||||
)
|
)
|
||||||
const companyAssignment = item.companyAssignments.find((assignment: any) => assignment.isActive)
|
const companyAssignment = item.companyAssignments.find((assignment: any) => assignment.isActive)
|
||||||
const roleAllowed = item.roleVisibilities.length === 0 || item.roleVisibilities.some((visibility: any) => visibility.role === role)
|
const roleAllowed = item.roleVisibilities.length === 0 || item.roleVisibilities.some((visibility: any) => visibility.role === role)
|
||||||
const subscriptionEntitled = Boolean(subscriptionAssignment || (defaultsToAllSubscriptions && currentPlan))
|
const subscriptionEntitled = Boolean(subscriptionHasFullAccess && (subscriptionAssignment || (defaultsToAllSubscriptions && currentPlanName)))
|
||||||
const entitled = Boolean(subscriptionEntitled || companyAssignment)
|
const entitled = Boolean(subscriptionHasFullAccess && (subscriptionEntitled || companyAssignment))
|
||||||
const visible = Boolean(item.isActive && entitled && roleAllowed && employeeIsActive)
|
const visible = Boolean(item.isActive && entitled && roleAllowed && employeeIsActive)
|
||||||
const displayOrder = companyAssignment?.displayOrder ?? subscriptionAssignment?.displayOrder ?? item.displayOrder
|
const displayOrder = companyAssignment?.displayOrder ?? subscriptionAssignment?.displayOrder ?? item.displayOrder
|
||||||
|
|
||||||
const reasons: string[] = []
|
const reasons: string[] = []
|
||||||
if (!item.isActive) reasons.push('Hidden because the menu item is inactive.')
|
if (!item.isActive) reasons.push('Hidden because the menu item is inactive.')
|
||||||
if (!employeeIsActive) reasons.push('Hidden because the user account is inactive.')
|
if (!employeeIsActive) reasons.push('Hidden because the user account is inactive.')
|
||||||
|
if (!subscriptionHasFullAccess) reasons.push(`Hidden because subscription status ${subscriptionStatus} does not allow dashboard feature access.`)
|
||||||
if (!entitled) reasons.push(describeEntitlement(currentPlanName, false, false, false))
|
if (!entitled) reasons.push(describeEntitlement(currentPlanName, false, false, false))
|
||||||
if (entitled) {
|
if (entitled) {
|
||||||
reasons.push(
|
reasons.push(
|
||||||
@@ -518,6 +538,8 @@ async function getMenuEvaluationContext(companyId: string, role: EmployeeRole, e
|
|||||||
company,
|
company,
|
||||||
role,
|
role,
|
||||||
currentPlan: currentPlanName,
|
currentPlan: currentPlanName,
|
||||||
|
subscriptionStatus,
|
||||||
|
subscriptionAccessLevel: subscriptionAccessLevel as AccessLevel,
|
||||||
items: sortByDisplayOrder(evaluated),
|
items: sortByDisplayOrder(evaluated),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -572,12 +594,40 @@ function buildMenuTree(items: Array<{
|
|||||||
return roots
|
return roots
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasFeatureMenuRoute(items: Array<{ itemType: MenuItemType; routeOrUrl: string | null }>) {
|
||||||
|
return items.some((item) => (
|
||||||
|
item.itemType === 'INTERNAL_PAGE' &&
|
||||||
|
item.routeOrUrl &&
|
||||||
|
item.routeOrUrl !== '/' &&
|
||||||
|
!MENU_RECOVERY_ROUTES.has(item.routeOrUrl)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBaselineEmployeeMenu(role: EmployeeRole) {
|
||||||
|
return BASELINE_EMPLOYEE_MENU_ITEMS
|
||||||
|
.filter((item) => hasMinRole(role, item.minRole))
|
||||||
|
.map((item) => ({
|
||||||
|
id: `baseline_${item.systemKey}`,
|
||||||
|
systemKey: item.systemKey,
|
||||||
|
label: item.label,
|
||||||
|
itemType: 'INTERNAL_PAGE' as MenuItemType,
|
||||||
|
routeOrUrl: item.routeOrUrl,
|
||||||
|
icon: item.icon,
|
||||||
|
parentId: null,
|
||||||
|
openInNewTab: false,
|
||||||
|
displayOrder: item.displayOrder,
|
||||||
|
children: [],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
export async function previewCompanyMenu(input: MenuPreviewInput) {
|
export async function previewCompanyMenu(input: MenuPreviewInput) {
|
||||||
const context = await getMenuEvaluationContext(input.companyId, input.role)
|
const context = await getMenuEvaluationContext(input.companyId, input.role)
|
||||||
return {
|
return {
|
||||||
company: context.company,
|
company: context.company,
|
||||||
role: context.role,
|
role: context.role,
|
||||||
subscriptionPlan: context.currentPlan,
|
subscriptionPlan: context.currentPlan,
|
||||||
|
subscriptionStatus: context.subscriptionStatus,
|
||||||
|
subscriptionAccessLevel: context.subscriptionAccessLevel,
|
||||||
items: context.items,
|
items: context.items,
|
||||||
finalMenu: buildMenuTree(
|
finalMenu: buildMenuTree(
|
||||||
context.items
|
context.items
|
||||||
@@ -610,12 +660,7 @@ export async function getEmployeeMenu(employeeId: string) {
|
|||||||
|
|
||||||
const context = await getMenuEvaluationContext(employee.companyId, employee.role, employee.isActive)
|
const context = await getMenuEvaluationContext(employee.companyId, employee.role, employee.isActive)
|
||||||
const visibleItems = context.items.filter((item) => item.visible)
|
const visibleItems = context.items.filter((item) => item.visible)
|
||||||
|
const menuItems = buildMenuTree(
|
||||||
return {
|
|
||||||
companyId: employee.companyId,
|
|
||||||
role: employee.role,
|
|
||||||
subscriptionPlan: context.currentPlan,
|
|
||||||
items: buildMenuTree(
|
|
||||||
visibleItems.map((item) => ({
|
visibleItems.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
systemKey: item.systemKey,
|
systemKey: item.systemKey,
|
||||||
@@ -627,7 +672,19 @@ export async function getEmployeeMenu(employeeId: string) {
|
|||||||
openInNewTab: item.openInNewTab,
|
openInNewTab: item.openInNewTab,
|
||||||
displayOrder: item.displayOrder,
|
displayOrder: item.displayOrder,
|
||||||
})),
|
})),
|
||||||
),
|
)
|
||||||
|
const resolvedItems =
|
||||||
|
context.subscriptionAccessLevel === 'full' && employee.isActive && !hasFeatureMenuRoute(menuItems)
|
||||||
|
? buildBaselineEmployeeMenu(employee.role)
|
||||||
|
: menuItems
|
||||||
|
|
||||||
|
return {
|
||||||
|
companyId: employee.companyId,
|
||||||
|
role: employee.role,
|
||||||
|
subscriptionPlan: context.currentPlan,
|
||||||
|
subscriptionStatus: context.subscriptionStatus,
|
||||||
|
subscriptionAccessLevel: context.subscriptionAccessLevel,
|
||||||
|
items: resolvedItems,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
vi.mock('../../lib/prisma', () => ({
|
vi.mock('../../lib/prisma', () => ({
|
||||||
prisma: {
|
prisma: {
|
||||||
rentalPayment: { findMany: vi.fn(), findFirst: vi.fn(), findFirstOrThrow: vi.fn(), update: vi.fn(), updateMany: vi.fn(), create: vi.fn() },
|
rentalPayment: { findMany: vi.fn(), findFirst: vi.fn(), findFirstOrThrow: vi.fn(), update: vi.fn(), updateMany: vi.fn(), create: vi.fn() },
|
||||||
reservation: { findFirstOrThrow: vi.fn(), update: vi.fn() },
|
reservation: { findFirstOrThrow: vi.fn(), update: vi.fn(), findUniqueOrThrow: vi.fn() },
|
||||||
|
$transaction: vi.fn(async (callback) => callback({
|
||||||
|
reservation: {
|
||||||
|
findUniqueOrThrow: vi.fn().mockResolvedValue({
|
||||||
|
id: 'reservation_1',
|
||||||
|
totalAmount: 5000,
|
||||||
|
rentalPayments: [{ amount: 2500 }, { amount: 1500 }],
|
||||||
|
}),
|
||||||
|
update: vi.fn(),
|
||||||
|
},
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -46,13 +56,10 @@ describe('payment.repo edge queries', () => {
|
|||||||
vi.useRealTimers()
|
vi.useRealTimers()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('increments paid amount and promotes reservation payment status to paid', async () => {
|
it('recomputes paid amount from successful charge payments', async () => {
|
||||||
await repo.incrementReservationPaid('reservation_1', 2500)
|
await repo.incrementReservationPaid('reservation_1', 2500)
|
||||||
|
|
||||||
expect(prisma.reservation.update).toHaveBeenCalledWith({
|
expect(prisma.$transaction).toHaveBeenCalled()
|
||||||
where: { id: 'reservation_1' },
|
|
||||||
data: { paymentStatus: 'PAID', paidAmount: { increment: 2500 } },
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('maps partial refunds to the correct payment status', async () => {
|
it('maps partial refunds to the correct payment status', async () => {
|
||||||
|
|||||||
@@ -32,12 +32,15 @@ export function findPaymentOrThrow(paymentId: string, companyId: string, reserva
|
|||||||
export function findReservationOrThrow(id: string, companyId: string) {
|
export function findReservationOrThrow(id: string, companyId: string) {
|
||||||
return prisma.reservation.findFirstOrThrow({
|
return prisma.reservation.findFirstOrThrow({
|
||||||
where: { id, companyId },
|
where: { id, companyId },
|
||||||
include: { vehicle: true, customer: true },
|
include: { vehicle: true, customer: true, rentalPayments: true },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findReservation(id: string, companyId: string) {
|
export function findReservation(id: string, companyId: string) {
|
||||||
return prisma.reservation.findFirstOrThrow({ where: { id, companyId } })
|
return prisma.reservation.findFirstOrThrow({
|
||||||
|
where: { id, companyId },
|
||||||
|
include: { rentalPayments: true },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function markPaymentSucceeded(id: string) {
|
export function markPaymentSucceeded(id: string) {
|
||||||
@@ -48,8 +51,26 @@ export function markPaymentFailed(query: { amanpayTransactionId?: string; paypal
|
|||||||
return prisma.rentalPayment.updateMany({ where: query, data: { status: 'FAILED' } })
|
return prisma.rentalPayment.updateMany({ where: query, data: { status: 'FAILED' } })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function incrementReservationPaid(reservationId: string, amount: number) {
|
export function incrementReservationPaid(reservationId: string, _amount: number) {
|
||||||
return prisma.reservation.update({ where: { id: reservationId }, data: { paymentStatus: 'PAID', paidAmount: { increment: amount } } })
|
return prisma.$transaction(async (tx) => {
|
||||||
|
const reservation = await tx.reservation.findUniqueOrThrow({
|
||||||
|
where: { id: reservationId },
|
||||||
|
include: {
|
||||||
|
rentalPayments: {
|
||||||
|
where: { type: 'CHARGE', status: 'SUCCEEDED' },
|
||||||
|
select: { amount: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const paidAmount = reservation.rentalPayments.reduce((total, payment) => total + payment.amount, 0)
|
||||||
|
return tx.reservation.update({
|
||||||
|
where: { id: reservationId },
|
||||||
|
data: {
|
||||||
|
paidAmount,
|
||||||
|
paymentStatus: paidAmount >= reservation.totalAmount ? 'PAID' : 'PARTIAL',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createPayment(data: {
|
export function createPayment(data: {
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ router.post('/reservations/:id/capture-paypal', requireRole('MANAGER'), async (r
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/reservations/:id/manual', requireRole('MANAGER'), async (req, res, next) => {
|
router.post('/reservations/:id/manual', requireRole('OWNER'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = parseParams(reservationParamSchema, req)
|
const { id } = parseParams(reservationParamSchema, req)
|
||||||
const body = parseBody(manualPaymentSchema, req)
|
const body = parseBody(manualPaymentSchema, req)
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ const reservation = {
|
|||||||
depositAmount: 300,
|
depositAmount: 300,
|
||||||
totalAmount: 1200,
|
totalAmount: 1200,
|
||||||
paidAmount: 200,
|
paidAmount: 200,
|
||||||
|
rentalPayments: [],
|
||||||
vehicle: { make: 'Dacia', model: 'Duster' },
|
vehicle: { make: 'Dacia', model: 'Duster' },
|
||||||
customer: { firstName: 'Nora', lastName: 'Driver', email: 'nora@example.com' },
|
customer: { firstName: 'Nora', lastName: 'Driver', email: 'nora@example.com' },
|
||||||
}
|
}
|
||||||
@@ -106,7 +107,11 @@ describe('payment.service', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('refuses to initialize a charge for a fully paid reservation before touching gateways', async () => {
|
it('refuses to initialize a charge for a fully paid reservation before touching gateways', async () => {
|
||||||
vi.mocked(repo.findReservationOrThrow).mockResolvedValue({ ...reservation, paymentStatus: 'PAID' } as never)
|
vi.mocked(repo.findReservationOrThrow).mockResolvedValue({
|
||||||
|
...reservation,
|
||||||
|
paymentStatus: 'PAID',
|
||||||
|
rentalPayments: [{ type: 'CHARGE', status: 'SUCCEEDED', amount: 1200 }],
|
||||||
|
} as never)
|
||||||
|
|
||||||
await expect(initCharge('reservation_1', 'company_1', {
|
await expect(initCharge('reservation_1', 'company_1', {
|
||||||
provider: 'PAYPAL',
|
provider: 'PAYPAL',
|
||||||
@@ -120,8 +125,36 @@ describe('payment.service', () => {
|
|||||||
expect(repo.createPayment).not.toHaveBeenCalled()
|
expect(repo.createPayment).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('allows an outstanding deposit when the rental invoice is fully paid', async () => {
|
||||||
|
vi.mocked(repo.findReservationOrThrow).mockResolvedValue({
|
||||||
|
...reservation,
|
||||||
|
paymentStatus: 'PAID',
|
||||||
|
rentalPayments: [{ type: 'CHARGE', status: 'SUCCEEDED', amount: 1200 }],
|
||||||
|
} as never)
|
||||||
|
vi.mocked(amanpay.isConfigured).mockReturnValue(true)
|
||||||
|
vi.mocked(amanpay.createCheckout).mockResolvedValue({ checkoutUrl: 'https://pay.example/checkout', transactionId: 'aman_txn_2' } as never)
|
||||||
|
vi.mocked(repo.createPayment).mockResolvedValue({ id: 'payment_2', status: 'PENDING' } as never)
|
||||||
|
|
||||||
|
await initCharge('reservation_1', 'company_1', {
|
||||||
|
provider: 'AMANPAY',
|
||||||
|
type: 'DEPOSIT',
|
||||||
|
currency: 'MAD',
|
||||||
|
successUrl: 'https://app.example/success',
|
||||||
|
failureUrl: 'https://app.example/failure',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(amanpay.createCheckout).toHaveBeenCalledWith(expect.objectContaining({ amount: 300 }))
|
||||||
|
expect(repo.createPayment).toHaveBeenCalledWith(expect.objectContaining({ type: 'DEPOSIT' }))
|
||||||
|
})
|
||||||
|
|
||||||
it('records manual payments and marks the reservation as partially or fully paid from the balance math', async () => {
|
it('records manual payments and marks the reservation as partially or fully paid from the balance math', async () => {
|
||||||
vi.mocked(repo.findReservation).mockResolvedValue({ id: 'reservation_1', totalAmount: 1000, paidAmount: 700 } as never)
|
vi.mocked(repo.findReservation).mockResolvedValue({
|
||||||
|
id: 'reservation_1',
|
||||||
|
totalAmount: 1000,
|
||||||
|
depositAmount: 300,
|
||||||
|
paidAmount: 700,
|
||||||
|
rentalPayments: [{ type: 'CHARGE', status: 'SUCCEEDED', amount: 700 }],
|
||||||
|
} as never)
|
||||||
vi.mocked(repo.createPayment).mockResolvedValue({ id: 'payment_1', amount: 300, status: 'SUCCEEDED' } as never)
|
vi.mocked(repo.createPayment).mockResolvedValue({ id: 'payment_1', amount: 300, status: 'SUCCEEDED' } as never)
|
||||||
|
|
||||||
const payment = await recordManualPayment('reservation_1', 'company_1', {
|
const payment = await recordManualPayment('reservation_1', 'company_1', {
|
||||||
@@ -133,7 +166,7 @@ describe('payment.service', () => {
|
|||||||
|
|
||||||
expect(repo.createPayment).toHaveBeenCalledWith(expect.objectContaining({
|
expect(repo.createPayment).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
status: 'SUCCEEDED',
|
status: 'SUCCEEDED',
|
||||||
paymentProvider: 'AMANPAY',
|
paymentProvider: 'MANUAL',
|
||||||
paymentMethod: 'CASH',
|
paymentMethod: 'CASH',
|
||||||
paidAt: expect.any(Date),
|
paidAt: expect.any(Date),
|
||||||
}))
|
}))
|
||||||
@@ -142,7 +175,13 @@ describe('payment.service', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('rejects manual overpayments instead of silently corrupting the reservation balance', async () => {
|
it('rejects manual overpayments instead of silently corrupting the reservation balance', async () => {
|
||||||
vi.mocked(repo.findReservation).mockResolvedValue({ id: 'reservation_1', totalAmount: 1000, paidAmount: 950 } as never)
|
vi.mocked(repo.findReservation).mockResolvedValue({
|
||||||
|
id: 'reservation_1',
|
||||||
|
totalAmount: 1000,
|
||||||
|
depositAmount: 300,
|
||||||
|
paidAmount: 950,
|
||||||
|
rentalPayments: [{ type: 'CHARGE', status: 'SUCCEEDED', amount: 950 }],
|
||||||
|
} as never)
|
||||||
|
|
||||||
await expect(recordManualPayment('reservation_1', 'company_1', {
|
await expect(recordManualPayment('reservation_1', 'company_1', {
|
||||||
amount: 100,
|
amount: 100,
|
||||||
@@ -156,8 +195,8 @@ describe('payment.service', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('applies paid AmanPay and denied PayPal webhook events to the matching records', async () => {
|
it('applies paid AmanPay and denied PayPal webhook events to the matching records', async () => {
|
||||||
vi.mocked(repo.findByAmanpay).mockResolvedValue({ id: 'payment_1', reservationId: 'reservation_1', amount: 450 } as never)
|
vi.mocked(repo.findByAmanpay).mockResolvedValue({ id: 'payment_1', reservationId: 'reservation_1', amount: 450, type: 'CHARGE' } as never)
|
||||||
vi.mocked(repo.findByPaypal).mockResolvedValue({ id: 'payment_2', reservationId: 'reservation_2', amount: 500 } as never)
|
vi.mocked(repo.findByPaypal).mockResolvedValue({ id: 'payment_2', reservationId: 'reservation_2', amount: 500, type: 'CHARGE' } as never)
|
||||||
|
|
||||||
await handleAmanpayWebhook({ transaction_id: 'aman_txn_1', status: 'paid' })
|
await handleAmanpayWebhook({ transaction_id: 'aman_txn_1', status: 'paid' })
|
||||||
await handlePaypalWebhook({ event_type: 'PAYMENT.CAPTURE.COMPLETED', resource: { id: 'paypal_capture_1' } })
|
await handlePaypalWebhook({ event_type: 'PAYMENT.CAPTURE.COMPLETED', resource: { id: 'paypal_capture_1' } })
|
||||||
@@ -171,7 +210,7 @@ describe('payment.service', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('captures PayPal orders, stores the capture id, and increments the original reservation payment', async () => {
|
it('captures PayPal orders, stores the capture id, and increments the original reservation payment', async () => {
|
||||||
vi.mocked(repo.findByPaypalForCompany).mockResolvedValue({ id: 'payment_1', reservationId: 'reservation_1', amount: 800 } as never)
|
vi.mocked(repo.findByPaypalForCompany).mockResolvedValue({ id: 'payment_1', reservationId: 'reservation_1', amount: 800, type: 'CHARGE' } as never)
|
||||||
vi.mocked(paypal.captureOrder).mockResolvedValue({ purchase_units: [{ payments: { captures: [{ id: 'capture_123' }] } }] } as never)
|
vi.mocked(paypal.captureOrder).mockResolvedValue({ purchase_units: [{ payments: { captures: [{ id: 'capture_123' }] } }] } as never)
|
||||||
vi.mocked(repo.updatePaypalCapture).mockResolvedValue({ id: 'payment_1', status: 'SUCCEEDED', paypalCaptureId: 'capture_123' } as never)
|
vi.mocked(repo.updatePaypalCapture).mockResolvedValue({ id: 'payment_1', status: 'SUCCEEDED', paypalCaptureId: 'capture_123' } as never)
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,17 @@ export function listByReservation(reservationId: string, companyId: string) {
|
|||||||
return repo.findByReservation(reservationId, companyId)
|
return repo.findByReservation(reservationId, companyId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sumSucceededPayments(payments: any[], type: 'CHARGE' | 'DEPOSIT') {
|
||||||
|
return payments.reduce((total: number, payment: any) => {
|
||||||
|
if (payment.type !== type || payment.status !== 'SUCCEEDED') return total
|
||||||
|
return total + payment.amount
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInvoicePaid(reservation: any, rentalPayments: any[]) {
|
||||||
|
return Math.max(sumSucceededPayments(rentalPayments, 'CHARGE'), reservation.paidAmount ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
async function applyAmanpayWebhook(event: any) {
|
async function applyAmanpayWebhook(event: any) {
|
||||||
const transactionId = event.transaction_id ?? event.id
|
const transactionId = event.transaction_id ?? event.id
|
||||||
const status = event.status?.toUpperCase()
|
const status = event.status?.toUpperCase()
|
||||||
@@ -19,8 +30,10 @@ async function applyAmanpayWebhook(event: any) {
|
|||||||
const payment = await repo.findByAmanpay(transactionId)
|
const payment = await repo.findByAmanpay(transactionId)
|
||||||
if (payment && payment.status !== 'SUCCEEDED') {
|
if (payment && payment.status !== 'SUCCEEDED') {
|
||||||
await repo.markPaymentSucceeded(payment.id)
|
await repo.markPaymentSucceeded(payment.id)
|
||||||
|
if (payment.type === 'CHARGE') {
|
||||||
await repo.incrementReservationPaid(payment.reservationId, payment.amount)
|
await repo.incrementReservationPaid(payment.reservationId, payment.amount)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else if (status === 'FAILED') {
|
} else if (status === 'FAILED') {
|
||||||
await repo.markPaymentFailed({ amanpayTransactionId: transactionId })
|
await repo.markPaymentFailed({ amanpayTransactionId: transactionId })
|
||||||
}
|
}
|
||||||
@@ -33,8 +46,10 @@ async function applyPaypalWebhook(event: any) {
|
|||||||
const payment = await repo.findByPaypal(captureId)
|
const payment = await repo.findByPaypal(captureId)
|
||||||
if (payment && payment.status !== 'SUCCEEDED') {
|
if (payment && payment.status !== 'SUCCEEDED') {
|
||||||
await repo.markPaymentSucceeded(payment.id)
|
await repo.markPaymentSucceeded(payment.id)
|
||||||
|
if (payment.type === 'CHARGE') {
|
||||||
await repo.incrementReservationPaid(payment.reservationId, payment.amount)
|
await repo.incrementReservationPaid(payment.reservationId, payment.amount)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else if (eventType === 'PAYMENT.CAPTURE.DENIED') {
|
} else if (eventType === 'PAYMENT.CAPTURE.DENIED') {
|
||||||
await repo.markPaymentFailed({ paypalCaptureId: event.resource?.id })
|
await repo.markPaymentFailed({ paypalCaptureId: event.resource?.id })
|
||||||
}
|
}
|
||||||
@@ -65,9 +80,18 @@ export async function initCharge(reservationId: string, companyId: string, body:
|
|||||||
currency: 'MAD'; successUrl: string; failureUrl: string
|
currency: 'MAD'; successUrl: string; failureUrl: string
|
||||||
}) {
|
}) {
|
||||||
const reservation = await repo.findReservationOrThrow(reservationId, companyId)
|
const reservation = await repo.findReservationOrThrow(reservationId, companyId)
|
||||||
if (reservation.paymentStatus === 'PAID') throw new ConflictError('Reservation is already fully paid')
|
const rentalPayments = (reservation as any).rentalPayments ?? []
|
||||||
|
const invoicePaid = getInvoicePaid(reservation, rentalPayments)
|
||||||
|
const depositCollected = sumSucceededPayments(rentalPayments, 'DEPOSIT')
|
||||||
|
const balanceDue = body.type === 'DEPOSIT'
|
||||||
|
? reservation.depositAmount - depositCollected
|
||||||
|
: reservation.totalAmount - invoicePaid
|
||||||
|
|
||||||
const amount = body.type === 'DEPOSIT' ? reservation.depositAmount : reservation.totalAmount
|
if (balanceDue <= 0) {
|
||||||
|
throw new ConflictError(body.type === 'DEPOSIT' ? 'Security deposit is already fully collected' : 'Reservation is already fully paid')
|
||||||
|
}
|
||||||
|
|
||||||
|
const amount = balanceDue
|
||||||
const description = `${body.type === 'DEPOSIT' ? 'Deposit' : 'Rental'}: ${reservation.vehicle.make} ${reservation.vehicle.model}`
|
const description = `${body.type === 'DEPOSIT' ? 'Deposit' : 'Rental'}: ${reservation.vehicle.make} ${reservation.vehicle.model}`
|
||||||
const orderId = `${reservationId}-${body.type}-${Date.now()}`
|
const orderId = `${reservationId}-${body.type}-${Date.now()}`
|
||||||
const webhookBase = process.env.API_URL ?? 'http://localhost:4000'
|
const webhookBase = process.env.API_URL ?? 'http://localhost:4000'
|
||||||
@@ -103,7 +127,9 @@ export async function capturePaypal(reservationId: string, companyId: string, pa
|
|||||||
const capture = await paypal.captureOrder(paypalOrderId) as Record<string, any>
|
const capture = await paypal.captureOrder(paypalOrderId) as Record<string, any>
|
||||||
const captureId = capture.purchase_units?.[0]?.payments?.captures?.[0]?.id ?? paypalOrderId
|
const captureId = capture.purchase_units?.[0]?.payments?.captures?.[0]?.id ?? paypalOrderId
|
||||||
const updated = await repo.updatePaypalCapture(payment.id, captureId)
|
const updated = await repo.updatePaypalCapture(payment.id, captureId)
|
||||||
|
if (payment.type === 'CHARGE') {
|
||||||
await repo.incrementReservationPaid(payment.reservationId, payment.amount)
|
await repo.incrementReservationPaid(payment.reservationId, payment.amount)
|
||||||
|
}
|
||||||
return updated
|
return updated
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,13 +137,28 @@ export async function recordManualPayment(reservationId: string, companyId: stri
|
|||||||
amount: number; currency: string; type: string; paymentMethod: string
|
amount: number; currency: string; type: string; paymentMethod: string
|
||||||
}) {
|
}) {
|
||||||
const reservation = await repo.findReservation(reservationId, companyId)
|
const reservation = await repo.findReservation(reservationId, companyId)
|
||||||
const remaining = Math.max(reservation.totalAmount - reservation.paidAmount, 0)
|
const rentalPayments = (reservation as any).rentalPayments ?? []
|
||||||
if (remaining <= 0) throw new ConflictError('Reservation is already fully paid')
|
const invoicePaid = getInvoicePaid(reservation, rentalPayments)
|
||||||
if (body.amount > remaining) throw new ValidationError('Payment amount exceeds remaining balance')
|
const depositCollected = sumSucceededPayments(rentalPayments, 'DEPOSIT')
|
||||||
|
const remaining = body.type === 'DEPOSIT'
|
||||||
|
? reservation.depositAmount - depositCollected
|
||||||
|
: reservation.totalAmount - invoicePaid
|
||||||
|
|
||||||
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() })
|
if (remaining <= 0) {
|
||||||
const newPaidAmount = reservation.paidAmount + body.amount
|
throw new ConflictError(body.type === 'DEPOSIT' ? 'Security deposit is already fully collected' : 'Reservation is already fully paid')
|
||||||
|
}
|
||||||
|
if (body.amount <= 0) {
|
||||||
|
throw new ValidationError('Payment amount must be greater than zero')
|
||||||
|
}
|
||||||
|
if (body.amount > remaining) {
|
||||||
|
throw new ValidationError(body.type === 'DEPOSIT' ? 'Payment amount exceeds deposit outstanding' : 'Payment amount exceeds remaining balance')
|
||||||
|
}
|
||||||
|
|
||||||
|
const payment = await repo.createPayment({ companyId, reservationId, amount: body.amount, currency: body.currency, status: 'SUCCEEDED', type: body.type, paymentProvider: 'MANUAL', paymentMethod: body.paymentMethod, paidAt: new Date() })
|
||||||
|
if (body.type === 'CHARGE') {
|
||||||
|
const newPaidAmount = invoicePaid + body.amount
|
||||||
await repo.setReservationPaidAmount(reservationId, newPaidAmount, newPaidAmount >= reservation.totalAmount ? 'PAID' : 'PARTIAL')
|
await repo.setReservationPaidAmount(reservationId, newPaidAmount, newPaidAmount >= reservation.totalAmount ? 'PAID' : 'PARTIAL')
|
||||||
|
}
|
||||||
return payment
|
return payment
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export async function confirmReservation(id: string, companyId: string) {
|
|||||||
},
|
},
|
||||||
}).catch(() => null)
|
}).catch(() => null)
|
||||||
|
|
||||||
if (reservation.source === 'MARKETPLACE') {
|
if (reservation.source === 'CARPLACE') {
|
||||||
const progress = buildBookingRequestProgress({
|
const progress = buildBookingRequestProgress({
|
||||||
source: reservation.source,
|
source: reservation.source,
|
||||||
status: 'CONFIRMED',
|
status: 'CONFIRMED',
|
||||||
@@ -193,7 +193,7 @@ export async function closeReservation(id: string, companyId: string, closedBy:
|
|||||||
const lang = (company?.brand?.defaultLocale ?? 'fr') as Lang
|
const lang = (company?.brand?.defaultLocale ?? 'fr') as Lang
|
||||||
const companyName = company?.brand?.displayName ?? company?.name ?? 'the rental company'
|
const companyName = company?.brand?.displayName ?? company?.name ?? 'the rental company'
|
||||||
const vehicleLabel = `${updated.vehicle.year} ${updated.vehicle.make} ${updated.vehicle.model}`
|
const vehicleLabel = `${updated.vehicle.year} ${updated.vehicle.make} ${updated.vehicle.model}`
|
||||||
const reviewUrl = `${process.env.MARKETPLACE_URL ?? 'http://localhost:3000'}/review?token=${reservation.reviewToken}`
|
const reviewUrl = `${process.env.CARPLACE_URL ?? 'http://localhost:3000'}/review?token=${reservation.reviewToken}`
|
||||||
|
|
||||||
sendTransactionalEmail({
|
sendTransactionalEmail({
|
||||||
to: customer.email,
|
to: customer.email,
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ describe('reservation.presenter boundary behavior', () => {
|
|||||||
const result = serializeReservationForDashboard({
|
const result = serializeReservationForDashboard({
|
||||||
id: 'reservation_1',
|
id: 'reservation_1',
|
||||||
status: 'DRAFT',
|
status: 'DRAFT',
|
||||||
source: 'MARKETPLACE',
|
source: 'CARPLACE',
|
||||||
contractNumber: null,
|
contractNumber: null,
|
||||||
invoiceNumber: null,
|
invoiceNumber: null,
|
||||||
paymentStatus: 'UNPAID',
|
paymentStatus: 'UNPAID',
|
||||||
@@ -84,7 +84,7 @@ describe('reservation.presenter boundary behavior', () => {
|
|||||||
const result = serializeReservationForDashboard({
|
const result = serializeReservationForDashboard({
|
||||||
id: 'reservation_1',
|
id: 'reservation_1',
|
||||||
status: 'DRAFT',
|
status: 'DRAFT',
|
||||||
source: 'MARKETPLACE',
|
source: 'CARPLACE',
|
||||||
contractNumber: null,
|
contractNumber: null,
|
||||||
invoiceNumber: null,
|
invoiceNumber: null,
|
||||||
paymentStatus: 'UNPAID',
|
paymentStatus: 'UNPAID',
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ describe('serializeReservationForDashboard', () => {
|
|||||||
const result = serializeReservationForDashboard({
|
const result = serializeReservationForDashboard({
|
||||||
id: 'reservation-1',
|
id: 'reservation-1',
|
||||||
status: 'CONFIRMED',
|
status: 'CONFIRMED',
|
||||||
source: 'MARKETPLACE',
|
source: 'CARPLACE',
|
||||||
contractNumber: null,
|
contractNumber: null,
|
||||||
invoiceNumber: null,
|
invoiceNumber: null,
|
||||||
paymentStatus: 'UNPAID',
|
paymentStatus: 'UNPAID',
|
||||||
|
|||||||
@@ -68,8 +68,8 @@ export function buildBookingRequestProgress(reservation: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const companyConfirmed = !['DRAFT', 'CANCELLED', 'NO_SHOW'].includes(reservation.status)
|
const companyConfirmed = !['DRAFT', 'CANCELLED', 'NO_SHOW'].includes(reservation.status)
|
||||||
const documentsRequired = reservation.source === 'MARKETPLACE' && companyConfirmed && documentsMissing.length > 0
|
const documentsRequired = reservation.source === 'CARPLACE' && companyConfirmed && documentsMissing.length > 0
|
||||||
const paymentRequired = reservation.source === 'MARKETPLACE' && companyConfirmed && !['PAID', 'COMPLETED'].includes(paymentStatus)
|
const paymentRequired = reservation.source === 'CARPLACE' && companyConfirmed && !['PAID', 'COMPLETED'].includes(paymentStatus)
|
||||||
|
|
||||||
let stage: 'REQUEST_SENT' | 'COMPANY_CONFIRMED' | 'DOCUMENTS_REQUIRED' | 'PAYMENT_REQUIRED' | 'BOOKING_CONFIRMED' | 'CANCELLED'
|
let stage: 'REQUEST_SENT' | 'COMPANY_CONFIRMED' | 'DOCUMENTS_REQUIRED' | 'PAYMENT_REQUIRED' | 'BOOKING_CONFIRMED' | 'CANCELLED'
|
||||||
if (['CANCELLED', 'NO_SHOW'].includes(reservation.status)) stage = 'CANCELLED'
|
if (['CANCELLED', 'NO_SHOW'].includes(reservation.status)) stage = 'CANCELLED'
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ describe('review.service', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
vi.setSystemTime(new Date('2026-06-08T12:00:00.000Z'))
|
vi.setSystemTime(new Date('2026-06-08T12:00:00.000Z'))
|
||||||
process.env.MARKETPLACE_URL = 'https://market.example'
|
process.env.CARPLACE_URL = 'https://market.example'
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns reviews with rating filters and pagination metadata', async () => {
|
it('returns reviews with rating filters and pagination metadata', async () => {
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export async function sendReviewReminder(id: string, companyId: string) {
|
|||||||
const companyName = company?.brand?.displayName ?? company?.name ?? 'the rental company'
|
const companyName = company?.brand?.displayName ?? company?.name ?? 'the rental company'
|
||||||
const vehicle = reservation.vehicle
|
const vehicle = reservation.vehicle
|
||||||
const vehicleLabel = vehicle ? `${vehicle.year} ${vehicle.make} ${vehicle.model}` : 'your rental vehicle'
|
const vehicleLabel = vehicle ? `${vehicle.year} ${vehicle.make} ${vehicle.model}` : 'your rental vehicle'
|
||||||
const reviewUrl = `${process.env.MARKETPLACE_URL ?? 'http://localhost:3000'}/review?token=${reservation.reviewToken}`
|
const reviewUrl = `${process.env.CARPLACE_URL ?? 'http://localhost:3000'}/review?token=${reservation.reviewToken}`
|
||||||
|
|
||||||
await sendTransactionalEmail({
|
await sendTransactionalEmail({
|
||||||
to: customer.email,
|
to: customer.email,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ describe('site.presenter', () => {
|
|||||||
paymentMethodsEnabled: ['PAYPAL'],
|
paymentMethodsEnabled: ['PAYPAL'],
|
||||||
defaultLocale: 'fr',
|
defaultLocale: 'fr',
|
||||||
defaultCurrency: 'MAD',
|
defaultCurrency: 'MAD',
|
||||||
isListedOnMarketplace: true,
|
isListedOnCarplace: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ describe('site.presenter', () => {
|
|||||||
paymentMethodsEnabled: ['PAYPAL'],
|
paymentMethodsEnabled: ['PAYPAL'],
|
||||||
defaultLocale: 'fr',
|
defaultLocale: 'fr',
|
||||||
defaultCurrency: 'MAD',
|
defaultCurrency: 'MAD',
|
||||||
isListedOnMarketplace: true,
|
isListedOnCarplace: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
expect(result.brand).not.toHaveProperty('amanpaySecretKey')
|
expect(result.brand).not.toHaveProperty('amanpaySecretKey')
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ export function presentBrand(company: {
|
|||||||
paypalEmail: brand.paypalEmail,
|
paypalEmail: brand.paypalEmail,
|
||||||
paypalMerchantId: brand.paypalMerchantId,
|
paypalMerchantId: brand.paypalMerchantId,
|
||||||
paymentMethodsEnabled: brand.paymentMethodsEnabled,
|
paymentMethodsEnabled: brand.paymentMethodsEnabled,
|
||||||
isListedOnMarketplace: brand.isListedOnMarketplace,
|
isListedOnCarplace: brand.isListedOnCarplace,
|
||||||
marketplaceRating: brand.marketplaceRating,
|
carplaceRating: brand.carplaceRating,
|
||||||
homePageConfig: brand.homePageConfig,
|
homePageConfig: brand.homePageConfig,
|
||||||
menuConfig: brand.menuConfig,
|
menuConfig: brand.menuConfig,
|
||||||
} : null,
|
} : null,
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export const bookSchema = z.object({
|
|||||||
offerId: z.string().cuid().optional(),
|
offerId: z.string().cuid().optional(),
|
||||||
promoCodeUsed: z.string().optional(),
|
promoCodeUsed: z.string().optional(),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
source: z.enum(['PUBLIC_SITE', 'MARKETPLACE']).default('PUBLIC_SITE'),
|
source: z.enum(['PUBLIC_SITE', 'CARPLACE']).default('PUBLIC_SITE'),
|
||||||
selectedInsurancePolicyIds: z.array(z.string()).default([]),
|
selectedInsurancePolicyIds: z.array(z.string()).default([]),
|
||||||
additionalDrivers: z.array(z.object({
|
additionalDrivers: z.array(z.object({
|
||||||
firstName: z.string().min(1),
|
firstName: z.string().min(1),
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ vi.mock('../../lib/prisma', () => ({
|
|||||||
planFeature: { findMany: vi.fn() },
|
planFeature: { findMany: vi.fn() },
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
vi.mock('../../services/platformContentService', () => ({ getMarketplaceHomepageContent: vi.fn() }))
|
vi.mock('../../services/platformContentService', () => ({ getCarplaceHomepageContent: vi.fn() }))
|
||||||
vi.mock('../../services/vehicleAvailabilityService', () => ({ getVehicleAvailabilitySummary: vi.fn() }))
|
vi.mock('../../services/vehicleAvailabilityService', () => ({ getVehicleAvailabilitySummary: vi.fn() }))
|
||||||
vi.mock('../../services/insuranceService', () => ({ applyInsurancesToReservation: vi.fn() }))
|
vi.mock('../../services/insuranceService', () => ({ applyInsurancesToReservation: vi.fn() }))
|
||||||
vi.mock('../../services/additionalDriverService', () => ({ applyAdditionalDriversToReservation: vi.fn() }))
|
vi.mock('../../services/additionalDriverService', () => ({ applyAdditionalDriversToReservation: vi.fn() }))
|
||||||
@@ -59,11 +59,19 @@ import * as service from './site.service'
|
|||||||
|
|
||||||
const company = { id: 'company_1', slug: 'atlas', email: 'office@example.test', contractSettings: { depositRequired: true }, brand: { publicEmail: 'hello@example.test' } }
|
const company = { id: 'company_1', slug: 'atlas', email: 'office@example.test', contractSettings: { depositRequired: true }, brand: { publicEmail: 'hello@example.test' } }
|
||||||
|
|
||||||
|
function futureDateRange(startOffsetDays: number, durationDays: number) {
|
||||||
|
const start = new Date(Date.now() + startOffsetDays * 24 * 60 * 60 * 1000)
|
||||||
|
const end = new Date(start.getTime() + durationDays * 24 * 60 * 60 * 1000)
|
||||||
|
return { start, end }
|
||||||
|
}
|
||||||
|
|
||||||
function bookingBody(overrides: Record<string, unknown> = {}) {
|
function bookingBody(overrides: Record<string, unknown> = {}) {
|
||||||
|
const { start, end } = futureDateRange(30, 3)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
vehicleId: 'vehicle_1',
|
vehicleId: 'vehicle_1',
|
||||||
startDate: '2026-07-01T00:00:00.000Z',
|
startDate: start.toISOString(),
|
||||||
endDate: '2026-07-04T00:00:00.000Z',
|
endDate: end.toISOString(),
|
||||||
firstName: 'Nora',
|
firstName: 'Nora',
|
||||||
lastName: 'Renter',
|
lastName: 'Renter',
|
||||||
email: 'nora@example.test',
|
email: 'nora@example.test',
|
||||||
@@ -103,6 +111,11 @@ describe('site.service public booking/payment boundaries', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('calculates booking totals from base rate, free-day promo, pricing rules, insurance and additional drivers', async () => {
|
it('calculates booking totals from base rate, free-day promo, pricing rules, insurance and additional drivers', async () => {
|
||||||
|
const body = bookingBody({
|
||||||
|
promoCodeUsed: 'FREEDAY',
|
||||||
|
selectedInsurancePolicyIds: ['insurance_1'],
|
||||||
|
additionalDrivers: [{ firstName: 'Second' }],
|
||||||
|
})
|
||||||
vi.mocked(repo.findVehicleById).mockResolvedValue({ id: 'vehicle_1', companyId: 'company_1', dailyRate: 500, category: 'SUV' } as never)
|
vi.mocked(repo.findVehicleById).mockResolvedValue({ id: 'vehicle_1', companyId: 'company_1', dailyRate: 500, category: 'SUV' } as never)
|
||||||
vi.mocked(repo.upsertCustomer).mockResolvedValue({ id: 'customer_1' } as never)
|
vi.mocked(repo.upsertCustomer).mockResolvedValue({ id: 'customer_1' } as never)
|
||||||
vi.mocked(repo.findOfferByPromoCode).mockResolvedValue({ id: 'offer_1', type: 'FREE_DAY', discountValue: 1 } as never)
|
vi.mocked(repo.findOfferByPromoCode).mockResolvedValue({ id: 'offer_1', type: 'FREE_DAY', discountValue: 1 } as never)
|
||||||
@@ -111,8 +124,8 @@ describe('site.service public booking/payment boundaries', () => {
|
|||||||
id: 'reservation_1',
|
id: 'reservation_1',
|
||||||
bookingReference: 'BK-2026-AB12CD',
|
bookingReference: 'BK-2026-AB12CD',
|
||||||
status: 'DRAFT',
|
status: 'DRAFT',
|
||||||
startDate: new Date('2026-07-01T10:00:00.000Z'),
|
startDate: new Date(body.startDate),
|
||||||
endDate: new Date('2026-07-04T10:00:00.000Z'),
|
endDate: new Date(body.endDate),
|
||||||
pickupLocation: 'Casablanca',
|
pickupLocation: 'Casablanca',
|
||||||
returnLocation: 'Casablanca',
|
returnLocation: 'Casablanca',
|
||||||
vehicle: { make: 'Dacia', model: 'Duster', year: 2024 },
|
vehicle: { make: 'Dacia', model: 'Duster', year: 2024 },
|
||||||
@@ -125,11 +138,7 @@ describe('site.service public booking/payment boundaries', () => {
|
|||||||
paymentStatus: 'UNPAID',
|
paymentStatus: 'UNPAID',
|
||||||
} as never)
|
} as never)
|
||||||
|
|
||||||
const result = await service.createBooking('atlas', bookingBody({
|
const result = await service.createBooking('atlas', body)
|
||||||
promoCodeUsed: 'FREEDAY',
|
|
||||||
selectedInsurancePolicyIds: ['insurance_1'],
|
|
||||||
additionalDrivers: [{ firstName: 'Second' }],
|
|
||||||
}))
|
|
||||||
|
|
||||||
expect(repo.createReservation).toHaveBeenCalledWith(expect.objectContaining({
|
expect(repo.createReservation).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
totalDays: 3,
|
totalDays: 3,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { AppError, NotFoundError } from '../../http/errors'
|
|||||||
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
|
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
|
||||||
import { applyPricingRules } from '../../services/pricingRuleService'
|
import { applyPricingRules } from '../../services/pricingRuleService'
|
||||||
import { validateLicense } from '../../services/licenseValidationService'
|
import { validateLicense } from '../../services/licenseValidationService'
|
||||||
import { getMarketplaceHomepageContent } from '../../services/platformContentService'
|
import { getCarplaceHomepageContent } from '../../services/platformContentService'
|
||||||
import { prisma } from '../../lib/prisma'
|
import { prisma } from '../../lib/prisma'
|
||||||
import * as amanpay from '../../services/amanpayService'
|
import * as amanpay from '../../services/amanpayService'
|
||||||
import * as paypal from '../../services/paypalService'
|
import * as paypal from '../../services/paypalService'
|
||||||
@@ -30,7 +30,7 @@ function assertAllowedPaymentRedirect(urlValue: string, company: any) {
|
|||||||
allowedHosts.add('127.0.0.1:3000')
|
allowedHosts.add('127.0.0.1:3000')
|
||||||
allowedHosts.add('127.0.0.1:4000')
|
allowedHosts.add('127.0.0.1:4000')
|
||||||
}
|
}
|
||||||
for (const value of [process.env.MARKETPLACE_URL, process.env.DASHBOARD_URL, process.env.NEXT_PUBLIC_MARKETPLACE_URL, process.env.NEXT_PUBLIC_DASHBOARD_URL]) {
|
for (const value of [process.env.CARPLACE_URL, process.env.DASHBOARD_URL, process.env.NEXT_PUBLIC_CARPLACE_URL, process.env.NEXT_PUBLIC_DASHBOARD_URL]) {
|
||||||
if (!value) continue
|
if (!value) continue
|
||||||
try { allowedHosts.add(new URL(value).host) } catch {}
|
try { allowedHosts.add(new URL(value).host) } catch {}
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,7 @@ function generateBookingReference() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getPlatformHomepage() {
|
export async function getPlatformHomepage() {
|
||||||
return getMarketplaceHomepageContent()
|
return getCarplaceHomepageContent()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPlatformPricing() {
|
export async function getPlatformPricing() {
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ vi.mock('../../services/paypalService', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('../../services/platformContentService', () => ({
|
vi.mock('../../services/platformContentService', () => ({
|
||||||
getMarketplaceHomepageContent: vi.fn(),
|
getCarplaceHomepageContent: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
import * as repo from './site.repo'
|
import * as repo from './site.repo'
|
||||||
@@ -82,6 +82,12 @@ import {
|
|||||||
|
|
||||||
const SLUG = 'test-company'
|
const SLUG = 'test-company'
|
||||||
|
|
||||||
|
function futureDateRange(startOffsetDays: number, durationDays: number) {
|
||||||
|
const start = new Date(Date.now() + startOffsetDays * 24 * 60 * 60 * 1000)
|
||||||
|
const end = new Date(start.getTime() + durationDays * 24 * 60 * 60 * 1000)
|
||||||
|
return { start, end }
|
||||||
|
}
|
||||||
|
|
||||||
function makeCompany(overrides: object = {}) {
|
function makeCompany(overrides: object = {}) {
|
||||||
return {
|
return {
|
||||||
id: 'co-1', slug: SLUG, name: 'Test Co', phone: null, email: 'co@test.com',
|
id: 'co-1', slug: SLUG, name: 'Test Co', phone: null, email: 'co@test.com',
|
||||||
@@ -173,10 +179,11 @@ describe('validatePromoCode', () => {
|
|||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
describe('createBooking', () => {
|
describe('createBooking', () => {
|
||||||
|
const { start: bookingStart, end: bookingEnd } = futureDateRange(30, 3)
|
||||||
const baseBody = {
|
const baseBody = {
|
||||||
vehicleId: 'v-1',
|
vehicleId: 'v-1',
|
||||||
startDate: '2026-07-01T00:00:00.000Z',
|
startDate: bookingStart.toISOString(),
|
||||||
endDate: '2026-07-04T00:00:00.000Z',
|
endDate: bookingEnd.toISOString(),
|
||||||
firstName: 'Ali', lastName: 'Ben', email: 'ali@test.com',
|
firstName: 'Ali', lastName: 'Ben', email: 'ali@test.com',
|
||||||
phone: '+212600000000',
|
phone: '+212600000000',
|
||||||
pickupLocation: 'Casablanca',
|
pickupLocation: 'Casablanca',
|
||||||
@@ -194,8 +201,8 @@ describe('createBooking', () => {
|
|||||||
id: 'r-1',
|
id: 'r-1',
|
||||||
bookingReference: 'BK-2026-ABC123',
|
bookingReference: 'BK-2026-ABC123',
|
||||||
status: 'DRAFT',
|
status: 'DRAFT',
|
||||||
startDate: new Date('2026-07-01T00:00:00.000Z'),
|
startDate: bookingStart,
|
||||||
endDate: new Date('2026-07-04T00:00:00.000Z'),
|
endDate: bookingEnd,
|
||||||
pickupLocation: 'Casablanca',
|
pickupLocation: 'Casablanca',
|
||||||
returnLocation: 'Casablanca',
|
returnLocation: 'Casablanca',
|
||||||
vehicle: { make: 'Toyota', model: 'Camry', year: 2022 },
|
vehicle: { make: 'Toyota', model: 'Camry', year: 2022 },
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ export type AccessLevel = 'full' | 'limited' | 'read_only' | 'none'
|
|||||||
|
|
||||||
export const SUBSCRIPTION_POLICY = {
|
export const SUBSCRIPTION_POLICY = {
|
||||||
trial: {
|
trial: {
|
||||||
durationDays: 14,
|
durationDays: 30,
|
||||||
requiresPaymentMethod: false, // set true when payment capture is mandatory
|
requiresPaymentMethod: false, // set true when payment capture is mandatory
|
||||||
oneTrialPerCompany: true,
|
oneTrialPerCompany: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -70,18 +70,18 @@ describe('subscription.service operational edges', () => {
|
|||||||
|
|
||||||
it('builds plans from pricing config rows when platform overrides exist', async () => {
|
it('builds plans from pricing config rows when platform overrides exist', async () => {
|
||||||
vi.mocked(prisma.pricingConfig.findMany).mockResolvedValue([
|
vi.mocked(prisma.pricingConfig.findMany).mockResolvedValue([
|
||||||
{ plan: 'STARTER', billingPeriod: 'MONTHLY', amount: 9900 },
|
{ plan: 'STARTER', billingPeriod: 'MONTHLY', amount: 14900 },
|
||||||
{ plan: 'STARTER', billingPeriod: 'ANNUAL', amount: 99000 },
|
{ plan: 'STARTER', billingPeriod: 'ANNUAL', amount: 143040 },
|
||||||
{ plan: 'PRO', billingPeriod: 'MONTHLY', amount: 29900 },
|
{ plan: 'PRO', billingPeriod: 'MONTHLY', amount: 39900 },
|
||||||
] as never)
|
] as never)
|
||||||
|
|
||||||
await expect(service.getPlans()).resolves.toEqual({
|
await expect(service.getPlans()).resolves.toEqual({
|
||||||
STARTER: {
|
STARTER: {
|
||||||
MONTHLY: { MAD: 9900 },
|
MONTHLY: { MAD: 14900 },
|
||||||
ANNUAL: { MAD: 99000 },
|
ANNUAL: { MAD: 143040 },
|
||||||
},
|
},
|
||||||
PRO: {
|
PRO: {
|
||||||
MONTHLY: { MAD: 29900 },
|
MONTHLY: { MAD: 39900 },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -102,7 +102,7 @@ describe('subscription.service operational edges', () => {
|
|||||||
'PRO',
|
'PRO',
|
||||||
'MONTHLY',
|
'MONTHLY',
|
||||||
'MAD',
|
'MAD',
|
||||||
new Date('2026-06-15T00:00:00.000Z'),
|
new Date('2026-07-01T00:00:00.000Z'),
|
||||||
)
|
)
|
||||||
expect(repo.createEvent).toHaveBeenCalledWith(expect.objectContaining({
|
expect(repo.createEvent).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
subscriptionId: 'sub_1',
|
subscriptionId: 'sub_1',
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { clearSessionCookie, setSessionCookie } from './sessionCookies'
|
||||||
|
|
||||||
|
const originalEnv = { ...process.env }
|
||||||
|
|
||||||
|
function mockResponse() {
|
||||||
|
return {
|
||||||
|
cookie: vi.fn(),
|
||||||
|
clearCookie: vi.fn(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = { ...originalEnv }
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('session cookies', () => {
|
||||||
|
it('sets employee session cookies for the configured parent domain', () => {
|
||||||
|
process.env.NODE_ENV = 'production'
|
||||||
|
process.env.SESSION_COOKIE_DOMAIN = '.rentaldrivego.ma'
|
||||||
|
const res = mockResponse()
|
||||||
|
|
||||||
|
setSessionCookie(res as never, 'employee', 'token-123', 1000)
|
||||||
|
|
||||||
|
expect(res.cookie).toHaveBeenCalledWith('employee_session', 'token-123', {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
domain: '.rentaldrivego.ma',
|
||||||
|
maxAge: 1000,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears cookies with the same domain attributes used when setting them', () => {
|
||||||
|
process.env.NODE_ENV = 'production'
|
||||||
|
process.env.SESSION_COOKIE_DOMAIN = '.rentaldrivego.ma'
|
||||||
|
const res = mockResponse()
|
||||||
|
|
||||||
|
clearSessionCookie(res as never, 'employee')
|
||||||
|
|
||||||
|
expect(res.clearCookie).toHaveBeenCalledWith('employee_session', {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
domain: '.rentaldrivego.ma',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits the domain option when no shared cookie domain is configured', () => {
|
||||||
|
delete process.env.SESSION_COOKIE_DOMAIN
|
||||||
|
const res = mockResponse()
|
||||||
|
|
||||||
|
setSessionCookie(res as never, 'employee', 'token-123')
|
||||||
|
|
||||||
|
expect(res.cookie).toHaveBeenCalledWith('employee_session', 'token-123', {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: false,
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Response } from 'express'
|
import type { CookieOptions, Response } from 'express'
|
||||||
import type { ActorType } from './tokens'
|
import type { ActorType } from './tokens'
|
||||||
|
|
||||||
const COOKIE_NAMES: Record<ActorType, string> = {
|
const COOKIE_NAMES: Record<ActorType, string> = {
|
||||||
@@ -11,21 +11,23 @@ export function getSessionCookieName(actorType: ActorType) {
|
|||||||
return COOKIE_NAMES[actorType]
|
return COOKIE_NAMES[actorType]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setSessionCookie(res: Response, actorType: ActorType, token: string, maxAgeMs?: number) {
|
function getSessionCookieOptions(actorType: ActorType, maxAgeMs?: number): CookieOptions {
|
||||||
res.cookie(getSessionCookieName(actorType), token, {
|
const domain = process.env.SESSION_COOKIE_DOMAIN?.trim()
|
||||||
|
|
||||||
|
return {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: process.env.NODE_ENV === 'production',
|
||||||
sameSite: actorType === 'admin' ? 'strict' : 'lax',
|
sameSite: actorType === 'admin' ? 'strict' : 'lax',
|
||||||
path: '/',
|
path: '/',
|
||||||
maxAge: maxAgeMs,
|
...(domain ? { domain } : {}),
|
||||||
})
|
...(maxAgeMs !== undefined ? { maxAge: maxAgeMs } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSessionCookie(res: Response, actorType: ActorType, token: string, maxAgeMs?: number) {
|
||||||
|
res.cookie(getSessionCookieName(actorType), token, getSessionCookieOptions(actorType, maxAgeMs))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearSessionCookie(res: Response, actorType: ActorType) {
|
export function clearSessionCookie(res: Response, actorType: ActorType) {
|
||||||
res.clearCookie(getSessionCookieName(actorType), {
|
res.clearCookie(getSessionCookieName(actorType), getSessionCookieOptions(actorType))
|
||||||
httpOnly: true,
|
|
||||||
secure: process.env.NODE_ENV === 'production',
|
|
||||||
sameSite: actorType === 'admin' ? 'strict' : 'lax',
|
|
||||||
path: '/',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { prisma } from '../lib/prisma'
|
import { prisma } from '../lib/prisma'
|
||||||
|
import { resolveSettingsEntitlements } from '../modules/companies/settingsEntitlements'
|
||||||
|
import { validateLicense } from './licenseValidationService'
|
||||||
|
|
||||||
type AdditionalDriverCharge = 'PER_DAY' | 'FLAT' | 'FREE'
|
type AdditionalDriverCharge = 'PER_DAY' | 'FLAT' | 'FREE'
|
||||||
import { validateLicense } from './licenseValidationService'
|
|
||||||
|
|
||||||
export interface AdditionalDriverInput {
|
export interface AdditionalDriverInput {
|
||||||
firstName: string
|
firstName: string
|
||||||
@@ -40,7 +41,11 @@ export async function applyAdditionalDriversToReservation(
|
|||||||
return { records: [], additionalDriverTotal: 0, requiresManualApproval: false }
|
return { records: [], additionalDriverTotal: 0, requiresManualApproval: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
const settings = await prisma.contractSettings.findUnique({ where: { companyId } })
|
const entitlements = await resolveSettingsEntitlements(companyId)
|
||||||
|
const canApplyAutomatedFees = entitlements.features['settings.additional_driver_fees']?.available
|
||||||
|
const settings = canApplyAutomatedFees
|
||||||
|
? await prisma.contractSettings.findUnique({ where: { companyId } })
|
||||||
|
: null
|
||||||
const chargeType = settings?.additionalDriverCharge ?? 'FREE'
|
const chargeType = settings?.additionalDriverCharge ?? 'FREE'
|
||||||
const chargeValue =
|
const chargeValue =
|
||||||
chargeType === 'PER_DAY'
|
chargeType === 'PER_DAY'
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ describe('financialReportService', () => {
|
|||||||
pricingRulesTotal: 10,
|
pricingRulesTotal: 10,
|
||||||
depositAmount: 500,
|
depositAmount: 500,
|
||||||
totalAmount: 640,
|
totalAmount: 640,
|
||||||
source: 'MARKETPLACE',
|
source: 'CARPLACE',
|
||||||
startDate: new Date('2026-06-18T10:00:00.000Z'),
|
startDate: new Date('2026-06-18T10:00:00.000Z'),
|
||||||
endDate: new Date('2026-06-20T10:00:00.000Z'),
|
endDate: new Date('2026-06-20T10:00:00.000Z'),
|
||||||
vehicle: { year: 2023, make: 'Renault', model: 'Clio', licensePlate: 'B-456' },
|
vehicle: { year: 2023, make: 'Renault', model: 'Clio', licensePlate: 'B-456' },
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { InsurancePolicy } from '@rentaldrivego/database'
|
import { InsurancePolicy } from '@rentaldrivego/database'
|
||||||
import { prisma } from '../lib/prisma'
|
import { prisma } from '../lib/prisma'
|
||||||
|
import { resolveSettingsEntitlements } from '../modules/companies/settingsEntitlements'
|
||||||
|
|
||||||
export function calculateInsuranceCharge(
|
export function calculateInsuranceCharge(
|
||||||
policy: InsurancePolicy,
|
policy: InsurancePolicy,
|
||||||
@@ -21,6 +22,11 @@ export async function applyInsurancesToReservation(
|
|||||||
totalDays: number,
|
totalDays: number,
|
||||||
baseRentalAmount: number
|
baseRentalAmount: number
|
||||||
) {
|
) {
|
||||||
|
const entitlements = await resolveSettingsEntitlements(companyId)
|
||||||
|
if (!entitlements.features['settings.insurance_policies']?.available) {
|
||||||
|
return { records: [], insuranceTotal: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
const allPolicies = await prisma.insurancePolicy.findMany({ where: { companyId, isActive: true } })
|
const allPolicies = await prisma.insurancePolicy.findMany({ where: { companyId, isActive: true } })
|
||||||
const required = allPolicies.filter((p: InsurancePolicy) => p.isRequired)
|
const required = allPolicies.filter((p: InsurancePolicy) => p.isRequired)
|
||||||
const selected = allPolicies.filter((p: InsurancePolicy) => selectedPolicyIds.includes(p.id) && !p.isRequired)
|
const selected = allPolicies.filter((p: InsurancePolicy) => selectedPolicyIds.includes(p.id) && !p.isRequired)
|
||||||
|
|||||||
@@ -54,4 +54,32 @@ describe('invoicePdfService', () => {
|
|||||||
props: expect.objectContaining({ data: expect.objectContaining({ invoiceNumber: 'INV-2026-000001' }) }),
|
props: expect.objectContaining({ data: expect.objectContaining({ invoiceNumber: 'INV-2026-000001' }) }),
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('falls back to a simple PDF when the React PDF renderer fails', async () => {
|
||||||
|
renderToBufferMock.mockRejectedValueOnce(new Error('renderer failed'))
|
||||||
|
|
||||||
|
const result = await generateInvoicePdf({
|
||||||
|
invoiceNumber: 'INV-2026-000002',
|
||||||
|
issueDate: '2026-06-09T00:00:00.000Z',
|
||||||
|
dueDate: null,
|
||||||
|
company: { name: 'Atlas Cars', email: 'billing@atlas.test', address: { formatted: 'Casablanca' } },
|
||||||
|
amount: 80000,
|
||||||
|
currency: 'MAD',
|
||||||
|
status: 'OPEN',
|
||||||
|
paymentProvider: 'MANUAL',
|
||||||
|
lineItems: [{ description: 'Migration assistance', amount: 30000, currency: 'MAD', quantity: 2, unitAmount: 15000 }],
|
||||||
|
totals: {
|
||||||
|
subtotalAmount: 80000,
|
||||||
|
discountAmount: 0,
|
||||||
|
creditAmount: 0,
|
||||||
|
taxAmount: 0,
|
||||||
|
totalAmount: 80000,
|
||||||
|
amountPaid: 0,
|
||||||
|
amountDue: 80000,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.subarray(0, 8).toString()).toBe('%PDF-1.4')
|
||||||
|
expect(result.toString()).toContain('INV-2026-000002')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -318,17 +318,27 @@ function fmt(amount: number, currency: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fmtDate(iso: string | null | undefined) {
|
function fmtDate(iso: string | null | undefined) {
|
||||||
if (!iso) return '—'
|
if (!iso) return '-'
|
||||||
return new Date(iso).toLocaleDateString('en-GB', { day: '2-digit', month: 'long', year: 'numeric' })
|
return new Date(iso).toLocaleDateString('en-GB', { day: '2-digit', month: 'long', year: 'numeric' })
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatAddress(address: any): string {
|
function formatAddress(address: any): string {
|
||||||
if (!address) return ''
|
if (!address) return ''
|
||||||
if (typeof address === 'string') return address
|
if (typeof address === 'string') return address
|
||||||
|
if (typeof address.formatted === 'string') return address.formatted
|
||||||
const parts = [address.street, address.city, address.region, address.country, address.zip]
|
const parts = [address.street, address.city, address.region, address.country, address.zip]
|
||||||
return parts.filter(Boolean).join(', ')
|
return parts.filter(Boolean).join(', ')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pdfText(value: unknown) {
|
||||||
|
return String(value ?? '')
|
||||||
|
.normalize('NFKD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.replace(/[\u2013\u2014]/g, '-')
|
||||||
|
.replace(/\u00d7/g, 'x')
|
||||||
|
.replace(/[^\x20-\x7E]/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
function InvoiceDocument({ data }: { data: InvoiceData }) {
|
function InvoiceDocument({ data }: { data: InvoiceData }) {
|
||||||
const statusColor = STATUS_COLORS[data.status] ?? '#6b7280'
|
const statusColor = STATUS_COLORS[data.status] ?? '#6b7280'
|
||||||
const addressStr = formatAddress(data.company.address)
|
const addressStr = formatAddress(data.company.address)
|
||||||
@@ -374,11 +384,11 @@ function InvoiceDocument({ data }: { data: InvoiceData }) {
|
|||||||
View,
|
View,
|
||||||
{ style: s.invoiceMeta },
|
{ style: s.invoiceMeta },
|
||||||
React.createElement(Text, { style: s.invoiceTitle }, 'INVOICE'),
|
React.createElement(Text, { style: s.invoiceTitle }, 'INVOICE'),
|
||||||
React.createElement(Text, { style: s.invoiceNumber }, data.invoiceNumber),
|
React.createElement(Text, { style: s.invoiceNumber }, pdfText(data.invoiceNumber)),
|
||||||
React.createElement(
|
React.createElement(
|
||||||
View,
|
View,
|
||||||
{ style: [s.statusBadge, { backgroundColor: statusColor }] },
|
{ style: [s.statusBadge, { backgroundColor: statusColor }] },
|
||||||
React.createElement(Text, { style: s.statusText }, data.status),
|
React.createElement(Text, { style: s.statusText }, pdfText(data.status)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -391,13 +401,13 @@ function InvoiceDocument({ data }: { data: InvoiceData }) {
|
|||||||
View,
|
View,
|
||||||
{ style: s.col },
|
{ style: s.col },
|
||||||
React.createElement(Text, { style: s.colLabel }, 'Bill To'),
|
React.createElement(Text, { style: s.colLabel }, 'Bill To'),
|
||||||
React.createElement(Text, { style: s.companyName }, data.company.name),
|
React.createElement(Text, { style: s.companyName }, pdfText(data.company.name)),
|
||||||
React.createElement(Text, { style: s.companyDetail }, data.company.email),
|
React.createElement(Text, { style: s.companyDetail }, pdfText(data.company.email)),
|
||||||
data.company.phone
|
data.company.phone
|
||||||
? React.createElement(Text, { style: s.companyDetail }, data.company.phone)
|
? React.createElement(Text, { style: s.companyDetail }, pdfText(data.company.phone))
|
||||||
: null,
|
: null,
|
||||||
addressStr
|
addressStr
|
||||||
? React.createElement(Text, { style: [s.companyDetail, { marginTop: 4 }] }, addressStr)
|
? React.createElement(Text, { style: [s.companyDetail, { marginTop: 4 }] }, pdfText(addressStr))
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
React.createElement(
|
React.createElement(
|
||||||
@@ -408,7 +418,7 @@ function InvoiceDocument({ data }: { data: InvoiceData }) {
|
|||||||
View,
|
View,
|
||||||
{ style: s.metaRow },
|
{ style: s.metaRow },
|
||||||
React.createElement(Text, { style: s.metaKey }, 'Invoice Number'),
|
React.createElement(Text, { style: s.metaKey }, 'Invoice Number'),
|
||||||
React.createElement(Text, { style: s.metaValue }, data.invoiceNumber),
|
React.createElement(Text, { style: s.metaValue }, pdfText(data.invoiceNumber)),
|
||||||
),
|
),
|
||||||
React.createElement(
|
React.createElement(
|
||||||
View,
|
View,
|
||||||
@@ -428,20 +438,20 @@ function InvoiceDocument({ data }: { data: InvoiceData }) {
|
|||||||
View,
|
View,
|
||||||
{ style: s.metaRow },
|
{ style: s.metaRow },
|
||||||
React.createElement(Text, { style: s.metaKey }, 'Currency'),
|
React.createElement(Text, { style: s.metaKey }, 'Currency'),
|
||||||
React.createElement(Text, { style: s.metaValue }, data.currency),
|
React.createElement(Text, { style: s.metaValue }, pdfText(data.currency)),
|
||||||
),
|
),
|
||||||
React.createElement(
|
React.createElement(
|
||||||
View,
|
View,
|
||||||
{ style: s.metaRow },
|
{ style: s.metaRow },
|
||||||
React.createElement(Text, { style: s.metaKey }, 'Billing Period'),
|
React.createElement(Text, { style: s.metaKey }, 'Billing Period'),
|
||||||
React.createElement(Text, { style: s.metaValue }, periodLabel),
|
React.createElement(Text, { style: s.metaValue }, pdfText(periodLabel)),
|
||||||
),
|
),
|
||||||
data.subscription
|
data.subscription
|
||||||
? React.createElement(
|
? React.createElement(
|
||||||
View,
|
View,
|
||||||
{ style: s.metaRow },
|
{ style: s.metaRow },
|
||||||
React.createElement(Text, { style: s.metaKey }, 'Plan'),
|
React.createElement(Text, { style: s.metaKey }, 'Plan'),
|
||||||
React.createElement(Text, { style: s.metaValue }, planLabel),
|
React.createElement(Text, { style: s.metaValue }, pdfText(planLabel)),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
@@ -460,17 +470,17 @@ function InvoiceDocument({ data }: { data: InvoiceData }) {
|
|||||||
),
|
),
|
||||||
...lineItems.map((item) => {
|
...lineItems.map((item) => {
|
||||||
const periodStr = item.periodStart && item.periodEnd
|
const periodStr = item.periodStart && item.periodEnd
|
||||||
? `${fmtDate(item.periodStart)} – ${fmtDate(item.periodEnd)}`
|
? `${fmtDate(item.periodStart)} - ${fmtDate(item.periodEnd)}`
|
||||||
: '—'
|
: '-'
|
||||||
const description = item.quantity && item.quantity > 1 && item.unitAmount !== undefined
|
const description = item.quantity && item.quantity > 1 && item.unitAmount !== undefined
|
||||||
? `${item.description} (${item.quantity} × ${fmt(item.unitAmount, item.currency)})`
|
? `${item.description} (${item.quantity} x ${fmt(item.unitAmount, item.currency)})`
|
||||||
: item.description
|
: item.description
|
||||||
return React.createElement(
|
return React.createElement(
|
||||||
View,
|
View,
|
||||||
{ key: `${item.description}-${item.amount}-${item.periodStart ?? ''}`, style: s.tableRow },
|
{ key: `${item.description}-${item.amount}-${item.periodStart ?? ''}`, style: s.tableRow },
|
||||||
React.createElement(Text, { style: [s.tableCell, s.col60] }, description),
|
React.createElement(Text, { style: [s.tableCell, s.col60] }, pdfText(description)),
|
||||||
React.createElement(Text, { style: [s.tableCellMuted, s.col20Center, { textAlign: 'center' }] }, periodStr),
|
React.createElement(Text, { style: [s.tableCellMuted, s.col20Center, { textAlign: 'center' }] }, pdfText(periodStr)),
|
||||||
React.createElement(Text, { style: [s.tableCell, s.col20, { textAlign: 'right' }] }, fmt(item.amount, item.currency)),
|
React.createElement(Text, { style: [s.tableCell, s.col20, { textAlign: 'right' }] }, pdfText(fmt(item.amount, item.currency))),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -522,14 +532,14 @@ function InvoiceDocument({ data }: { data: InvoiceData }) {
|
|||||||
View,
|
View,
|
||||||
{ style: s.paymentRow },
|
{ style: s.paymentRow },
|
||||||
React.createElement(Text, { style: s.paymentKey }, 'Payment Provider'),
|
React.createElement(Text, { style: s.paymentKey }, 'Payment Provider'),
|
||||||
React.createElement(Text, { style: s.paymentValue }, data.paymentProvider),
|
React.createElement(Text, { style: s.paymentValue }, pdfText(data.paymentProvider)),
|
||||||
),
|
),
|
||||||
data.transactionId
|
data.transactionId
|
||||||
? React.createElement(
|
? React.createElement(
|
||||||
View,
|
View,
|
||||||
{ style: s.paymentRow },
|
{ style: s.paymentRow },
|
||||||
React.createElement(Text, { style: s.paymentKey }, 'Transaction ID'),
|
React.createElement(Text, { style: s.paymentKey }, 'Transaction ID'),
|
||||||
React.createElement(Text, { style: s.paymentValue }, data.transactionId),
|
React.createElement(Text, { style: s.paymentValue }, pdfText(data.transactionId)),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
React.createElement(
|
React.createElement(
|
||||||
@@ -542,7 +552,7 @@ function InvoiceDocument({ data }: { data: InvoiceData }) {
|
|||||||
View,
|
View,
|
||||||
{ style: s.paymentRow },
|
{ style: s.paymentRow },
|
||||||
React.createElement(Text, { style: s.paymentKey }, 'Status'),
|
React.createElement(Text, { style: s.paymentKey }, 'Status'),
|
||||||
React.createElement(Text, { style: [s.paymentValue, { color: statusColor }] }, data.status),
|
React.createElement(Text, { style: [s.paymentValue, { color: statusColor }] }, pdfText(data.status)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -550,7 +560,7 @@ function InvoiceDocument({ data }: { data: InvoiceData }) {
|
|||||||
React.createElement(
|
React.createElement(
|
||||||
View,
|
View,
|
||||||
{ style: s.footer },
|
{ style: s.footer },
|
||||||
React.createElement(Text, { style: s.footerText }, 'RentalDriveGo — Car Rental Management Platform'),
|
React.createElement(Text, { style: s.footerText }, 'RentalDriveGo - Car Rental Management Platform'),
|
||||||
React.createElement(Text, { style: s.footerText }, `Generated on ${fmtDate(new Date().toISOString())}`),
|
React.createElement(Text, { style: s.footerText }, `Generated on ${fmtDate(new Date().toISOString())}`),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -559,8 +569,12 @@ function InvoiceDocument({ data }: { data: InvoiceData }) {
|
|||||||
|
|
||||||
export async function generateInvoicePdf(data: InvoiceData): Promise<Buffer> {
|
export async function generateInvoicePdf(data: InvoiceData): Promise<Buffer> {
|
||||||
const doc = React.createElement(InvoiceDocument, { data })
|
const doc = React.createElement(InvoiceDocument, { data })
|
||||||
|
try {
|
||||||
const buffer = await renderToBuffer(doc as any)
|
const buffer = await renderToBuffer(doc as any)
|
||||||
return buffer as unknown as Buffer
|
return buffer as unknown as Buffer
|
||||||
|
} catch {
|
||||||
|
return buildSimpleInvoicePdf(data)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildInvoiceNumber(invoiceId: string, createdAt: Date): string {
|
export function buildInvoiceNumber(invoiceId: string, createdAt: Date): string {
|
||||||
@@ -568,3 +582,81 @@ export function buildInvoiceNumber(invoiceId: string, createdAt: Date): string {
|
|||||||
const short = invoiceId.slice(-6).toUpperCase()
|
const short = invoiceId.slice(-6).toUpperCase()
|
||||||
return `INV-${year}-${short}`
|
return `INV-${year}-${short}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pdfEscape(value: unknown) {
|
||||||
|
return pdfText(value).replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)')
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSimpleInvoicePdf(data: InvoiceData) {
|
||||||
|
const lineItems = data.lineItems?.length
|
||||||
|
? data.lineItems
|
||||||
|
: [{ description: 'Invoice charge', amount: data.amount, currency: data.currency }]
|
||||||
|
const totals = data.totals ?? {
|
||||||
|
subtotalAmount: data.amount,
|
||||||
|
discountAmount: 0,
|
||||||
|
creditAmount: 0,
|
||||||
|
taxAmount: 0,
|
||||||
|
totalAmount: data.amount,
|
||||||
|
amountPaid: data.paidAt ? data.amount : 0,
|
||||||
|
amountDue: data.paidAt ? 0 : data.amount,
|
||||||
|
}
|
||||||
|
const address = formatAddress(data.company.address)
|
||||||
|
const lines = [
|
||||||
|
'RentalDriveGo - Invoice',
|
||||||
|
`Invoice Number: ${data.invoiceNumber}`,
|
||||||
|
`Status: ${data.status}`,
|
||||||
|
`Issue Date: ${fmtDate(data.issueDate)}`,
|
||||||
|
data.dueDate ? `Due Date: ${fmtDate(data.dueDate)}` : null,
|
||||||
|
`Bill To: ${data.company.name}`,
|
||||||
|
`Email: ${data.company.email}`,
|
||||||
|
data.company.phone ? `Phone: ${data.company.phone}` : null,
|
||||||
|
address ? `Address: ${address}` : null,
|
||||||
|
'',
|
||||||
|
'Line Items',
|
||||||
|
...lineItems.map((item) => `${item.description} - ${fmt(item.amount, item.currency)}`),
|
||||||
|
'',
|
||||||
|
`Subtotal: ${fmt(totals.subtotalAmount, data.currency)}`,
|
||||||
|
totals.discountAmount ? `Discounts: -${fmt(totals.discountAmount, data.currency)}` : null,
|
||||||
|
totals.creditAmount ? `Credits: -${fmt(totals.creditAmount, data.currency)}` : null,
|
||||||
|
totals.taxAmount ? `Tax: ${fmt(totals.taxAmount, data.currency)}` : null,
|
||||||
|
`Total: ${fmt(totals.totalAmount, data.currency)}`,
|
||||||
|
`Amount Paid: ${fmt(totals.amountPaid, data.currency)}`,
|
||||||
|
`Amount Due: ${fmt(totals.amountDue, data.currency)}`,
|
||||||
|
'',
|
||||||
|
`Payment Provider: ${data.paymentProvider}`,
|
||||||
|
data.transactionId ? `Transaction ID: ${data.transactionId}` : null,
|
||||||
|
].filter((line): line is string => line !== null)
|
||||||
|
|
||||||
|
const content = [
|
||||||
|
'BT',
|
||||||
|
'/F1 18 Tf',
|
||||||
|
'50 790 Td',
|
||||||
|
`(${pdfEscape(lines[0])}) Tj`,
|
||||||
|
'/F1 10 Tf',
|
||||||
|
...lines.slice(1).flatMap((line) => ['0 -18 Td', `(${pdfEscape(line)}) Tj`]),
|
||||||
|
'ET',
|
||||||
|
].join('\n')
|
||||||
|
const objects = [
|
||||||
|
'<< /Type /Catalog /Pages 2 0 R >>',
|
||||||
|
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
|
||||||
|
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>',
|
||||||
|
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
|
||||||
|
`<< /Length ${Buffer.byteLength(content, 'utf8')} >>\nstream\n${content}\nendstream`,
|
||||||
|
]
|
||||||
|
|
||||||
|
let pdf = '%PDF-1.4\n'
|
||||||
|
const offsets = [0]
|
||||||
|
objects.forEach((object, index) => {
|
||||||
|
offsets.push(Buffer.byteLength(pdf, 'utf8'))
|
||||||
|
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`
|
||||||
|
})
|
||||||
|
const xrefOffset = Buffer.byteLength(pdf, 'utf8')
|
||||||
|
pdf += `xref\n0 ${objects.length + 1}\n`
|
||||||
|
pdf += '0000000000 65535 f \n'
|
||||||
|
for (let i = 1; i < offsets.length; i += 1) {
|
||||||
|
pdf += `${String(offsets[i]).padStart(10, '0')} 00000 n \n`
|
||||||
|
}
|
||||||
|
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`
|
||||||
|
|
||||||
|
return Buffer.from(pdf, 'utf8')
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,15 +27,15 @@ describe('platformContentService', () => {
|
|||||||
it('returns cloned default homepage content when the file does not exist or cannot be parsed', async () => {
|
it('returns cloned default homepage content when the file does not exist or cannot be parsed', async () => {
|
||||||
const service = await importFreshService()
|
const service = await importFreshService()
|
||||||
|
|
||||||
const first = await service.getMarketplaceHomepageContent()
|
const first = await service.getCarplaceHomepageContent()
|
||||||
first.en.heroTitle = 'Mutated by test'
|
first.en.heroTitle = 'Mutated by test'
|
||||||
|
|
||||||
const second = await service.getMarketplaceHomepageContent()
|
const second = await service.getCarplaceHomepageContent()
|
||||||
expect(second.en.sections).toContain('hero')
|
expect(second.en.sections).toContain('hero')
|
||||||
expect(second.en.heroTitle).not.toBe('Mutated by test')
|
expect(second.en.heroTitle).not.toBe('Mutated by test')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('saves only the marketplace homepage field while preserving unrelated platform content fields', async () => {
|
it('saves only the Carplace homepage field while preserving unrelated platform content fields', async () => {
|
||||||
const dataPath = path.join(tempDir, 'apps/api/src/data/platform-content.json')
|
const dataPath = path.join(tempDir, 'apps/api/src/data/platform-content.json')
|
||||||
await writeFile(dataPath, JSON.stringify({ otherSetting: { keep: true } }), 'utf8').catch(async () => {
|
await writeFile(dataPath, JSON.stringify({ otherSetting: { keep: true } }), 'utf8').catch(async () => {
|
||||||
await import('fs/promises').then((fs) => fs.mkdir(path.dirname(dataPath), { recursive: true }))
|
await import('fs/promises').then((fs) => fs.mkdir(path.dirname(dataPath), { recursive: true }))
|
||||||
@@ -43,13 +43,13 @@ describe('platformContentService', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const service = await importFreshService()
|
const service = await importFreshService()
|
||||||
const homepage = await service.getMarketplaceHomepageContent()
|
const homepage = await service.getCarplaceHomepageContent()
|
||||||
homepage.en.heroTitle = 'A better marketplace title'
|
homepage.en.heroTitle = 'A better Carplace title'
|
||||||
|
|
||||||
await expect(service.saveMarketplaceHomepageContent(homepage)).resolves.toBe(homepage)
|
await expect(service.saveCarplaceHomepageContent(homepage)).resolves.toBe(homepage)
|
||||||
|
|
||||||
const raw = JSON.parse(await readFile(dataPath, 'utf8'))
|
const raw = JSON.parse(await readFile(dataPath, 'utf8'))
|
||||||
expect(raw.otherSetting).toEqual({ keep: true })
|
expect(raw.otherSetting).toEqual({ keep: true })
|
||||||
expect(raw.marketplaceHomepage.en.heroTitle).toBe('A better marketplace title')
|
expect(raw.carplaceHomepage.en.heroTitle).toBe('A better Carplace title')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { mkdir, readFile, writeFile } from 'fs/promises'
|
import { mkdir, readFile, writeFile } from 'fs/promises'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { cloneMarketplaceHomepageContent, type MarketplaceHomepageConfig } from '@rentaldrivego/types'
|
import {
|
||||||
|
cloneCarplaceHomepageContent,
|
||||||
|
type CarplaceHomepageConfig,
|
||||||
|
} from '@rentaldrivego/types'
|
||||||
|
|
||||||
function resolveContentPath() {
|
function resolveContentPath() {
|
||||||
const cwd = process.cwd()
|
const cwd = process.cwd()
|
||||||
@@ -12,7 +15,7 @@ function resolveContentPath() {
|
|||||||
const platformContentPath = resolveContentPath()
|
const platformContentPath = resolveContentPath()
|
||||||
|
|
||||||
type PlatformContentFile = {
|
type PlatformContentFile = {
|
||||||
marketplaceHomepage?: MarketplaceHomepageConfig
|
carplaceHomepage?: CarplaceHomepageConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readPlatformContentFile(): Promise<PlatformContentFile> {
|
async function readPlatformContentFile(): Promise<PlatformContentFile> {
|
||||||
@@ -29,16 +32,16 @@ async function writePlatformContentFile(content: PlatformContentFile) {
|
|||||||
await writeFile(platformContentPath, JSON.stringify(content, null, 2))
|
await writeFile(platformContentPath, JSON.stringify(content, null, 2))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMarketplaceHomepageContent() {
|
export async function getCarplaceHomepageContent() {
|
||||||
const content = await readPlatformContentFile()
|
const content = await readPlatformContentFile()
|
||||||
return content.marketplaceHomepage ?? cloneMarketplaceHomepageContent()
|
return content.carplaceHomepage ?? cloneCarplaceHomepageContent()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveMarketplaceHomepageContent(homepage: MarketplaceHomepageConfig) {
|
export async function saveCarplaceHomepageContent(homepage: CarplaceHomepageConfig) {
|
||||||
const content = await readPlatformContentFile()
|
const content = await readPlatformContentFile()
|
||||||
await writePlatformContentFile({
|
await writePlatformContentFile({
|
||||||
...content,
|
...content,
|
||||||
marketplaceHomepage: homepage,
|
carplaceHomepage: homepage,
|
||||||
})
|
})
|
||||||
return homepage
|
return homepage
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { prisma } from '../lib/prisma'
|
import { prisma } from '../lib/prisma'
|
||||||
|
import { resolveSettingsEntitlements } from '../modules/companies/settingsEntitlements'
|
||||||
|
|
||||||
interface DriverInfo {
|
interface DriverInfo {
|
||||||
dateOfBirth?: Date | null
|
dateOfBirth?: Date | null
|
||||||
@@ -12,6 +13,11 @@ export async function applyPricingRules(
|
|||||||
dailyRate: number,
|
dailyRate: number,
|
||||||
totalDays: number
|
totalDays: number
|
||||||
): Promise<{ applied: any[]; total: number }> {
|
): Promise<{ applied: any[]; total: number }> {
|
||||||
|
const entitlements = await resolveSettingsEntitlements(companyId)
|
||||||
|
if (!entitlements.features['settings.pricing_rules']?.available) {
|
||||||
|
return { applied: [], total: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
const rules = await prisma.pricingRule.findMany({ where: { companyId, isActive: true } })
|
const rules = await prisma.pricingRule.findMany({ where: { companyId, isActive: true } })
|
||||||
const customer = await prisma.customer.findFirstOrThrow({ where: { id: customerId, companyId } })
|
const customer = await prisma.customer.findFirstOrThrow({ where: { id: customerId, companyId } })
|
||||||
const allDrivers: DriverInfo[] = [customer, ...additionalDrivers]
|
const allDrivers: DriverInfo[] = [customer, ...additionalDrivers]
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ describe('OpenAPI document boundary contract', () => {
|
|||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
expect(openApiDocument.tags).toEqual(expect.arrayContaining([
|
expect(openApiDocument.tags).toEqual(expect.arrayContaining([
|
||||||
expect.objectContaining({ name: 'Marketplace' }),
|
expect.objectContaining({ name: 'Carplace' }),
|
||||||
expect.objectContaining({ name: 'Subscriptions' }),
|
expect.objectContaining({ name: 'Subscriptions' }),
|
||||||
expect.objectContaining({ name: 'Admin' }),
|
expect.objectContaining({ name: 'Admin' }),
|
||||||
]))
|
]))
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ export const openApiDocument: JsonObject = {
|
|||||||
{ name: 'Companies', description: 'Company profile & settings' },
|
{ name: 'Companies', description: 'Company profile & settings' },
|
||||||
{ name: 'Team', description: 'Employee management' },
|
{ name: 'Team', description: 'Employee management' },
|
||||||
{ name: 'Subscriptions', description: 'Plan management & billing' },
|
{ name: 'Subscriptions', description: 'Plan management & billing' },
|
||||||
{ name: 'Marketplace', description: 'Public booking portal' },
|
{ name: 'Carplace', description: 'Public booking portal' },
|
||||||
{ name: 'Site', description: 'White-label site API' },
|
{ name: 'Site', description: 'White-label site API' },
|
||||||
{ name: 'Admin', description: 'Platform administration' },
|
{ name: 'Admin', description: 'Platform administration' },
|
||||||
],
|
],
|
||||||
@@ -996,20 +996,20 @@ export const openApiDocument: JsonObject = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════
|
||||||
// MARKETPLACE (public — no auth required)
|
// CARPLACE (public — no auth required)
|
||||||
// ════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════
|
||||||
'/marketplace/cities': {
|
'/carplace/cities': {
|
||||||
get: { tags: ['Marketplace'], summary: 'Cities with active companies', security: [], responses: { '200': ok } },
|
get: { tags: ['Carplace'], summary: 'Cities with active companies', security: [], responses: { '200': ok } },
|
||||||
},
|
},
|
||||||
'/marketplace/companies': {
|
'/carplace/companies': {
|
||||||
get: { tags: ['Marketplace'], summary: 'List companies', security: [], responses: { '200': ok } },
|
get: { tags: ['Carplace'], summary: 'List companies', security: [], responses: { '200': ok } },
|
||||||
},
|
},
|
||||||
'/marketplace/offers': {
|
'/carplace/offers': {
|
||||||
get: { tags: ['Marketplace'], summary: 'Public offers', security: [], responses: { '200': ok } },
|
get: { tags: ['Carplace'], summary: 'Public offers', security: [], responses: { '200': ok } },
|
||||||
},
|
},
|
||||||
'/marketplace/search': {
|
'/carplace/search': {
|
||||||
get: {
|
get: {
|
||||||
tags: ['Marketplace'],
|
tags: ['Carplace'],
|
||||||
summary: 'Search available vehicles',
|
summary: 'Search available vehicles',
|
||||||
security: [],
|
security: [],
|
||||||
parameters: [
|
parameters: [
|
||||||
@@ -1021,30 +1021,30 @@ export const openApiDocument: JsonObject = {
|
|||||||
responses: { '200': ok },
|
responses: { '200': ok },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'/marketplace/reservations': {
|
'/carplace/reservations': {
|
||||||
post: { tags: ['Marketplace'], summary: 'Create marketplace booking', security: [], responses: { '201': ok } },
|
post: { tags: ['Carplace'], summary: 'Create carplace booking', security: [], responses: { '201': ok } },
|
||||||
},
|
},
|
||||||
'/marketplace/offers/{code}/validate': {
|
'/carplace/offers/{code}/validate': {
|
||||||
post: { tags: ['Marketplace'], summary: 'Validate promo code', security: [], parameters: [{ name: 'code', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
|
post: { tags: ['Carplace'], summary: 'Validate promo code', security: [], parameters: [{ name: 'code', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
|
||||||
},
|
},
|
||||||
'/marketplace/review/{token}': {
|
'/carplace/review/{token}': {
|
||||||
get: { tags: ['Marketplace'], summary: 'Get review form by token', security: [], parameters: [{ name: 'token', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
|
get: { tags: ['Carplace'], summary: 'Get review form by token', security: [], parameters: [{ name: 'token', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
|
||||||
post: { tags: ['Marketplace'], summary: 'Submit review', security: [], parameters: [{ name: 'token', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
|
post: { tags: ['Carplace'], summary: 'Submit review', security: [], parameters: [{ name: 'token', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
|
||||||
},
|
},
|
||||||
'/marketplace/{slug}': {
|
'/carplace/{slug}': {
|
||||||
get: { tags: ['Marketplace'], summary: 'Company public page', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
|
get: { tags: ['Carplace'], summary: 'Company public page', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
|
||||||
},
|
},
|
||||||
'/marketplace/{slug}/reviews': {
|
'/carplace/{slug}/reviews': {
|
||||||
get: { tags: ['Marketplace'], summary: 'Company reviews', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
|
get: { tags: ['Carplace'], summary: 'Company reviews', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
|
||||||
},
|
},
|
||||||
'/marketplace/{slug}/vehicles': {
|
'/carplace/{slug}/vehicles': {
|
||||||
get: { tags: ['Marketplace'], summary: 'Company vehicles', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
|
get: { tags: ['Carplace'], summary: 'Company vehicles', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
|
||||||
},
|
},
|
||||||
'/marketplace/{slug}/vehicles/{id}': {
|
'/carplace/{slug}/vehicles/{id}': {
|
||||||
get: { tags: ['Marketplace'], summary: 'Vehicle details', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }, idPath()], responses: { '200': ok } },
|
get: { tags: ['Carplace'], summary: 'Vehicle details', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }, idPath()], responses: { '200': ok } },
|
||||||
},
|
},
|
||||||
'/marketplace/{slug}/offers': {
|
'/carplace/{slug}/offers': {
|
||||||
get: { tags: ['Marketplace'], summary: 'Company offers', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
|
get: { tags: ['Carplace'], summary: 'Company offers', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
|
||||||
},
|
},
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ vi.mock('../../modules/auth/auth.company.service', () => ({
|
|||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../modules/auth/auth.account.service', () => ({
|
||||||
|
startAccount: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('../../modules/auth/auth.renter.service', () => ({
|
vi.mock('../../modules/auth/auth.renter.service', () => ({
|
||||||
signupDisabled: vi.fn(() => {
|
signupDisabled: vi.fn(() => {
|
||||||
const err = new Error('renter disabled') as Error & { statusCode?: number; code?: string }
|
const err = new Error('renter disabled') as Error & { statusCode?: number; code?: string }
|
||||||
@@ -49,6 +53,7 @@ vi.mock('../../modules/auth/auth.renter.service', () => ({
|
|||||||
|
|
||||||
import request from 'supertest'
|
import request from 'supertest'
|
||||||
import { createApp } from '../../app'
|
import { createApp } from '../../app'
|
||||||
|
import * as accountService from '../../modules/auth/auth.account.service'
|
||||||
import * as companyService from '../../modules/auth/auth.company.service'
|
import * as companyService from '../../modules/auth/auth.company.service'
|
||||||
import * as renterService from '../../modules/auth/auth.renter.service'
|
import * as renterService from '../../modules/auth/auth.renter.service'
|
||||||
|
|
||||||
@@ -90,6 +95,10 @@ const signupPayload = {
|
|||||||
describe('auth API boundaries', () => {
|
describe('auth API boundaries', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
vi.mocked(accountService.startAccount).mockResolvedValue({
|
||||||
|
message: 'Account created. Please check your email to verify your address before signing in.',
|
||||||
|
email: 'owner@example.com',
|
||||||
|
} as never)
|
||||||
vi.mocked(companyService.signup).mockResolvedValue({ companyId: 'company_1', nextStep: 'workspace_created' } as never)
|
vi.mocked(companyService.signup).mockResolvedValue({ companyId: 'company_1', nextStep: 'workspace_created' } as never)
|
||||||
vi.mocked(renterService.getMe).mockResolvedValue({ id: 'renter_1', savedCompanies: [] } as never)
|
vi.mocked(renterService.getMe).mockResolvedValue({ id: 'renter_1', savedCompanies: [] } as never)
|
||||||
vi.mocked(renterService.updateMe).mockResolvedValue({ id: 'renter_1', firstName: 'Nora' } as never)
|
vi.mocked(renterService.updateMe).mockResolvedValue({ id: 'renter_1', firstName: 'Nora' } as never)
|
||||||
@@ -117,6 +126,38 @@ describe('auth API boundaries', () => {
|
|||||||
expect(companyService.signup).not.toHaveBeenCalled()
|
expect(companyService.signup).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('POST /auth/account/start validates minimal signup and returns 201', async () => {
|
||||||
|
const res = await request(app).post('/api/v1/auth/account/start').send({
|
||||||
|
email: 'owner@example.com',
|
||||||
|
password: 'super-secret',
|
||||||
|
preferredLanguage: 'fr',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(201)
|
||||||
|
expect(res.body).toEqual({
|
||||||
|
data: {
|
||||||
|
message: 'Account created. Please check your email to verify your address before signing in.',
|
||||||
|
email: 'owner@example.com',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(accountService.startAccount).toHaveBeenCalledWith({
|
||||||
|
email: 'owner@example.com',
|
||||||
|
password: 'super-secret',
|
||||||
|
preferredLanguage: 'fr',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('POST /auth/account/start rejects malformed minimal signup payloads before service execution', async () => {
|
||||||
|
const res = await request(app).post('/api/v1/auth/account/start').send({
|
||||||
|
email: 'not-email',
|
||||||
|
password: 'short',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
expect(res.body.error).toBe('validation_error')
|
||||||
|
expect(accountService.startAccount).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
it('preserves removed company auth compatibility endpoints as explicit 410 responses', async () => {
|
it('preserves removed company auth compatibility endpoints as explicit 410 responses', async () => {
|
||||||
const complete = await request(app).post('/api/v1/auth/company/complete-signup').send({})
|
const complete = await request(app).post('/api/v1/auth/company/complete-signup').send({})
|
||||||
const verify = await request(app).post('/api/v1/auth/company/verify-email').send({})
|
const verify = await request(app).post('/api/v1/auth/company/verify-email').send({})
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ vi.mock('../../lib/prisma', () => ({
|
|||||||
prisma: {
|
prisma: {
|
||||||
employee: { findUnique: vi.fn() },
|
employee: { findUnique: vi.fn() },
|
||||||
company: { findUnique: vi.fn() },
|
company: { findUnique: vi.fn() },
|
||||||
|
subscription: { findUnique: vi.fn() },
|
||||||
adminUser: { findUnique: vi.fn() },
|
adminUser: { findUnique: vi.fn() },
|
||||||
renter: { findUnique: vi.fn() },
|
renter: { findUnique: vi.fn() },
|
||||||
},
|
},
|
||||||
@@ -53,6 +54,7 @@ describe('auth middleware API boundaries', () => {
|
|||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
process.env.JWT_SECRET = 'test-secret'
|
process.env.JWT_SECRET = 'test-secret'
|
||||||
process.env.NEXT_PUBLIC_DASHBOARD_URL = 'https://dashboard.example.test'
|
process.env.NEXT_PUBLIC_DASHBOARD_URL = 'https://dashboard.example.test'
|
||||||
|
vi.mocked(prisma.subscription.findUnique).mockResolvedValue({ status: 'ACTIVE' } as never)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects protected company routes before service execution when no token is present', async () => {
|
it('rejects protected company routes before service execution when no token is present', async () => {
|
||||||
@@ -94,7 +96,7 @@ describe('auth middleware API boundaries', () => {
|
|||||||
expect(res.status).toBe(402)
|
expect(res.status).toBe(402)
|
||||||
expect(res.body).toEqual(expect.objectContaining({
|
expect(res.body).toEqual(expect.objectContaining({
|
||||||
error: 'subscription_suspended',
|
error: 'subscription_suspended',
|
||||||
billingUrl: 'https://dashboard.example.test/billing',
|
billingUrl: 'https://dashboard.example.test/subscription',
|
||||||
}))
|
}))
|
||||||
expect(vehicleService.listVehicles).not.toHaveBeenCalled()
|
expect(vehicleService.listVehicles).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|||||||
+17
-11
@@ -40,12 +40,13 @@ vi.mock('../../modules/notifications/notification.service', () => ({
|
|||||||
getRenterPreferences: vi.fn(),
|
getRenterPreferences: vi.fn(),
|
||||||
setRenterPreferences: vi.fn(),
|
setRenterPreferences: vi.fn(),
|
||||||
}))
|
}))
|
||||||
vi.mock('../../modules/marketplace/marketplace.service', () => ({
|
vi.mock('../../modules/carplace/carplace.service', () => ({
|
||||||
getCities: vi.fn(),
|
getCities: vi.fn(),
|
||||||
getListedCompanies: vi.fn(),
|
getListedCompanies: vi.fn(),
|
||||||
searchVehicles: vi.fn(),
|
searchVehicles: vi.fn(),
|
||||||
|
searchVehiclesPage: vi.fn(),
|
||||||
getVehicleDetail: vi.fn(),
|
getVehicleDetail: vi.fn(),
|
||||||
createMarketplaceReservation: vi.fn(),
|
createCarplaceReservation: vi.fn(),
|
||||||
getCompanyPage: vi.fn(),
|
getCompanyPage: vi.fn(),
|
||||||
getCompanyReviews: vi.fn(),
|
getCompanyReviews: vi.fn(),
|
||||||
getCompanyVehicles: vi.fn(),
|
getCompanyVehicles: vi.fn(),
|
||||||
@@ -61,11 +62,11 @@ import request from 'supertest'
|
|||||||
import { createApp } from '../../app'
|
import { createApp } from '../../app'
|
||||||
import * as employeeService from '../../modules/auth/auth.employee.service'
|
import * as employeeService from '../../modules/auth/auth.employee.service'
|
||||||
import * as notificationService from '../../modules/notifications/notification.service'
|
import * as notificationService from '../../modules/notifications/notification.service'
|
||||||
import * as marketplaceService from '../../modules/marketplace/marketplace.service'
|
import * as carplaceService from '../../modules/carplace/carplace.service'
|
||||||
|
|
||||||
const app = createApp()
|
const app = createApp()
|
||||||
|
|
||||||
describe('employee, notification, and marketplace API validation contracts', () => {
|
describe('employee, notification, and carplace API validation contracts', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
vi.mocked(employeeService.login).mockResolvedValue({ token: 'jwt', employee: { id: 'employee_1' } } as never)
|
vi.mocked(employeeService.login).mockResolvedValue({ token: 'jwt', employee: { id: 'employee_1' } } as never)
|
||||||
@@ -73,8 +74,12 @@ describe('employee, notification, and marketplace API validation contracts', ()
|
|||||||
vi.mocked(employeeService.updateLanguage).mockResolvedValue({ language: 'ar' } as never)
|
vi.mocked(employeeService.updateLanguage).mockResolvedValue({ language: 'ar' } as never)
|
||||||
vi.mocked(employeeService.resetPassword).mockResolvedValue({ message: 'Password updated successfully. You can now sign in.' } as never)
|
vi.mocked(employeeService.resetPassword).mockResolvedValue({ message: 'Password updated successfully. You can now sign in.' } as never)
|
||||||
vi.mocked(notificationService.setPreferences).mockResolvedValue({ success: true } as never)
|
vi.mocked(notificationService.setPreferences).mockResolvedValue({ success: true } as never)
|
||||||
vi.mocked(marketplaceService.searchVehicles).mockResolvedValue({ companies: [], vehicles: [], pagination: { page: 1, pageSize: 20, total: 0 } } as never)
|
vi.mocked(carplaceService.searchVehiclesPage).mockResolvedValue({
|
||||||
vi.mocked(marketplaceService.submitReview).mockResolvedValue({ id: 'review_1' } as never)
|
items: [],
|
||||||
|
pagination: { page: 1, pageSize: 20, totalItems: 0, totalPages: 0 },
|
||||||
|
facets: { categories: [], transmissions: [], makes: [], price: { min: null, max: null } },
|
||||||
|
} as never)
|
||||||
|
vi.mocked(carplaceService.submitReview).mockResolvedValue({ id: 'review_1' } as never)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('normalizes employee login email before service execution', async () => {
|
it('normalizes employee login email before service execution', async () => {
|
||||||
@@ -110,19 +115,20 @@ describe('employee, notification, and marketplace API validation contracts', ()
|
|||||||
expect(notificationService.setPreferences).not.toHaveBeenCalled()
|
expect(notificationService.setPreferences).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('defaults marketplace search pagination at the public route boundary', async () => {
|
it('defaults carplace search pagination at the public route boundary', async () => {
|
||||||
const res = await request(app).get('/api/v1/marketplace/search?city=Casablanca')
|
const res = await request(app).get('/api/v1/carplace/search?city=Casablanca')
|
||||||
|
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
expect(marketplaceService.searchVehicles).toHaveBeenCalledWith(expect.objectContaining({
|
expect(carplaceService.searchVehiclesPage).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
city: 'Casablanca',
|
city: 'Casablanca',
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 20,
|
pageSize: 20,
|
||||||
}))
|
}))
|
||||||
|
expect(res.body.data.pagination).toEqual({ page: 1, pageSize: 20, totalItems: 0, totalPages: 0 })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects out-of-range public review ratings before service execution', async () => {
|
it('rejects out-of-range public review ratings before service execution', async () => {
|
||||||
const res = await request(app).post('/api/v1/marketplace/review/token_123').send({
|
const res = await request(app).post('/api/v1/carplace/review/token_123').send({
|
||||||
overallRating: 6,
|
overallRating: 6,
|
||||||
vehicleRating: 5,
|
vehicleRating: 5,
|
||||||
serviceRating: 5,
|
serviceRating: 5,
|
||||||
@@ -130,6 +136,6 @@ describe('employee, notification, and marketplace API validation contracts', ()
|
|||||||
})
|
})
|
||||||
|
|
||||||
expect(res.status).toBe(400)
|
expect(res.status).toBe(400)
|
||||||
expect(marketplaceService.submitReview).not.toHaveBeenCalled()
|
expect(carplaceService.submitReview).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user