62 Commits

Author SHA1 Message Date
root f16e1ab26b new merge for develop
Build & Deploy / Build & Push Docker Image (push) Failing after 1s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / Type Check (all packages) (push) Failing after 10s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Carplace Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped
2026-07-04 01:09:15 -04:00
root f8d8f050f8 Fix Gitea CI external action dependency
Build & Deploy / Build & Push Docker Image (push) Failing after 1s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / Type Check (all packages) (push) Failing after 10s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Carplace Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped
2026-07-04 00:52:34 -04:00
root 831cd8c7af ci: remove GitHub action dependencies from Gitea workflows
Build & Deploy / Build & Push Docker Image (push) Failing after 3m14s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / Type Check (all packages) (push) Failing after 10s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Carplace Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped
2026-07-04 00:25:11 -04:00
root 2a79ac8e63 fix traefik file
Build & Deploy / Build & Push Docker Image (push) Failing after 11s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / Type Check (all packages) (push) Failing after 10s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Carplace Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped
2026-07-03 01:07:31 -04:00
root b220807d70 Fix dashboard auth image preload and API base resolution
Build & Deploy / Build & Push Docker Image (push) Successful in 3m10s
Test / Type Check (all packages) (push) Successful in 52s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 46s
Test / Homepage Unit Tests (push) Successful in 44s
Test / Carplace Unit Tests (push) Successful in 40s
Test / Admin Unit Tests (push) Successful in 41s
Test / Dashboard Unit Tests (push) Successful in 40s
Test / API Integration Tests (push) Successful in 59s
Test / Type Check (all packages) (pull_request) Failing after 10s
Test / API Unit Tests (pull_request) Has been skipped
Test / Homepage Unit Tests (pull_request) Has been skipped
Test / Carplace Unit Tests (pull_request) Has been skipped
Test / Admin Unit Tests (pull_request) Has been skipped
Test / Dashboard Unit Tests (pull_request) Has been skipped
Test / API Integration Tests (pull_request) Has been skipped
2026-07-02 23:04:54 -04:00
root 2de00868ad fix(dashboard): proxy API requests through dashboard route
Build & Deploy / Build & Push Docker Image (push) Successful in 2m58s
Test / Type Check (all packages) (push) Successful in 54s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 47s
Test / Homepage Unit Tests (push) Successful in 46s
Test / Carplace Unit Tests (push) Successful in 40s
Test / Admin Unit Tests (push) Successful in 42s
Test / Dashboard Unit Tests (push) Successful in 39s
Test / API Integration Tests (push) Successful in 59s
2026-07-02 22:14:34 -04:00
root 2959285425 fix redirect 2nd time
Build & Deploy / Build & Push Docker Image (push) Successful in 2m55s
Test / Type Check (all packages) (push) Successful in 53s
Build & Deploy / Deploy to VPS (push) Successful in 3s
Test / API Unit Tests (push) Successful in 46s
Test / Homepage Unit Tests (push) Successful in 44s
Test / Carplace Unit Tests (push) Successful in 40s
Test / Admin Unit Tests (push) Successful in 42s
Test / Dashboard Unit Tests (push) Successful in 41s
Test / API Integration Tests (push) Successful in 1m0s
2026-07-02 22:01:16 -04:00
root c27f0909df fix sidebar menu redirect in production
Build & Deploy / Build & Push Docker Image (push) Successful in 3m15s
Test / Type Check (all packages) (push) Successful in 55s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 47s
Test / Homepage Unit Tests (push) Successful in 42s
Test / Carplace Unit Tests (push) Successful in 44s
Test / Admin Unit Tests (push) Successful in 42s
Test / Dashboard Unit Tests (push) Successful in 40s
Test / API Integration Tests (push) Successful in 59s
2026-07-02 21:43:56 -04:00
root 511ab4d03b fix the login user
Build & Deploy / Build & Push Docker Image (push) Successful in 3m5s
Test / Type Check (all packages) (push) Successful in 55s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 46s
Test / Homepage Unit Tests (push) Successful in 47s
Test / Carplace Unit Tests (push) Successful in 41s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 42s
Test / API Integration Tests (push) Successful in 1m0s
2026-07-02 21:21:15 -04:00
root 00d01bdaf4 fix company login issue
Build & Deploy / Build & Push Docker Image (push) Successful in 3m8s
Test / Type Check (all packages) (push) Successful in 55s
Build & Deploy / Deploy to VPS (push) Successful in 3s
Test / API Unit Tests (push) Successful in 48s
Test / Homepage Unit Tests (push) Successful in 42s
Test / Carplace Unit Tests (push) Successful in 42s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 40s
Test / API Integration Tests (push) Successful in 1m0s
2026-07-02 19:23:26 -04:00
root 68bf9ca610 fix carplace page
Build & Deploy / Build & Push Docker Image (push) Successful in 3m16s
Test / Type Check (all packages) (push) Successful in 53s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 56s
Test / Homepage Unit Tests (push) Successful in 43s
Test / Carplace Unit Tests (push) Successful in 40s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 41s
Test / API Integration Tests (push) Successful in 1m0s
2026-07-02 19:05:12 -04:00
root f330efb1ad fix test issues
Build & Deploy / Build & Push Docker Image (push) Successful in 3m1s
Test / Type Check (all packages) (push) Successful in 55s
Build & Deploy / Deploy to VPS (push) Successful in 5s
Test / API Unit Tests (push) Successful in 47s
Test / Homepage Unit Tests (push) Successful in 42s
Test / Carplace Unit Tests (push) Successful in 42s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 41s
Test / API Integration Tests (push) Successful in 1m0s
2026-07-02 18:50:03 -04:00
root e3f40410e9 fix(api): report database schema mismatches clearly
Build & Deploy / Build & Push Docker Image (push) Successful in 3m10s
Test / Type Check (all packages) (push) Successful in 1m2s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Failing after 48s
Test / Homepage Unit Tests (push) Successful in 45s
Test / Storefront Unit Tests (push) Failing after 41s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Failing after 41s
Test / API Integration Tests (push) Successful in 59s
2026-07-02 18:40:42 -04:00
root c21916559f chore: remove unused marketplace configuration
Build & Deploy / Build & Push Docker Image (push) Successful in 3m9s
Test / Type Check (all packages) (push) Successful in 56s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Failing after 49s
Test / Homepage Unit Tests (push) Successful in 42s
Test / Storefront Unit Tests (push) Failing after 40s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Failing after 41s
Test / API Integration Tests (push) Successful in 1m0s
2026-07-02 18:24:19 -04:00
root 7ff2dbb139 chore: wire Carplace into dev and production stacks
Build & Deploy / Build & Push Docker Image (push) Successful in 2m55s
Test / Type Check (all packages) (push) Successful in 54s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Failing after 48s
Test / Homepage Unit Tests (push) Successful in 43s
Test / Storefront Unit Tests (push) Failing after 40s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Failing after 42s
Test / API Integration Tests (push) Successful in 1m0s
2026-07-02 18:15:42 -04:00
root ad5f5ebab7 fix(api): support shared session cookie domain
Build & Deploy / Build & Push Docker Image (push) Successful in 2m49s
Test / Type Check (all packages) (push) Successful in 55s
Build & Deploy / Deploy to VPS (push) Successful in 3s
Test / API Unit Tests (push) Successful in 48s
Test / Homepage Unit Tests (push) Successful in 46s
Test / Storefront Unit Tests (push) Successful in 47s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 40s
Test / API Integration Tests (push) Successful in 59s
2026-07-02 15:10:40 -04:00
root a947e48c49 Fixed the dashboard sidebar redirect issue by making browser-side dashboard API calls always go through the same-origin proxy at /dashboard/api/v1
Build & Deploy / Build & Push Docker Image (push) Successful in 2m56s
Test / Type Check (all packages) (push) Successful in 53s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 49s
Test / Homepage Unit Tests (push) Successful in 44s
Test / Storefront Unit Tests (push) Successful in 42s
Test / Admin Unit Tests (push) Successful in 46s
Test / Dashboard Unit Tests (push) Successful in 40s
Test / API Integration Tests (push) Has been cancelled
2026-07-02 15:02:10 -04:00
root 6913c298ad Route storefront marketplace under /storefront
Build & Deploy / Build & Push Docker Image (push) Successful in 2m57s
Test / Type Check (all packages) (push) Successful in 57s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 47s
Test / Homepage Unit Tests (push) Successful in 43s
Test / Storefront Unit Tests (push) Successful in 42s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 45s
Test / API Integration Tests (push) Successful in 1m4s
Replace the legacy /explore marketplace route with /storefront across the
storefront app and production routing.

- move the marketplace index page to the storefront root route
- move company and vehicle detail pages from /explore/* to /* internally
- update marketplace links, renter dashboard links, saved company links, and
  header navigation to target /storefront
- remove remaining explore route references and wording from storefront code
- configure production Traefik to route /storefront and strip the prefix before
  forwarding to the storefront service
- set the storefront production asset prefix to /storefront so Next chunks load
  from /storefront/_next instead of the shared root /_next path
- redirect locale entry paths such as /storefront/fr back to /storefront while
  persisting the selected language cookie
- update middleware tests for the locale redirect and adjusted redirect mock

Verification:
- npm run test --workspace @rentaldrivego/storefront
- npm run build --workspace @rentaldrivego/storefront
2026-07-02 14:47:50 -04:00
root b45f84d62b The dashboard middleware was treating /dashboard/api/v1/* as protected dashboard pages, so even the login request:
Build & Deploy / Build & Push Docker Image (push) Successful in 2m49s
Test / Type Check (all packages) (push) Successful in 53s
Build & Deploy / Deploy to VPS (push) Successful in 3s
Test / API Unit Tests (push) Successful in 47s
Test / Homepage Unit Tests (push) Successful in 43s
Test / Storefront Unit Tests (push) Successful in 40s
Test / Admin Unit Tests (push) Successful in 42s
Test / Dashboard Unit Tests (push) Successful in 41s
Test / API Integration Tests (push) Successful in 1m0s
/dashboard/api/v1/auth/employee/login
was getting 307 redirected to /dashboard/sign-in. That meant sign-in could never set/read the employee_session cookie, and every dashboard page kept bouncing back to sign-in.
2026-07-02 14:19:45 -04:00
root 781512399b Update homepage routes to include language and mode
Build & Deploy / Build & Push Docker Image (push) Successful in 2m51s
Test / Type Check (all packages) (push) Successful in 52s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 49s
Test / Homepage Unit Tests (push) Successful in 44s
Test / Storefront Unit Tests (push) Successful in 39s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 40s
Test / API Integration Tests (push) Successful in 1m0s
2026-07-02 14:10:17 -04:00
root 56c3bcf666 fix homepage storefront proxy and CSP
Build & Deploy / Build & Push Docker Image (push) Successful in 2m50s
Test / Type Check (all packages) (push) Successful in 52s
Build & Deploy / Deploy to VPS (push) Successful in 3s
Test / API Unit Tests (push) Successful in 54s
Test / Homepage Unit Tests (push) Successful in 44s
Test / Storefront Unit Tests (push) Successful in 41s
Test / Admin Unit Tests (push) Successful in 42s
Test / Dashboard Unit Tests (push) Successful in 41s
Test / API Integration Tests (push) Successful in 1m0s
2026-07-02 13:55:10 -04:00
root 9c11f3660d Fix dashboard redirects leaking internal port
Build & Deploy / Build & Push Docker Image (push) Successful in 2m54s
Test / Type Check (all packages) (push) Successful in 53s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 48s
Test / Homepage Unit Tests (push) Successful in 43s
Test / Storefront Unit Tests (push) Successful in 40s
Test / Admin Unit Tests (push) Successful in 42s
Test / Dashboard Unit Tests (push) Successful in 41s
Test / API Integration Tests (push) Successful in 1m1s
2026-07-02 13:34:28 -04:00
root 7c1bd2d0c0 Support insecure VPN registry pulls
Build & Deploy / Build & Push Docker Image (push) Successful in 2m52s
Test / Type Check (all packages) (push) Successful in 50s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 49s
Test / Homepage Unit Tests (push) Successful in 42s
Test / Storefront Unit Tests (push) Successful in 39s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 41s
Test / API Integration Tests (push) Successful in 1m1s
2026-07-02 12:06:01 -04:00
root 1fd1ddf3f2 Support registry token for VPN image pulls
Build & Deploy / Build & Push Docker Image (push) Successful in 2m50s
Test / Type Check (all packages) (push) Successful in 50s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 45s
Test / Homepage Unit Tests (push) Successful in 42s
Test / Storefront Unit Tests (push) Successful in 40s
Test / Admin Unit Tests (push) Successful in 41s
Test / Dashboard Unit Tests (push) Successful in 40s
Test / API Integration Tests (push) Successful in 59s
2026-07-02 11:49:58 -04:00
root 0394f0ea2a Update production registry pull over VPN
Build & Deploy / Build & Push Docker Image (push) Successful in 2m53s
Test / Type Check (all packages) (push) Successful in 54s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 46s
Test / Homepage Unit Tests (push) Successful in 43s
Test / Storefront Unit Tests (push) Successful in 40s
Test / Admin Unit Tests (push) Successful in 41s
Test / Dashboard Unit Tests (push) Successful in 42s
Test / API Integration Tests (push) Successful in 58s
Configure production defaults to pull images directly from the VPN registry at 10.0.0.4, with the VPS reachable at 10.0.0.1, and disable archive handoff by default.
2026-07-02 11:36:35 -04:00
root 2993dd5b57 fix: return baseline menu for full-access accounts
Build & Deploy / Build & Push Docker Image (push) Failing after 6m52s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / Type Check (all packages) (push) Failing after 11s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Storefront Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped
2026-07-02 03:31:14 -04:00
root e93b988146 fix: align dashboard guard with fallback menu access
Build & Deploy / Build & Push Docker Image (push) Successful in 2m46s
Test / Type Check (all packages) (push) Successful in 53s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 49s
Test / Homepage Unit Tests (push) Successful in 43s
Test / Storefront Unit Tests (push) Successful in 41s
Test / Admin Unit Tests (push) Successful in 41s
Test / Dashboard Unit Tests (push) Successful in 41s
Test / API Integration Tests (push) Successful in 1m1s
2026-07-02 03:18:42 -04:00
root ea2bd215f6 fix: align dashboard guard with fallback menu access
Build & Deploy / Build & Push Docker Image (push) Successful in 2m56s
Test / Type Check (all packages) (push) Successful in 51s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 47s
Test / Homepage Unit Tests (push) Successful in 46s
Test / Storefront Unit Tests (push) Successful in 42s
Test / Admin Unit Tests (push) Successful in 42s
Test / Dashboard Unit Tests (push) Successful in 39s
Test / API Integration Tests (push) Successful in 1m1s
2026-07-02 03:06:45 -04:00
root 13b54f07de fix: normalize homepage API URL and enforce expired trial access
Build & Deploy / Build & Push Docker Image (push) Successful in 2m54s
Test / Type Check (all packages) (push) Successful in 52s
Build & Deploy / Deploy to VPS (push) Successful in 3s
Test / API Unit Tests (push) Failing after 1m9s
Test / Homepage Unit Tests (push) Successful in 45s
Test / Storefront Unit Tests (push) Successful in 41s
Test / Admin Unit Tests (push) Successful in 43s
Test / Dashboard Unit Tests (push) Successful in 43s
Test / API Integration Tests (push) Successful in 1m0s
2026-07-02 02:45:08 -04:00
root 75a1d39050 fix: enforce expired trial subscription menu access
Build & Deploy / Build & Push Docker Image (push) Successful in 2m48s
Test / Type Check (all packages) (push) Successful in 52s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Failing after 1m9s
Test / Homepage Unit Tests (push) Successful in 43s
Test / Storefront Unit Tests (push) Successful in 42s
Test / Admin Unit Tests (push) Successful in 47s
Test / Dashboard Unit Tests (push) Successful in 41s
Test / API Integration Tests (push) Successful in 1m0s
2026-07-02 02:32:37 -04:00
root 0dcbe18c54 fix production API and storefront routing
Build & Deploy / Build & Push Docker Image (push) Successful in 2m52s
Test / Type Check (all packages) (push) Successful in 57s
Build & Deploy / Deploy to VPS (push) Successful in 3s
Test / API Unit Tests (push) Successful in 48s
Test / Homepage Unit Tests (push) Successful in 43s
Test / Storefront Unit Tests (push) Successful in 45s
Test / Admin Unit Tests (push) Successful in 47s
Test / Dashboard Unit Tests (push) Successful in 42s
Test / API Integration Tests (push) Successful in 59s
Normalize dashboard API base URLs so bare API origins still call /api/v1 endpoints. Reuse the shared dashboard API base on email verification.

Update production storefront URL examples to use the root domain and route footer, policy, and workspace storefront paths through Traefik.
2026-07-02 02:02:31 -04:00
root 8ef61d7005 I also hardened the dashboard API helper so a bare origin
Build & Deploy / Build & Push Docker Image (push) Successful in 2m47s
Test / Type Check (all packages) (push) Successful in 54s
Build & Deploy / Deploy to VPS (push) Successful in 3s
Test / API Unit Tests (push) Successful in 47s
Test / Homepage Unit Tests (push) Successful in 44s
Test / Storefront Unit Tests (push) Successful in 42s
Test / Admin Unit Tests (push) Successful in 41s
Test / Dashboard Unit Tests (push) Successful in 42s
Test / API Integration Tests (push) Successful in 1m0s
2026-07-02 01:43:27 -04:00
root 56984c4ea7 fix the acme email
Build & Deploy / Build & Push Docker Image (push) Successful in 2m50s
Test / Type Check (all packages) (push) Successful in 54s
Build & Deploy / Deploy to VPS (push) Successful in 3s
Test / API Unit Tests (push) Successful in 49s
Test / Homepage Unit Tests (push) Successful in 44s
Test / Storefront Unit Tests (push) Successful in 41s
Test / Admin Unit Tests (push) Successful in 41s
Test / Dashboard Unit Tests (push) Successful in 42s
Test / API Integration Tests (push) Successful in 1m1s
2026-07-02 01:05:32 -04:00
root 330fc11791 Updated the homepage CSP for the new browser error.
Build & Deploy / Build & Push Docker Image (push) Successful in 3m1s
Test / Type Check (all packages) (push) Successful in 57s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 49s
Test / Homepage Unit Tests (push) Successful in 43s
Test / Storefront Unit Tests (push) Successful in 42s
Test / Admin Unit Tests (push) Successful in 42s
Test / Dashboard Unit Tests (push) Successful in 42s
Test / API Integration Tests (push) Successful in 1m0s
2026-07-02 00:30:30 -04:00
root 0ddf67c754 Fix the remaining CSP errors. The repeated hash was from Next’s generated <Image> markup: style="color:transparent".
Build & Deploy / Build & Push Docker Image (push) Successful in 3m3s
Test / Type Check (all packages) (push) Successful in 57s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 50s
Test / Homepage Unit Tests (push) Successful in 45s
Test / Storefront Unit Tests (push) Successful in 43s
Test / Admin Unit Tests (push) Successful in 42s
Test / Dashboard Unit Tests (push) Successful in 41s
Test / API Integration Tests (push) Successful in 1m3s
2026-07-01 23:52:09 -04:00
root bcfe518af2 Fix the homepage CSP inline-style violations by removing React style attributes from the homepage app and moving them into CSS modules.
Build & Deploy / Build & Push Docker Image (push) Successful in 2m55s
Test / Type Check (all packages) (push) Successful in 59s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 51s
Test / Homepage Unit Tests (push) Successful in 43s
Test / Storefront Unit Tests (push) Successful in 42s
Test / Admin Unit Tests (push) Successful in 42s
Test / Dashboard Unit Tests (push) Successful in 44s
Test / API Integration Tests (push) Successful in 1m0s
2026-07-01 23:33:37 -04:00
root 30631504f1 fix production domain
Build & Deploy / Build & Push Docker Image (push) Successful in 2m57s
Test / Type Check (all packages) (push) Successful in 58s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 51s
Test / Homepage Unit Tests (push) Successful in 44s
Test / Storefront Unit Tests (push) Successful in 43s
Test / Admin Unit Tests (push) Successful in 43s
Test / Dashboard Unit Tests (push) Successful in 42s
Test / API Integration Tests (push) Successful in 1m2s
2026-07-01 22:51:46 -04:00
root 757ad41c9b add image pull to production
Build & Deploy / Build & Push Docker Image (push) Successful in 3m15s
Test / Type Check (all packages) (push) Successful in 1m2s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 51s
Test / Homepage Unit Tests (push) Successful in 45s
Test / Storefront Unit Tests (push) Successful in 44s
Test / Admin Unit Tests (push) Successful in 42s
Test / Dashboard Unit Tests (push) Successful in 44s
Test / API Integration Tests (push) Successful in 1m1s
2026-07-01 22:25:00 -04:00
root 97d5ecfcf0 pull docker image
Build & Deploy / Build & Push Docker Image (push) Successful in 3m4s
Test / Type Check (all packages) (push) Successful in 1m2s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 52s
Test / Homepage Unit Tests (push) Successful in 45s
Test / Storefront Unit Tests (push) Successful in 44s
Test / Admin Unit Tests (push) Successful in 43s
Test / Dashboard Unit Tests (push) Successful in 43s
Test / API Integration Tests (push) Successful in 1m1s
2026-07-01 22:12:49 -04:00
root 63f8df1e38 fix production
Build & Deploy / Build & Push Docker Image (push) Successful in 3m26s
Test / Type Check (all packages) (push) Successful in 59s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 52s
Test / Homepage Unit Tests (push) Successful in 45s
Test / Storefront Unit Tests (push) Has been cancelled
Test / Admin Unit Tests (push) Has been cancelled
Test / Dashboard Unit Tests (push) Has been cancelled
Test / API Integration Tests (push) Has been cancelled
2026-07-01 22:06:02 -04:00
root e2b564aea5 fix prod
Build & Deploy / Build & Push Docker Image (push) Successful in 2m51s
Test / Type Check (all packages) (push) Successful in 51s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 49s
Test / Homepage Unit Tests (push) Successful in 42s
Test / Storefront Unit Tests (push) Successful in 40s
Test / Admin Unit Tests (push) Successful in 41s
Test / Dashboard Unit Tests (push) Successful in 41s
Test / API Integration Tests (push) Successful in 59s
2026-07-01 09:18:14 -04:00
root 184a4bac8b fix production storfront and homepage
Build & Deploy / Build & Push Docker Image (push) Successful in 2m45s
Test / Type Check (all packages) (push) Successful in 53s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 47s
Test / Homepage Unit Tests (push) Successful in 43s
Test / Storefront Unit Tests (push) Successful in 43s
Test / Admin Unit Tests (push) Successful in 41s
Test / Dashboard Unit Tests (push) Successful in 41s
Test / API Integration Tests (push) Successful in 1m0s
2026-07-01 09:02:31 -04:00
root 9703de974a fix api tests
Build & Deploy / Build & Push Docker Image (push) Successful in 2m52s
Test / Type Check (all packages) (push) Successful in 55s
Build & Deploy / Deploy to VPS (push) Successful in 3s
Test / API Unit Tests (push) Successful in 47s
Test / Homepage Unit Tests (push) Successful in 41s
Test / Storefront Unit Tests (push) Successful in 41s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 41s
Test / API Integration Tests (push) Successful in 1m1s
2026-07-01 01:43:36 -04:00
root 5dfd5b1814 fix prod deployment
Build & Deploy / Build & Push Docker Image (push) Failing after 34s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 57s
Test / API Unit Tests (push) Failing after 52s
Test / Homepage Unit Tests (push) Successful in 46s
Test / Storefront Unit Tests (push) Successful in 44s
Test / Admin Unit Tests (push) Successful in 41s
Test / Dashboard Unit Tests (push) Successful in 43s
Test / API Integration Tests (push) Has been cancelled
2026-07-01 01:37:37 -04:00
root 13d0512048 registry fix
Build & Deploy / Build & Push Docker Image (push) Successful in 9m39s
Test / Type Check (all packages) (push) Successful in 4m12s
Build & Deploy / Deploy to VPS (push) Successful in 7s
Test / API Unit Tests (push) Successful in 3m37s
Test / Homepage Unit Tests (push) Successful in 3m24s
Test / Storefront Unit Tests (push) Successful in 2m45s
Test / Admin Unit Tests (push) Successful in 3m41s
Test / Dashboard Unit Tests (push) Successful in 3m7s
Test / API Integration Tests (push) Successful in 3m31s
2026-06-30 09:01:18 -04:00
root b0c78ad8a2 registry fix
Build & Deploy / Build & Push Docker Image (push) Failing after 10m37s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / Type Check (all packages) (push) Has been cancelled
Test / API Unit Tests (push) Has been cancelled
Test / Homepage Unit Tests (push) Has been cancelled
Test / Storefront Unit Tests (push) Has been cancelled
Test / Admin Unit Tests (push) Has been cancelled
Test / Dashboard Unit Tests (push) Has been cancelled
Test / API Integration Tests (push) Has been cancelled
2026-06-30 08:49:09 -04:00
root d8b7b92cb1 fixed registry secure
Build & Deploy / Build & Push Docker Image (push) Failing after 10m27s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 3m9s
Test / API Unit Tests (push) Successful in 3m40s
Test / Homepage Unit Tests (push) Successful in 3m5s
Test / Storefront Unit Tests (push) Successful in 4m28s
Test / Admin Unit Tests (push) Successful in 3m39s
Test / Dashboard Unit Tests (push) Successful in 3m8s
Test / API Integration Tests (push) Successful in 3m58s
2026-06-30 01:38:01 -04:00
root fb77e91730 fix registry creation image
Build & Deploy / Build & Push Docker Image (push) Failing after 11m2s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 3m40s
Test / API Unit Tests (push) Has been cancelled
Test / Homepage Unit Tests (push) Has been cancelled
Test / Storefront Unit Tests (push) Has been cancelled
Test / Admin Unit Tests (push) Has been cancelled
Test / Dashboard Unit Tests (push) Has been cancelled
Test / API Integration Tests (push) Has been cancelled
2026-06-30 01:22:26 -04:00
root 9ad92765c1 fix test failure
Test / Type Check (all packages) (push) Successful in 3m30s
Test / API Unit Tests (push) Successful in 3m5s
Test / Homepage Unit Tests (push) Successful in 3m0s
Test / Storefront Unit Tests (push) Successful in 3m36s
Test / Admin Unit Tests (push) Successful in 4m0s
Test / Dashboard Unit Tests (push) Successful in 3m11s
Test / API Integration Tests (push) Successful in 3m6s
Build & Deploy / Build & Push Docker Image (push) Failing after 14s
Build & Deploy / Deploy to VPS (push) Has been skipped
2026-06-30 00:23:31 -04:00
root f22e0d45e1 fix subscription page
Build & Deploy / Build & Push Docker Image (push) Successful in 7m57s
Test / Type Check (all packages) (push) Successful in 4m27s
Build & Deploy / Deploy to VPS (push) Successful in 7s
Test / API Unit Tests (push) Failing after 3m2s
Test / Homepage Unit Tests (push) Successful in 4m3s
Test / Storefront Unit Tests (push) Successful in 3m32s
Test / Admin Unit Tests (push) Successful in 3m27s
Test / Dashboard Unit Tests (push) Successful in 3m3s
Test / API Integration Tests (push) Failing after 3m53s
2026-06-29 23:15:55 -04:00
root a752a399c2 fix payment customer and dashboard settings
Build & Deploy / Build & Push Docker Image (push) Successful in 12m39s
Test / Type Check (all packages) (push) Successful in 5m8s
Build & Deploy / Deploy to VPS (push) Successful in 7s
Test / API Unit Tests (push) Failing after 4m24s
Test / Homepage Unit Tests (push) Successful in 3m27s
Test / Storefront Unit Tests (push) Successful in 4m48s
Test / Admin Unit Tests (push) Successful in 3m0s
Test / Dashboard Unit Tests (push) Successful in 4m18s
Test / API Integration Tests (push) Failing after 4m34s
2026-06-29 22:15:34 -04:00
root e1e2f55c93 add list of cities for car pick drop off locations 2026-06-29 20:32:08 -04:00
root 85d1aad956 fix test build cpu issue
Build & Deploy / Build & Push Docker Image (push) Successful in 8m21s
Test / Type Check (all packages) (push) Successful in 3m36s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 2m46s
Test / Homepage Unit Tests (push) Successful in 3m16s
Test / Storefront Unit Tests (push) Successful in 3m11s
Test / Admin Unit Tests (push) Successful in 3m52s
Test / Dashboard Unit Tests (push) Successful in 3m18s
Test / API Integration Tests (push) Failing after 3m30s
2026-06-29 20:03:07 -04:00
root 26ab64e473 add test yml
Build & Deploy / Build & Push Docker Image (push) Successful in 8m12s
Test / Type Check (all packages) (push) Successful in 2m42s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 4m10s
Test / Homepage Unit Tests (push) Failing after 2m51s
Test / Storefront Unit Tests (push) Failing after 3m39s
Test / Admin Unit Tests (push) Successful in 3m10s
Test / Dashboard Unit Tests (push) Failing after 4m13s
Test / API Integration Tests (push) Failing after 3m37s
2026-06-29 18:52:58 -04:00
root 1e100cf367 fix cpu dependencies
Build & Deploy / Build & Push Docker Image (push) Successful in 8m56s
Test / Type Check (all packages) (push) Successful in 3m10s
Build & Deploy / Deploy to VPS (push) Successful in 3s
Test / API Unit Tests (push) Failing after 3m10s
Test / Homepage Unit Tests (push) Failing after 3m10s
Test / Storefront Unit Tests (push) Failing after 3m30s
Test / Admin Unit Tests (push) Failing after 3m43s
Test / Dashboard Unit Tests (push) Has been cancelled
Test / API Integration Tests (push) Has been cancelled
2026-06-29 18:25:10 -04:00
root 3d6607e6c8 make the booking in many steps
Build & Deploy / Build & Push Docker Image (push) Successful in 8m27s
Test / Type Check (all packages) (push) Successful in 3m34s
Build & Deploy / Deploy to VPS (push) Successful in 2s
Test / API Unit Tests (push) Failing after 3m16s
Test / Homepage Unit Tests (push) Failing after 2m40s
Test / Storefront Unit Tests (push) Failing after 24s
Test / Admin Unit Tests (push) Failing after 22s
Test / Dashboard Unit Tests (push) Failing after 24s
Test / API Integration Tests (push) Failing after 33s
2026-06-29 08:54:41 -04:00
root ae10f5272f fix: resolve homepage unit test failures — React runtime, TS target, and pricing currency
Build & Deploy / Build & Push Docker Image (push) Failing after 6m25s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 3m16s
Test / API Unit Tests (push) Failing after 2m35s
Test / Homepage Unit Tests (push) Failing after 3m6s
Test / Storefront Unit Tests (push) Failing after 4m2s
Test / Admin Unit Tests (push) Failing after 3m13s
Test / Dashboard Unit Tests (push) Failing after 3m17s
Test / API Integration Tests (push) Failing after 3m11s
Three fixes:

1. React/ReactDOM version mismatch (root cause of 11 test failures):
   @testing-library/react (hoisted to root) resolved react-dom@18.3.1 from
   root node_modules, but homepage components use React 19. React 19 JSX
   elements cannot be rendered by react-dom 18, producing 'Objects are not
   valid as a React child' errors.
   Fix: added react-dom@^19.2.0 to root package.json, making root-level
   react-dom v19.2.7. @testing-library/react now resolves react-dom@19,
   matching the homepage's React 19 components.

2. TypeScript target (warning elimination):
   Changed homepage tsconfig target from ES2024 to ES2022. The installed
   Vite/esbuild version does not recognize ES2024.

3. Pricing currency test (test/configuration mismatch):
   The production pricing-config.ts uses currency 'MAD' (Moroccan dirham).
   Updated the test expectation from 'USD' to 'MAD' to match.

Validation:
- Focused tests: 6/6 files, 14/14 tests pass
- Full homepage: 16/16 files, 51/51 tests pass
- Full API: 150/150 files, 716/716 tests pass (no regressions)
2026-06-29 00:38:21 -04:00
root a8472ddeed fix: add emailVerified to employee test fixture and add unverified-employee negative test
Root cause: the employee fixture used by the 'logs in active employees with a
signed token and company context' test did not include emailVerified: true,
so the auth.employee.service.login guard at line 108 correctly rejected the
login with 401 email_not_verified.

Changes:
- Added emailVerified: true to the shared employee fixture
- Added a new negative test that verifies login rejects employees with
  emailVerified: false (401 email_not_verified)

The production email-verification guard in auth.employee.service.ts remains
unchanged and strictly enforced.
2026-06-29 00:33:33 -04:00
root 6de0690b76 Fix the admin TypeScript error
Test / Type Check (all packages) (push) Successful in 25s
Test / API Unit Tests (push) Failing after 24s
Test / Homepage Unit Tests (push) Failing after 18s
Test / Storefront Unit Tests (push) Successful in 17s
Test / Admin Unit Tests (push) Successful in 16s
Test / Dashboard Unit Tests (push) Successful in 17s
Test / API Integration Tests (push) Successful in 34s
Build & Deploy / Build & Push Docker Image (push) Successful in 54s
Build & Deploy / Deploy to VPS (push) Successful in 3s
2026-06-28 16:07:31 -04:00
root 0c9f3f8635 fix: resolve TS18046 errors for React 19 element props type
React 19 types React.ReactElement props as unknown instead of any. Test
helper functions that access element.props.children, .className, or .href
need explicit props type parameters on React.ReactElement and isValidElement.

Changes:
- admin/PublicShell.test.ts: add WithChildren type, use React.ReactElement<WithChildren>
- dashboard/PublicShell.test.ts: same pattern for childTypes and contentWrapper
- dashboard/StatCard.test.ts: add ElementWithClass type for className access
- dashboard/public-auth-pages.test.ts: add ElementWithHref type for href access

No any, @ts-ignore, or type assertion bypass used.
2026-06-28 01:21:36 -04:00
root 8aab968e09 refactor: rename marketplace to storefront across the entire monorepo
Build & Deploy / Build & Push Docker Image (push) Successful in 1m2s
Test / Type Check (all packages) (push) Failing after 28s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Storefront Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped
Build & Deploy / Deploy to VPS (push) Successful in 3s
Comprehensive rename of all marketplace references to storefront:
- API module: apps/api/src/modules/marketplace/ → storefront/
- Components: MarketplaceHeader → StorefrontHeader, MarketplaceShell →
  StorefrontShell, MarketplaceFooter → StorefrontFooter
- Types: marketplace-homepage.ts → storefront-homepage.ts
- Test files: employee-marketplace-* → employee-storefront-*
- All source code identifiers, imports, route paths, and strings
- Documentation (docs/), CI config (.gitlab-ci.yml), scripts
- Dashboard, admin, storefront workspace references
- Prisma field names preserved (isListedOnMarketplace, marketplaceRating,
  marketplaceFunnelEvent) as they map to database schema

Validation:
- API type-check: 0 errors
- Storefront type-check: 0 errors
- Dashboard type-check: 0 errors
- Full monorepo type-check: only pre-existing admin TS18046
2026-06-28 01:14:46 -04:00
root bffda95a7c fix: resolve TS2339 jest-dom matcher errors in homepage tests
Root cause: @vitest/coverage-v8@^4.1.0 forced vitest@4.1.9 into
apps/homepage/node_modules, creating two vitest installations (v2 at root,
v4 in homepage). @testing-library/jest-dom's vitest.d.ts augmented the root
vitest v2 module, but homepage tests imported vitest v4 — so the type
augmentation for matchers (toBeDisabled, toHaveAttribute, etc.) never applied.

Fix: downgrade @vitest/coverage-v8 from ^4.1.0 to ^2.1.9 to match vitest 2.x
used by the rest of the monorepo. This unifies vitest at v2.1.9 across all
workspaces, allowing the jest-dom type augmentation to take effect.

Validation:
- npm ls vitest: single v2.1.9 installation (no nested vitest in homepage)
- Homepage type-check (tsc --noEmit): 0 errors (all TS2339 resolved)
- Existing setup.ts and vitest.config.ts were already correctly configured
2026-06-28 00:30:51 -04:00
436 changed files with 31341 additions and 13870 deletions
+2 -3
View File
@@ -11,17 +11,16 @@ API_PORT=4000
API_URL=http://localhost:4000
API_INTERNAL_URL=http://api: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_ADMIN_URL=http://localhost:3000/admin
DASHBOARD_URL=http://localhost:3000/dashboard
ADMIN_URL=http://localhost:3000/admin
DASHBOARD_INTERNAL_URL=http://host.docker.internal:3001
ADMIN_INTERNAL_URL=http://host.docker.internal:3002
# Asset prefix for the dashboard/admin dev servers so browsers load JS chunks from the
# correct port rather than through the marketplace proxy (which doesn't have those chunks).
# Asset prefixes for apps served through the homepage dev proxy.
DASHBOARD_ASSET_PREFIX=http://localhost:3001/dashboard
ADMIN_ASSET_PREFIX=http://localhost:3002/admin
CARPLACE_ASSET_PREFIX=http://localhost:3004/carplace
JWT_SECRET=placeholder
JWT_EXPIRY=8h
RENTER_JWT_EXPIRY=7d
+17 -20
View File
@@ -1,43 +1,40 @@
# ── Traefik domains — used in docker-compose labels ──────────────────────────
ACME_EMAIL=ops@example.com
ACME_EMAIL=rentaldrivego@gmail.com
API_DOMAIN=api.rentaldrivego.ma
PUBLIC_SITE_DOMAIN=rentaldrivego.ma
PGMANAGE_DOMAIN=pgmanage.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 ────────────────────────────────────────────
# Used for pull-based deployments. CI injects APP_IMAGE and APP_VERSION
# automatically during registry-backed deploys, so these can stay commented out.
# Used for production deployments. CI injects IMAGE_TAG automatically during
# 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_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 ──────────────────────────────────────────────────────────────────
# 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)
DATABASE_URL=postgresql://dbadmin:PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK@localhost:5432/rentaldrivego
DATABASE_URL_FROM_POSTGRES=true
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
POSTGRES_DB=rentaldrivego
POSTGRES_USER=postgres
POSTGRES_PASSWORD=placeholder
POSTGRES_USER=dbadmin
POSTGRES_PASSWORD=PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK
# ── Cache ─────────────────────────────────────────────────────────────────────
REDIS_URL=redis://redis:6379
REDIS_PASSWORD=PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK
REDIS_URL=redis://:PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK@redis:6379
# ── API ───────────────────────────────────────────────────────────────────────
API_PORT=4000
# SSR server-side calls go via the internal Docker network
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
ADMIN_INTERNAL_URL=http://admin:3002
# Public-facing API URL visible to browsers — MUST be your real domain, not localhost
@@ -51,9 +48,8 @@ NEXT_PUBLIC_API_URL=https://api.rentaldrivego.ma/api/v1
# Required for production builds. Must be an absolute https:// URL with no trailing slash.
SITE_ORIGIN=https://rentaldrivego.ma
# Marketplace sits at the domain root (Traefik routes /dashboard and /admin to their apps)
NEXT_PUBLIC_MARKETING_URL=https://rentaldrivego.ma
NEXT_PUBLIC_STOREFRONT_URL=https://rentaldrivego.ma/storefront
# 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_ADMIN_URL=https://rentaldrivego.ma/admin
NEXT_PUBLIC_HOMEPAGE_URL=https://rentaldrivego.ma
@@ -67,7 +63,7 @@ ADMIN_URL=https://rentaldrivego.ma/admin
CORS_ORIGINS=https://rentaldrivego.ma,https://www.rentaldrivego.ma
# ── Auth ──────────────────────────────────────────────────────────────────────
JWT_SECRET=placeholder
JWT_SECRET=eb9ab3eb5d6648bdb82cbef29afde498c58f089829a037dfea6cb5e98ef2aae0
JWT_EXPIRY=8h
RENTER_JWT_EXPIRY=7d
NODE_ENV=production
@@ -79,11 +75,12 @@ EMAIL_FROM_NAME=RentalDriveGo
# Option A — Resend
#RESEND_API_KEY=C8qPDuFwsv5l@KsGhL/V
# Option B — SMTP (Gmail)
# 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=placeholder
MAIL_PASSWORD=kfahihfzbcvkczew
MAIL_FROM_ADDRESS=rentaldrivego@gmail.com
MAIL_FROM_NAME=RentalDriveGo
MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com
+6 -3
View File
@@ -1,5 +1,5 @@
# Traefik domains
ACME_EMAIL=ops@example.com
ACME_EMAIL=rentaldrivego@gmail.com
API_DOMAIN=api.example.com
PUBLIC_SITE_DOMAIN=example.com
PGMANAGE_DOMAIN=pgmanage.example.com
@@ -7,6 +7,9 @@ PORTAINER_DOMAIN=portainer.example.com
REGISTRY_DOMAIN=registry.example.com
REGISTRY_UPSTREAM_URL=http://10.0.0.10:5000
# Image tag
IMAGE_TAG=latest
# Database
DATABASE_URL_FROM_POSTGRES=true
POSTGRES_HOST=postgres
@@ -17,6 +20,7 @@ POSTGRES_PASSWORD=placeholder
DATABASE_URL=postgresql://placeholder:placeholder@placeholder:5432/placeholder
# Cache
REDIS_PASSWORD=placeholder
REDIS_URL=redis://redis:6379
# API
@@ -26,9 +30,8 @@ API_URL=https://api.example.com
NEXT_PUBLIC_API_URL=https://api.example.com/api/v1
# Frontend public URLs
NEXT_PUBLIC_MARKETING_URL=https://example.com
NEXT_PUBLIC_HOMEPAGE_URL=https://example.com
NEXT_PUBLIC_STOREFRONT_URL=https://example.com/storefront
NEXT_PUBLIC_STOREFRONT_URL=https://example.com
NEXT_PUBLIC_DASHBOARD_URL=https://example.com/dashboard
NEXT_PUBLIC_ADMIN_URL=https://example.com/admin
NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=example.com
+27 -5
View File
@@ -4,7 +4,13 @@
# ═══════════════════════════════════════════════════════════════
# ─── 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_PORT=4000
@@ -12,7 +18,7 @@ API_URL=http://localhost:4000
NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1
# ─── JWT (Renter auth + Admin auth) ───────────────────────────
JWT_SECRET=placeholder
JWT_SECRET=8bf96de20297c2c295a60e4040e937a7eb8ec7d7d2b83e79a1fc98463322a97c
JWT_EXPIRY=8h
RENTER_JWT_EXPIRY=7d
@@ -71,6 +77,7 @@ NEXT_PUBLIC_FIREBASE_APP_ID=1:123456789:web:abc123
# MAIL_* SMTP variables are intentionally omitted here.
# ─── Redis (Real-time / Socket.io) ────────────────────────────
REDIS_PASSWORD=replace-with-production-redis-password
REDIS_URL=redis://localhost:6379
# ─── App URLs ──────────────────────────────────────────────────
@@ -79,20 +86,35 @@ REDIS_URL=redis://localhost:6379
# SITE_ORIGIN=https://your-production-domain.example
NEXT_PUBLIC_MARKETING_URL=http://localhost:3000
NEXT_PUBLIC_DASHBOARD_URL=http://localhost:3000/dashboard
NEXT_PUBLIC_ADMIN_URL=http://localhost:3000/admin
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:
NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=localhost:3003
API_DOMAIN=api.rentaldrivego.ma
PUBLIC_SITE_DOMAIN=rentaldrivego.ma
DASHBOARD_URL=http://localhost:3000/dashboard
# ─── Admin seed (first SUPER_ADMIN created on db:seed) ────────
ADMIN_SEED_EMAIL=admin@rentaldrivego.ma
ADMIN_SEED_PASSWORD=placeholder
ADMIN_SEED_PASSWORD=PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK
ADMIN_SEED_FIRST_NAME=Super
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 ──────────────────────────────────────────────────────
NODE_ENV=development
NODE_ENV=production
+115 -31
View File
@@ -14,7 +14,9 @@ env:
# TODO: Remove after installing internal CA certificate on the runner
GIT_SSL_NO_VERIFY: "true"
REGISTRY_HOST: 192.168.3.80
DEPLOY_REGISTRY_HOST: 10.0.0.4
DOCKERFILE_PATH: Dockerfile.production
DOCKER_PLATFORM: linux/amd64
DEPLOY_ROOT: /opt/rentaldrivego
jobs:
@@ -25,10 +27,56 @@ jobs:
image_repository: ${{ steps.image-meta.outputs.repository }}
docker_image: ${{ steps.image-meta.outputs.full }}
steps:
- uses: actions/checkout@v4
- 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
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
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
id: image-meta
@@ -54,7 +102,7 @@ jobs:
env:
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
NEXT_PUBLIC_HOMEPAGE_URL: ${{ secrets.NEXT_PUBLIC_HOMEPAGE_URL }}
NEXT_PUBLIC_STOREFRONT_URL: ${{ secrets.NEXT_PUBLIC_STOREFRONT_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 }}
@@ -62,7 +110,7 @@ jobs:
for variable in \
NEXT_PUBLIC_API_URL \
NEXT_PUBLIC_HOMEPAGE_URL \
NEXT_PUBLIC_STOREFRONT_URL \
NEXT_PUBLIC_CARPLACE_URL \
NEXT_PUBLIC_DASHBOARD_URL \
NEXT_PUBLIC_ADMIN_URL \
SITE_ORIGIN
@@ -83,31 +131,41 @@ jobs:
- name: Log in to Gitea Container Registry
if: steps.registry-check.outputs.available == 'true'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY_HOST }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
shell: bash
run: |
printf '%s' "${{ secrets.REGISTRY_PASSWORD }}" | \
docker login "$REGISTRY_HOST" \
--username "${{ secrets.REGISTRY_USERNAME }}" \
--password-stdin
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ${{ env.DOCKERFILE_PATH }}
push: ${{ steps.registry-check.outputs.available == 'true' }}
tags: |
${{ steps.image-meta.outputs.full }}
${{ steps.image-meta.outputs.latest }}
build-args: |
API_INTERNAL_URL=http://api:4000/api/v1
DASHBOARD_INTERNAL_URL=http://dashboard:3001
ADMIN_INTERNAL_URL=http://admin:3002
NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}
NEXT_PUBLIC_HOMEPAGE_URL=${{ secrets.NEXT_PUBLIC_HOMEPAGE_URL }}
NEXT_PUBLIC_STOREFRONT_URL=${{ secrets.NEXT_PUBLIC_STOREFRONT_URL }}
NEXT_PUBLIC_DASHBOARD_URL=${{ secrets.NEXT_PUBLIC_DASHBOARD_URL }}
NEXT_PUBLIC_ADMIN_URL=${{ secrets.NEXT_PUBLIC_ADMIN_URL }}
SITE_ORIGIN=${{ secrets.SITE_ORIGIN }}
shell: bash
env:
BUILDKIT_NO_CLIENT_TOKEN: "true"
run: |
set -euo pipefail
OUTPUT_ARG="--load"
if [ "${{ steps.registry-check.outputs.available }}" = "true" ]; then
OUTPUT_ARG="--push"
fi
docker buildx build \
--file "$DOCKERFILE_PATH" \
--platform "$DOCKER_PLATFORM" \
--tag "${{ steps.image-meta.outputs.full }}" \
--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:
name: Deploy to VPS
@@ -117,7 +175,27 @@ jobs:
DOCKER_IMAGE: ${{ needs.build-image.outputs.docker_image }}
IMAGE_REPOSITORY: ${{ needs.build-image.outputs.image_repository }}
steps:
- uses: actions/checkout@v4
- 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
git rev-parse --short HEAD
- name: Check deploy credentials
id: check-creds
@@ -134,7 +212,12 @@ jobs:
- name: Install SSH client
if: steps.check-creds.outputs.skip == 'false'
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
if: steps.check-creds.outputs.skip == 'false'
@@ -171,9 +254,10 @@ jobs:
ssh "${{ secrets.VPS_USER }}@${{ secrets.VPS_IP }}" "
set -e
cd '$DEPLOY_ROOT'
APP_IMAGE='$IMAGE_REPOSITORY' \
APP_IMAGE='$DEPLOY_REGISTRY_HOST/$IMAGE_REPOSITORY' \
APP_VERSION='${{ gitea.sha }}' \
REGISTRY_HOST='$REGISTRY_HOST' \
IMAGE_TAG='${{ gitea.sha }}' \
REGISTRY_HOST='$DEPLOY_REGISTRY_HOST' \
REGISTRY_USER='${{ secrets.REGISTRY_USERNAME }}' \
REGISTRY_PASSWORD=\$(printf '%s' '$REGISTRY_PASSWORD_B64' | base64 -d) \
bash scripts/docker-prod-deploy.sh
+336 -38
View File
@@ -14,17 +14,63 @@ env:
NODE_VERSION: "20"
# TODO: Remove after installing internal CA certificate on the runner
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:
type-check:
name: Type Check (all packages)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- run: npm ci
- 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
@@ -33,11 +79,53 @@ jobs:
runs-on: ubuntu-latest
needs: type-check
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- run: npm ci
- 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 test:api
@@ -46,37 +134,163 @@ jobs:
runs-on: ubuntu-latest
needs: type-check
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- run: npm ci
- 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:homepage
storefront-tests:
name: Storefront Unit Tests
carplace-tests:
name: Carplace Unit Tests
runs-on: ubuntu-latest
needs: type-check
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- run: npm ci
- 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:storefront
- run: npm run test:carplace
admin-tests:
name: Admin Unit Tests
runs-on: ubuntu-latest
needs: type-check
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- run: npm ci
- 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 test:admin
dashboard-tests:
@@ -84,11 +298,53 @@ jobs:
runs-on: ubuntu-latest
needs: type-check
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- run: npm ci
- 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:dashboard
@@ -123,11 +379,53 @@ jobs:
JWT_EXPIRY: 8h
FILE_STORAGE_ROOT: /tmp/rentaldrivego-test-storage
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- run: npm ci
- 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 db:deploy
- run: npx prisma db push --schema packages/database/prisma/schema.prisma
+2
View File
@@ -13,6 +13,8 @@ apps/api/storage/
.env
.env.local
.env.*.local
.env.docker.production
production/.env.docker.production
# Turbo
.turbo
+2 -2
View File
@@ -28,14 +28,14 @@ api_tests:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
marketplace_tests:
storefront_tests:
stage: test
image: node:20-bookworm
before_script:
- npm ci
- npm run build --workspace @rentaldrivego/types
script:
- npm run test:marketplace
- npm run test:storefront
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
+6 -4
View File
@@ -18,7 +18,7 @@ ARG ADMIN_INTERNAL_URL=http://admin:3002
# NEXT_PUBLIC_* vars must be present at build time — they are inlined into JS bundles
ARG NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_HOMEPAGE_URL
ARG NEXT_PUBLIC_STOREFRONT_URL
ARG NEXT_PUBLIC_CARPLACE_URL
ARG NEXT_PUBLIC_DASHBOARD_URL
ARG NEXT_PUBLIC_ADMIN_URL
@@ -31,12 +31,12 @@ ENV API_INTERNAL_URL=$API_INTERNAL_URL \
ADMIN_INTERNAL_URL=$ADMIN_INTERNAL_URL \
NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \
NEXT_PUBLIC_HOMEPAGE_URL=$NEXT_PUBLIC_HOMEPAGE_URL \
NEXT_PUBLIC_STOREFRONT_URL=$NEXT_PUBLIC_STOREFRONT_URL \
NEXT_PUBLIC_CARPLACE_URL=$NEXT_PUBLIC_CARPLACE_URL \
NEXT_PUBLIC_DASHBOARD_URL=$NEXT_PUBLIC_DASHBOARD_URL \
NEXT_PUBLIC_ADMIN_URL=$NEXT_PUBLIC_ADMIN_URL \
SITE_ORIGIN=$SITE_ORIGIN
RUN npm ci
RUN npm ci --include=optional
RUN npm run db:generate
RUN npm run build
@@ -44,7 +44,8 @@ FROM node:20-bookworm AS runner
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 \
&& groupadd --system --gid 10001 app \
@@ -56,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/apps ./apps
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
RUN chmod 755 /usr/local/bin/rdg-entrypoint.sh
+2 -2
View File
@@ -49,13 +49,13 @@
"sha256": "49e03206558248eb5de2b9a8463be6e0b43e01df518bc0b625fe2953a18185c6"
},
{
"path": "storefront/src/components/MarketplaceFooter.tsx",
"path": "storefront/src/components/StorefrontFooter.tsx",
"status": "modified",
"bytes": 4632,
"sha256": "169d04dcf0976d2637ef5371ca295e9c362bd089d8d6ec313b4e8b4131d5bdd3"
},
{
"path": "storefront/src/components/MarketplaceHeader.tsx",
"path": "storefront/src/components/StorefrontHeader.tsx",
"status": "modified",
"bytes": 10438,
"sha256": "25afc57ff9893f255eddf19580e84b510e6032764daaa5727e2e8d669dca4213"
@@ -60,15 +60,15 @@ Changed files:
- `apps/api/src/app.ts`
- `apps/dashboard/src/middleware.ts`
- `apps/marketplace/src/middleware.ts`
- `apps/storefront/src/middleware.ts`
- `apps/admin/src/middleware.ts`
- `apps/dashboard/src/middleware.test.ts`
- `apps/marketplace/src/middleware.test.ts`
- `apps/storefront/src/middleware.test.ts`
What changed:
- 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.
Security effect:
+1 -1
View File
@@ -5,7 +5,7 @@ const apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http:
const apiUrl = new URL(apiOrigin)
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
// 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).
@@ -71,7 +71,7 @@ interface CompanyDetail {
whatsappNumber: string | null
defaultLocale: string
defaultCurrency: string
isListedOnMarketplace: boolean
isListedOnCarplace: boolean
} | null
contractSettings: {
legalName: string | null
@@ -171,7 +171,7 @@ interface FormState {
whatsappNumber: string
defaultLocale: string
defaultCurrency: string
isListedOnMarketplace: boolean
isListedOnCarplace: boolean
}
contractSettings: {
legalName: string
@@ -277,7 +277,7 @@ function createFormState(company: CompanyDetail): FormState {
whatsappNumber: company.brand?.whatsappNumber ?? '',
defaultLocale: company.brand?.defaultLocale ?? 'en',
defaultCurrency: company.brand?.defaultCurrency ?? 'MAD',
isListedOnMarketplace: company.brand?.isListedOnMarketplace ?? true,
isListedOnCarplace: company.brand?.isListedOnCarplace ?? true,
},
contractSettings: {
legalName: company.contractSettings?.legalName ?? '',
@@ -420,7 +420,7 @@ export default function AdminCompanyDetailPage() {
whatsappNumber: toNullable(form.brand.whatsappNumber),
defaultLocale: form.brand.defaultLocale,
defaultCurrency: form.brand.defaultCurrency,
isListedOnMarketplace: form.brand.isListedOnMarketplace,
isListedOnCarplace: form.brand.isListedOnCarplace,
},
contractSettings: {
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 })} />
</label>
<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 })} />
Listed on marketplace
<input type="checkbox" checked={form.brand.isListedOnCarplace} onChange={(e) => updateSection('brand', { isListedOnCarplace: e.target.checked })} />
Listed on Carplace
</label>
</div>
</section>
+4 -4
View File
@@ -24,7 +24,7 @@ export default function AdminDashboardPage() {
brand: 'RentalDriveGo',
eyebrow: 'Operations command',
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',
readiness: 'Operational readiness',
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'],
['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: {
kpis: ['Entreprises totales', 'Entreprises actives', 'Locataires totaux', 'Réservations totales'],
brand: 'RentalDriveGo',
eyebrow: 'Centre des opérations',
platformOverview: 'Tableau de bord admin RentalDriveGo',
subtitle: 'Suivez la santé de la marketplace, les abonnements, lactivité des locataires et les actions opérateur depuis une même interface.',
subtitle: 'Suivez la santé de la Carplace, les abonnements, lactivité des locataires et les actions opérateur depuis une même interface.',
liveSignal: 'Signal plateforme en direct',
readiness: 'Disponibilité opérationnelle',
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'],
['Journaux daudit', 'Tracer chaque action admin sensible dans RentalDriveGo.', 'Voir les journaux'],
],
health: ['Comptes locataires', 'Identité marketplace', 'Piste daudit admin'],
health: ['Comptes locataires', 'Identité Carplace', 'Piste daudit admin'],
},
ar: {
kpis: ['إجمالي الشركات', 'الشركات النشطة', 'إجمالي المستأجرين', 'إجمالي الحجوزات'],
@@ -3,12 +3,12 @@
import { useEffect, useState } from 'react'
import Link from 'next/link'
import {
cloneMarketplaceHomepageContent,
resolveMarketplaceHomepageSections,
type MarketplaceHomepageConfig,
type MarketplaceHomepageContent,
type MarketplaceHomepageSectionType,
type MarketplaceLanguage,
cloneCarplaceHomepageContent,
resolveCarplaceHomepageSections,
type CarplaceHomepageConfig,
type CarplaceHomepageContent,
type CarplaceHomepageSectionType,
type CarplaceLanguage,
} from '@rentaldrivego/types'
import { useAdminI18n } from '@/components/I18nProvider'
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 LABEL_CLASS = 'mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500'
function cloneHomepageContent(content: MarketplaceHomepageConfig) {
const cloned = JSON.parse(JSON.stringify(content)) as MarketplaceHomepageConfig
;(['en', 'fr', 'ar'] as MarketplaceLanguage[]).forEach((language) => {
cloned[language].sections = resolveMarketplaceHomepageSections(cloned[language].sections)
function cloneHomepageContent(content: CarplaceHomepageConfig) {
const cloned = JSON.parse(JSON.stringify(content)) as CarplaceHomepageConfig
;(['en', 'fr', 'ar'] as CarplaceLanguage[]).forEach((language) => {
cloned[language].sections = resolveCarplaceHomepageSections(cloned[language].sections)
})
return cloned
}
const HOMEPAGE_SECTIONS: MarketplaceHomepageSectionType[] = [
const HOMEPAGE_SECTIONS: CarplaceHomepageSectionType[] = [
'hero',
'surface',
'pillars',
@@ -58,12 +58,12 @@ export default function AdminSiteConfigPage() {
const copy = {
en: {
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',
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',
savingHomepage: 'Saving…',
homepageSaved: 'Marketplace homepage saved.',
homepageSaved: 'Carplace homepage saved.',
search: 'Search by company or slug…',
loading: 'Loading…',
empty: 'No companies found',
@@ -86,7 +86,7 @@ export default function AdminSiteConfigPage() {
previewTitle: 'Live preview',
previewDescription: 'Draft changes appear here before you save them.',
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',
removeSection: 'Remove',
expand: 'Expand',
@@ -94,12 +94,12 @@ export default function AdminSiteConfigPage() {
},
fr: {
title: 'Configuration du site',
description: 'Modifiez ici la page daccueil principale de la marketplace, ou ouvrez une entreprise pour gérer sa page publique et son menu.',
description: 'Modifiez ici la page daccueil principale de la Carplace, ou ouvrez une entreprise pour gérer sa page publique et son menu.',
homepageTitle: 'Page daccueil du site principal',
homepageDescription: 'Cette section contrôle la page daccueil marketplace affichée sur le site principal de RentalDriveGo.',
homepageDescription: 'Cette section contrôle la page daccueil Carplace affichée sur le site principal de RentalDriveGo.',
saveHomepage: 'Enregistrer la page daccueil',
savingHomepage: 'Enregistrement…',
homepageSaved: 'Page daccueil marketplace enregistrée.',
homepageSaved: 'Page daccueil Carplace enregistrée.',
search: 'Rechercher par entreprise ou slug…',
loading: 'Chargement…',
empty: 'Aucune entreprise trouvée',
@@ -112,7 +112,7 @@ export default function AdminSiteConfigPage() {
companiesTitle: 'Sites de marque des entreprises',
languageLabel: 'Langue',
hero: 'Bloc principal',
surface: 'Bloc marketplace',
surface: 'Bloc Carplace',
companySection: 'Bloc opérateur',
renterSection: 'Bloc client',
valueSection: 'Blocs de valeur',
@@ -122,7 +122,7 @@ export default function AdminSiteConfigPage() {
previewTitle: 'Aperçu en direct',
previewDescription: 'Les brouillons apparaissent ici avant lenregistrement.',
homepageSections: 'Sections de la page daccueil',
homepageSectionsDescription: 'Ajoutez ou retirez des blocs de la page daccueil marketplace principale.',
homepageSectionsDescription: 'Ajoutez ou retirez des blocs de la page daccueil Carplace principale.',
addSection: 'Ajouter',
removeSection: 'Retirer',
expand: 'Ouvrir',
@@ -171,8 +171,8 @@ export default function AdminSiteConfigPage() {
const [search, setSearch] = useState('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [homepage, setHomepage] = useState<MarketplaceHomepageConfig>(cloneMarketplaceHomepageContent())
const [homepageLanguage, setHomepageLanguage] = useState<MarketplaceLanguage>(language)
const [homepage, setHomepage] = useState<CarplaceHomepageConfig>(cloneCarplaceHomepageContent())
const [homepageLanguage, setHomepageLanguage] = useState<CarplaceLanguage>(language)
const [homepageSaving, setHomepageSaving] = useState(false)
const [homepageMessage, setHomepageMessage] = useState<string | null>(null)
const [homepageExpanded, setHomepageExpanded] = useState(false)
@@ -189,7 +189,7 @@ export default function AdminSiteConfigPage() {
credentials: 'include',
cache: 'no-store',
}),
fetch(`${ADMIN_API_BASE}/admin/site-config/marketplace-homepage`, {
fetch(`${ADMIN_API_BASE}/admin/site-config/carplace-homepage`, {
credentials: 'include',
cache: 'no-store',
}),
@@ -202,7 +202,7 @@ export default function AdminSiteConfigPage() {
const homepageJson = await homepageRes.json()
if (homepageRes.ok && homepageJson?.data) {
setHomepage(cloneHomepageContent(homepageJson.data as MarketplaceHomepageConfig))
setHomepage(cloneHomepageContent(homepageJson.data as CarplaceHomepageConfig))
}
} catch (err: any) {
setError(err.message)
@@ -225,7 +225,7 @@ export default function AdminSiteConfigPage() {
)
}, [search, companies])
function updateHomepageContent(patch: Partial<MarketplaceHomepageContent>) {
function updateHomepageContent(patch: Partial<CarplaceHomepageContent>) {
setHomepage((current) => ({
...current,
[homepageLanguage]: {
@@ -257,16 +257,16 @@ export default function AdminSiteConfigPage() {
}
function getActiveSections() {
return resolveMarketplaceHomepageSections(activeContent.sections)
return resolveCarplaceHomepageSections(activeContent.sections)
}
function addHomepageSection(section: MarketplaceHomepageSectionType) {
function addHomepageSection(section: CarplaceHomepageSectionType) {
const sections = getActiveSections()
if (sections.includes(section)) return
updateHomepageContent({ sections: [...sections, section] })
}
function removeHomepageSection(section: MarketplaceHomepageSectionType) {
function removeHomepageSection(section: CarplaceHomepageSectionType) {
const sections = getActiveSections().filter((item) => item !== section)
if (sections.length === 0) return
updateHomepageContent({ sections })
@@ -276,7 +276,7 @@ export default function AdminSiteConfigPage() {
setHomepageSaving(true)
setHomepageMessage(null)
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',
headers: {
'Content-Type': 'application/json',
@@ -286,7 +286,7 @@ export default function AdminSiteConfigPage() {
})
const json = await res.json()
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)
} catch (err: any) {
setError(err.message)
@@ -298,7 +298,7 @@ export default function AdminSiteConfigPage() {
const activeContent = homepage[homepageLanguage]
const activeSections = getActiveSections()
const availableSections = HOMEPAGE_SECTIONS.filter((section) => !activeSections.includes(section))
const sectionLabels: Record<MarketplaceHomepageSectionType, string> = {
const sectionLabels: Record<CarplaceHomepageSectionType, string> = {
hero: copy.hero,
surface: copy.surface,
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">
<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
key={value}
type="button"
@@ -6,7 +6,9 @@ vi.mock('@/components/PublicFooter', () => ({ default: function MockAdminFooter(
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) => {
const type = child.type as any
if (typeof type === 'string') return type
+8 -4
View File
@@ -24,11 +24,12 @@ import subscriptionsRouter, {
subscriptionWebhookRouter,
} from './modules/subscriptions/subscription.routes'
import paymentsRouter from './modules/payments/payment.routes'
import billingRouter from './modules/billing/billing.routes'
import customersRouter from './modules/customers/customer.routes'
import vehiclesRouter from './modules/vehicles/vehicle.routes'
import companiesRouter from './modules/companies/company.routes'
import reservationsRouter from './modules/reservations/reservation.routes'
import marketplaceRouter from './modules/marketplace/marketplace.routes'
import carplaceRouter from './modules/carplace/carplace.routes'
import siteRouter from './modules/site/site.routes'
import reviewsRouter from './modules/reviews/review.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/report`, description: 'Analytics report' },
{ method: 'GET', path: `${v1}/notifications/company`, description: 'Company notifications' },
{ method: 'GET', path: `${v1}/marketplace/cities`, description: 'Marketplace cities' },
{ method: 'GET', path: `${v1}/marketplace/search`, description: 'Marketplace search' },
{ method: 'GET', path: `${v1}/carplace/home`, description: 'Carplace home' },
{ 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}/companies/me`, description: 'Current company profile' },
{ 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`, adminLimiter, adminRouter)
app.use(`${v1}/marketplace`, publicLimiter, marketplaceRouter)
app.use(`${v1}/carplace`, publicLimiter, carplaceRouter)
app.use(`${v1}/site`, publicLimiter, siteRouter)
app.use(`${v1}/subscriptions`, subscriptionPublicRouter)
app.use(`${v1}/subscriptions`, subscriptionWebhookRouter)
@@ -202,6 +205,7 @@ export function createApp() {
app.use(`${v1}/companies`, apiLimiter, companiesRouter)
app.use(`${v1}/subscriptions`, apiLimiter, subscriptionsRouter)
app.use(`${v1}/payments`, apiLimiter, paymentsRouter)
app.use(`${v1}/billing`, apiLimiter, billingRouter)
app.use(`${v1}/reviews`, apiLimiter, reviewsRouter)
app.use(`${v1}/complaints`, apiLimiter, complaintsRouter)
app.use(`${v1}/licenses`, publicLimiter, licenseValidationRouter)
+71 -71
View File
@@ -1,5 +1,5 @@
{
"marketplaceHomepage": {
"carplaceHomepage": {
"en": {
"sections": [
"hero",
@@ -13,13 +13,13 @@
"closing"
],
"heroKicker": "RentalDriveGo",
"heroTitle": "Marketplace discovery with a sharper front door.",
"heroBody": "Rental companies run private operations, renters browse a shared marketplace, and every booking ends on the companys own branded checkout.",
"heroTitle": "Carplace discovery with a sharper front door.",
"heroBody": "Rental companies run private operations, renters browse a shared Carplace, and every booking ends on the companys own branded checkout.",
"startTrial": "Start free trial",
"exploreVehicles": "Explore vehicles",
"surfaceLabel": "Marketplace surface",
"surfaceLabel": "Carplace surface",
"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",
"trustedFleets": "Trusted fleets",
"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.",
"renterKicker": "Renter experience",
"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": [
{
"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",
@@ -47,7 +47,7 @@
"metrics": [
{
"value": "01",
"label": "Shared marketplace visibility"
"label": "Shared Carplace visibility"
},
{
"value": "02",
@@ -61,7 +61,7 @@
"featureLabel": "What the product covers",
"features": [
"Fleet management with multi-photo uploads",
"Marketplace offers and redirect booking flow",
"Carplace offers and redirect booking flow",
"Branded public booking site per company",
"Customer CRM, analytics, and billing controls"
],
@@ -71,12 +71,12 @@
{
"number": "1",
"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",
"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",
@@ -89,12 +89,12 @@
{
"step": "1",
"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",
"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",
@@ -104,8 +104,8 @@
],
"stepLabel": "Step",
"readyKicker": "Ready to launch",
"readyTitle": "The marketplace 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.",
"readyTitle": "The Carplace homepage should explain the product in seconds.",
"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",
"createWorkspace": "Create workspace"
},
@@ -122,13 +122,13 @@
"closing"
],
"heroKicker": "RentalDriveGo",
"heroTitle": "Une vitrine marketplace 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.",
"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 Carplace commune et chaque réservation se finalise sur le parcours de paiement aux couleurs de l'entreprise.",
"startTrial": "Commencer lessai gratuit",
"exploreVehicles": "Explorer les véhicules",
"surfaceLabel": "Vitrine marketplace",
"surfaceLabel": "Vitrine Carplace",
"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",
"trustedFleets": "Flottes fiables",
"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.",
"renterKicker": "Expérience client",
"renterTitle": "Laissez les clients comparer rapidement, puis redirigez-les vers le bon site dentreprise.",
"renterBody": "La marketplace sert de moteur de découverte, pas dagrégateur sans suite. Recherche ici, réservation là-bas, paiement direct.",
"renterBody": "La Carplace sert de moteur de découverte, pas dagrégateur sans suite. Recherche ici, réservation là-bas, paiement direct.",
"pillars": [
{
"title": "Publication unifiée",
"body": "Les photos, tarifs et offres circulent depuis un seul flux dadministration vers la découverte marketplace et les pages de réservation de marque."
"body": "Les photos, tarifs et offres circulent depuis un seul flux dadministration vers la découverte Carplace et les pages de réservation de marque."
},
{
"title": "Découverte qualifiée",
@@ -156,7 +156,7 @@
"metrics": [
{
"value": "01",
"label": "Visibilité marketplace partagée"
"label": "Visibilité Carplace partagée"
},
{
"value": "02",
@@ -170,40 +170,40 @@
"featureLabel": "Ce que couvre le produit",
"features": [
"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",
"CRM client, analytics et contrôle de facturation"
],
"howitworksKicker": "MISE EN ROUTE",
"howitworksTitle": "Comment ça marche",
"howitworksSteps": [
{
"number": "1",
"title": "Créer un compte",
"description": "Inscrivez-vous à votre espace entreprise et choisissez votre forfait pour l'essai gratuit de 90 jours."
},
{
"number": "2",
"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."
},
{
"number": "3",
"title": "Commencer à vendre",
"description": "Les clients découvrent votre flotte et les réservations arrivent directement à votre paiement de marque."
}
],
"stepsTitle": "Comment les entreprises démarrent",
"CRM client, analytics et contrôle de facturation"
],
"howitworksKicker": "MISE EN ROUTE",
"howitworksTitle": "Comment ça marche",
"howitworksSteps": [
{
"number": "1",
"title": "Créer un compte",
"description": "Inscrivez-vous à votre espace entreprise et choisissez votre forfait pour l'essai gratuit de 30 jours."
},
{
"number": "2",
"title": "Ajouter votre flotte",
"description": "Téléversez les photos des véhicules, fixez les tarifs et créez des offres visibles sur la Carplace."
},
{
"number": "3",
"title": "Commencer à vendre",
"description": "Les clients découvrent votre flotte et les réservations arrivent directement à votre paiement de marque."
}
],
"stepsTitle": "Comment les entreprises démarrent",
"steps": [
{
"step": "1",
"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",
"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",
@@ -213,8 +213,8 @@
],
"stepLabel": "Étape",
"readyKicker": "Prêt à démarrer",
"readyTitle": "La page daccueil marketplace 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.",
"readyTitle": "La page daccueil Carplace doit présenter le produit en quelques secondes.",
"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",
"createWorkspace": "Créer lespace"
},
@@ -281,28 +281,28 @@
"إدارة الأسطول مع رفع عدة صور",
"عروض السوق والتحويل إلى مسار الحجز",
"موقع حجز عام مخصص لكل شركة",
"إدارة العملاء والتحليلات والفوترة"
],
"howitworksKicker": "البدء",
"howitworksTitle": "كيف يعمل",
"howitworksSteps": [
{
"number": "1",
"title": "إنشاء حساب",
"description": "قم بالتسجيل للحصول على مساحة عمل شركتك واختر خطتك للتجربة المجانية لمدة 90 يوماً."
},
{
"number": "2",
"title": "أضف أسطولك",
"description": "قم برفع صور السيارات، وحدد الأسعار، وأنشئ عروضاً مرئية في السوق."
},
{
"number": "3",
"title": "ابدأ البيع",
"description": "يكتشف المستأجرون أسطولك وتأتي الحجوزات مباشرة إلى دفعتك المخصصة."
}
],
"stepsTitle": "كيف تبدأ الشركات",
"إدارة العملاء والتحليلات والفوترة"
],
"howitworksKicker": "البدء",
"howitworksTitle": "كيف يعمل",
"howitworksSteps": [
{
"number": "1",
"title": "إنشاء حساب",
"description": "قم بالتسجيل للحصول على مساحة عمل شركتك واختر خطتك للتجربة المجانية لمدة 30 يوماً."
},
{
"number": "2",
"title": "أضف أسطولك",
"description": "قم برفع صور السيارات، وحدد الأسعار، وأنشئ عروضاً مرئية في السوق."
},
{
"number": "3",
"title": "ابدأ البيع",
"description": "يكتشف المستأجرون أسطولك وتأتي الحجوزات مباشرة إلى دفعتك المخصصة."
}
],
"stepsTitle": "كيف تبدأ الشركات",
"steps": [
{
"step": "1",
@@ -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', () => {
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 }))
}
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.statusCode >= 500) console.error('[API Error]', { requestId: req.requestId, err })
return res.status(err.statusCode).json(withRequestId(req, {
+3 -3
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
formatDate,
marketplaceReservationEmail,
carplaceReservationEmail,
resetPasswordEmail,
signupEmail,
} from './emailTranslations'
@@ -40,8 +40,8 @@ describe('emailTranslations', () => {
expect(html).toContain('45')
})
it('includes optional contact phone in marketplace reservation HTML when present', () => {
const html = marketplaceReservationEmail.html({
it('includes optional contact phone in Carplace reservation HTML when present', () => {
const html = carplaceReservationEmail.html({
firstName: 'Yassine',
vehicleYear: 2024,
vehicleMake: 'Dacia',
+2 -2
View File
@@ -117,9 +117,9 @@ export const resetPasswordEmail = {
}, lang),
}
// ─── Marketplace reservation request ─────────────────────────────────────────
// ─── Carplace reservation request ─────────────────────────────────────────
export const marketplaceReservationEmail = {
export const carplaceReservationEmail = {
subject: (vehicleName: string, lang: Lang) => t({
en: `Reservation Request Received — ${vehicleName}`,
fr: `Demande de réservation reçue — ${vehicleName}`,
+1 -1
View File
@@ -82,7 +82,7 @@ export const apiLimiter = rateLimit({
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({
windowMs: 60 * 1000,
max: 60,
@@ -1,6 +1,15 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
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'
function responseStub() {
@@ -12,44 +21,46 @@ function responseStub() {
describe('requireSubscription middleware', () => {
beforeEach(() => {
vi.clearAllMocks()
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 res = responseStub()
const next = vi.fn() as NextFunction
requireSubscription(req, res, next)
await requireSubscription(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'No company context', statusCode: 401 })
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 res = responseStub()
const next = vi.fn() as NextFunction
requireSubscription(req, res, next)
await requireSubscription(req, res, next)
expect(res.status).toHaveBeenCalledWith(402)
expect(res.json).toHaveBeenCalledWith({
error: 'subscription_suspended',
message: 'Your account has been suspended. Please contact support or renew your subscription.',
statusCode: 402,
billingUrl: 'https://dashboard.example.test/billing',
billingUrl: 'https://dashboard.example.test/subscription',
})
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 res = responseStub()
const next = vi.fn() as NextFunction
requireSubscription(req, res, next)
await requireSubscription(req, res, next)
expect(res.status).toHaveBeenCalledWith(402)
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
@@ -59,14 +70,55 @@ describe('requireSubscription middleware', () => {
expect(next).not.toHaveBeenCalled()
})
it('allows active companies through', () => {
const req = { company: { status: 'ACTIVE' } } as Request
it('blocks expired trial subscriptions with a subscription recovery URL', async () => {
vi.mocked(prisma.subscription.findUnique).mockResolvedValue({ status: 'EXPIRED' } as never)
const req = { company: { id: 'company_1', status: 'TRIALING' } } as Request
const res = responseStub()
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(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()
})
})
+40 -15
View File
@@ -1,4 +1,6 @@
import { Request, Response, NextFunction } from 'express'
import { prisma } from '../lib/prisma'
import { getAccessLevel, hasAnyAccess } from '../modules/subscriptions/subscription.policy'
import { sendUnauthorized, sendPaymentRequired } from './authHelpers'
const BLOCKED_STATUSES = ['SUSPENDED', 'PENDING']
@@ -8,22 +10,45 @@ const BLOCKED_STATUSES = ['SUSPENDED', 'PENDING']
* Must be applied after `requireTenant`.
*
* 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) {
const company = req.company
if (!company) return sendUnauthorized(res, 'unauthenticated', 'No company context')
export async function requireSubscription(req: Request, res: Response, next: NextFunction) {
try {
const company = req.company
if (!company) return sendUnauthorized(res, 'unauthenticated', 'No company context')
if (BLOCKED_STATUSES.includes(company.status)) {
return sendPaymentRequired(
res,
`subscription_${company.status.toLowerCase()}`,
company.status === 'SUSPENDED'
? 'Your account has been suspended. Please contact support or renew your subscription.'
: 'Your account is pending activation. Please complete your subscription setup.',
{ billingUrl: `${process.env.NEXT_PUBLIC_DASHBOARD_URL}/billing` },
)
if (BLOCKED_STATUSES.includes(company.status)) {
return sendPaymentRequired(
res,
`subscription_${company.status.toLowerCase()}`,
company.status === 'SUSPENDED'
? 'Your account has been suspended. Please contact support or renew your subscription.'
: 'Your account is pending activation. Please complete your subscription setup.',
{ billingUrl: `${process.env.NEXT_PUBLIC_DASHBOARD_URL}/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()
} catch (error) {
next(error)
}
next()
}
+4 -4
View File
@@ -559,16 +559,16 @@ router.post('/subscriptions/:subscriptionId/cancel', requireAdminAuth, requireAd
// ─── 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 {
ok(res, await service.getMarketplaceHomepage())
ok(res, await service.getCarplaceHomepage())
} 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 {
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) }
})
+28 -20
View File
@@ -1,5 +1,13 @@
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({
email: z.string().email().max(255).trim().toLowerCase(),
@@ -154,7 +162,7 @@ export const adminCompanyUpdateSchema = z.object({
publicCity: nullableString, publicCountry: nullableString, websiteUrl: nullableUrl,
whatsappNumber: nullableString, defaultLocale: z.string().min(2).optional(),
defaultCurrency: z.literal('MAD').optional(),
isListedOnMarketplace: z.boolean().optional(),
isListedOnCarplace: z.boolean().optional(),
homePageConfig: z.any().optional(),
menuConfig: z.any().optional(),
}).optional(),
@@ -175,35 +183,35 @@ export const adminCompanyUpdateSchema = z.object({
}).optional(),
})
const marketplaceMetricSchema: z.ZodType<MarketplaceHomepageMetric> = z.object({ value: z.string().min(1), label: z.string().min(1) })
const marketplacePillarSchema: z.ZodType<MarketplaceHomepagePillar> = z.object({ title: z.string().min(1), body: z.string().min(1) })
const marketplaceStepSchema: z.ZodType<MarketplaceHomepageStep> = z.object({ step: z.string().min(1), title: z.string().min(1), body: z.string().min(1) })
const marketplaceHowItWorksStepSchema: z.ZodType<MarketplaceHomepageHowItWorksStep> = 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 marketplaceSectionSchema: z.ZodType<MarketplaceHomepageSectionType> = z.enum(['hero', 'surface', 'pillars', 'audiences', 'features', 'howitworks', 'steps', 'testimonials', 'closing'])
const carplaceMetricSchema: z.ZodType<CarplaceHomepageMetric> = z.object({ value: z.string().min(1), label: z.string().min(1) })
const carplacePillarSchema: z.ZodType<CarplaceHomepagePillar> = z.object({ 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 carplaceHowItWorksStepSchema: z.ZodType<CarplaceHomepageHowItWorksStep> = z.object({ number: z.string().min(1), title: z.string().min(1), description: z.string().min(1) })
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 carplaceSectionSchema: z.ZodType<CarplaceHomepageSectionType> = z.enum(['hero', 'surface', 'pillars', 'audiences', 'features', 'howitworks', 'steps', 'testimonials', 'closing'])
const marketplaceHomepageContentSchema: z.ZodType<MarketplaceHomepageContent> = z.object({
sections: z.array(marketplaceSectionSchema).min(1),
const carplaceHomepageContentSchema: z.ZodType<CarplaceHomepageContent> = z.object({
sections: z.array(carplaceSectionSchema).min(1),
heroKicker: z.string().min(1), heroTitle: z.string().min(1), heroBody: z.string().min(1),
startTrial: z.string().min(1), exploreVehicles: z.string().min(1),
surfaceLabel: z.string().min(1), surfaceTitle: z.string().min(1), surfaceBody: z.string().min(1),
liveLabel: z.string().min(1), trustedFleets: z.string().min(1), brandedFlows: z.string().min(1), multiTenant: z.string().min(1),
companyKicker: z.string().min(1), companyTitle: z.string().min(1), companyBody: z.string().min(1),
renterKicker: z.string().min(1), renterTitle: z.string().min(1), renterBody: z.string().min(1),
pillars: z.array(marketplacePillarSchema).length(3),
metrics: z.array(marketplaceMetricSchema).length(3),
pillars: z.array(carplacePillarSchema).length(3),
metrics: z.array(carplaceMetricSchema).length(3),
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),
stepsTitle: z.string().min(1), steps: z.array(marketplaceStepSchema).length(3), stepLabel: z.string().min(1),
testimonialsKicker: z.string().min(1), testimonialsTitle: z.string().min(1), testimonials: z.array(marketplaceTestimonialSchema).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(carplaceStepSchema).length(3), stepLabel: z.string().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),
viewPricing: z.string().min(1), createWorkspace: z.string().min(1),
})
export const marketplaceHomepageConfigSchema = z.object({
en: marketplaceHomepageContentSchema,
fr: marketplaceHomepageContentSchema,
ar: marketplaceHomepageContentSchema,
export const carplaceHomepageConfigSchema = z.object({
en: carplaceHomepageContentSchema,
fr: carplaceHomepageContentSchema,
ar: carplaceHomepageContentSchema,
})
export const idParamSchema = z.object({
@@ -300,7 +308,7 @@ export const billingRefundSchema = z.object({
})
export const homepageUpdateSchema = z.object({
homepage: marketplaceHomepageConfigSchema,
homepage: carplaceHomepageConfigSchema,
})
export const pricingUpdateSchema = z.object({
+6 -6
View File
@@ -3,7 +3,7 @@ import crypto from 'crypto'
import { authenticator } from 'otplib'
import { signActorToken } from '../../security/tokens'
import qrcode from 'qrcode'
import { getMarketplaceHomepageContent, saveMarketplaceHomepageContent } from '../../services/platformContentService'
import { getCarplaceHomepageContent, saveCarplaceHomepageContent } from '../../services/platformContentService'
import { sendTransactionalEmail } from '../../services/notificationService'
import * as presenter from './admin.presenter'
import * as repo from './admin.repo'
@@ -402,16 +402,16 @@ export function issueBillingRefund(
return billingService.issueRefund(invoiceId, data, adminId, ip)
}
export function getMarketplaceHomepage() {
return getMarketplaceHomepageContent()
export function getCarplaceHomepage() {
return getCarplaceHomepageContent()
}
export async function updateMarketplaceHomepage(homepage: any, adminId: string, ip?: string) {
const saved = await saveMarketplaceHomepageContent(homepage)
export async function updateCarplaceHomepage(homepage: any, adminId: string, ip?: string) {
const saved = await saveCarplaceHomepageContent(homepage)
await repo.createAuditLog({
adminUserId: adminId,
action: 'UPDATE',
resource: 'MarketplaceHomepage',
resource: 'CarplaceHomepage',
after: toAuditJson(saved),
ipAddress: ip,
userAgent: undefined,
@@ -85,7 +85,7 @@ describe('analytics.service', () => {
},
] as never)
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 } },
] as never)
vi.mocked(prisma.subscription.findUnique).mockResolvedValue({
@@ -115,7 +115,7 @@ describe('analytics.service', () => {
totalAmount: 1200,
})])
expect(result.sourceBreakdown).toEqual([
{ source: 'MARKETPLACE', count: 3, revenue: 3600 },
{ source: 'CARPLACE', count: 3, revenue: 3600 },
{ source: 'DIRECT', count: 2, revenue: 0 },
])
expect(result.subscription).toEqual({
@@ -37,7 +37,7 @@ export async function startAccount(body: AccountStartInput) {
const result = await prisma.$transaction(async (tx: any) => {
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({
data: {
@@ -104,6 +104,7 @@ describe('auth.company.repo.createCompanySignup', () => {
companyId: 'company_1',
clerkUserId: 'local_owner_company_1',
email: 'owner@example.com',
emailVerified: baseInput.now,
role: 'OWNER',
preferredLanguage: 'fr',
isActive: true,
@@ -136,6 +136,7 @@ export async function createCompanySignup(db: DbClient, input: CompanySignupInpu
email: input.ownerEmail,
phone: input.companyPhone,
passwordHash: input.passwordHash,
emailVerified: input.now,
role: 'OWNER',
preferredLanguage: input.preferredLanguage,
isActive: true,
@@ -13,7 +13,7 @@ import { companySignupSchema } from './auth.company.schemas'
import type { output } from 'zod'
type CompanySignupInput = output<typeof companySignupSchema>
const TRIAL_PERIOD_DAYS = 90
const TRIAL_PERIOD_DAYS = 30
function slugify(value: string) {
return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 50) || 'company'
@@ -32,6 +32,8 @@ const employee = {
preferredLanguage: 'fr',
companyId: 'company_1',
isActive: true,
emailVerified: true,
emailVerificationToken: null,
passwordHash: 'hash_old',
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 () => {
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')
}
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')
}
@@ -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
}
@@ -1,7 +1,7 @@
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', () => {
const nextAvailableAt = new Date('2026-08-01T10:00:00.000Z')
const vehicle = { id: 'vehicle_1', make: 'Dacia', model: 'Logan', dailyRate: 25000 }
@@ -11,9 +11,9 @@ const prismaMock = vi.hoisted(() => ({
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 () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-09T10:00:00.000Z'))
@@ -33,38 +33,41 @@ describe('marketplace.repo public query and write boundaries', () => {
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()
expect(prismaMock.company.findMany).toHaveBeenCalledWith({
where: {
status: { in: ['ACTIVE', 'TRIALING'] },
brand: { isListedOnMarketplace: true, publicCity: { not: null } },
brand: { isListedOnCarplace: true, publicCity: { not: null } },
},
select: { brand: { select: { publicCity: true } } },
})
})
it('requires published vehicles and an active company when loading marketplace vehicle details', async () => {
await repo.findVehicleForMarketplace('vehicle_1', 'atlas')
it('requires published vehicles and an active company when loading carplace vehicle details', async () => {
await repo.findVehicleForCarplace('vehicle_1', 'atlas')
expect(prismaMock.vehicle.findFirst).toHaveBeenCalledWith({
where: {
id: 'vehicle_1',
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 } } },
})
})
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({
id: 'customer_1',
address: { fullAddress: 'Old address', loyaltyNote: 'keep', internationalLicenseNumber: 'INT-1' },
})
await repo.upsertMarketplaceCustomer('company_1', {
await repo.upsertCarplaceCustomer('company_1', {
email: 'renter@example.test',
firstName: 'Nora',
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 endDate = new Date('2026-07-05T10:00:00.000Z')
await repo.createMarketplaceReservation({
await repo.createCarplaceReservation({
companyId: 'company_1',
vehicleId: 'vehicle_1',
customerId: 'customer_1',
@@ -108,7 +111,7 @@ describe('marketplace.repo public query and write boundaries', () => {
companyId: 'company_1',
vehicleId: 'vehicle_1',
customerId: 'customer_1',
source: 'MARKETPLACE',
source: 'CARPLACE',
status: 'DRAFT',
}),
})
@@ -13,7 +13,7 @@ export async function findCitiesFromCompanies() {
return prisma.company.findMany({
where: {
status: { in: ['ACTIVE', 'TRIALING'] },
brand: { isListedOnMarketplace: true, publicCity: { not: null } },
brand: { isListedOnCarplace: true, publicCity: { not: null } },
},
select: { brand: { select: { publicCity: true } } },
})
@@ -23,7 +23,7 @@ export async function findListedCompanies(where: any, skip: number, take: number
return prisma.company.findMany({
where,
include: {
brand: { select: { displayName: true, logoUrl: true, subdomain: true, publicCity: true, publicCountry: true, marketplaceRating: true, primaryColor: true } },
brand: { select: { displayName: true, logoUrl: true, subdomain: true, publicCity: true, publicCountry: true, carplaceRating: true, primaryColor: true } },
_count: { select: { vehicles: { where: { isPublished: true, status: 'AVAILABLE' } } } },
},
skip,
@@ -35,31 +35,31 @@ export async function findPublishedVehicles(where: any) {
return prisma.vehicle.findMany({
where,
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' },
})
}
export async function findVehicleForMarketplace(vehicleId: string, companySlug: string) {
export async function findVehicleForCarplace(vehicleId: string, companySlug: string) {
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 } } },
})
}
export async function findVehicleForMarketplaceById(vehicleId: string) {
export async function findVehicleForCarplaceById(vehicleId: string) {
return prisma.vehicle.findFirst({
where: {
id: vehicleId,
isPublished: true,
company: { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnMarketplace: true } },
company: { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnCarplace: true } },
},
include: { company: { include: { brand: true } } },
})
}
export async function upsertMarketplaceCustomer(companyId: string, data: {
export async function upsertCarplaceCustomer(companyId: string, data: {
email: string
firstName: string
lastName: string
@@ -115,17 +115,17 @@ export async function upsertMarketplaceCustomer(companyId: string, data: {
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
startDate: Date; endDate: Date; pickupLocation?: string | null; returnLocation?: string | null
dailyRate: number; totalDays: number; totalAmount: number; notes?: string; bookingReference?: string
}) {
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
companySlug: string
vehicleId: string
@@ -138,12 +138,12 @@ export async function createMarketplaceFunnelEvent(data: {
where: {
id: data.vehicleId,
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 },
})
return prisma.marketplaceFunnelEvent.create({
return prisma.carplaceFunnelEvent.create({
data: {
eventName: data.eventName,
companyId: vehicle?.companyId ?? null,
@@ -159,7 +159,7 @@ export async function createMarketplaceFunnelEvent(data: {
export async function findCompanyPage(slug: string) {
return prisma.company.findFirst({
where: { slug, status: { in: ['ACTIVE', 'TRIALING'] } },
where: { status: { in: ['ACTIVE', 'TRIALING'] }, OR: [{ slug }, { brand: { subdomain: slug } }] },
include: {
brand: true,
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) {
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) {
@@ -189,7 +189,7 @@ export async function findCompanyVehicles(companyId: 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({
where: { id: vehicleId, companyId: company.id, isPublished: true, status: 'AVAILABLE' },
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
@@ -1,7 +1,7 @@
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', () => {
expect(paginationSchema.parse({ page: '2', pageSize: '40' })).toEqual({ page: 2, pageSize: 40 })
expect(paginationSchema.parse({})).toEqual({ page: 1, pageSize: 20 })
@@ -18,8 +18,8 @@ describe('marketplace.schemas', () => {
expect(() => searchSchema.parse({ maxPrice: '-1' })).toThrow()
})
it('defaults marketplace reservation language while keeping date and email validation strict', () => {
const parsed = marketplaceReservationSchema.parse({
it('defaults carplace reservation language while keeping date and email validation strict', () => {
const parsed = carplaceReservationSchema.parse({
vehicleId: 'ckvvehicle000000000000001',
companySlug: 'atlas-cars',
firstName: 'Aya',
@@ -30,8 +30,8 @@ describe('marketplace.schemas', () => {
})
expect(parsed.language).toBe('fr')
expect(() => marketplaceReservationSchema.parse({ ...parsed, email: 'bad-email' })).toThrow()
expect(() => marketplaceReservationSchema.parse({ ...parsed, startDate: '2026-07-01' })).toThrow()
expect(() => carplaceReservationSchema.parse({ ...parsed, email: 'bad-email' })).toThrow()
expect(() => carplaceReservationSchema.parse({ ...parsed, startDate: '2026-07-01' })).toThrow()
})
it('validates review ratings as bounded integers', () => {
@@ -29,7 +29,17 @@ export const vehicleAvailabilityQuerySchema = z.object({
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(),
companySlug: z.string().trim().max(100).optional(),
firstName: z.string().min(1).max(100),
@@ -37,6 +47,7 @@ export const marketplaceReservationSchema = z.object({
email: z.string().email(),
// Contact info — collected at reservation time
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
dateOfBirth: z.string().datetime().optional(),
nationality: z.string().min(1).max(100).optional(),
@@ -52,11 +63,13 @@ export const marketplaceReservationSchema = z.object({
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(),
notes: z.string().max(500).optional(),
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([
'booking_form_viewed',
'trip_dates_selected',
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('./marketplace.repo', () => ({
vi.mock('./carplace.repo', () => ({
findCompanyPage: vi.fn(),
findCompanyBySlug: vi.fn(),
findCompanyReviews: vi.fn(),
@@ -12,16 +12,16 @@ vi.mock('./marketplace.repo', () => ({
vi.mock('../../services/vehicleAvailabilityService', () => ({ getVehicleAvailabilitySummary: vi.fn() }))
vi.mock('../../services/notificationService', () => ({ sendNotification: vi.fn(), sendTransactionalEmail: vi.fn() }))
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 { getCompanyOffers, getCompanyPage, getCompanyReviews, getCompanyVehicles, getVehicleDetail, validateOfferCode } from './marketplace.service'
import { getCompanyOffers, getCompanyPage, getCompanyReviews, getCompanyVehicles, getVehicleDetail, validateOfferCode } from './carplace.service'
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 () => {
const nextAvailableAt = new Date('2026-07-14T09:00:00.000Z')
vi.mocked(repo.findCompanyPage).mockResolvedValue({
@@ -1,8 +1,8 @@
import { AppError, NotFoundError, ConflictError } from '../../http/errors'
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
import { sendNotification, sendTransactionalEmail } from '../../services/notificationService'
import { marketplaceReservationEmail, type Lang } from '../../lib/emailTranslations'
import * as repo from './marketplace.repo'
import { carplaceReservationEmail, type Lang } from '../../lib/emailTranslations'
import * as repo from './carplace.repo'
function normalizeLocation(value?: string | null) {
const normalized = value?.trim()
@@ -21,6 +21,28 @@ function includesLocation(values: string[], target: string) {
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(
vehicle: { pickupLocations?: string[]; dropoffLocations?: string[]; allowDifferentDropoff?: boolean },
params: { pickupLocation?: string; dropoffLocation?: string; dropoffMode?: 'same' | 'different' },
@@ -65,7 +87,7 @@ export async function getListedCompanies(params: {
}) {
const page = params.page ?? 1
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.hasOffer === 'true') {
where.offers = { some: { isPublic: true, isActive: true, validUntil: { gte: new Date() } } }
@@ -79,53 +101,106 @@ export async function searchVehicles(params: {
maxPrice?: number; transmission?: string; make?: string; model?: string
page?: number; pageSize?: number
}) {
const page = params.page ?? 1
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 pageSize = params.pageSize ?? 20
const where: any = {
isPublished: true,
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.transmission) where.transmission = params.transmission
if (params.make) where.make = { contains: params.make, mode: 'insensitive' }
if (params.model) where.model = { contains: params.model, mode: 'insensitive' }
if (params.make) where.make = { contains: params.make, mode: 'insensitive' }
if (params.model) where.model = { contains: params.model, mode: 'insensitive' }
if (params.city) {
where.company = { ...where.company, brand: { isListedOnMarketplace: true, publicCity: { contains: params.city, mode: 'insensitive' } } }
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 locationFiltered = vehicles.filter((vehicle: any) => matchesVehicleLocationRules(vehicle, params))
const pagedVehicles = locationFiltered.slice((page - 1) * pageSize, page * pageSize)
const availabilityByVehicle = new Map<string, any>()
const availability = await Promise.all(
pagedVehicles.map(async (v: any) => {
const a = await getVehicleAvailabilitySummary(v.id, {
companyId: v.companyId,
range: params.startDate && params.endDate
? { startDate: new Date(params.startDate), endDate: new Date(params.endDate) }
: undefined,
})
return [v.id, a] as const
}),
)
const availMap = new Map<string, any>(availability)
let availableVehicles = locationFiltered
if (range) {
const availability = await Promise.all(
locationFiltered.map(async (vehicle: any) => {
const result = await getVehicleAvailabilitySummary(vehicle.id, { companyId: vehicle.companyId, range })
availabilityByVehicle.set(vehicle.id, result)
return result.available ? vehicle : null
}),
)
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,
}))
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 getMarketplaceVehicleAvailability(
export async function getCarplaceVehicleAvailability(
slug: string,
vehicleId: 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')
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() {
const year = new Date().getUTCFullYear()
const random = Math.random().toString(36).slice(2, 8).toUpperCase()
return `BK-${year}-${random}`
}
export async function createMarketplaceReservation(body: {
export async function createCarplaceReservation(body: {
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
driverLicense?: string; licenseExpiry?: string; licenseIssuedAt?: string
licenseCountry?: string; licenseCategory?: string; internationalLicenseNumber?: 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 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')
}
const vehicle = await repo.findVehicleForMarketplaceById(body.vehicleId)
const vehicle = await repo.findVehicleForCarplaceById(body.vehicleId)
if (!vehicle) throw new NotFoundError('Vehicle not found')
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,
firstName: body.firstName,
lastName: body.lastName,
@@ -215,10 +355,19 @@ export async function createMarketplaceReservation(body: {
internationalLicenseNumber: body.internationalLicenseNumber,
})
const totalDays = Math.max(1, Math.ceil((endDate.getTime() - startDate.getTime()) / 86_400_000))
const totalAmount = vehicle.dailyRate * totalDays
const totalDays = Math.max(1, Math.ceil((endDate.getTime() - startDate.getTime()) / 86_400_000))
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,
vehicleId: body.vehicleId,
customerId: customer.id,
@@ -229,7 +378,7 @@ export async function createMarketplaceReservation(body: {
dailyRate: vehicle.dailyRate,
totalDays,
totalAmount,
notes: body.notes,
notes: reservationNotes,
bookingReference: generateBookingReference(),
})
@@ -245,14 +394,14 @@ export async function createMarketplaceReservation(body: {
sendTransactionalEmail({
to: body.email,
subject: marketplaceReservationEmail.subject(`${vehicle.make} ${vehicle.model}`, lang),
html: marketplaceReservationEmail.html(emailOpts, lang),
text: marketplaceReservationEmail.text(emailOpts, lang),
subject: carplaceReservationEmail.subject(`${vehicle.make} ${vehicle.model}`, lang),
html: carplaceReservationEmail.html(emailOpts, lang),
text: carplaceReservationEmail.text(emailOpts, lang),
}).catch(() => null)
sendNotification({
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)}.`,
companyId: vehicle.companyId,
channels: ['IN_APP'],
@@ -270,7 +419,7 @@ export async function createMarketplaceReservation(body: {
}
}
export async function trackMarketplaceFunnelEvent(body: {
export async function trackCarplaceFunnelEvent(body: {
eventName: string
companySlug: string
vehicleId: string
@@ -278,7 +427,7 @@ export async function trackMarketplaceFunnelEvent(body: {
path?: string
metadata?: Record<string, string | number | boolean | null>
}, renterId?: string) {
await repo.createMarketplaceFunnelEvent({
await repo.createCarplaceFunnelEvent({
...body,
renterId: renterId ?? null,
})
@@ -1,15 +1,15 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NotFoundError, ConflictError, AppError } from '../../http/errors'
vi.mock('./marketplace.repo', () => ({
vi.mock('./carplace.repo', () => ({
findPublicOffers: vi.fn(),
findCitiesFromCompanies: vi.fn(),
findListedCompanies: vi.fn(),
findPublishedVehicles: vi.fn(),
findVehicleForMarketplace: vi.fn(),
findVehicleForMarketplaceById: vi.fn(),
upsertMarketplaceCustomer: vi.fn(),
createMarketplaceReservation: vi.fn(),
findVehicleForCarplace: vi.fn(),
findVehicleForCarplaceById: vi.fn(),
upsertCarplaceCustomer: vi.fn(),
createCarplaceReservation: vi.fn(),
findCompanyPage: vi.fn(),
findCompanyBySlug: vi.fn(),
findCompanyReviews: vi.fn(),
@@ -33,19 +33,19 @@ vi.mock('../../services/notificationService', () => ({
}))
vi.mock('../../lib/emailTranslations', () => ({
marketplaceReservationEmail: {
carplaceReservationEmail: {
subject: vi.fn(() => 'Subject'),
html: vi.fn(() => '<html>'),
text: vi.fn(() => 'text'),
},
}))
import * as repo from './marketplace.repo'
import * as repo from './carplace.repo'
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
import {
getCities, searchVehicles, createMarketplaceReservation,
getCities, searchVehicles, createCarplaceQuote, createCarplaceReservation,
getReviewContext, submitReview, validateOfferCode,
} from './marketplace.service'
} from './carplace.service'
const SLUG = 'test-company'
@@ -80,7 +80,7 @@ describe('getCities', () => {
// ────────────────────────────────────────────────────────────────────────────
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(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
@@ -91,7 +91,7 @@ describe('searchVehicles', () => {
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(getVehicleAvailabilitySummary).mockResolvedValue({ available: false, status: 'RESERVED', nextAvailableAt: new Date('2025-07-01'), blockingReason: 'RESERVATION' })
@@ -101,7 +101,33 @@ describe('searchVehicles', () => {
companyId: 'co-1',
range: { startDate: new Date('2025-06-01T00:00:00.000Z'), endDate: new Date('2025-06-04T00:00:00.000Z') },
})
expect(result[0].availability).toBe(false)
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 () => {
@@ -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 = {
vehicleId: 'v-1', companySlug: SLUG,
firstName: 'Ali', lastName: 'Ben', email: 'ali@test.com',
@@ -137,49 +205,63 @@ describe('createMarketplaceReservation', () => {
}
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(repo.upsertMarketplaceCustomer).mockResolvedValue({ id: 'c-1' } as any)
vi.mocked(repo.createMarketplaceReservation).mockResolvedValue({ id: 'r-1', bookingReference: 'BK-2026-ABC123' } as any)
vi.mocked(repo.upsertCarplaceCustomer).mockResolvedValue({ id: 'c-1' } 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.bookingReference).toBe('BK-2026-ABC123')
expect(repo.upsertMarketplaceCustomer).toHaveBeenCalledWith('co-1', expect.objectContaining({
expect(repo.upsertCarplaceCustomer).toHaveBeenCalledWith('co-1', expect.objectContaining({
identityDocumentNumber: 'CIN123456',
fullAddress: '123 Avenue Hassan II, Casablanca',
driverLicense: 'DL-123456',
}))
expect(repo.createMarketplaceReservation).toHaveBeenCalledWith(expect.objectContaining({
expect(repo.createCarplaceReservation).toHaveBeenCalledWith(expect.objectContaining({
pickupLocation: 'Casablanca',
returnLocation: 'Casablanca',
}))
})
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 () => {
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' })
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 () => {
vi.mocked(repo.findVehicleForMarketplaceById).mockResolvedValue(makeVehicle() as any)
vi.mocked(repo.findVehicleForCarplaceById).mockResolvedValue(makeVehicle() as any)
await expect(
createMarketplaceReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Rabat' }),
createCarplaceReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Rabat' }),
).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 () => {
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' })
})
})
@@ -14,7 +14,7 @@ const fullBrand = {
amanpaySecretKey: 'super-secret-key',
paypalEmail: 'paypal@example.com',
paypalMerchantId: 'paypal-merchant-xyz',
isListedOnMarketplace: true,
isListedOnCarplace: true,
}
describe('presentBrand', () => {
@@ -84,7 +84,7 @@ describe('presentBrand', () => {
primaryColor: '#1A56DB',
defaultLocale: 'en',
defaultCurrency: 'MAD',
isListedOnMarketplace: true,
isListedOnCarplace: true,
})
})
@@ -8,6 +8,12 @@ import { parseBody, parseParams } from '../../http/validate'
import { ok, created, noContent } from '../../http/respond'
import { imageUpload, assertImageFile } from '../../http/upload'
import * as service from './company.service'
import {
normalizeSettingsLocale,
requireSettingsFeature,
resolveSettingsEntitlements,
resolveSettingsMenu,
} from './settingsEntitlements'
import {
companySchema, brandSchema, contractSettingsSchema,
insurancePolicySchema, pricingRuleSchema, accountingSettingsSchema,
@@ -40,7 +46,7 @@ router.get('/me/brand', async (req, res, next) => {
} 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 {
const body = parseBody(brandSchema, req)
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) }
})
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 {
assertImageFile(req.file, 'logo')
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) }
})
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 {
assertImageFile(req.file, 'hero image')
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) }
})
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) => {
try {
const { subdomain } = parseBody(subdomainSchema, req)
@@ -101,7 +119,7 @@ router.get('/me/contract-settings', async (req, res, next) => {
} 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 {
const body = parseBody(contractSettingsSchema, req)
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) }
})
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 {
const body = parseBody(insurancePolicySchema, req)
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) }
})
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 {
const { id } = parseParams(idParamSchema, 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) }
})
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 {
const { id } = parseParams(idParamSchema, req)
await service.deleteInsurancePolicy(id, req.companyId)
@@ -148,7 +166,7 @@ router.get('/me/pricing-rules', async (req, res, next) => {
} 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 {
const body = parseBody(pricingRuleSchema, req)
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) }
})
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 {
const { id } = parseParams(idParamSchema, 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) }
})
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 {
const { id } = parseParams(idParamSchema, req)
await service.deletePricingRule(id, req.companyId)
@@ -180,7 +198,7 @@ router.get('/me/accounting-settings', async (req, res, next) => {
} 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 {
const body = parseBody(accountingSettingsSchema, req)
const settings = await service.updateAccountingSettings(req.companyId, body)
@@ -26,7 +26,7 @@ export const brandSchema = z.object({
amanpaySecretKey: z.string().optional(),
paypalEmail: optionalEmailField(),
paypalMerchantId: z.string().optional(),
isListedOnMarketplace: z.boolean().optional(),
isListedOnCarplace: z.boolean().optional(),
homePageConfig: z.object({
heroTitle: 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'
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', () => ({
findCompany: vi.fn(),
updateCompany: vi.fn(),
@@ -28,8 +31,43 @@ vi.mock('./company.repo', () => ({
}))
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 currentBrand = {
id: 'brand_1',
companyId: 'company_1',
@@ -44,6 +82,7 @@ const currentBrand = {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(entitlements.resolveSettingsEntitlements).mockResolvedValue(buildEditableEntitlements() as any)
delete process.env.NEXT_PUBLIC_CUSTOM_DOMAIN_TARGET
})
@@ -1,7 +1,8 @@
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 * as repo from './company.repo'
import { resolveSettingsEntitlements, SettingsFeatureKey } from './settingsEntitlements'
function buildPaymentMethodsEnabled(input: {
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) {
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 paymentMethodsEnabled = buildPaymentMethodsEnabled({
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) {
await assertSettingsFeature(companyId, 'settings.branding_basic')
const url = await uploadImage(file, `companies/${companyId}/brand`, 'logo')
return presentBrand(await repo.upsertBrand(
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) {
await assertSettingsFeature(companyId, 'settings.branding_hero')
const url = await uploadImage(file, `companies/${companyId}/brand`, 'hero')
return presentBrand(await repo.upsertBrand(
companyId,
@@ -95,6 +104,14 @@ export async function getContractSettings(companyId: string) {
}
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)
}
@@ -103,16 +120,19 @@ export async function getInsurancePolicies(companyId: string) {
}
export async function createInsurancePolicy(companyId: string, data: any) {
await assertSettingsFeature(companyId, 'settings.insurance_policies')
return repo.createInsurancePolicy(companyId, data)
}
export async function updateInsurancePolicy(id: string, companyId: string, data: any) {
await assertSettingsFeature(companyId, 'settings.insurance_policies')
const result = await repo.updateInsurancePolicy(id, companyId, data)
if (result.count === 0) throw new NotFoundError('Insurance policy not found')
return repo.findInsurancePolicyOrThrow(id)
}
export async function deleteInsurancePolicy(id: string, companyId: string) {
await assertSettingsFeature(companyId, 'settings.insurance_policies')
const result = await repo.deleteInsurancePolicy(id, companyId)
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) {
await assertSettingsFeature(companyId, 'settings.pricing_rules')
return repo.createPricingRule(companyId, data)
}
export async function updatePricingRule(id: string, companyId: string, data: any) {
await assertSettingsFeature(companyId, 'settings.pricing_rules')
const result = await repo.updatePricingRule(id, companyId, data)
if (result.count === 0) throw new NotFoundError('Pricing rule not found')
return repo.findPricingRuleOrThrow(id)
}
export async function deletePricingRule(id: string, companyId: string) {
await assertSettingsFeature(companyId, 'settings.pricing_rules')
const result = await repo.deletePricingRule(id, companyId)
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) {
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)
}
@@ -151,3 +177,10 @@ export async function getApiKey(companyId: string) {
export async function regenerateApiKey(companyId: string) {
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.')
}
}
+42 -2
View File
@@ -1,12 +1,51 @@
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('./settingsEntitlements', () => ({
resolveSettingsEntitlements: vi.fn(),
}))
vi.mock('../../lib/storage', () => ({
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 = {
id: 'comp_1',
name: 'Test Rentals',
@@ -33,6 +72,7 @@ const mockBrand = {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(entitlements.resolveSettingsEntitlements).mockResolvedValue(buildEditableEntitlements() as any)
})
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 dassurance', 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 lenvoi 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')
})
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',
])
})
})
+74 -17
View File
@@ -1,5 +1,6 @@
import { AppError } from '../../http/errors'
import { prisma } from '../../lib/prisma'
import { getAccessLevel, hasFullAccess, type AccessLevel } from '../subscriptions/subscription.policy'
type EmployeeRole = 'OWNER' | 'MANAGER' | 'AGENT'
type Plan = 'STARTER' | 'GROWTH' | 'PRO'
@@ -68,6 +69,17 @@ const MENU_ITEM_TYPES_WITHOUT_ROUTE = new Set([
'SECTION_LABEL',
'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) {
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) {
return plan
}
@@ -466,23 +482,27 @@ async function getMenuEvaluationContext(companyId: string, role: EmployeeRole, e
})
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 defaultsToAllSubscriptions = item.subscriptionAssignments.length === 0
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 roleAllowed = item.roleVisibilities.length === 0 || item.roleVisibilities.some((visibility: any) => visibility.role === role)
const subscriptionEntitled = Boolean(subscriptionAssignment || (defaultsToAllSubscriptions && currentPlan))
const entitled = Boolean(subscriptionEntitled || companyAssignment)
const subscriptionEntitled = Boolean(subscriptionHasFullAccess && (subscriptionAssignment || (defaultsToAllSubscriptions && currentPlanName)))
const entitled = Boolean(subscriptionHasFullAccess && (subscriptionEntitled || companyAssignment))
const visible = Boolean(item.isActive && entitled && roleAllowed && employeeIsActive)
const displayOrder = companyAssignment?.displayOrder ?? subscriptionAssignment?.displayOrder ?? item.displayOrder
const reasons: string[] = []
if (!item.isActive) reasons.push('Hidden because the menu item 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(
@@ -518,6 +538,8 @@ async function getMenuEvaluationContext(companyId: string, role: EmployeeRole, e
company,
role,
currentPlan: currentPlanName,
subscriptionStatus,
subscriptionAccessLevel: subscriptionAccessLevel as AccessLevel,
items: sortByDisplayOrder(evaluated),
}
}
@@ -572,12 +594,40 @@ function buildMenuTree(items: Array<{
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) {
const context = await getMenuEvaluationContext(input.companyId, input.role)
return {
company: context.company,
role: context.role,
subscriptionPlan: context.currentPlan,
subscriptionStatus: context.subscriptionStatus,
subscriptionAccessLevel: context.subscriptionAccessLevel,
items: context.items,
finalMenu: buildMenuTree(
context.items
@@ -610,24 +660,31 @@ export async function getEmployeeMenu(employeeId: string) {
const context = await getMenuEvaluationContext(employee.companyId, employee.role, employee.isActive)
const visibleItems = context.items.filter((item) => item.visible)
const menuItems = buildMenuTree(
visibleItems.map((item) => ({
id: item.id,
systemKey: item.systemKey,
label: item.label,
itemType: item.itemType,
routeOrUrl: item.routeOrUrl,
icon: item.icon,
parentId: item.parentId,
openInNewTab: item.openInNewTab,
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,
items: buildMenuTree(
visibleItems.map((item) => ({
id: item.id,
systemKey: item.systemKey,
label: item.label,
itemType: item.itemType,
routeOrUrl: item.routeOrUrl,
icon: item.icon,
parentId: item.parentId,
openInNewTab: item.openInNewTab,
displayOrder: item.displayOrder,
})),
),
subscriptionStatus: context.subscriptionStatus,
subscriptionAccessLevel: context.subscriptionAccessLevel,
items: resolvedItems,
}
}
@@ -3,7 +3,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../../lib/prisma', () => ({
prisma: {
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()
})
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)
expect(prisma.reservation.update).toHaveBeenCalledWith({
where: { id: 'reservation_1' },
data: { paymentStatus: 'PAID', paidAmount: { increment: 2500 } },
})
expect(prisma.$transaction).toHaveBeenCalled()
})
it('maps partial refunds to the correct payment status', async () => {
+25 -4
View File
@@ -32,12 +32,15 @@ export function findPaymentOrThrow(paymentId: string, companyId: string, reserva
export function findReservationOrThrow(id: string, companyId: string) {
return prisma.reservation.findFirstOrThrow({
where: { id, companyId },
include: { vehicle: true, customer: true },
include: { vehicle: true, customer: true, rentalPayments: true },
})
}
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) {
@@ -48,8 +51,26 @@ export function markPaymentFailed(query: { amanpayTransactionId?: string; paypal
return prisma.rentalPayment.updateMany({ where: query, data: { status: 'FAILED' } })
}
export function incrementReservationPaid(reservationId: string, amount: number) {
return prisma.reservation.update({ where: { id: reservationId }, data: { paymentStatus: 'PAID', paidAmount: { increment: amount } } })
export function incrementReservationPaid(reservationId: string, _amount: number) {
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: {
@@ -72,7 +72,7 @@ router.post('/reservations/:id/capture-paypal', requireRole('MANAGER'), async (r
} 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 {
const { id } = parseParams(reservationParamSchema, req)
const body = parseBody(manualPaymentSchema, req)
@@ -52,6 +52,7 @@ const reservation = {
depositAmount: 300,
totalAmount: 1200,
paidAmount: 200,
rentalPayments: [],
vehicle: { make: 'Dacia', model: 'Duster' },
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 () => {
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', {
provider: 'PAYPAL',
@@ -120,8 +125,36 @@ describe('payment.service', () => {
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 () => {
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)
const payment = await recordManualPayment('reservation_1', 'company_1', {
@@ -133,7 +166,7 @@ describe('payment.service', () => {
expect(repo.createPayment).toHaveBeenCalledWith(expect.objectContaining({
status: 'SUCCEEDED',
paymentProvider: 'AMANPAY',
paymentProvider: 'MANUAL',
paymentMethod: 'CASH',
paidAt: expect.any(Date),
}))
@@ -142,7 +175,13 @@ describe('payment.service', () => {
})
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', {
amount: 100,
@@ -156,8 +195,8 @@ describe('payment.service', () => {
})
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.findByPaypal).mockResolvedValue({ id: 'payment_2', reservationId: 'reservation_2', amount: 500 } 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, type: 'CHARGE' } as never)
await handleAmanpayWebhook({ transaction_id: 'aman_txn_1', status: 'paid' })
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 () => {
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(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)
}
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) {
const transactionId = event.transaction_id ?? event.id
const status = event.status?.toUpperCase()
@@ -19,7 +30,9 @@ async function applyAmanpayWebhook(event: any) {
const payment = await repo.findByAmanpay(transactionId)
if (payment && payment.status !== 'SUCCEEDED') {
await repo.markPaymentSucceeded(payment.id)
await repo.incrementReservationPaid(payment.reservationId, payment.amount)
if (payment.type === 'CHARGE') {
await repo.incrementReservationPaid(payment.reservationId, payment.amount)
}
}
} else if (status === 'FAILED') {
await repo.markPaymentFailed({ amanpayTransactionId: transactionId })
@@ -33,7 +46,9 @@ async function applyPaypalWebhook(event: any) {
const payment = await repo.findByPaypal(captureId)
if (payment && payment.status !== 'SUCCEEDED') {
await repo.markPaymentSucceeded(payment.id)
await repo.incrementReservationPaid(payment.reservationId, payment.amount)
if (payment.type === 'CHARGE') {
await repo.incrementReservationPaid(payment.reservationId, payment.amount)
}
}
} else if (eventType === 'PAYMENT.CAPTURE.DENIED') {
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
}) {
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 orderId = `${reservationId}-${body.type}-${Date.now()}`
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 captureId = capture.purchase_units?.[0]?.payments?.captures?.[0]?.id ?? paypalOrderId
const updated = await repo.updatePaypalCapture(payment.id, captureId)
await repo.incrementReservationPaid(payment.reservationId, payment.amount)
if (payment.type === 'CHARGE') {
await repo.incrementReservationPaid(payment.reservationId, payment.amount)
}
return updated
}
@@ -111,13 +137,28 @@ export async function recordManualPayment(reservationId: string, companyId: stri
amount: number; currency: string; type: string; paymentMethod: string
}) {
const reservation = await repo.findReservation(reservationId, companyId)
const remaining = Math.max(reservation.totalAmount - reservation.paidAmount, 0)
if (remaining <= 0) throw new ConflictError('Reservation is already fully paid')
if (body.amount > remaining) throw new ValidationError('Payment amount exceeds remaining balance')
const rentalPayments = (reservation as any).rentalPayments ?? []
const invoicePaid = getInvoicePaid(reservation, rentalPayments)
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() })
const newPaidAmount = reservation.paidAmount + body.amount
await repo.setReservationPaidAmount(reservationId, newPaidAmount, newPaidAmount >= reservation.totalAmount ? 'PAID' : 'PARTIAL')
if (remaining <= 0) {
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')
}
return payment
}
@@ -96,7 +96,7 @@ export async function confirmReservation(id: string, companyId: string) {
},
}).catch(() => null)
if (reservation.source === 'MARKETPLACE') {
if (reservation.source === 'CARPLACE') {
const progress = buildBookingRequestProgress({
source: reservation.source,
status: 'CONFIRMED',
@@ -193,7 +193,7 @@ export async function closeReservation(id: string, companyId: string, closedBy:
const lang = (company?.brand?.defaultLocale ?? 'fr') as Lang
const companyName = company?.brand?.displayName ?? company?.name ?? 'the rental company'
const vehicleLabel = `${updated.vehicle.year} ${updated.vehicle.make} ${updated.vehicle.model}`
const reviewUrl = `${process.env.STOREFRONT_URL ?? 'http://localhost:3000'}/review?token=${reservation.reviewToken}`
const reviewUrl = `${process.env.CARPLACE_URL ?? 'http://localhost:3000'}/review?token=${reservation.reviewToken}`
sendTransactionalEmail({
to: customer.email,
@@ -60,7 +60,7 @@ describe('reservation.presenter boundary behavior', () => {
const result = serializeReservationForDashboard({
id: 'reservation_1',
status: 'DRAFT',
source: 'MARKETPLACE',
source: 'CARPLACE',
contractNumber: null,
invoiceNumber: null,
paymentStatus: 'UNPAID',
@@ -84,7 +84,7 @@ describe('reservation.presenter boundary behavior', () => {
const result = serializeReservationForDashboard({
id: 'reservation_1',
status: 'DRAFT',
source: 'MARKETPLACE',
source: 'CARPLACE',
contractNumber: null,
invoiceNumber: null,
paymentStatus: 'UNPAID',
@@ -19,7 +19,7 @@ describe('serializeReservationForDashboard', () => {
const result = serializeReservationForDashboard({
id: 'reservation-1',
status: 'CONFIRMED',
source: 'MARKETPLACE',
source: 'CARPLACE',
contractNumber: null,
invoiceNumber: null,
paymentStatus: 'UNPAID',
@@ -68,8 +68,8 @@ export function buildBookingRequestProgress(reservation: {
}
const companyConfirmed = !['DRAFT', 'CANCELLED', 'NO_SHOW'].includes(reservation.status)
const documentsRequired = reservation.source === 'MARKETPLACE' && companyConfirmed && documentsMissing.length > 0
const paymentRequired = reservation.source === 'MARKETPLACE' && companyConfirmed && !['PAID', 'COMPLETED'].includes(paymentStatus)
const documentsRequired = reservation.source === 'CARPLACE' && companyConfirmed && documentsMissing.length > 0
const paymentRequired = reservation.source === 'CARPLACE' && companyConfirmed && !['PAID', 'COMPLETED'].includes(paymentStatus)
let stage: 'REQUEST_SENT' | 'COMPANY_CONFIRMED' | 'DOCUMENTS_REQUIRED' | 'PAYMENT_REQUIRED' | 'BOOKING_CONFIRMED' | 'CANCELLED'
if (['CANCELLED', 'NO_SHOW'].includes(reservation.status)) stage = 'CANCELLED'
@@ -49,7 +49,7 @@ describe('review.service', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.setSystemTime(new Date('2026-06-08T12:00:00.000Z'))
process.env.STOREFRONT_URL = 'https://market.example'
process.env.CARPLACE_URL = 'https://market.example'
})
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 vehicle = reservation.vehicle
const vehicleLabel = vehicle ? `${vehicle.year} ${vehicle.make} ${vehicle.model}` : 'your rental vehicle'
const reviewUrl = `${process.env.STOREFRONT_URL ?? 'http://localhost:3000'}/review?token=${reservation.reviewToken}`
const reviewUrl = `${process.env.CARPLACE_URL ?? 'http://localhost:3000'}/review?token=${reservation.reviewToken}`
await sendTransactionalEmail({
to: customer.email,
@@ -18,7 +18,7 @@ describe('site.presenter', () => {
paymentMethodsEnabled: ['PAYPAL'],
defaultLocale: 'fr',
defaultCurrency: 'MAD',
isListedOnMarketplace: true,
isListedOnCarplace: true,
},
})
@@ -32,7 +32,7 @@ describe('site.presenter', () => {
paymentMethodsEnabled: ['PAYPAL'],
defaultLocale: 'fr',
defaultCurrency: 'MAD',
isListedOnMarketplace: true,
isListedOnCarplace: true,
},
})
expect(result.brand).not.toHaveProperty('amanpaySecretKey')
+2 -2
View File
@@ -30,8 +30,8 @@ export function presentBrand(company: {
paypalEmail: brand.paypalEmail,
paypalMerchantId: brand.paypalMerchantId,
paymentMethodsEnabled: brand.paymentMethodsEnabled,
isListedOnMarketplace: brand.isListedOnMarketplace,
marketplaceRating: brand.marketplaceRating,
isListedOnCarplace: brand.isListedOnCarplace,
carplaceRating: brand.carplaceRating,
homePageConfig: brand.homePageConfig,
menuConfig: brand.menuConfig,
} : null,
+1 -1
View File
@@ -35,7 +35,7 @@ export const bookSchema = z.object({
offerId: z.string().cuid().optional(),
promoCodeUsed: 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([]),
additionalDrivers: z.array(z.object({
firstName: z.string().min(1),
@@ -10,7 +10,7 @@ vi.mock('../../lib/prisma', () => ({
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/insuranceService', () => ({ applyInsurancesToReservation: 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' } }
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> = {}) {
const { start, end } = futureDateRange(30, 3)
return {
vehicleId: 'vehicle_1',
startDate: '2026-07-01T00:00:00.000Z',
endDate: '2026-07-04T00:00:00.000Z',
startDate: start.toISOString(),
endDate: end.toISOString(),
firstName: 'Nora',
lastName: 'Renter',
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 () => {
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.upsertCustomer).mockResolvedValue({ id: 'customer_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',
bookingReference: 'BK-2026-AB12CD',
status: 'DRAFT',
startDate: new Date('2026-07-01T10:00:00.000Z'),
endDate: new Date('2026-07-04T10:00:00.000Z'),
startDate: new Date(body.startDate),
endDate: new Date(body.endDate),
pickupLocation: 'Casablanca',
returnLocation: 'Casablanca',
vehicle: { make: 'Dacia', model: 'Duster', year: 2024 },
@@ -125,11 +138,7 @@ describe('site.service public booking/payment boundaries', () => {
paymentStatus: 'UNPAID',
} as never)
const result = await service.createBooking('atlas', bookingBody({
promoCodeUsed: 'FREEDAY',
selectedInsurancePolicyIds: ['insurance_1'],
additionalDrivers: [{ firstName: 'Second' }],
}))
const result = await service.createBooking('atlas', body)
expect(repo.createReservation).toHaveBeenCalledWith(expect.objectContaining({
totalDays: 3,
+3 -3
View File
@@ -3,7 +3,7 @@ import { AppError, NotFoundError } from '../../http/errors'
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
import { applyPricingRules } from '../../services/pricingRuleService'
import { validateLicense } from '../../services/licenseValidationService'
import { getMarketplaceHomepageContent } from '../../services/platformContentService'
import { getCarplaceHomepageContent } from '../../services/platformContentService'
import { prisma } from '../../lib/prisma'
import * as amanpay from '../../services/amanpayService'
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:4000')
}
for (const value of [process.env.STOREFRONT_URL, process.env.DASHBOARD_URL, process.env.NEXT_PUBLIC_STOREFRONT_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
try { allowedHosts.add(new URL(value).host) } catch {}
}
@@ -53,7 +53,7 @@ function generateBookingReference() {
}
export async function getPlatformHomepage() {
return getMarketplaceHomepageContent()
return getCarplaceHomepageContent()
}
export async function getPlatformPricing() {
+12 -5
View File
@@ -66,7 +66,7 @@ vi.mock('../../services/paypalService', () => ({
}))
vi.mock('../../services/platformContentService', () => ({
getMarketplaceHomepageContent: vi.fn(),
getCarplaceHomepageContent: vi.fn(),
}))
import * as repo from './site.repo'
@@ -82,6 +82,12 @@ import {
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 = {}) {
return {
id: 'co-1', slug: SLUG, name: 'Test Co', phone: null, email: 'co@test.com',
@@ -173,10 +179,11 @@ describe('validatePromoCode', () => {
// ────────────────────────────────────────────────────────────────────────────
describe('createBooking', () => {
const { start: bookingStart, end: bookingEnd } = futureDateRange(30, 3)
const baseBody = {
vehicleId: 'v-1',
startDate: '2026-07-01T00:00:00.000Z',
endDate: '2026-07-04T00:00:00.000Z',
startDate: bookingStart.toISOString(),
endDate: bookingEnd.toISOString(),
firstName: 'Ali', lastName: 'Ben', email: 'ali@test.com',
phone: '+212600000000',
pickupLocation: 'Casablanca',
@@ -194,8 +201,8 @@ describe('createBooking', () => {
id: 'r-1',
bookingReference: 'BK-2026-ABC123',
status: 'DRAFT',
startDate: new Date('2026-07-01T00:00:00.000Z'),
endDate: new Date('2026-07-04T00:00:00.000Z'),
startDate: bookingStart,
endDate: bookingEnd,
pickupLocation: 'Casablanca',
returnLocation: 'Casablanca',
vehicle: { make: 'Toyota', model: 'Camry', year: 2022 },
@@ -2,7 +2,7 @@ export type AccessLevel = 'full' | 'limited' | 'read_only' | 'none'
export const SUBSCRIPTION_POLICY = {
trial: {
durationDays: 14,
durationDays: 30,
requiresPaymentMethod: false, // set true when payment capture is mandatory
oneTrialPerCompany: true,
},
@@ -70,18 +70,18 @@ describe('subscription.service operational edges', () => {
it('builds plans from pricing config rows when platform overrides exist', async () => {
vi.mocked(prisma.pricingConfig.findMany).mockResolvedValue([
{ plan: 'STARTER', billingPeriod: 'MONTHLY', amount: 9900 },
{ plan: 'STARTER', billingPeriod: 'ANNUAL', amount: 99000 },
{ plan: 'PRO', billingPeriod: 'MONTHLY', amount: 29900 },
{ plan: 'STARTER', billingPeriod: 'MONTHLY', amount: 14900 },
{ plan: 'STARTER', billingPeriod: 'ANNUAL', amount: 143040 },
{ plan: 'PRO', billingPeriod: 'MONTHLY', amount: 39900 },
] as never)
await expect(service.getPlans()).resolves.toEqual({
STARTER: {
MONTHLY: { MAD: 9900 },
ANNUAL: { MAD: 99000 },
MONTHLY: { MAD: 14900 },
ANNUAL: { MAD: 143040 },
},
PRO: {
MONTHLY: { MAD: 29900 },
MONTHLY: { MAD: 39900 },
},
})
})
@@ -102,7 +102,7 @@ describe('subscription.service operational edges', () => {
'PRO',
'MONTHLY',
'MAD',
new Date('2026-06-15T00:00:00.000Z'),
new Date('2026-07-01T00:00:00.000Z'),
)
expect(repo.createEvent).toHaveBeenCalledWith(expect.objectContaining({
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: '/',
})
})
})
+13 -11
View File
@@ -1,4 +1,4 @@
import type { Response } from 'express'
import type { CookieOptions, Response } from 'express'
import type { ActorType } from './tokens'
const COOKIE_NAMES: Record<ActorType, string> = {
@@ -11,21 +11,23 @@ export function getSessionCookieName(actorType: ActorType) {
return COOKIE_NAMES[actorType]
}
export function setSessionCookie(res: Response, actorType: ActorType, token: string, maxAgeMs?: number) {
res.cookie(getSessionCookieName(actorType), token, {
function getSessionCookieOptions(actorType: ActorType, maxAgeMs?: number): CookieOptions {
const domain = process.env.SESSION_COOKIE_DOMAIN?.trim()
return {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: actorType === 'admin' ? 'strict' : 'lax',
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) {
res.clearCookie(getSessionCookieName(actorType), {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: actorType === 'admin' ? 'strict' : 'lax',
path: '/',
})
res.clearCookie(getSessionCookieName(actorType), getSessionCookieOptions(actorType))
}
@@ -1,7 +1,8 @@
import { prisma } from '../lib/prisma'
import { resolveSettingsEntitlements } from '../modules/companies/settingsEntitlements'
import { validateLicense } from './licenseValidationService'
type AdditionalDriverCharge = 'PER_DAY' | 'FLAT' | 'FREE'
import { validateLicense } from './licenseValidationService'
export interface AdditionalDriverInput {
firstName: string
@@ -40,7 +41,11 @@ export async function applyAdditionalDriversToReservation(
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 chargeValue =
chargeType === 'PER_DAY'
@@ -49,7 +49,7 @@ describe('financialReportService', () => {
pricingRulesTotal: 10,
depositAmount: 500,
totalAmount: 640,
source: 'MARKETPLACE',
source: 'CARPLACE',
startDate: new Date('2026-06-18T10:00:00.000Z'),
endDate: new Date('2026-06-20T10:00:00.000Z'),
vehicle: { year: 2023, make: 'Renault', model: 'Clio', licensePlate: 'B-456' },
@@ -1,5 +1,6 @@
import { InsurancePolicy } from '@rentaldrivego/database'
import { prisma } from '../lib/prisma'
import { resolveSettingsEntitlements } from '../modules/companies/settingsEntitlements'
export function calculateInsuranceCharge(
policy: InsurancePolicy,
@@ -21,6 +22,11 @@ export async function applyInsurancesToReservation(
totalDays: 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 required = allPolicies.filter((p: InsurancePolicy) => 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' }) }),
}))
})
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')
})
})
+115 -23
View File
@@ -318,17 +318,27 @@ function fmt(amount: number, currency: string) {
}
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' })
}
function formatAddress(address: any): string {
if (!address) return ''
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]
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 }) {
const statusColor = STATUS_COLORS[data.status] ?? '#6b7280'
const addressStr = formatAddress(data.company.address)
@@ -374,11 +384,11 @@ function InvoiceDocument({ data }: { data: InvoiceData }) {
View,
{ style: s.invoiceMeta },
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(
View,
{ 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,
{ style: s.col },
React.createElement(Text, { style: s.colLabel }, 'Bill To'),
React.createElement(Text, { style: s.companyName }, data.company.name),
React.createElement(Text, { style: s.companyDetail }, data.company.email),
React.createElement(Text, { style: s.companyName }, pdfText(data.company.name)),
React.createElement(Text, { style: s.companyDetail }, pdfText(data.company.email)),
data.company.phone
? React.createElement(Text, { style: s.companyDetail }, data.company.phone)
? React.createElement(Text, { style: s.companyDetail }, pdfText(data.company.phone))
: null,
addressStr
? React.createElement(Text, { style: [s.companyDetail, { marginTop: 4 }] }, addressStr)
? React.createElement(Text, { style: [s.companyDetail, { marginTop: 4 }] }, pdfText(addressStr))
: null,
),
React.createElement(
@@ -408,7 +418,7 @@ function InvoiceDocument({ data }: { data: InvoiceData }) {
View,
{ style: s.metaRow },
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(
View,
@@ -428,20 +438,20 @@ function InvoiceDocument({ data }: { data: InvoiceData }) {
View,
{ style: s.metaRow },
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(
View,
{ style: s.metaRow },
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
? React.createElement(
View,
{ style: s.metaRow },
React.createElement(Text, { style: s.metaKey }, 'Plan'),
React.createElement(Text, { style: s.metaValue }, planLabel),
React.createElement(Text, { style: s.metaValue }, pdfText(planLabel)),
)
: null,
),
@@ -460,17 +470,17 @@ function InvoiceDocument({ data }: { data: InvoiceData }) {
),
...lineItems.map((item) => {
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
? `${item.description} (${item.quantity} × ${fmt(item.unitAmount, item.currency)})`
? `${item.description} (${item.quantity} x ${fmt(item.unitAmount, item.currency)})`
: item.description
return React.createElement(
View,
{ key: `${item.description}-${item.amount}-${item.periodStart ?? ''}`, style: s.tableRow },
React.createElement(Text, { style: [s.tableCell, s.col60] }, description),
React.createElement(Text, { style: [s.tableCellMuted, s.col20Center, { textAlign: 'center' }] }, periodStr),
React.createElement(Text, { style: [s.tableCell, s.col20, { textAlign: 'right' }] }, fmt(item.amount, item.currency)),
React.createElement(Text, { style: [s.tableCell, s.col60] }, pdfText(description)),
React.createElement(Text, { style: [s.tableCellMuted, s.col20Center, { textAlign: 'center' }] }, pdfText(periodStr)),
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,
{ style: s.paymentRow },
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
? React.createElement(
View,
{ style: s.paymentRow },
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,
React.createElement(
@@ -542,7 +552,7 @@ function InvoiceDocument({ data }: { data: InvoiceData }) {
View,
{ style: s.paymentRow },
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(
View,
{ 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())}`),
),
),
@@ -559,8 +569,12 @@ function InvoiceDocument({ data }: { data: InvoiceData }) {
export async function generateInvoicePdf(data: InvoiceData): Promise<Buffer> {
const doc = React.createElement(InvoiceDocument, { data })
const buffer = await renderToBuffer(doc as any)
return buffer as unknown as Buffer
try {
const buffer = await renderToBuffer(doc as any)
return buffer as unknown as Buffer
} catch {
return buildSimpleInvoicePdf(data)
}
}
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()
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 () => {
const service = await importFreshService()
const first = await service.getMarketplaceHomepageContent()
const first = await service.getCarplaceHomepageContent()
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.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')
await writeFile(dataPath, JSON.stringify({ otherSetting: { keep: true } }), 'utf8').catch(async () => {
await import('fs/promises').then((fs) => fs.mkdir(path.dirname(dataPath), { recursive: true }))
@@ -43,13 +43,13 @@ describe('platformContentService', () => {
})
const service = await importFreshService()
const homepage = await service.getMarketplaceHomepageContent()
homepage.en.heroTitle = 'A better marketplace title'
const homepage = await service.getCarplaceHomepageContent()
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'))
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 path from 'path'
import { cloneMarketplaceHomepageContent, type MarketplaceHomepageConfig } from '@rentaldrivego/types'
import {
cloneCarplaceHomepageContent,
type CarplaceHomepageConfig,
} from '@rentaldrivego/types'
function resolveContentPath() {
const cwd = process.cwd()
@@ -12,7 +15,7 @@ function resolveContentPath() {
const platformContentPath = resolveContentPath()
type PlatformContentFile = {
marketplaceHomepage?: MarketplaceHomepageConfig
carplaceHomepage?: CarplaceHomepageConfig
}
async function readPlatformContentFile(): Promise<PlatformContentFile> {
@@ -29,16 +32,16 @@ async function writePlatformContentFile(content: PlatformContentFile) {
await writeFile(platformContentPath, JSON.stringify(content, null, 2))
}
export async function getMarketplaceHomepageContent() {
export async function getCarplaceHomepageContent() {
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()
await writePlatformContentFile({
...content,
marketplaceHomepage: homepage,
carplaceHomepage: homepage,
})
return homepage
}
@@ -1,4 +1,5 @@
import { prisma } from '../lib/prisma'
import { resolveSettingsEntitlements } from '../modules/companies/settingsEntitlements'
interface DriverInfo {
dateOfBirth?: Date | null
@@ -12,6 +13,11 @@ export async function applyPricingRules(
dailyRate: number,
totalDays: 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 customer = await prisma.customer.findFirstOrThrow({ where: { id: customerId, companyId } })
const allDrivers: DriverInfo[] = [customer, ...additionalDrivers]
@@ -16,7 +16,7 @@ describe('OpenAPI document boundary contract', () => {
}),
}))
expect(openApiDocument.tags).toEqual(expect.arrayContaining([
expect.objectContaining({ name: 'Marketplace' }),
expect.objectContaining({ name: 'Carplace' }),
expect.objectContaining({ name: 'Subscriptions' }),
expect.objectContaining({ name: 'Admin' }),
]))
+27 -27
View File
@@ -119,7 +119,7 @@ export const openApiDocument: JsonObject = {
{ name: 'Companies', description: 'Company profile & settings' },
{ name: 'Team', description: 'Employee management' },
{ 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: 'Admin', description: 'Platform administration' },
],
@@ -996,20 +996,20 @@ export const openApiDocument: JsonObject = {
},
// ════════════════════════════════════════════════════════════════
// MARKETPLACE (public — no auth required)
// CARPLACE (public — no auth required)
// ════════════════════════════════════════════════════════════════
'/marketplace/cities': {
get: { tags: ['Marketplace'], summary: 'Cities with active companies', security: [], responses: { '200': ok } },
'/carplace/cities': {
get: { tags: ['Carplace'], summary: 'Cities with active companies', security: [], responses: { '200': ok } },
},
'/marketplace/companies': {
get: { tags: ['Marketplace'], summary: 'List companies', security: [], responses: { '200': ok } },
'/carplace/companies': {
get: { tags: ['Carplace'], summary: 'List companies', security: [], responses: { '200': ok } },
},
'/marketplace/offers': {
get: { tags: ['Marketplace'], summary: 'Public offers', security: [], responses: { '200': ok } },
'/carplace/offers': {
get: { tags: ['Carplace'], summary: 'Public offers', security: [], responses: { '200': ok } },
},
'/marketplace/search': {
'/carplace/search': {
get: {
tags: ['Marketplace'],
tags: ['Carplace'],
summary: 'Search available vehicles',
security: [],
parameters: [
@@ -1021,30 +1021,30 @@ export const openApiDocument: JsonObject = {
responses: { '200': ok },
},
},
'/marketplace/reservations': {
post: { tags: ['Marketplace'], summary: 'Create marketplace booking', security: [], responses: { '201': ok } },
'/carplace/reservations': {
post: { tags: ['Carplace'], summary: 'Create carplace booking', security: [], responses: { '201': ok } },
},
'/marketplace/offers/{code}/validate': {
post: { tags: ['Marketplace'], summary: 'Validate promo code', security: [], parameters: [{ name: 'code', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
'/carplace/offers/{code}/validate': {
post: { tags: ['Carplace'], summary: 'Validate promo code', security: [], parameters: [{ name: 'code', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
},
'/marketplace/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 } },
post: { tags: ['Marketplace'], summary: 'Submit review', security: [], parameters: [{ name: 'token', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
'/carplace/review/{token}': {
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: ['Carplace'], summary: 'Submit review', security: [], parameters: [{ name: 'token', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
},
'/marketplace/{slug}': {
get: { tags: ['Marketplace'], summary: 'Company public page', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
'/carplace/{slug}': {
get: { tags: ['Carplace'], summary: 'Company public page', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
},
'/marketplace/{slug}/reviews': {
get: { tags: ['Marketplace'], summary: 'Company reviews', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
'/carplace/{slug}/reviews': {
get: { tags: ['Carplace'], summary: 'Company reviews', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
},
'/marketplace/{slug}/vehicles': {
get: { tags: ['Marketplace'], summary: 'Company vehicles', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
'/carplace/{slug}/vehicles': {
get: { tags: ['Carplace'], summary: 'Company vehicles', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
},
'/marketplace/{slug}/vehicles/{id}': {
get: { tags: ['Marketplace'], summary: 'Vehicle details', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }, idPath()], responses: { '200': ok } },
'/carplace/{slug}/vehicles/{id}': {
get: { tags: ['Carplace'], summary: 'Vehicle details', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }, idPath()], responses: { '200': ok } },
},
'/marketplace/{slug}/offers': {
get: { tags: ['Marketplace'], summary: 'Company offers', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
'/carplace/{slug}/offers': {
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', () => ({
signupDisabled: vi.fn(() => {
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 { createApp } from '../../app'
import * as accountService from '../../modules/auth/auth.account.service'
import * as companyService from '../../modules/auth/auth.company.service'
import * as renterService from '../../modules/auth/auth.renter.service'
@@ -90,6 +95,10 @@ const signupPayload = {
describe('auth API boundaries', () => {
beforeEach(() => {
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(renterService.getMe).mockResolvedValue({ id: 'renter_1', savedCompanies: [] } 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()
})
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 () => {
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({})
@@ -12,6 +12,7 @@ vi.mock('../../lib/prisma', () => ({
prisma: {
employee: { findUnique: vi.fn() },
company: { findUnique: vi.fn() },
subscription: { findUnique: vi.fn() },
adminUser: { findUnique: vi.fn() },
renter: { findUnique: vi.fn() },
},
@@ -53,6 +54,7 @@ describe('auth middleware API boundaries', () => {
vi.clearAllMocks()
process.env.JWT_SECRET = 'test-secret'
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 () => {
@@ -94,7 +96,7 @@ describe('auth middleware API boundaries', () => {
expect(res.status).toBe(402)
expect(res.body).toEqual(expect.objectContaining({
error: 'subscription_suspended',
billingUrl: 'https://dashboard.example.test/billing',
billingUrl: 'https://dashboard.example.test/subscription',
}))
expect(vehicleService.listVehicles).not.toHaveBeenCalled()
})
@@ -40,12 +40,13 @@ vi.mock('../../modules/notifications/notification.service', () => ({
getRenterPreferences: vi.fn(),
setRenterPreferences: vi.fn(),
}))
vi.mock('../../modules/marketplace/marketplace.service', () => ({
vi.mock('../../modules/carplace/carplace.service', () => ({
getCities: vi.fn(),
getListedCompanies: vi.fn(),
searchVehicles: vi.fn(),
searchVehiclesPage: vi.fn(),
getVehicleDetail: vi.fn(),
createMarketplaceReservation: vi.fn(),
createCarplaceReservation: vi.fn(),
getCompanyPage: vi.fn(),
getCompanyReviews: vi.fn(),
getCompanyVehicles: vi.fn(),
@@ -61,11 +62,11 @@ import request from 'supertest'
import { createApp } from '../../app'
import * as employeeService from '../../modules/auth/auth.employee.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()
describe('employee, notification, and marketplace API validation contracts', () => {
describe('employee, notification, and carplace API validation contracts', () => {
beforeEach(() => {
vi.clearAllMocks()
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.resetPassword).mockResolvedValue({ message: 'Password updated successfully. You can now sign in.' } 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(marketplaceService.submitReview).mockResolvedValue({ id: 'review_1' } as never)
vi.mocked(carplaceService.searchVehiclesPage).mockResolvedValue({
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 () => {
@@ -110,19 +115,20 @@ describe('employee, notification, and marketplace API validation contracts', ()
expect(notificationService.setPreferences).not.toHaveBeenCalled()
})
it('defaults marketplace search pagination at the public route boundary', async () => {
const res = await request(app).get('/api/v1/marketplace/search?city=Casablanca')
it('defaults carplace search pagination at the public route boundary', async () => {
const res = await request(app).get('/api/v1/carplace/search?city=Casablanca')
expect(res.status).toBe(200)
expect(marketplaceService.searchVehicles).toHaveBeenCalledWith(expect.objectContaining({
expect(carplaceService.searchVehiclesPage).toHaveBeenCalledWith(expect.objectContaining({
city: 'Casablanca',
page: 1,
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 () => {
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,
vehicleRating: 5,
serviceRating: 5,
@@ -130,6 +136,6 @@ describe('employee, notification, and marketplace API validation contracts', ()
})
expect(res.status).toBe(400)
expect(marketplaceService.submitReview).not.toHaveBeenCalled()
expect(carplaceService.submitReview).not.toHaveBeenCalled()
})
})
@@ -30,12 +30,13 @@ vi.mock('../../modules/site/site.service', () => ({
handleContact: vi.fn(),
}))
vi.mock('../../modules/marketplace/marketplace.service', () => ({
vi.mock('../../modules/carplace/carplace.service', () => ({
getPublicOffers: vi.fn(),
getCities: vi.fn(),
getListedCompanies: vi.fn(),
searchVehicles: vi.fn(),
createMarketplaceReservation: vi.fn(),
searchVehiclesPage: vi.fn(),
createCarplaceReservation: vi.fn(),
getReviewContext: vi.fn(),
submitReview: vi.fn(),
validateOfferCode: vi.fn(),
@@ -49,7 +50,7 @@ vi.mock('../../modules/marketplace/marketplace.service', () => ({
import request from 'supertest'
import { createApp } from '../../app'
import * as siteService from '../../modules/site/site.service'
import * as marketplaceService from '../../modules/marketplace/marketplace.service'
import * as carplaceService from '../../modules/carplace/carplace.service'
const app = createApp()
@@ -59,9 +60,13 @@ describe('public validation API contracts', () => {
vi.mocked(siteService.checkAvailability).mockResolvedValue({ available: true, nextAvailableAt: null } as never)
vi.mocked(siteService.initPayment).mockResolvedValue({ checkoutUrl: 'https://pay.example.test' } as never)
vi.mocked(siteService.handleContact).mockResolvedValue({ success: true } as never)
vi.mocked(marketplaceService.searchVehicles).mockResolvedValue({ data: [], pagination: { page: 1, pageSize: 20 } } as never)
vi.mocked(marketplaceService.createMarketplaceReservation).mockResolvedValue({ id: 'reservation_1' } as never)
vi.mocked(marketplaceService.submitReview).mockResolvedValue({ id: 'review_1' } as never)
vi.mocked(carplaceService.searchVehiclesPage).mockResolvedValue({
items: [],
pagination: { page: 2, pageSize: 30, totalItems: 0, totalPages: 0 },
facets: { categories: [], transmissions: [], makes: [], price: { min: null, max: null } },
} as never)
vi.mocked(carplaceService.createCarplaceReservation).mockResolvedValue({ id: 'reservation_1' } as never)
vi.mocked(carplaceService.submitReview).mockResolvedValue({ id: 'review_1' } as never)
})
it('rejects malformed public availability checks before site service execution', async () => {
@@ -120,27 +125,28 @@ describe('public validation API contracts', () => {
})
})
it('coerces marketplace search pagination and price filters', async () => {
const res = await request(app).get('/api/v1/marketplace/search?city=Rabat&maxPrice=500&page=2&pageSize=30')
it('coerces carplace search pagination and price filters', async () => {
const res = await request(app).get('/api/v1/carplace/search?city=Rabat&maxPrice=500&page=2&pageSize=30')
expect(res.status).toBe(200)
expect(marketplaceService.searchVehicles).toHaveBeenCalledWith({
expect(carplaceService.searchVehiclesPage).toHaveBeenCalledWith({
city: 'Rabat',
maxPrice: 500,
page: 2,
pageSize: 30,
})
expect(res.body.data.pagination).toEqual({ page: 2, pageSize: 30, totalItems: 0, totalPages: 0 })
})
it('rejects oversized marketplace pagination before search execution', async () => {
const res = await request(app).get('/api/v1/marketplace/search?pageSize=500')
it('rejects oversized carplace pagination before search execution', async () => {
const res = await request(app).get('/api/v1/carplace/search?pageSize=500')
expect(res.status).toBe(400)
expect(marketplaceService.searchVehicles).not.toHaveBeenCalled()
expect(carplaceService.searchVehiclesPage).not.toHaveBeenCalled()
})
it('rejects marketplace reservations with invalid public contact data before service execution', async () => {
const res = await request(app).post('/api/v1/marketplace/reservations').send({
it('rejects carplace reservations with invalid public contact data before service execution', async () => {
const res = await request(app).post('/api/v1/carplace/reservations').send({
vehicleId: 'ckvvehicle000000000000001',
companySlug: 'atlas-cars',
firstName: 'Aya',
@@ -151,13 +157,13 @@ describe('public validation API contracts', () => {
})
expect(res.status).toBe(400)
expect(marketplaceService.createMarketplaceReservation).not.toHaveBeenCalled()
expect(carplaceService.createCarplaceReservation).not.toHaveBeenCalled()
})
it('rejects invalid public review ratings before creating reviews', async () => {
const res = await request(app).post('/api/v1/marketplace/review/token_1').send({ overallRating: 6 })
const res = await request(app).post('/api/v1/carplace/review/token_1').send({ overallRating: 6 })
expect(res.status).toBe(400)
expect(marketplaceService.submitReview).not.toHaveBeenCalled()
expect(carplaceService.submitReview).not.toHaveBeenCalled()
})
})
@@ -8,6 +8,7 @@ vi.mock('../../lib/prisma', () => ({
prisma: {
employee: { findUnique: vi.fn() },
company: { findUnique: vi.fn() },
subscription: { findUnique: vi.fn() },
},
}))
vi.mock('../../modules/vehicles/vehicle.service', () => ({
@@ -57,6 +58,7 @@ beforeEach(() => {
vi.mocked(jwt.verify).mockReturnValue({ sub: 'employee_1', type: 'employee' } as never)
vi.mocked(prisma.employee.findUnique).mockResolvedValue(employee() as never)
vi.mocked(prisma.company.findUnique).mockResolvedValue({ id: 'company_1', status: 'ACTIVE' } as never)
vi.mocked(prisma.subscription.findUnique).mockResolvedValue({ status: 'ACTIVE' } as never)
})
describe('vehicle pricing API contracts', () => {
@@ -10,7 +10,7 @@ import { createApp } from '../../app'
const app = createApp()
describe('employee and marketplace public boundary smoke', () => {
describe('employee and carplace public boundary smoke', () => {
it('rejects malformed employee login payloads without leaking internals', async () => {
const res = await request(app).post('/api/v1/auth/employee/login').send({ email: 'not-an-email', password: 'x' })
@@ -19,7 +19,7 @@ describe('employee and marketplace public boundary smoke', () => {
})
it('rejects invalid public review payloads without executing private infrastructure', async () => {
const res = await request(app).post('/api/v1/marketplace/review/token_123').send({ overallRating: 0 })
const res = await request(app).post('/api/v1/carplace/review/token_123').send({ overallRating: 0 })
expect(res.status).toBe(400)
expect(JSON.stringify(res.body)).not.toContain('Prisma')
@@ -24,12 +24,12 @@ vi.mock('../../modules/site/site.service', () => ({
capturePaypal: vi.fn(),
handleContact: vi.fn(),
}))
vi.mock('../../modules/marketplace/marketplace.service', () => ({
vi.mock('../../modules/carplace/carplace.service', () => ({
getPublicOffers: vi.fn(),
getCities: vi.fn(),
getListedCompanies: vi.fn(),
searchVehicles: vi.fn(),
createMarketplaceReservation: vi.fn(),
createCarplaceReservation: vi.fn(),
getReviewContext: vi.fn(),
submitReview: vi.fn(),
validateOfferCode: vi.fn(),
@@ -43,7 +43,7 @@ vi.mock('../../modules/marketplace/marketplace.service', () => ({
import request from 'supertest'
import { createApp } from '../../app'
import * as siteService from '../../modules/site/site.service'
import * as marketplaceService from '../../modules/marketplace/marketplace.service'
import * as carplaceService from '../../modules/carplace/carplace.service'
const app = createApp()
@@ -61,8 +61,8 @@ describe('public validation e2e smoke', () => {
expect(payment.status).toBe(400)
expect(siteService.initPayment).not.toHaveBeenCalled()
const review = await request(app).post('/api/v1/marketplace/review/token_1').send({ overallRating: 0 })
const review = await request(app).post('/api/v1/carplace/review/token_1').send({ overallRating: 0 })
expect(review.status).toBe(400)
expect(marketplaceService.submitReview).not.toHaveBeenCalled()
expect(carplaceService.submitReview).not.toHaveBeenCalled()
})
})
@@ -8,6 +8,7 @@ vi.mock('../../lib/prisma', () => ({
prisma: {
employee: { findUnique: vi.fn().mockResolvedValue({ id: 'employee_1', role: 'MANAGER', isActive: true, companyId: 'company_1', company: { id: 'company_1', status: 'ACTIVE' } }) },
company: { findUnique: vi.fn().mockResolvedValue({ id: 'company_1', status: 'ACTIVE' }) },
subscription: { findUnique: vi.fn().mockResolvedValue({ status: 'ACTIVE' }) },
},
}))
vi.mock('../../modules/vehicles/vehicle.service', () => ({

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