Compare commits

..
3 Commits
Author SHA1 Message Date
athul rd c4baa42b2c aaa 2026-07-28 08:48:22 +05:30
athul rd 3330a92a44 Merge branch 'main' of https://git.fistinnovations.com/Fist-Innovations/vehicle_rent_management 2026-07-28 08:38:59 +05:30
athul rdandClaude Opus 4.8 aa8abf4e56 Initial commit: MV Rent car rental management system
Spring Boot + Thymeleaf app: fleet, drivers, vendors, bookings, billing/
settlements, payments, maintenance, inspections, reviews, telematics, audit
log, staff/user management, and separate admin/vendor/driver portals.
Includes Docker Compose deployment (app + Postgres).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 08:37:05 +05:30
244 changed files with 17967 additions and 136 deletions
+10
View File
@@ -0,0 +1,10 @@
target
.git
.gitignore
uploads
*.log
.idea
.vscode
.DS_Store
docs
README.md
+21
View File
@@ -0,0 +1,21 @@
# Copy to `.env` on the server and fill in real values. NEVER commit the real .env.
# ---- Database (required) ----
DB_USER=postgres
DB_PASSWORD=change-me-to-a-strong-password
# ---- Email notifications (optional) ----
MAIL_ENABLED=false
MAIL_HOST=
MAIL_PORT=587
MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_FROM=no-reply@mvrent.com
MAIL_ADMIN=admin@mvrent.com
# ---- GPS telematics ingest key (optional) ----
TELEMATICS_API_KEY=
# ---- Google OAuth2 login (optional) ----
GOOGLE_CLIENT_ID=not-configured
GOOGLE_CLIENT_SECRET=not-configured
+23 -135
View File
@@ -1,138 +1,26 @@
# ---> Java ### Secrets / environment (NEVER commit real .env) ###
# Compiled class file .env
*.class .env.*
!.env.example
# Log file target/
*.jar
.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
### IntelliJ ###
.idea
*.iws
*.iml
*.ipr
### VS Code ###
.vscode/
.DS_Store
### Logs ###
*.log *.log
# BlueJ files ### Uploaded files (configurable storage dir) ###
*.ctxt uploads/
# Mobile Tools for Java (J2ME)
.mtj.tmp/
# Package Files #
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
replay_pid*
# ---> JetBrains
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# AWS User-specific
.idea/**/aws.xml
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# SonarLint plugin
.idea/sonarlint/
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
# ---> VisualStudioCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
# ---> Maven
target/
pom.xml.tag
pom.xml.releaseBackup
pom.xml.versionsBackup
pom.xml.next
release.properties
dependency-reduced-pom.xml
buildNumber.properties
.mvn/timing.properties
# https://github.com/takari/maven-wrapper#usage-without-binary-jar
.mvn/wrapper/maven-wrapper.jar
# Eclipse m2e generated files
# Eclipse Core
.project
# JDT-specific (Eclipse Java Development Tools)
.classpath
+121
View File
@@ -0,0 +1,121 @@
# Deploying MV Rent to a server (Docker Compose)
Target: `root@93.127.166.135`. The stack runs the Spring Boot app + PostgreSQL in
containers. The server only needs Docker — no Java or Postgres install required.
---
## 1. Install Docker on the server (one time)
SSH in and install Docker + the Compose plugin:
```bash
ssh root@93.127.166.135
# Ubuntu/Debian:
curl -fsSL https://get.docker.com | sh
docker --version && docker compose version
```
Open the firewall for HTTP (and HTTPS if you'll add TLS later):
```bash
ufw allow 80/tcp && ufw allow 443/tcp # skip if ufw isn't enabled
```
## 2. Copy the project to the server
From your **local machine** (in the project folder `car-rent`):
```bash
# excludes build output, .git and local uploads
rsync -az --delete \
--exclude target --exclude .git --exclude uploads \
./ root@93.127.166.135:/opt/mvrent/
```
(Or `git clone` your repo into `/opt/mvrent` if it's pushed somewhere.)
## 3. Configure secrets
On the **server**:
```bash
cd /opt/mvrent
cp .env.example .env
nano .env # set a strong DB_PASSWORD (and mail creds if you want email)
```
## 4. Build & start
```bash
cd /opt/mvrent
docker compose up -d --build
```
First run takes a few minutes (it builds the app image and downloads Postgres).
On startup the app creates the schema and seeds demo data automatically.
## 5. Verify
```bash
docker compose ps # both services "running"/"healthy"
docker compose logs -f app # look for "Started CarRentalApplication"
curl -I http://localhost # expect HTTP 200/302
```
Then open **http://93.127.166.135/** in a browser.
| Area | URL | Default login |
|------|-----|---------------|
| Public site | `/` | — |
| Admin | `/admin/login` | `admin` / `admin123` |
| Vendor portal | `/vendor/login` | created from admin |
| Driver portal | `/driver/login` | created from admin |
> ⚠️ **Change the admin password immediately** (Admin → profile) and delete the
> demo customer if this is production.
---
## Day-2 operations
**Update after code changes** (re-run rsync from step 2, then):
```bash
cd /opt/mvrent && docker compose up -d --build
```
**Logs / restart / stop:**
```bash
docker compose logs -f app
docker compose restart app
docker compose down # stop (keeps data volumes)
```
**Back up the database:**
```bash
docker compose exec db pg_dump -U postgres mv_rent > backup-$(date +%F).sql
```
**Restore:**
```bash
cat backup-YYYY-MM-DD.sql | docker compose exec -T db psql -U postgres -d mv_rent
```
Uploaded files persist in the `uploads` Docker volume; DB in `db-data`.
---
## Optional: domain + HTTPS (nginx + Let's Encrypt)
If you point a domain at the server, put nginx in front and get a free cert.
First change the app's port mapping in `docker-compose.yml` from `"80:8080"` to
`"127.0.0.1:8080:8080"` (so only nginx is public), then:
```bash
apt install -y nginx certbot python3-certbot-nginx
# create /etc/nginx/sites-available/mvrent -> proxy_pass http://127.0.0.1:8080;
certbot --nginx -d yourdomain.com
```
Ask me and I'll generate the exact nginx server block for your domain.
+19
View File
@@ -0,0 +1,19 @@
# ---------- Build stage ----------
FROM maven:3.9-eclipse-temurin-17 AS build
WORKDIR /build
# Cache dependencies first
COPY pom.xml .
RUN mvn -B -q dependency:go-offline
# Build the app
COPY src ./src
RUN mvn -B -q -DskipTests clean package
# ---------- Runtime stage ----------
FROM eclipse-temurin:17-jre
WORKDIR /app
# Uploaded documents/photos live here (mounted as a volume)
ENV STORAGE_DIR=/data/uploads
RUN mkdir -p /data/uploads
COPY --from=build /build/target/car-rental-0.0.1-SNAPSHOT.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "app.jar"]
+93 -1
View File
@@ -1,2 +1,94 @@
# vehicle_rent_management # EVM Wheels — Car Rental Management
A Spring Boot 3 + Thymeleaf + PostgreSQL web app for managing a self-drive car rental business.
## Features
- **Public site** — landing page with hero, search bar, and featured cars
- **Browse & detail** — `/cars` (with optional date-range availability filter) and `/cars/{id}`
- **Auth** — username/password registration & login (Spring Security + BCrypt)
- **Customer flow** — book a car, view your bookings at `/my-bookings`
- **Admin panel** — `/admin` dashboard, full CRUD for cars, manage all bookings & update status
- **Date-range conflict detection** — prevents double-booking the same car
## Stack
| Layer | Choice |
|----------|--------------------------------|
| Runtime | Java 17, Spring Boot 3.3 |
| Web | Spring MVC + Thymeleaf |
| Data | Spring Data JPA + Hibernate |
| DB | PostgreSQL |
| Security | Spring Security 6 + BCrypt |
| Build | Maven |
## Prerequisites
- JDK 17+
- Maven 3.9+
- PostgreSQL 13+ running locally
## Database setup
```sql
CREATE DATABASE evm_wheels;
```
By default the app connects with `postgres` / `postgres` on `localhost:5432/evm_wheels`. Override via env vars or by editing [src/main/resources/application.properties](src/main/resources/application.properties):
```bash
export DB_URL=jdbc:postgresql://localhost:5432/evm_wheels
export DB_USER=postgres
export DB_PASSWORD=postgres
```
Hibernate's `ddl-auto=update` creates the schema on first run, and `data.sql` seeds 6 demo cars. Users are seeded from [DataInitializer.java](src/main/java/com/evmwheels/carrental/config/DataInitializer.java) using the real password encoder.
## Run
```bash
mvn spring-boot:run
```
Then open <http://localhost:8080>.
## Seeded accounts
| Role | Username | Password |
|----------|----------|------------|
| Admin | `admin` | `admin123` |
| Customer | `demo` | `demo1234` |
Change these immediately if you deploy this anywhere real.
## Project layout
```
src/main/java/com/evmwheels/carrental/
├── CarRentalApplication.java
├── config/ SecurityConfig, DataInitializer
├── controller/ Home, Car, Booking, Auth, Admin
├── model/ Car, User, Booking + enums
├── repository/ Spring Data JPA interfaces
└── service/ Business logic (booking conflict checks, etc.)
src/main/resources/
├── application.properties
├── data.sql seed cars
├── static/{css,images}/ assets
└── templates/ Thymeleaf views
```
## Routes
| Method | Path | Access |
|--------|----------------------------------|------------|
| GET | `/` | public |
| GET | `/cars`, `/cars/{id}` | public |
| GET/POST | `/login`, `/register` | public |
| GET/POST | `/cars/{id}/book` | customer |
| GET | `/my-bookings` | customer |
| GET | `/admin` | admin |
| GET/POST | `/admin/cars`, `/admin/cars/**` | admin |
| GET | `/admin/bookings` | admin |
| POST | `/admin/bookings/{id}/status` | admin |
+31
View File
@@ -0,0 +1,31 @@
# Database backup
`mv_rent_backup.sql` is a plain-SQL PostgreSQL dump of the `mv_rent` database
(schema + data), created with:
```bash
pg_dump --no-owner --no-privileges --clean --if-exists mv_rent -f db/mv_rent_backup.sql
```
It includes `DROP ... IF EXISTS` before each object, so restoring overwrites the
target database's contents.
> ⚠️ Contains application data: user emails and **bcrypt-hashed** passwords
> (not plaintext). Keep this repository private.
## Restore
**Into a local Postgres:**
```bash
createdb mv_rent 2>/dev/null # if it doesn't exist yet
psql -h localhost -U postgres -d mv_rent -f db/mv_rent_backup.sql
```
**Into the Docker Compose deployment (server or local):**
```bash
cd /opt/mvrent # or your project dir
docker compose exec -T db psql -U postgres -d mv_rent < db/mv_rent_backup.sql
```
Stop the `app` container first if you want a clean restore without it writing
concurrently: `docker compose stop app && …restore… && docker compose start app`.
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
services:
db:
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_DB: mv_rent
POSTGRES_USER: ${DB_USER:-postgres}
POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env}
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres} -d mv_rent"]
interval: 10s
timeout: 5s
retries: 6
app:
build: .
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
DB_URL: jdbc:postgresql://db:5432/mv_rent
DB_USER: ${DB_USER:-postgres}
DB_PASSWORD: ${DB_PASSWORD}
STORAGE_DIR: /data/uploads
# Optional integrations — safe defaults; override in .env to activate
MAIL_ENABLED: ${MAIL_ENABLED:-false}
MAIL_HOST: ${MAIL_HOST:-}
MAIL_PORT: ${MAIL_PORT:-587}
MAIL_USERNAME: ${MAIL_USERNAME:-}
MAIL_PASSWORD: ${MAIL_PASSWORD:-}
MAIL_FROM: ${MAIL_FROM:-no-reply@mvrent.local}
MAIL_ADMIN: ${MAIL_ADMIN:-admin@mvrent.local}
TELEMATICS_API_KEY: ${TELEMATICS_API_KEY:-}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-not-configured}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-not-configured}
volumes:
- uploads:/data/uploads
ports:
# host:container — default 80 on the server; override HTTP_PORT locally
- "${HTTP_PORT:-80}:8080"
volumes:
db-data:
uploads:
+232
View File
@@ -0,0 +1,232 @@
# Car Rental Management System — Implementation Plan
> Status: **DRAFT for review** · Date: 2026-06-26
> Baseline: Spring Boot 3.3.4 · Java 17 · Thymeleaf · PostgreSQL · Spring Security (admin + customer chains, Google OAuth2)
> Purpose: turn the current customer-booking + admin-CRUD app into the full multi-party platform described in the feature spec.
---
## 1. Scope summary
The current app covers ~2530% of the spec (customer-facing booking + basic admin car/booking CRUD).
This plan adds the four missing pillars — **Driver, Vendor, Billing/Settlement, Reporting** — plus the
**Organization/config** foundation and enrichment of Fleet, Customer, and Booking.
Current coverage vs target:
| Area | Now | Target |
|---|---|---|
| Organization / Config | ~20% | Org profile, staff users + permissions, tax/currency/commission config, category masters |
| Fleet & Car | ~45% | + year, colour, ownership, feature tags, hourly/monthly rates, documents+expiry, maintenance |
| Driver | 0% | full module |
| Vendor | 0% | full module + vendor login + settlement |
| Customer | ~50% | + categories (Individual/Company/Corporate), GST/billing |
| Trip Booking | ~40% | + with-driver, hours/months, tax & driver charges, invoice |
| Billing/Settlement | ~5% | invoices, commission, settlements, profit split |
| Reporting | 0% | filtered reports + Excel/PDF export, dashboards |
| Customer page | ~55% | richer filters + driver option |
---
## 2. Target architecture
- **Single Spring Boot modular-monolith** (no microservices). Package-by-feature:
`org`, `fleet`, `driver`, `vendor`, `customer`, `booking`, `billing`, `reporting`, `web`, `security`, `config`.
- **Three security filter chains** (extends today's pattern):
1. `/admin/**` → staff (ROLE_ADMIN, ROLE_STAFF) — existing admin chain, expanded
2. `/vendor/**` → ROLE_VENDOR — **new** vendor portal chain
3. everything else → customers + Google OAuth (existing customer chain)
- **Persistence**: PostgreSQL + JPA. Move `ddl-auto=update`**Flyway migrations** (versioned schema; required once money/settlement tables exist).
- **Money**: always `BigDecimal`, scale 2, currency from config. Never `double`.
- **Exports**: Apache POI (Excel), OpenPDF or Flying Saucer (HTML→PDF) for invoices/statements/reports.
- **Server-side rendering** stays Thymeleaf; add small JS for dynamic price calc on the customer page.
---
## 3. Role & permission model
Replace the 2-value `Role` enum with a richer model:
```
Role (enum): ADMIN, STAFF, VENDOR, CUSTOMER
Permission (enum): MANAGE_FLEET, MANAGE_DRIVERS, MANAGE_VENDORS, MANAGE_BOOKINGS,
MANAGE_BILLING, VIEW_REPORTS, MANAGE_CONFIG, MANAGE_USERS
```
- `ADMIN` = all permissions. `STAFF` = a configurable subset (stored per-user).
- `VENDOR` = scoped to own data only (own cars, own trips, own settlements).
- Authorization: method-level `@PreAuthorize("hasAuthority('MANAGE_FLEET')")` on services/controllers.
---
## 4. Domain model (entities & key fields)
New entities in **bold**; modified existing in _italics_.
### 4.1 Organization / Config
- **Organization** — name, legalName, logoUrl, address, gstNumber, contactEmail, phone, currencyCode
- **SystemConfig** (singleton) — currencyCode, defaultTaxPercent, defaultCommissionPercent,
defaultDriverDailyCharge, invoicePrefix, settlementCycle (WEEKLY/MONTHLY)
- **Category** (generic master) — `type` (VEHICLE / DRIVER / CUSTOMER), name, description, active
→ replaces hard-coded enums so categories are admin-configurable
- _User_ — add `permissions` (set), `vendor` (nullable FK for vendor-portal users)
### 4.2 Fleet
- _Car_ — add: `yearOfRegistration`, `colour`, `ownership` (OWNED / VENDOR), `vendor` (nullable FK),
`hourlyRate`, `dailyRate` (rename pricePerDay), `monthlyRate`, `unitsTotal`,
`featureTags` (AC, GPS, … as a set/element-collection), `status` (ACTIVE / MAINTENANCE / RETIRED)
- **CarDocument** — car FK, docType (RC / INSURANCE / PERMIT / POLLUTION), fileUrl, issuedDate, expiryDate
- **CarPhoto** — car FK, url, sortOrder (gallery instead of single imageUrl)
### 4.3 Driver
- **Driver** — name, phone, licenceNumber, licenceExpiry, yearsExperience, category (FK Category),
employmentType (EMPLOYED / VENDOR), vendor (nullable FK), hourlyCharge, dailyCharge, monthlyCharge,
status (AVAILABLE / ON_TRIP / OFF), photoUrl
- **DriverDocument** — driver FK, docType, fileUrl, expiryDate
- Trip history derived from `Booking` where `driver = X`
### 4.4 Vendor
- **Vendor** — name, contactPerson, phone, email, address, gstNumber,
bankAccount/settlementDetails, commissionPercent (nullable → overrides global), active
- Vendor login = a `User` with ROLE_VENDOR linked to the Vendor
- **VendorSettlement** — vendor FK, periodStart, periodEnd, grossRevenue, commissionAmount,
driverCharges, netPayable, status (DRAFT / FINALISED / PAID), generatedAt
### 4.5 Customer
- _User_ (customer) — add: `customerCategory` (INDIVIDUAL / COMPANY / CORPORATE),
`companyName`, `gstNumber`, `billingAddress`, `contactPersons` (for company/corporate)
### 4.6 Booking & Billing
- _Booking_ — add: `driver` (nullable FK), `withDriver` (bool), `rentalUnit` (HOUR / DAY / MONTH),
`quantity` (units of the period), `vehicleCharge`, `driverCharge`, `taxPercent`, `taxAmount`,
`commissionPercent`, `commissionAmount`, `revenueOwner` (OWNED / VENDOR), `vendor` (nullable FK)
- **Invoice** — booking FK, invoiceNumber, issuedAt, lineItems, subtotal, tax, total, pdfUrl
- **CommissionRule** — scope (GLOBAL / VENDOR / VEHICLE), refId, percent
(resolution order: vehicle → vendor → global)
### 4.7 ER overview (relationships)
```
Organization 1─* User
User *─1 Vendor (vendor-portal users) Vendor 1─* Car
Vendor 1─* Driver Car 1─* CarDocument / CarPhoto
Category 1─* Car / Driver / User(customer) Driver 1─* DriverDocument
Customer(User) 1─* Booking Car 1─* Booking
Driver 1─* Booking (optional) Booking 1─1 Invoice
Vendor 1─* Booking (revenue attribution) Vendor 1─* VendorSettlement
```
---
## 5. Calculation rules (single source of truth)
```
periodCount = ceil(duration in chosen unit) // HOUR / DAY / MONTH
vehicleCharge = rate(unit) × periodCount × units
driverCharge = withDriver ? driverRate(unit) × periodCount : 0
subtotal = vehicleCharge + driverCharge
taxAmount = subtotal × taxPercent/100
total = subtotal + taxAmount
// Vendor settlement (per vendor trip)
commissionPercent = resolve(vehicle → vendor → global)
commissionAmount = vehicleCharge × commissionPercent/100
netVendorPayable = vehicleCharge commissionAmount + (vendorDriverCharge orgDriverMargin)
// Owned vs vendor profit split tracked via Booking.revenueOwner
```
All money `BigDecimal`, `RoundingMode.HALF_UP`, scale 2, currency from `SystemConfig`.
---
## 6. Screen inventory
**Admin / Staff (`/admin`)**
- Dashboard (KPIs + owned-vs-vendor split), Fleet (list/form + docs/photos/maintenance),
Drivers (list/form/profile + trip history), Vendors (list/form + settlements),
Customers (list/detail), Bookings (list/detail/assign-driver/invoice),
Billing & Settlements, Reports (filter + export), Settings (org, config, categories, staff & permissions)
**Vendor portal (`/vendor`)** — new
- Login, Dashboard (own trips/revenue), My Cars, My Drivers, Trips (period filter),
Settlements (view + export PDF/Excel)
**Customer page (public)**
- Home, Car listing **with filters** (category, date, seating, transmission, price, driver option) + live price,
Car detail, Booking (with/without driver + category), Register/Login, My Bookings + invoices
---
## 7. Phased delivery plan
Each phase is independently shippable and ends with a working app + tests.
### Phase 0 — Foundations (enablers)
- Add Flyway; convert current schema to `V1__baseline.sql`; turn off `ddl-auto`
- Expand `Role` + add `Permission`; method security
- `Organization` + `SystemConfig` + `Category` master; Settings screens
- Replace hard-coded category enums with `Category` lookups (keep enums as seed data)
### Phase 1 — Fleet enrichment
- Car: year, colour, ownership, feature tags, hourly/daily/monthly rates, units, status(maintenance)
- CarDocument + CarPhoto with expiry tracking + expiry warnings on dashboard
- Maintenance blocking in availability logic
### Phase 2 — Driver module
- Driver entity + documents + categories + charges + availability
- Driver list/form/profile + trip history; driver-availability check
### Phase 3 — Vendor module
- Vendor onboarding + ROLE_VENDOR login + vendor filter chain (`/vendor/**`)
- Vendor dashboard (own cars/drivers/trips); commission config (global/vendor/vehicle)
### Phase 4 — Booking upgrades
- With/without driver + driver category assignment
- Hourly/daily/monthly periods; tax + driver-charge calc; revenue attribution
- Driver double-booking prevention; invoice generation (PDF)
### Phase 5 — Billing & settlement
- Invoices/receipts for customers
- Vendor settlement aggregation per cycle + statement (Excel + PDF) + profit split
### Phase 6 — Reporting
- Filtered reports (date, vehicle, category, driver, vendor, customer category, ownership, status)
- Excel + PDF export; summary dashboard (owned vs vendor); vendor-wise & driver-wise reports
### Phase 7 — Customer page polish
- Full filter set + live cost-by-duration; with-driver booking flow; responsive QA
---
## 8. Dependencies to add
- `flyway-core` + `flyway-database-postgresql`
- `org.apache.poi:poi-ooxml` (Excel)
- `com.github.librepdf:openpdf` **or** `org.xhtmlrenderer:flying-saucer-pdf` (PDF from Thymeleaf)
- (already present: web, security, oauth2-client, data-jpa, validation, thymeleaf, postgresql, lombok)
---
## 9. Cross-cutting concerns
- **Migrations**: every schema change via Flyway; never edit a shipped migration.
- **Validation**: bean validation + show `th:errors` on all admin/vendor forms (current admin forms lack this).
- **Auditing**: `createdAt/updatedAt/createdBy` via JPA auditing on money-bearing entities.
- **Performance**: fix N+1 in booking/report queries with fetch-joins; add pagination to all admin tables.
- **Files**: documents/photos need storage — decide local disk vs S3-compatible (config-driven path).
- **Testing**: service-layer unit tests for all calc rules; `@WebMvcTest` for access control per chain.
- **Seed data**: keep `DataInitializer` for org/config/admin + demo vendor/driver/cars.
---
## 10. Decisions (LOCKED — 2026-06-26, recommended defaults accepted)
1. **File storage****local filesystem** (config-driven path) for v1.
2. **PDF library****Flying Saucer** (Thymeleaf HTML/CSS → PDF).
3. **Tenancy****single organization** per install.
4. **Staff permissions****per-user permission checkboxes** (granular `Permission` set).
5. **Settlement cycle****monthly** default.
6. **Bookings****day/month** for v1 (hourly *rate fields* stored, hourly *booking flow* deferred).
### Deviation from §7
- **Flyway is deferred** to a later hardening phase. During active development the app keeps
`spring.jpa.hibernate.ddl-auto=update`, which additively creates the new tables/columns without
disrupting the running PostgreSQL database. Flyway baseline will be introduced before production.
> Plan locked. Implementation started with Phase 0 — Foundations.
+119
View File
@@ -0,0 +1,119 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.4</version>
<relativePath/>
</parent>
<groupId>com.mvrent</groupId>
<artifactId>car-rental</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>car-rental</name>
<description>MV Rent — Car Rental Management</description>
<properties>
<java.version>17</java.version>
<!-- Override Spring Boot's 1.18.34: needed for JDK 23+/25 (fixes TypeTag :: UNKNOWN) -->
<lombok.version>1.18.40</lombok.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity6</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<!-- PDF: Thymeleaf HTML -> PDF (Flying Saucer + OpenPDF) -->
<dependency>
<groupId>org.xhtmlrenderer</groupId>
<artifactId>flying-saucer-pdf-openpdf</artifactId>
<version>9.1.22</version>
</dependency>
<!-- Excel export -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>5.3.0</version>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,11 @@
package com.mvrent.carrental;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class CarRentalApplication {
public static void main(String[] args) {
SpringApplication.run(CarRentalApplication.class, args);
}
}
@@ -0,0 +1,10 @@
package com.mvrent.carrental.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
/** Enables @Async so outbound email/SMS never blocks the request thread. */
@Configuration
@EnableAsync
public class AsyncConfig {
}
@@ -0,0 +1,78 @@
package com.mvrent.carrental.config;
import com.mvrent.carrental.service.AuditService;
import lombok.RequiredArgsConstructor;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
/**
* Automatically records every mutating service-layer call (save / create /
* update / delete / add / remove / generate) to the audit trail. Argument
* <em>values</em> are never logged (to avoid leaking passwords etc.) — only the
* action, entity type and id.
*/
@Aspect
@Component
@RequiredArgsConstructor
public class AuditAspect {
private final AuditService auditService;
@Pointcut("execution(* com.mvrent.carrental.service.*.*(..))")
void serviceLayer() {}
@AfterReturning(pointcut = "serviceLayer()", returning = "result")
public void afterMutation(JoinPoint jp, Object result) {
String method = jp.getSignature().getName();
String action = mapAction(method);
if (action == null) return;
String cls = jp.getSignature().getDeclaringType().getSimpleName();
// Avoid recursion / noise from the auditing beans themselves.
if (cls.equals("AuditService") || cls.equals("AuditWriter")) return;
String entityType = cls.endsWith("Service") ? cls.substring(0, cls.length() - "Service".length()) : cls;
String entityId = extractId(result, jp.getArgs());
auditService.log(action, entityType, entityId, method);
}
private static String mapAction(String m) {
if (m.startsWith("delete") || m.startsWith("remove")) return "DELETE";
if (m.startsWith("add") || m.startsWith("create") || m.startsWith("register")) return "CREATE";
if (m.startsWith("generate")) return "GENERATE";
if (m.startsWith("save") || m.startsWith("update") || m.startsWith("change")) return "UPDATE";
return null;
}
/** Best-effort id: from the returned entity, else the first Long argument. */
private static String extractId(Object result, Object[] args) {
String fromResult = idOf(result);
if (fromResult != null) return fromResult;
if (args != null) {
for (Object a : args) {
if (a instanceof Long l) return String.valueOf(l);
}
for (Object a : args) {
String id = idOf(a);
if (id != null) return id;
}
}
return null;
}
private static String idOf(Object obj) {
if (obj == null) return null;
try {
Method getId = obj.getClass().getMethod("getId");
Object id = getId.invoke(obj);
return id != null ? String.valueOf(id) : null;
} catch (Exception ignored) {
return null;
}
}
}
@@ -0,0 +1,27 @@
package com.mvrent.carrental.config;
import com.mvrent.carrental.service.AuditService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.event.EventListener;
import org.springframework.security.authentication.event.AbstractAuthenticationFailureEvent;
import org.springframework.security.authentication.event.AuthenticationSuccessEvent;
import org.springframework.stereotype.Component;
/** Records login successes and failures in the audit trail. */
@Component
@RequiredArgsConstructor
public class AuthEventListener {
private final AuditService auditService;
@EventListener
public void onSuccess(AuthenticationSuccessEvent event) {
auditService.logAuth("LOGIN_SUCCESS", event.getAuthentication().getName());
}
@EventListener
public void onFailure(AbstractAuthenticationFailureEvent event) {
Object principal = event.getAuthentication() != null ? event.getAuthentication().getPrincipal() : null;
auditService.logAuth("LOGIN_FAILURE", principal != null ? String.valueOf(principal) : "unknown");
}
}
@@ -0,0 +1,32 @@
package com.mvrent.carrental.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ModelAttribute;
/**
* Exposes the configurable currency symbol/code (from application.properties) to
* every server-rendered view as {@code currencySymbol} / {@code currencyCode}.
*/
@ControllerAdvice
public class CurrencyAdvice {
private final String symbol;
private final String code;
public CurrencyAdvice(@Value("${app.currency.symbol}") String symbol,
@Value("${app.currency.code}") String code) {
this.symbol = symbol;
this.code = code;
}
@ModelAttribute("currencySymbol")
public String currencySymbol() {
return symbol;
}
@ModelAttribute("currencyCode")
public String currencyCode() {
return code;
}
}
@@ -0,0 +1,50 @@
package com.mvrent.carrental.config;
import com.mvrent.carrental.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ModelAttribute;
import java.util.HashMap;
import java.util.Map;
/**
* Exposes the logged-in user's display details to every view as {@code cu}
* (used by the topbar avatar / profile dropdown).
*/
@ControllerAdvice
@RequiredArgsConstructor
public class CurrentUserAdvice {
private final UserRepository userRepository;
@ModelAttribute("cu")
public Map<String, String> currentUser() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated() || auth instanceof AnonymousAuthenticationToken) {
return null;
}
return userRepository.findByUsername(auth.getName()).map(u -> {
Map<String, String> m = new HashMap<>();
String fullName = (u.getFullName() != null && !u.getFullName().isBlank())
? u.getFullName() : u.getUsername();
m.put("username", u.getUsername());
m.put("fullName", fullName);
m.put("email", u.getEmail());
m.put("role", u.getRole().name());
m.put("initials", initials(fullName));
return m;
}).orElse(null);
}
private static String initials(String name) {
String[] parts = name.trim().split("\\s+");
if (parts.length >= 2 && !parts[0].isEmpty() && !parts[1].isEmpty()) {
return ("" + parts[0].charAt(0) + parts[1].charAt(0)).toUpperCase();
}
return name.substring(0, Math.min(2, name.length())).toUpperCase();
}
}
@@ -0,0 +1,283 @@
package com.mvrent.carrental.config;
import com.mvrent.carrental.model.*;
import com.mvrent.carrental.repository.CarRepository;
import com.mvrent.carrental.repository.DriverRepository;
import com.mvrent.carrental.repository.MaintenanceRepository;
import com.mvrent.carrental.repository.ReviewRepository;
import com.mvrent.carrental.repository.UserRepository;
import com.mvrent.carrental.repository.VehicleLocationRepository;
import com.mvrent.carrental.service.ConfigService;
import com.mvrent.carrental.service.VendorService;
import java.math.BigDecimal;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.CommandLineRunner;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import java.util.EnumSet;
@Component
@RequiredArgsConstructor
@Slf4j
public class DataInitializer implements CommandLineRunner {
private final UserRepository userRepository;
private final DriverRepository driverRepository;
private final CarRepository carRepository;
private final PasswordEncoder passwordEncoder;
private final ConfigService configService;
private final VendorService vendorService;
private final JdbcTemplate jdbcTemplate;
private final MaintenanceRepository maintenanceRepository;
private final ReviewRepository reviewRepository;
private final VehicleLocationRepository vehicleLocationRepository;
@Override
public void run(String... args) {
// Fill new fleet columns on rows created before Phase 1 (null-safe, SQL level).
backfillCarDefaults();
// Organization + system config singletons (created on first access)
configService.getOrganization();
configService.getSystemConfig();
seedCategories();
seedAdmin();
seed("demo", "demo@mvrent.local", "demo1234", "Demo Customer", Role.CUSTOMER);
seedDrivers();
seedVendor();
seedDemoCustomerCategory();
seedCarFeatures();
seedTelematics();
seedMaintenance();
seedReviews();
}
/** Demo GPS positions across Kerala so the Live Tracking map isn't empty. */
private void seedTelematics() {
if (vehicleLocationRepository.count() > 0) return;
double[][] coords = {
{9.9312, 76.2673}, // Kochi
{8.5241, 76.9366}, // Trivandrum
{11.2588, 75.7804}, // Kozhikode
{10.5276, 76.2144}, // Thrissur
{9.5916, 76.5222} // Kottayam
};
java.util.List<Car> cars = carRepository.findAll();
int i = 0;
for (Car c : cars) {
if (i >= coords.length) break;
vehicleLocationRepository.save(VehicleLocation.builder()
.car(c).latitude(coords[i][0]).longitude(coords[i][1])
.speedKph(i % 2 == 0 ? 0.0 : 42.0).heading(90.0 * i)
.odometer(38000 + i * 5200).ignitionOn(i % 2 != 0)
.updatedAt(java.time.LocalDateTime.now().minusMinutes(i))
.build());
i++;
}
if (i > 0) log.info("Seeded {} demo vehicle locations", i);
}
private void seedMaintenance() {
if (maintenanceRepository.count() > 0) return;
carRepository.findAll().stream().findFirst().ifPresent(c ->
maintenanceRepository.save(MaintenanceRecord.builder()
.car(c).type(MaintenanceType.SERVICE).status(MaintenanceStatus.COMPLETED)
.serviceDate(java.time.LocalDate.now().minusMonths(2)).odometer(36500)
.cost(new BigDecimal("4800")).garage("AutoCare Kochi")
.notes("Engine oil, filter, brake pads")
.nextServiceDate(java.time.LocalDate.now().plusMonths(4)).nextServiceOdometer(46500)
.build()));
log.info("Seeded a demo maintenance record");
}
private void seedReviews() {
if (reviewRepository.count() > 0) return;
User demo = userRepository.findByUsername("demo").orElse(null);
if (demo == null) return;
carRepository.findAll().stream().findFirst().ifPresent(c -> {
reviewRepository.save(Review.builder().car(c).user(demo).rating(5)
.comment("Spotless car and a smooth booking experience. Highly recommend!")
.approved(true).build());
reviewRepository.save(Review.builder().car(c).user(demo).rating(4)
.comment("Great value for money. Pickup was quick.")
.approved(true).build());
});
log.info("Seeded demo reviews");
}
private void seedCarFeatures() {
java.util.List<Car> cars = carRepository.findAll();
boolean anyTags = cars.stream().anyMatch(c -> !c.getFeatureTags().isEmpty());
if (anyTags) return;
int i = 0;
for (Car c : cars) {
java.util.Set<String> tags = new java.util.LinkedHashSet<>();
tags.add("AC");
if (i % 2 == 0) { tags.add("GPS"); tags.add("Bluetooth"); }
if (c.getSeats() >= 7) tags.add("Spacious");
if (c.getFuelType() == FuelType.ELECTRIC) tags.add("Fast Charging");
c.setFeatureTags(tags);
carRepository.save(c);
i++;
}
log.info("Seeded feature tags on {} cars", cars.size());
}
private void seedDemoCustomerCategory() {
userRepository.findByUsername("demo").ifPresent(demo -> {
if (demo.getCustomerCategory() == null) {
configService.categoriesOfType(CategoryType.CUSTOMER).stream()
.filter(c -> c.getName().equals("Individual")).findFirst()
.ifPresent(cat -> {
demo.setCustomerCategory(cat);
userRepository.save(demo);
});
}
});
}
private void seedVendor() {
// Idempotent: get-or-create the demo vendor, then ensure portal user + attachments.
Vendor vendor = vendorService.findAll().stream()
.filter(v -> v.getName().equals("Kerala Cabs")).findFirst()
.orElseGet(() -> vendorService.save(Vendor.builder()
.name("Kerala Cabs").contactPerson("Joby Thomas")
.phone("+91 9847000000").email("ops@keralacabs.local")
.gstNumber("32ABCDE1234F1Z5").settlementDetails("A/C 00112233, IFSC SBIN0001234")
.commissionPercent(new BigDecimal("12")).active(true).build()));
if (!userRepository.existsByUsername("vendor1")) {
try {
vendorService.createPortalUser(vendor.getId(), "vendor1", "vendor1@mvrent.local", "vendor123");
log.info("Seeded vendor 'Kerala Cabs' + portal login vendor1/vendor123");
} catch (IllegalArgumentException ignored) {
// portal user already exists
}
}
// Attach one car + one driver to the vendor for demo (only if none yet attached)
if (carRepository.countByVendorId(vendor.getId()) == 0) {
carRepository.findAll().stream().findFirst().ifPresent(c -> {
c.setOwnership(CarOwnership.VENDOR);
c.setVendor(vendor);
carRepository.save(c);
});
}
driverRepository.findByVendorIdOrderByName(vendor.getId()).stream().findAny()
.ifPresentOrElse(d -> { }, () ->
driverRepository.findAll().stream()
.filter(d -> d.getName().contains("Suresh")).findFirst().ifPresent(d -> {
d.setEmploymentType(DriverEmploymentType.VENDOR);
d.setVendor(vendor);
driverRepository.save(d);
}));
}
private void seedDrivers() {
if (driverRepository.count() > 0) return;
Category standard = configService.categoriesOfType(CategoryType.DRIVER).stream()
.filter(c -> c.getName().equals("Standard")).findFirst().orElse(null);
Category premium = configService.categoriesOfType(CategoryType.DRIVER).stream()
.filter(c -> c.getName().equals("Premium")).findFirst().orElse(null);
driverRepository.save(Driver.builder()
.name("Rajesh Kumar").phone("+91 9847012345").licenceNumber("KL07-2018-0012345")
.licenceExpiry(java.time.LocalDate.now().plusYears(2)).yearsExperience(6)
.category(standard).employmentType(DriverEmploymentType.EMPLOYED)
.dailyCharge(new java.math.BigDecimal("800")).status(DriverStatus.AVAILABLE).build());
driverRepository.save(Driver.builder()
.name("Suresh Nair").phone("+91 9847098765").licenceNumber("KL01-2015-0098765")
.licenceExpiry(java.time.LocalDate.now().plusYears(1)).yearsExperience(11)
.category(premium).employmentType(DriverEmploymentType.EMPLOYED)
.dailyCharge(new java.math.BigDecimal("1200")).status(DriverStatus.AVAILABLE).build());
log.info("Seeded 2 demo drivers");
}
private void seedAdmin() {
User admin = userRepository.findByUsername("admin").orElse(null);
if (admin == null) {
admin = User.builder()
.username("admin")
.email("admin@mvrent.local")
.password(passwordEncoder.encode("admin123"))
.fullName("MV Rent Admin")
.role(Role.ADMIN)
.permissions(EnumSet.allOf(Permission.class))
.enabled(true)
.build();
userRepository.save(admin);
log.info("Seeded ADMIN user 'admin' (password: admin123) with all permissions");
} else if (admin.getRole() == Role.ADMIN && admin.getPermissions().isEmpty()) {
// Backfill permissions for an admin created before the permission model existed.
admin.setPermissions(EnumSet.allOf(Permission.class));
userRepository.save(admin);
log.info("Backfilled all permissions onto existing ADMIN user 'admin'");
}
}
private void seed(String username, String email, String rawPassword, String fullName, Role role) {
if (userRepository.existsByUsername(username)) return;
User user = User.builder()
.username(username)
.email(email)
.password(passwordEncoder.encode(rawPassword))
.fullName(fullName)
.role(role)
.enabled(true)
.build();
userRepository.save(user);
log.info("Seeded {} user '{}' (password: {})", role, username, rawPassword);
}
private void seedCategories() {
if (!configService.allCategories().isEmpty()) return;
// Vehicle categories
for (String n : new String[]{"Hatchback", "Sedan", "SUV", "MUV", "Luxury", "Bike"}) {
safeAdd(CategoryType.VEHICLE, n);
}
// Driver categories
for (String n : new String[]{"Standard", "Premium", "Chauffeur"}) {
safeAdd(CategoryType.DRIVER, n);
}
// Customer categories
for (String n : new String[]{"Individual", "Company", "Corporate"}) {
safeAdd(CategoryType.CUSTOMER, n);
}
log.info("Seeded default vehicle/driver/customer categories");
}
private void backfillCarDefaults() {
try {
// Drop the stale enum CHECK constraint Hibernate created when Role was
// only {ADMIN, CUSTOMER}; ddl-auto=update never widens it for STAFF/VENDOR.
jdbcTemplate.execute("ALTER TABLE users DROP CONSTRAINT IF EXISTS users_role_check");
jdbcTemplate.update("UPDATE cars SET status = 'ACTIVE' WHERE status IS NULL");
jdbcTemplate.update("UPDATE cars SET ownership = 'OWNED' WHERE ownership IS NULL");
jdbcTemplate.update("UPDATE cars SET units_total = 1 WHERE units_total IS NULL");
// Phase 4: fill new booking columns on pre-existing rows.
jdbcTemplate.update("UPDATE bookings SET with_driver = false WHERE with_driver IS NULL");
jdbcTemplate.update("UPDATE bookings SET rental_unit = 'DAY' WHERE rental_unit IS NULL");
jdbcTemplate.update("UPDATE bookings SET quantity = 1 WHERE quantity IS NULL");
} catch (DataAccessException ex) {
log.warn("Schema backfill skipped: {}", ex.getMessage());
}
}
private void safeAdd(CategoryType type, String name) {
try {
configService.addCategory(type, name, null);
} catch (IllegalArgumentException ignored) {
// already present
}
}
}
@@ -0,0 +1,19 @@
package com.mvrent.carrental.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
/**
* Standalone so the {@link PasswordEncoder} has no dependency on SecurityConfig —
* avoids a circular reference now that services (OAuth2LoginService) also need it.
*/
@Configuration
public class PasswordConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
@@ -0,0 +1,178 @@
package com.mvrent.carrental.config;
import com.mvrent.carrental.service.CustomUserDetailsService;
import com.mvrent.carrental.service.OAuth2LoginService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final CustomUserDetailsService userDetailsService;
private final OAuth2LoginService oAuth2LoginService;
private final PasswordEncoder passwordEncoder;
@Bean
public DaoAuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(passwordEncoder);
return provider;
}
/**
* Telematics ingest chain — applies only to /api/telematics/**.
* Stateless and CSRF-exempt; authenticated by an API key in the controller,
* so GPS devices can POST pings directly without a session.
*/
@Bean
@Order(0)
public SecurityFilterChain telematicsSecurityFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/telematics/**")
.csrf(csrf -> csrf.disable())
.sessionManagement(sm -> sm.sessionCreationPolicy(
org.springframework.security.config.http.SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll());
return http.build();
}
/**
* Admin module chain — applies only to /admin/**.
* Has its own login page (/admin/login) and logout (/admin/logout),
* completely separate from the customer/citizen login flow below.
*/
@Bean
@Order(1)
public SecurityFilterChain adminSecurityFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/admin/**")
.authenticationProvider(authenticationProvider())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/admin/login").permitAll()
.anyRequest().hasAnyRole("ADMIN", "STAFF")
)
.formLogin(form -> form
.loginPage("/admin/login")
.loginProcessingUrl("/admin/login")
.defaultSuccessUrl("/admin", true)
.failureUrl("/admin/login?error")
.permitAll()
)
.logout(logout -> logout
.logoutUrl("/admin/logout")
.logoutSuccessUrl("/admin/login?logout")
.permitAll()
);
return http.build();
}
/**
* Vendor portal chain — applies only to /vendor/**.
* Own login (/vendor/login) and logout (/vendor/logout); ROLE_VENDOR only.
*/
@Bean
@Order(2)
public SecurityFilterChain vendorSecurityFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/vendor/**")
.authenticationProvider(authenticationProvider())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/vendor/login").permitAll()
.anyRequest().hasRole("VENDOR")
)
.formLogin(form -> form
.loginPage("/vendor/login")
.loginProcessingUrl("/vendor/login")
.defaultSuccessUrl("/vendor", true)
.failureUrl("/vendor/login?error")
.permitAll()
)
.logout(logout -> logout
.logoutUrl("/vendor/logout")
.logoutSuccessUrl("/vendor/login?logout")
.permitAll()
);
return http.build();
}
/**
* Driver portal chain — applies only to /driver/**.
* Own login (/driver/login) and logout (/driver/logout); ROLE_DRIVER only.
*/
@Bean
@Order(3)
public SecurityFilterChain driverSecurityFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/driver/**")
.authenticationProvider(authenticationProvider())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/driver/login").permitAll()
.anyRequest().hasRole("DRIVER")
)
.formLogin(form -> form
.loginPage("/driver/login")
.loginProcessingUrl("/driver/login")
.defaultSuccessUrl("/driver", true)
.failureUrl("/driver/login?error")
.permitAll()
)
.logout(logout -> logout
.logoutUrl("/driver/logout")
.logoutSuccessUrl("/driver/login?logout")
.permitAll()
);
return http.build();
}
/**
* Customer / citizen chain — handles everything outside /admin/**, /vendor/** and /driver/**.
* Public browsing + the standard /login and /register flow.
*/
@Bean
@Order(4)
public SecurityFilterChain customerSecurityFilterChain(HttpSecurity http) throws Exception {
http
.authenticationProvider(authenticationProvider())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/cars", "/cars/**", "/login", "/register",
"/oauth2/**", "/login/oauth2/**",
"/css/**", "/js/**", "/images/**", "/uploads/**", "/webjars/**", "/error")
.permitAll()
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.defaultSuccessUrl("/", true)
.failureUrl("/login?error")
.permitAll()
)
.oauth2Login(oauth -> oauth
.loginPage("/login")
.defaultSuccessUrl("/", true)
.failureUrl("/login?error")
.userInfoEndpoint(info -> info.userService(oAuth2LoginService))
)
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/?logout")
.permitAll()
);
return http.build();
}
}
@@ -0,0 +1,43 @@
package com.mvrent.carrental.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.web.multipart.support.MultipartFilter;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.nio.file.Paths;
/** Serves uploaded files from the configurable storage directory. */
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Value("${app.storage.location}")
private String location;
@Value("${app.storage.url-prefix}")
private String urlPrefix;
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
String base = urlPrefix.endsWith("/") ? urlPrefix : urlPrefix + "/";
String fileUri = Paths.get(location).toAbsolutePath().normalize().toUri().toString();
registry.addResourceHandler(base + "**").addResourceLocations(fileUri);
}
/**
* Parse multipart requests before Spring Security so the CSRF token in a
* multipart form body can be read (otherwise uploads fail with 403).
*/
@Bean
public FilterRegistrationBean<MultipartFilter> multipartFilterRegistration() {
FilterRegistrationBean<MultipartFilter> registration =
new FilterRegistrationBean<>(new MultipartFilter());
registration.setOrder(Ordered.HIGHEST_PRECEDENCE);
registration.addUrlPatterns("/*");
return registration;
}
}
@@ -0,0 +1,27 @@
package com.mvrent.carrental.controller;
import com.mvrent.carrental.model.AuditLog;
import com.mvrent.carrental.service.AuditService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/** JSON API backing the read-only audit log viewer. */
@RestController
@RequestMapping("/admin/api/audit")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN')")
public class AdminAuditApiController {
private final AuditService auditService;
@GetMapping
public List<AuditLog> list(@RequestParam(defaultValue = "500") int limit) {
return auditService.recent(Math.min(Math.max(limit, 1), 2000));
}
}
@@ -0,0 +1,19 @@
package com.mvrent.carrental.controller;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping("/admin/audit")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN')")
public class AdminAuditController {
@GetMapping
public String page() {
return "admin/audit";
}
}
@@ -0,0 +1,18 @@
package com.mvrent.carrental.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
/**
* Dedicated entry point for the admin module — separate from the
* customer/citizen login handled by {@link AuthController}.
*/
@Controller
public class AdminAuthController {
/** Admin-only login page, served at /admin/login (no public registration). */
@GetMapping("/admin/login")
public String login() {
return "admin/login";
}
}
@@ -0,0 +1,122 @@
package com.mvrent.carrental.controller;
import com.mvrent.carrental.model.Booking;
import com.mvrent.carrental.model.BookingStatus;
import com.mvrent.carrental.model.RentalUnit;
import com.mvrent.carrental.model.User;
import com.mvrent.carrental.service.BookingService;
import com.mvrent.carrental.service.PaymentService;
import com.mvrent.carrental.service.UserService;
import com.mvrent.carrental.web.AdminBookingForm;
import com.mvrent.carrental.web.BookingDto;
import com.mvrent.carrental.web.PaymentDto;
import com.mvrent.carrental.web.PaymentForm;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
/** JSON API backing the AJAX Manage Bookings screen (inline status + counter bookings). */
@RestController
@RequestMapping("/admin/api/bookings")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_BOOKINGS')")
public class AdminBookingApiController {
private final BookingService bookingService;
private final UserService userService;
private final PaymentService paymentService;
@GetMapping
public List<BookingDto> list() {
Map<Long, BigDecimal> paid = paymentService.paidByBooking();
return bookingService.findAll().stream()
.map(b -> BookingDto.from(b, paid.get(b.getId())))
.toList();
}
/* ---------- Payments ---------- */
@GetMapping("/{id}/payments")
public Map<String, Object> payments(@PathVariable Long id) {
Booking b = bookingService.findById(id);
BigDecimal total = b.getTotalAmount() != null ? b.getTotalAmount() : BigDecimal.ZERO;
BigDecimal paid = paymentService.totalPaid(id);
return Map.of(
"total", total,
"paid", paid,
"balance", total.subtract(paid),
"payments", paymentService.forBooking(id).stream().map(PaymentDto::from).toList()
);
}
@PostMapping("/{id}/payments")
public ResponseEntity<?> addPayment(@PathVariable Long id, @RequestBody PaymentForm f) {
try {
paymentService.record(id, f.amount(), f.method(), f.reference(), f.paidAt(), f.notes());
return ResponseEntity.ok(Map.of("ok", true));
} catch (IllegalArgumentException ex) {
return bad(ex.getMessage());
}
}
@PostMapping("/payments/{paymentId}/delete")
public ResponseEntity<?> deletePayment(@PathVariable Long paymentId) {
paymentService.delete(paymentId);
return ResponseEntity.ok(Map.of("ok", true));
}
@PostMapping("/{id}/status")
public ResponseEntity<?> updateStatus(@PathVariable Long id, @RequestParam BookingStatus status) {
bookingService.updateStatus(id, status);
return ResponseEntity.ok(Map.of("ok", true, "id", id, "status", status.name()));
}
/** Admin creates a booking on behalf of a customer (counter / phone booking). */
@PostMapping
public ResponseEntity<?> create(@RequestBody AdminBookingForm f) {
if (f.carId() == null) return bad("Please select a car");
if (f.pickupDate() == null || f.returnDate() == null) return bad("Pickup and return dates are required");
if (isBlank(f.pickupLocation())) return bad("Pickup location is required");
if (isBlank(f.dropLocation())) return bad("Drop location is required");
// Resolve customer: an existing one, or create a walk-in record.
User customer;
try {
if (f.customerId() != null) {
customer = userService.findById(f.customerId());
} else if (!isBlank(f.customerName())) {
customer = userService.createWalkInCustomer(f.customerName(), f.customerPhone(), f.customerEmail());
} else {
return bad("Select a customer or enter a walk-in customer name");
}
} catch (IllegalArgumentException ex) {
return bad(ex.getMessage());
}
Booking form = new Booking();
form.setPickupDate(f.pickupDate());
form.setReturnDate(f.returnDate());
form.setPickupLocation(f.pickupLocation().trim());
form.setDropLocation(f.dropLocation().trim());
form.setRentalUnit(f.rentalUnit() != null ? f.rentalUnit() : RentalUnit.DAY);
form.setWithDriver(f.withDriver());
try {
Booking saved = bookingService.createBooking(customer, f.carId(), form, f.driverId());
return ResponseEntity.ok(Map.of("ok", true, "id", saved.getId()));
} catch (IllegalArgumentException | IllegalStateException ex) {
return bad(ex.getMessage());
}
}
private static boolean isBlank(String s) { return s == null || s.trim().isEmpty(); }
private static ResponseEntity<Map<String, Object>> bad(String msg) {
return ResponseEntity.badRequest().body(Map.of("ok", false, "message", msg == null ? "Could not create booking" : msg));
}
}
@@ -0,0 +1,163 @@
package com.mvrent.carrental.controller;
import com.mvrent.carrental.model.*;
import com.mvrent.carrental.service.CarService;
import com.mvrent.carrental.service.FileStorageService;
import com.mvrent.carrental.web.CarDto;
import com.mvrent.carrental.web.CarForm;
import com.mvrent.carrental.web.CarMediaDto;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.*;
import java.util.stream.Collectors;
/** JSON API backing the AJAX Manage Cars screen. */
@RestController
@RequestMapping("/admin/api/cars")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_FLEET')")
public class AdminCarApiController {
private final CarService carService;
private final FileStorageService fileStorage;
@GetMapping
public List<CarDto> list() {
return carService.findAll().stream().map(CarDto::from).toList();
}
@GetMapping("/{id}")
public CarDto get(@PathVariable Long id) {
return CarDto.from(carService.findById(id));
}
@PostMapping
public ResponseEntity<?> save(@RequestBody CarForm f) {
Map<String, String> errors = validate(f);
if (!errors.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("ok", false, "errors", errors));
}
Car car = new Car();
if (f.id() != null) car.setId(f.id());
car.setMake(trim(f.make()));
car.setModel(trim(f.model()));
car.setRegistrationNumber(trim(f.registrationNumber()));
car.setYearOfRegistration(f.yearOfRegistration());
car.setColour(trim(f.colour()));
car.setType(f.type());
car.setSeats(f.seats() == null ? 0 : f.seats());
car.setTransmission(f.transmission());
car.setFuelType(f.fuelType());
car.setHourlyRate(f.hourlyRate());
car.setPricePerDay(f.pricePerDay());
car.setMonthlyRate(f.monthlyRate());
car.setOwnership(f.ownership() == null ? CarOwnership.OWNED : f.ownership());
car.setCommissionPercentOverride(f.commissionPercentOverride());
car.setUnitsTotal(f.unitsTotal() == null ? 1 : f.unitsTotal());
car.setStatus(f.status() == null ? CarStatus.ACTIVE : f.status());
car.setImageUrl(trim(f.imageUrl()));
car.setAvailable(f.available() == null || f.available());
try {
Car saved = carService.saveFromForm(car, parseTags(f.featureTagsCsv()), f.vendorId());
return ResponseEntity.ok(Map.of("ok", true, "car", CarDto.from(saved)));
} catch (DataIntegrityViolationException ex) {
return ResponseEntity.badRequest().body(Map.of("ok", false,
"errors", Map.of("registrationNumber", "Registration number already exists")));
}
}
@PostMapping("/{id}/delete")
public ResponseEntity<?> delete(@PathVariable Long id) {
try {
carService.delete(id);
return ResponseEntity.ok(Map.of("ok", true));
} catch (DataIntegrityViolationException ex) {
return ResponseEntity.badRequest().body(Map.of("ok", false,
"message", "Cannot delete — this car has bookings. Set it to RETIRED instead."));
}
}
/* ---------- Documents & photos ---------- */
@GetMapping("/{id}/media")
public CarMediaDto media(@PathVariable Long id) {
return CarMediaDto.from(carService.findById(id));
}
@PostMapping("/{id}/documents")
public ResponseEntity<?> addDocument(@PathVariable Long id,
@RequestParam CarDocumentType docType,
@RequestParam(required = false) MultipartFile file,
@RequestParam(required = false) String fileUrl,
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate issuedDate,
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate expiryDate) {
String stored = fileStorage.store(file);
String url = stored != null ? stored : fileUrl;
if (url == null || url.isBlank()) {
return ResponseEntity.badRequest().body(Map.of("ok", false, "message", "Upload a file or provide a URL"));
}
carService.addDocument(id, docType, url, issuedDate, expiryDate);
return ResponseEntity.ok(Map.of("ok", true));
}
@PostMapping("/{id}/documents/{docId}/delete")
public ResponseEntity<?> deleteDocument(@PathVariable Long id, @PathVariable Long docId) {
carService.deleteDocument(docId);
return ResponseEntity.ok(Map.of("ok", true));
}
@PostMapping("/{id}/photos")
public ResponseEntity<?> addPhoto(@PathVariable Long id,
@RequestParam(required = false) MultipartFile file,
@RequestParam(required = false) String url,
@RequestParam(defaultValue = "0") int sortOrder) {
String stored = fileStorage.store(file);
String finalUrl = stored != null ? stored : url;
if (finalUrl == null || finalUrl.isBlank()) {
return ResponseEntity.badRequest().body(Map.of("ok", false, "message", "Upload a photo or provide a URL"));
}
carService.addPhoto(id, finalUrl, sortOrder);
return ResponseEntity.ok(Map.of("ok", true));
}
@PostMapping("/{id}/photos/{photoId}/delete")
public ResponseEntity<?> deletePhoto(@PathVariable Long id, @PathVariable Long photoId) {
carService.deletePhoto(photoId);
return ResponseEntity.ok(Map.of("ok", true));
}
private Map<String, String> validate(CarForm f) {
Map<String, String> e = new LinkedHashMap<>();
if (isBlank(f.make())) e.put("make", "Make is required");
if (isBlank(f.model())) e.put("model", "Model is required");
if (isBlank(f.registrationNumber())) e.put("registrationNumber", "Registration number is required");
if (f.type() == null) e.put("type", "Type is required");
if (f.pricePerDay() == null || f.pricePerDay().compareTo(BigDecimal.ZERO) < 0)
e.put("pricePerDay", "Valid daily rate is required");
if (f.seats() != null && (f.seats() < 2 || f.seats() > 15))
e.put("seats", "Seats must be 215");
return e;
}
private Set<String> parseTags(String csv) {
if (csv == null || csv.isBlank()) return new LinkedHashSet<>();
return Arrays.stream(csv.split(","))
.map(String::trim).filter(s -> !s.isEmpty())
.collect(Collectors.toCollection(LinkedHashSet::new));
}
private static boolean isBlank(String s) { return s == null || s.isBlank(); }
private static String trim(String s) { return s == null ? null : s.trim(); }
}
@@ -0,0 +1,60 @@
package com.mvrent.carrental.controller;
import com.mvrent.carrental.service.ConfigService;
import com.mvrent.carrental.web.CategoryDto;
import com.mvrent.carrental.web.CategoryForm;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/** JSON API backing the AJAX category master screen. */
@RestController
@RequestMapping("/admin/api/categories")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_CONFIG')")
public class AdminCategoryApiController {
private final ConfigService configService;
@GetMapping
public List<CategoryDto> list() {
return configService.allCategories().stream().map(CategoryDto::from).toList();
}
@PostMapping
public ResponseEntity<?> add(@RequestBody CategoryForm f) {
if (f.type() == null) {
return ResponseEntity.badRequest().body(Map.of("ok", false, "message", "Type is required"));
}
if (f.name() == null || f.name().isBlank()) {
return ResponseEntity.badRequest().body(Map.of("ok", false, "message", "Name is required"));
}
try {
return ResponseEntity.ok(Map.of("ok", true,
"category", CategoryDto.from(configService.addCategory(f.type(), f.name().trim(), f.description()))));
} catch (IllegalArgumentException ex) {
return ResponseEntity.badRequest().body(Map.of("ok", false, "message", ex.getMessage()));
}
}
@PostMapping("/{id}/toggle")
public ResponseEntity<?> toggle(@PathVariable Long id) {
configService.toggleCategory(id);
return ResponseEntity.ok(Map.of("ok", true));
}
@PostMapping("/{id}/delete")
public ResponseEntity<?> delete(@PathVariable Long id) {
try {
configService.deleteCategory(id);
return ResponseEntity.ok(Map.of("ok", true));
} catch (Exception ex) {
return ResponseEntity.badRequest().body(Map.of("ok", false,
"message", "Cannot delete — category is in use. Disable it instead."));
}
}
}
@@ -0,0 +1,214 @@
package com.mvrent.carrental.controller;
import com.mvrent.carrental.model.BookingStatus;
import com.mvrent.carrental.model.Car;
import com.mvrent.carrental.model.CarDocumentType;
import com.mvrent.carrental.model.Invoice;
import com.mvrent.carrental.service.BookingService;
import com.mvrent.carrental.service.CarService;
import com.mvrent.carrental.service.ConfigService;
import com.mvrent.carrental.service.DashboardService;
import com.mvrent.carrental.service.DriverService;
import com.mvrent.carrental.service.FileStorageService;
import com.mvrent.carrental.service.PaymentService;
import com.mvrent.carrental.service.UserService;
import com.mvrent.carrental.service.PdfService;
import com.mvrent.carrental.service.VendorService;
import jakarta.validation.Valid;
import org.springframework.web.multipart.MultipartFile;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import java.time.LocalDate;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@Controller
@RequestMapping("/admin")
@RequiredArgsConstructor
public class AdminController {
private final CarService carService;
private final BookingService bookingService;
private final VendorService vendorService;
private final PdfService pdfService;
private final ConfigService configService;
private final FileStorageService fileStorage;
private final DashboardService dashboardService;
private final DriverService driverService;
private final UserService userService;
private final PaymentService paymentService;
@GetMapping
public String dashboard(Model model) {
model.addAttribute("d", dashboardService.build());
return "admin/dashboard";
}
/* ---------- Cars ---------- */
@GetMapping("/cars")
public String cars(Model model) {
model.addAttribute("cars", carService.findAll());
model.addAttribute("vendors", vendorService.findActive());
return "admin/cars";
}
@GetMapping("/cars/new")
public String newCar(Model model) {
if (!model.containsAttribute("car")) {
model.addAttribute("car", new Car());
}
model.addAttribute("vendors", vendorService.findActive());
return "admin/car-form";
}
@PostMapping("/cars")
public String saveCar(@Valid @ModelAttribute("car") Car car,
BindingResult result,
@RequestParam(name = "featureTagsCsv", required = false) String featureTagsCsv,
@RequestParam(name = "vendorId", required = false) Long vendorId,
Model model,
RedirectAttributes ra) {
if (result.hasErrors()) {
model.addAttribute("vendors", vendorService.findActive());
return "admin/car-form";
}
carService.saveFromForm(car, parseTags(featureTagsCsv), vendorId);
ra.addFlashAttribute("success", "Car saved");
return "redirect:/admin/cars";
}
@GetMapping("/cars/{id}/edit")
public String editCar(@PathVariable Long id, Model model) {
model.addAttribute("car", carService.findById(id));
model.addAttribute("vendors", vendorService.findActive());
return "admin/car-form";
}
@PostMapping("/cars/{id}/delete")
public String deleteCar(@PathVariable Long id, RedirectAttributes ra) {
carService.delete(id);
ra.addFlashAttribute("success", "Car deleted");
return "redirect:/admin/cars";
}
/* ---------- Car documents ---------- */
@PostMapping("/cars/{id}/documents")
public String addDocument(@PathVariable Long id,
@RequestParam CarDocumentType docType,
@RequestParam(required = false) String fileUrl,
@RequestParam(required = false) MultipartFile file,
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate issuedDate,
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate expiryDate,
RedirectAttributes ra) {
String stored = fileStorage.store(file);
carService.addDocument(id, docType, stored != null ? stored : fileUrl, issuedDate, expiryDate);
ra.addFlashAttribute("success", docType + " document added");
return "redirect:/admin/cars/" + id + "/edit";
}
@PostMapping("/cars/{id}/documents/{docId}/delete")
public String deleteDocument(@PathVariable Long id, @PathVariable Long docId,
RedirectAttributes ra) {
carService.deleteDocument(docId);
ra.addFlashAttribute("success", "Document removed");
return "redirect:/admin/cars/" + id + "/edit";
}
/* ---------- Car photos ---------- */
@PostMapping("/cars/{id}/photos")
public String addPhoto(@PathVariable Long id,
@RequestParam(required = false) String url,
@RequestParam(required = false) MultipartFile file,
@RequestParam(defaultValue = "0") int sortOrder,
RedirectAttributes ra) {
String stored = fileStorage.store(file);
String finalUrl = stored != null ? stored : url;
if (finalUrl == null || finalUrl.isBlank()) {
ra.addFlashAttribute("error", "Provide a photo file or URL");
} else {
carService.addPhoto(id, finalUrl, sortOrder);
ra.addFlashAttribute("success", "Photo added");
}
return "redirect:/admin/cars/" + id + "/edit";
}
@PostMapping("/cars/{id}/photos/{photoId}/delete")
public String deletePhoto(@PathVariable Long id, @PathVariable Long photoId,
RedirectAttributes ra) {
carService.deletePhoto(photoId);
ra.addFlashAttribute("success", "Photo removed");
return "redirect:/admin/cars/" + id + "/edit";
}
/* ---------- Bookings ---------- */
@GetMapping("/bookings")
public String bookings(Model model) {
model.addAttribute("bookings", bookingService.findAll());
model.addAttribute("customers", userService.findCustomers());
model.addAttribute("availableCars", carService.findAvailable());
model.addAttribute("availableDrivers", driverService.findAvailable());
return "admin/bookings";
}
@PostMapping("/bookings/{id}/status")
public String updateBookingStatus(@PathVariable Long id,
@RequestParam BookingStatus status,
RedirectAttributes ra) {
bookingService.updateStatus(id, status);
ra.addFlashAttribute("success", "Booking #" + id + " set to " + status);
return "redirect:/admin/bookings";
}
@GetMapping("/bookings/{id}/invoice")
public String invoice(@PathVariable Long id, Model model) {
var booking = bookingService.findById(id);
java.math.BigDecimal paid = paymentService.totalPaid(id);
java.math.BigDecimal total = booking.getTotalAmount() != null ? booking.getTotalAmount() : java.math.BigDecimal.ZERO;
model.addAttribute("booking", booking);
model.addAttribute("invoice", bookingService.invoiceFor(id));
model.addAttribute("payments", paymentService.forBooking(id));
model.addAttribute("amountPaid", paid);
model.addAttribute("balance", total.subtract(paid));
model.addAttribute("pdfUrl", "/admin/bookings/" + id + "/invoice/pdf");
return "bookings/invoice";
}
@GetMapping("/bookings/{id}/invoice/pdf")
public ResponseEntity<byte[]> invoicePdf(@PathVariable Long id) {
Invoice invoice = bookingService.invoiceFor(id);
byte[] pdf = pdfService.render("pdf/invoice", Map.of(
"booking", bookingService.findById(id), "invoice", invoice,
"orgName", configService.getOrganization().getName()));
return ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF)
.header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" + invoice.getInvoiceNumber() + ".pdf\"")
.body(pdf);
}
/* ---------- helpers ---------- */
private Set<String> parseTags(String csv) {
if (csv == null || csv.isBlank()) return new LinkedHashSet<>();
return Arrays.stream(csv.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.collect(Collectors.toCollection(LinkedHashSet::new));
}
}
@@ -0,0 +1,125 @@
package com.mvrent.carrental.controller;
import com.mvrent.carrental.model.*;
import com.mvrent.carrental.service.DriverService;
import com.mvrent.carrental.service.FileStorageService;
import com.mvrent.carrental.web.DriverDto;
import com.mvrent.carrental.web.DriverForm;
import com.mvrent.carrental.web.DriverMediaDto;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/** JSON API backing the AJAX Manage Drivers screen. */
@RestController
@RequestMapping("/admin/api/drivers")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_DRIVERS')")
public class AdminDriverApiController {
private final DriverService driverService;
private final FileStorageService fileStorage;
@GetMapping
public List<DriverDto> list() {
return driverService.findAll().stream().map(DriverDto::from).toList();
}
@GetMapping("/{id}")
public DriverDto get(@PathVariable Long id) {
return DriverDto.from(driverService.findById(id));
}
@PostMapping
public ResponseEntity<?> save(@RequestBody DriverForm f) {
Map<String, String> errors = new LinkedHashMap<>();
if (isBlank(f.name())) errors.put("name", "Name is required");
if (isBlank(f.licenceNumber())) errors.put("licenceNumber", "Licence number is required");
if (!errors.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("ok", false, "errors", errors));
}
Driver d = new Driver();
if (f.id() != null) d.setId(f.id());
d.setName(trim(f.name()));
d.setPhone(trim(f.phone()));
d.setLicenceNumber(trim(f.licenceNumber()));
d.setLicenceExpiry(f.licenceExpiry());
d.setYearsExperience(f.yearsExperience() == null ? 0 : f.yearsExperience());
d.setEmploymentType(f.employmentType() == null ? DriverEmploymentType.EMPLOYED : f.employmentType());
d.setHourlyCharge(f.hourlyCharge());
d.setDailyCharge(f.dailyCharge());
d.setMonthlyCharge(f.monthlyCharge());
d.setStatus(f.status() == null ? DriverStatus.AVAILABLE : f.status());
d.setPhotoUrl(trim(f.photoUrl()));
try {
Driver saved = driverService.saveFromForm(d, f.categoryId(), f.vendorId());
return ResponseEntity.ok(Map.of("ok", true, "driver", DriverDto.from(saved)));
} catch (DataIntegrityViolationException ex) {
return ResponseEntity.badRequest().body(Map.of("ok", false,
"errors", Map.of("licenceNumber", "Licence number already exists")));
}
}
/* ---------- Documents ---------- */
@GetMapping("/{id}/documents")
public DriverMediaDto documents(@PathVariable Long id) {
return DriverMediaDto.from(driverService.findById(id));
}
@PostMapping("/{id}/documents")
public ResponseEntity<?> addDocument(@PathVariable Long id,
@RequestParam DriverDocumentType docType,
@RequestParam(required = false) MultipartFile file,
@RequestParam(required = false) String fileUrl,
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate issuedDate,
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate expiryDate) {
String stored = fileStorage.store(file);
String url = stored != null ? stored : fileUrl;
if (url == null || url.isBlank()) {
return ResponseEntity.badRequest().body(Map.of("ok", false, "message", "Upload a file or provide a URL"));
}
driverService.addDocument(id, docType, url, issuedDate, expiryDate);
return ResponseEntity.ok(Map.of("ok", true));
}
@PostMapping("/{id}/documents/{docId}/delete")
public ResponseEntity<?> deleteDocument(@PathVariable Long id, @PathVariable Long docId) {
driverService.deleteDocument(docId);
return ResponseEntity.ok(Map.of("ok", true));
}
@PostMapping("/{id}/delete")
public ResponseEntity<?> delete(@PathVariable Long id) {
try {
driverService.delete(id);
return ResponseEntity.ok(Map.of("ok", true));
} catch (DataIntegrityViolationException ex) {
return ResponseEntity.badRequest().body(Map.of("ok", false,
"message", "Cannot delete — this driver has trips. Set status to OFF instead."));
}
}
/** Inline activate/deactivate: flips a driver between AVAILABLE and OFF. */
@PostMapping("/{id}/status")
public ResponseEntity<?> setStatus(@PathVariable Long id, @RequestParam DriverStatus status) {
Driver saved = driverService.updateStatus(id, status);
return ResponseEntity.ok(Map.of("ok", true, "id", id, "status", saved.getStatus().name()));
}
private static boolean isBlank(String s) { return s == null || s.isBlank(); }
private static String trim(String s) { return s == null ? null : s.trim(); }
}
@@ -0,0 +1,144 @@
package com.mvrent.carrental.controller;
import com.mvrent.carrental.model.CategoryType;
import com.mvrent.carrental.model.Driver;
import com.mvrent.carrental.model.DriverDocumentType;
import com.mvrent.carrental.service.ConfigService;
import com.mvrent.carrental.service.DriverService;
import com.mvrent.carrental.service.FileStorageService;
import com.mvrent.carrental.service.VendorService;
import jakarta.validation.Valid;
import org.springframework.web.multipart.MultipartFile;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import java.time.LocalDate;
@Controller
@RequestMapping("/admin/drivers")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_DRIVERS')")
public class AdminDriverController {
private final DriverService driverService;
private final ConfigService configService;
private final VendorService vendorService;
private final FileStorageService fileStorage;
@GetMapping
public String list(Model model) {
model.addAttribute("drivers", driverService.findAll());
model.addAttribute("categories", configService.categoriesOfType(CategoryType.DRIVER));
model.addAttribute("vendors", vendorService.findActive());
return "admin/drivers";
}
@GetMapping("/new")
public String newDriver(Model model) {
if (!model.containsAttribute("driver")) {
model.addAttribute("driver", new Driver());
}
model.addAttribute("categories", configService.categoriesOfType(CategoryType.DRIVER));
model.addAttribute("vendors", vendorService.findActive());
return "admin/driver-form";
}
@PostMapping
public String save(@Valid @ModelAttribute("driver") Driver driver,
BindingResult result,
@RequestParam(required = false) Long categoryId,
@RequestParam(required = false) Long vendorId,
Model model,
RedirectAttributes ra) {
if (result.hasErrors()) {
model.addAttribute("categories", configService.categoriesOfType(CategoryType.DRIVER));
model.addAttribute("vendors", vendorService.findActive());
return "admin/driver-form";
}
driverService.saveFromForm(driver, categoryId, vendorId);
ra.addFlashAttribute("success", "Driver saved");
return "redirect:/admin/drivers";
}
@GetMapping("/{id}")
public String profile(@PathVariable Long id, Model model) {
Driver driver = driverService.findById(id);
model.addAttribute("driver", driver);
model.addAttribute("trips", driverService.tripHistory(driver));
model.addAttribute("totalTrips", driverService.totalTrips(driver));
model.addAttribute("initials", initials(driver.getName()));
return "admin/driver-profile";
}
private static String initials(String name) {
if (name == null || name.isBlank()) return "?";
String[] p = name.trim().split("\\s+");
if (p.length >= 2 && !p[0].isEmpty() && !p[1].isEmpty()) {
return ("" + p[0].charAt(0) + p[1].charAt(0)).toUpperCase();
}
return name.substring(0, Math.min(2, name.length())).toUpperCase();
}
@GetMapping("/{id}/edit")
public String edit(@PathVariable Long id, Model model) {
model.addAttribute("driver", driverService.findById(id));
model.addAttribute("categories", configService.categoriesOfType(CategoryType.DRIVER));
model.addAttribute("vendors", vendorService.findActive());
return "admin/driver-form";
}
@PostMapping("/{id}/delete")
public String delete(@PathVariable Long id, RedirectAttributes ra) {
driverService.delete(id);
ra.addFlashAttribute("success", "Driver deleted");
return "redirect:/admin/drivers";
}
/** Create a /driver portal login for this driver. */
@PostMapping("/{id}/portal-user")
public String createPortalUser(@PathVariable Long id,
@RequestParam String username,
@RequestParam String email,
@RequestParam String password,
RedirectAttributes ra) {
try {
driverService.createPortalUser(id, username, email, password);
ra.addFlashAttribute("success", "Driver portal login created for '" + username + "'");
} catch (IllegalArgumentException ex) {
ra.addFlashAttribute("error", ex.getMessage());
}
return "redirect:/admin/drivers/" + id;
}
/* ---------- Documents ---------- */
@PostMapping("/{id}/documents")
public String addDocument(@PathVariable Long id,
@RequestParam DriverDocumentType docType,
@RequestParam(required = false) String fileUrl,
@RequestParam(required = false) MultipartFile file,
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate issuedDate,
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate expiryDate,
RedirectAttributes ra) {
String stored = fileStorage.store(file);
driverService.addDocument(id, docType, stored != null ? stored : fileUrl, issuedDate, expiryDate);
ra.addFlashAttribute("success", docType + " document added");
return "redirect:/admin/drivers/" + id + "/edit";
}
@PostMapping("/{id}/documents/{docId}/delete")
public String deleteDocument(@PathVariable Long id, @PathVariable Long docId,
RedirectAttributes ra) {
driverService.deleteDocument(docId);
ra.addFlashAttribute("success", "Document removed");
return "redirect:/admin/drivers/" + id + "/edit";
}
}
@@ -0,0 +1,66 @@
package com.mvrent.carrental.controller;
import com.mvrent.carrental.model.InspectionCondition;
import com.mvrent.carrental.model.InspectionRecord;
import com.mvrent.carrental.service.InspectionService;
import com.mvrent.carrental.web.InspectionDto;
import com.mvrent.carrental.web.InspectionForm;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/** JSON API backing the AJAX Inspections screen. */
@RestController
@RequestMapping("/admin/api/inspections")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_FLEET')")
public class AdminInspectionApiController {
private final InspectionService inspectionService;
@GetMapping
public List<InspectionDto> list(@RequestParam(required = false) Long carId) {
var records = carId != null ? inspectionService.findByCar(carId) : inspectionService.findAll();
return records.stream().map(InspectionDto::from).toList();
}
@GetMapping("/{id}")
public InspectionDto get(@PathVariable Long id) {
return InspectionDto.from(inspectionService.findById(id));
}
@PostMapping
public ResponseEntity<?> save(@RequestBody InspectionForm f) {
if (f.carId() == null) return bad("Please select a car");
if (f.type() == null) return bad("Please select an inspection type");
InspectionRecord r = f.id() != null ? inspectionService.findById(f.id()) : new InspectionRecord();
r.setType(f.type());
r.setCondition(f.condition() != null ? f.condition() : InspectionCondition.GOOD);
r.setInspectionDate(f.inspectionDate());
r.setInspector(trim(f.inspector()));
r.setOdometer(f.odometer());
r.setFuelLevel(f.fuelLevel());
r.setDamageNotes(trim(f.damageNotes()));
r.setPhotoUrl(trim(f.photoUrl()));
InspectionRecord saved = inspectionService.save(r, f.carId(), f.bookingId());
return ResponseEntity.ok(Map.of("ok", true, "id", saved.getId()));
}
@PostMapping("/{id}/delete")
public ResponseEntity<?> delete(@PathVariable Long id) {
inspectionService.delete(id);
return ResponseEntity.ok(Map.of("ok", true));
}
private static String trim(String s) { return s == null || s.isBlank() ? null : s.trim(); }
private static ResponseEntity<Map<String, Object>> bad(String m) {
return ResponseEntity.badRequest().body(Map.of("ok", false, "message", m));
}
}
@@ -0,0 +1,24 @@
package com.mvrent.carrental.controller;
import com.mvrent.carrental.service.CarService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping("/admin/inspections")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_FLEET')")
public class AdminInspectionController {
private final CarService carService;
@GetMapping
public String page(Model model) {
model.addAttribute("cars", carService.findAll());
return "admin/inspections";
}
}
@@ -0,0 +1,67 @@
package com.mvrent.carrental.controller;
import com.mvrent.carrental.model.MaintenanceRecord;
import com.mvrent.carrental.model.MaintenanceStatus;
import com.mvrent.carrental.service.MaintenanceService;
import com.mvrent.carrental.web.MaintenanceDto;
import com.mvrent.carrental.web.MaintenanceForm;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/** JSON API backing the AJAX Maintenance screen. */
@RestController
@RequestMapping("/admin/api/maintenance")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_FLEET')")
public class AdminMaintenanceApiController {
private final MaintenanceService maintenanceService;
@GetMapping
public List<MaintenanceDto> list(@RequestParam(required = false) Long carId) {
var records = carId != null ? maintenanceService.findByCar(carId) : maintenanceService.findAll();
return records.stream().map(MaintenanceDto::from).toList();
}
@GetMapping("/{id}")
public MaintenanceDto get(@PathVariable Long id) {
return MaintenanceDto.from(maintenanceService.findById(id));
}
@PostMapping
public ResponseEntity<?> save(@RequestBody MaintenanceForm f) {
if (f.carId() == null) return bad("Please select a car");
if (f.type() == null) return bad("Please select a work type");
MaintenanceRecord r = f.id() != null ? maintenanceService.findById(f.id()) : new MaintenanceRecord();
r.setType(f.type());
r.setStatus(f.status() != null ? f.status() : MaintenanceStatus.COMPLETED);
r.setServiceDate(f.serviceDate());
r.setOdometer(f.odometer());
r.setCost(f.cost());
r.setGarage(trim(f.garage()));
r.setNotes(trim(f.notes()));
r.setNextServiceDate(f.nextServiceDate());
r.setNextServiceOdometer(f.nextServiceOdometer());
MaintenanceRecord saved = maintenanceService.save(r, f.carId());
return ResponseEntity.ok(Map.of("ok", true, "id", saved.getId()));
}
@PostMapping("/{id}/delete")
public ResponseEntity<?> delete(@PathVariable Long id) {
maintenanceService.delete(id);
return ResponseEntity.ok(Map.of("ok", true));
}
private static String trim(String s) { return s == null || s.isBlank() ? null : s.trim(); }
private static ResponseEntity<Map<String, Object>> bad(String m) {
return ResponseEntity.badRequest().body(Map.of("ok", false, "message", m));
}
}
@@ -0,0 +1,24 @@
package com.mvrent.carrental.controller;
import com.mvrent.carrental.service.CarService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping("/admin/maintenance")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_FLEET')")
public class AdminMaintenanceController {
private final CarService carService;
@GetMapping
public String page(Model model) {
model.addAttribute("cars", carService.findAll());
return "admin/maintenance";
}
}
@@ -0,0 +1,39 @@
package com.mvrent.carrental.controller;
import com.mvrent.carrental.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/** Admin profile page + AJAX password change. */
@Controller
@RequiredArgsConstructor
public class AdminProfileController {
private final UserService userService;
@GetMapping("/admin/profile")
public String profile(@AuthenticationPrincipal UserDetails principal, Model model) {
model.addAttribute("user", userService.findByUsername(principal.getUsername()));
return "admin/profile";
}
@PostMapping("/admin/api/profile/password")
@ResponseBody
public ResponseEntity<?> changePassword(@AuthenticationPrincipal UserDetails principal,
@RequestBody Map<String, String> body) {
try {
userService.changePassword(principal.getUsername(),
body.get("currentPassword"), body.get("newPassword"));
return ResponseEntity.ok(Map.of("ok", true));
} catch (IllegalArgumentException ex) {
return ResponseEntity.badRequest().body(Map.of("ok", false, "message", ex.getMessage()));
}
}
}
@@ -0,0 +1,120 @@
package com.mvrent.carrental.controller;
import com.mvrent.carrental.model.*;
import com.mvrent.carrental.service.*;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
@Controller
@RequestMapping("/admin/reports")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN') or hasAuthority('VIEW_REPORTS')")
public class AdminReportController {
private final ReportService reportService;
private final CarService carService;
private final DriverService driverService;
private final VendorService vendorService;
private final ConfigService configService;
private final ExcelService excelService;
private final PdfService pdfService;
/** Build a filter from raw query params, ignoring blanks (avoids empty-enum binding errors). */
private BookingFilter parse(Map<String, String> p) {
return new BookingFilter(
date(p.get("from")), date(p.get("to")),
lng(p.get("carId")), lng(p.get("driverId")), lng(p.get("vendorId")),
blank(p.get("ownership")) ? null : CarOwnership.valueOf(p.get("ownership")),
blank(p.get("status")) ? null : BookingStatus.valueOf(p.get("status")),
lng(p.get("customerCategoryId")));
}
private void addLookups(Model model) {
model.addAttribute("cars", carService.findAll());
model.addAttribute("drivers", driverService.findAll());
model.addAttribute("vendors", vendorService.findAll());
model.addAttribute("customerCategories", configService.categoriesOfType(CategoryType.CUSTOMER));
model.addAttribute("ownerships", CarOwnership.values());
model.addAttribute("statuses", BookingStatus.values());
}
@GetMapping
public String bookingsReport(@RequestParam Map<String, String> params, Model model) {
BookingFilter f = parse(params);
List<Booking> bookings = reportService.bookings(f);
model.addAttribute("bookings", bookings);
model.addAttribute("summary", reportService.summarize(bookings));
model.addAttribute("p", params); // echo filters back into the form + export links
addLookups(model);
return "admin/reports";
}
@GetMapping("/excel")
public ResponseEntity<byte[]> bookingsExcel(@RequestParam Map<String, String> params) {
List<Booking> bookings = reportService.bookings(parse(params));
byte[] xlsx = excelService.bookingsWorkbook(bookings, reportService.summarize(bookings));
return download(xlsx, AdminSettlementController.XLSX, "attachment", "bookings-report.xlsx");
}
@GetMapping("/pdf")
public ResponseEntity<byte[]> bookingsPdf(@RequestParam Map<String, String> params) {
List<Booking> bookings = reportService.bookings(parse(params));
byte[] pdf = pdfService.render("pdf/report-bookings", Map.of(
"bookings", bookings,
"summary", reportService.summarize(bookings),
"orgName", configService.getOrganization().getName()));
return download(pdf, MediaType.APPLICATION_PDF, "inline", "bookings-report.pdf");
}
/* ---------- Vendor-wise ---------- */
@GetMapping("/vendors")
public String vendorReport(Model model) {
model.addAttribute("rows", reportService.vendorReport());
return "admin/report-vendors";
}
@GetMapping("/vendors/excel")
public ResponseEntity<byte[]> vendorReportExcel() {
return download(excelService.vendorReportWorkbook(reportService.vendorReport()),
AdminSettlementController.XLSX, "attachment", "vendor-report.xlsx");
}
/* ---------- Driver-wise ---------- */
@GetMapping("/drivers")
public String driverReport(Model model) {
model.addAttribute("rows", reportService.driverReport());
return "admin/report-drivers";
}
@GetMapping("/drivers/excel")
public ResponseEntity<byte[]> driverReportExcel() {
return download(excelService.driverReportWorkbook(reportService.driverReport()),
AdminSettlementController.XLSX, "attachment", "driver-report.xlsx");
}
/* ---------- helpers ---------- */
private ResponseEntity<byte[]> download(byte[] body, MediaType type, String disposition, String filename) {
return ResponseEntity.ok().contentType(type)
.header(HttpHeaders.CONTENT_DISPOSITION, disposition + "; filename=\"" + filename + "\"")
.body(body);
}
private static boolean blank(String s) { return s == null || s.isBlank(); }
private static Long lng(String s) { return blank(s) ? null : Long.valueOf(s.trim()); }
private static LocalDate date(String s) { return blank(s) ? null : LocalDate.parse(s.trim()); }
}
@@ -0,0 +1,38 @@
package com.mvrent.carrental.controller;
import com.mvrent.carrental.service.ReviewService;
import com.mvrent.carrental.web.ReviewDto;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/** JSON API backing the admin review moderation screen. */
@RestController
@RequestMapping("/admin/api/reviews")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_FLEET')")
public class AdminReviewApiController {
private final ReviewService reviewService;
@GetMapping
public List<ReviewDto> list() {
return reviewService.findAll().stream().map(ReviewDto::from).toList();
}
@PostMapping("/{id}/approve")
public ResponseEntity<?> approve(@PathVariable Long id, @RequestParam boolean approved) {
reviewService.setApproved(id, approved);
return ResponseEntity.ok(Map.of("ok", true));
}
@PostMapping("/{id}/delete")
public ResponseEntity<?> delete(@PathVariable Long id) {
reviewService.delete(id);
return ResponseEntity.ok(Map.of("ok", true));
}
}
@@ -0,0 +1,19 @@
package com.mvrent.carrental.controller;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping("/admin/reviews")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_FLEET')")
public class AdminReviewController {
@GetMapping
public String page() {
return "admin/reviews";
}
}
@@ -0,0 +1,60 @@
package com.mvrent.carrental.controller;
import com.mvrent.carrental.model.Organization;
import com.mvrent.carrental.model.SystemConfig;
import com.mvrent.carrental.service.ConfigService;
import com.mvrent.carrental.web.ConfigForm;
import com.mvrent.carrental.web.OrgForm;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/** JSON API backing the AJAX Settings forms. */
@RestController
@RequestMapping("/admin/api/settings")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_CONFIG')")
public class AdminSettingsApiController {
private final ConfigService configService;
@PostMapping("/organization")
public ResponseEntity<?> saveOrganization(@RequestBody OrgForm f) {
if (f.name() == null || f.name().isBlank()) {
return ResponseEntity.badRequest().body(Map.of("ok", false, "message", "Organization name is required"));
}
Organization org = Organization.builder()
.name(f.name().trim())
.legalName(f.legalName())
.logoUrl(f.logoUrl())
.address(f.address())
.gstNumber(f.gstNumber())
.contactEmail(f.contactEmail())
.phone(f.phone())
.currencyCode(blankToDefault(f.currencyCode(), "INR"))
.build();
configService.saveOrganization(org);
return ResponseEntity.ok(Map.of("ok", true));
}
@PostMapping("/config")
public ResponseEntity<?> saveConfig(@RequestBody ConfigForm f) {
SystemConfig cfg = SystemConfig.builder()
.currencyCode(blankToDefault(f.currencyCode(), "INR"))
.defaultTaxPercent(f.defaultTaxPercent())
.defaultCommissionPercent(f.defaultCommissionPercent())
.defaultDriverDailyCharge(f.defaultDriverDailyCharge())
.invoicePrefix(blankToDefault(f.invoicePrefix(), "INV"))
.settlementCycle(f.settlementCycle())
.build();
configService.saveSystemConfig(cfg);
return ResponseEntity.ok(Map.of("ok", true));
}
private static String blankToDefault(String s, String def) {
return (s == null || s.isBlank()) ? def : s.trim();
}
}
@@ -0,0 +1,122 @@
package com.mvrent.carrental.controller;
import com.mvrent.carrental.model.CategoryType;
import com.mvrent.carrental.model.Organization;
import com.mvrent.carrental.model.SystemConfig;
import com.mvrent.carrental.service.ConfigService;
import com.mvrent.carrental.service.NotificationService;
import com.mvrent.carrental.service.SmsService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import java.util.Map;
@Controller
@RequestMapping("/admin/settings")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_CONFIG')")
public class AdminSettingsController {
private final ConfigService configService;
private final NotificationService notificationService;
private final SmsService smsService;
/* ---------- Organization + System config ---------- */
@GetMapping
public String settings(Model model) {
if (!model.containsAttribute("organization")) {
model.addAttribute("organization", configService.getOrganization());
}
if (!model.containsAttribute("config")) {
model.addAttribute("config", configService.getSystemConfig());
}
model.addAttribute("mailReady", notificationService.isReady());
model.addAttribute("smsEnabled", smsService.isEnabled());
model.addAttribute("adminEmail", notificationService.adminEmail());
return "admin/settings";
}
@PostMapping("/test-email")
@ResponseBody
public ResponseEntity<?> testEmail(@RequestParam String to) {
try {
notificationService.sendTest(to);
return ResponseEntity.ok(Map.of("ok", true));
} catch (RuntimeException ex) {
return ResponseEntity.badRequest().body(Map.of("ok", false, "message", ex.getMessage()));
}
}
@PostMapping("/organization")
public String saveOrganization(@Valid @ModelAttribute("organization") Organization organization,
BindingResult result,
Model model,
RedirectAttributes ra) {
if (result.hasErrors()) {
model.addAttribute("config", configService.getSystemConfig());
return "admin/settings";
}
configService.saveOrganization(organization);
ra.addFlashAttribute("success", "Organization profile saved");
return "redirect:/admin/settings";
}
@PostMapping("/config")
public String saveConfig(@Valid @ModelAttribute("config") SystemConfig config,
BindingResult result,
Model model,
RedirectAttributes ra) {
if (result.hasErrors()) {
model.addAttribute("organization", configService.getOrganization());
return "admin/settings";
}
configService.saveSystemConfig(config);
ra.addFlashAttribute("success", "System configuration saved");
return "redirect:/admin/settings";
}
/* ---------- Category masters ---------- */
@GetMapping("/categories")
public String categories(Model model) {
model.addAttribute("categories", configService.allCategories());
model.addAttribute("types", CategoryType.values());
return "admin/categories";
}
@PostMapping("/categories")
public String addCategory(@RequestParam CategoryType type,
@RequestParam String name,
@RequestParam(required = false) String description,
RedirectAttributes ra) {
try {
configService.addCategory(type, name.trim(), description);
ra.addFlashAttribute("success", type + " category '" + name + "' added");
} catch (IllegalArgumentException ex) {
ra.addFlashAttribute("error", ex.getMessage());
}
return "redirect:/admin/settings/categories";
}
@PostMapping("/categories/{id}/toggle")
public String toggleCategory(@PathVariable Long id, RedirectAttributes ra) {
configService.toggleCategory(id);
ra.addFlashAttribute("success", "Category updated");
return "redirect:/admin/settings/categories";
}
@PostMapping("/categories/{id}/delete")
public String deleteCategory(@PathVariable Long id, RedirectAttributes ra) {
configService.deleteCategory(id);
ra.addFlashAttribute("success", "Category deleted");
return "redirect:/admin/settings/categories";
}
}
@@ -0,0 +1,91 @@
package com.mvrent.carrental.controller;
import com.mvrent.carrental.model.SettlementStatus;
import com.mvrent.carrental.model.VendorSettlement;
import com.mvrent.carrental.service.ConfigService;
import com.mvrent.carrental.service.ExcelService;
import com.mvrent.carrental.service.PdfService;
import com.mvrent.carrental.service.SettlementService;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import java.time.LocalDate;
import java.util.Map;
@Controller
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_BILLING')")
public class AdminSettlementController {
static final MediaType XLSX =
MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
private final SettlementService settlementService;
private final PdfService pdfService;
private final ExcelService excelService;
private final ConfigService configService;
@PostMapping("/admin/vendors/{vendorId}/settlements")
public String generate(@PathVariable Long vendorId,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate periodStart,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate periodEnd,
RedirectAttributes ra) {
try {
VendorSettlement s = settlementService.generate(vendorId, periodStart, periodEnd);
ra.addFlashAttribute("success", "Settlement generated — net payable Rs. " + s.getNetPayable());
return "redirect:/admin/settlements/" + s.getId();
} catch (IllegalArgumentException ex) {
ra.addFlashAttribute("error", ex.getMessage());
return "redirect:/admin/vendors/" + vendorId;
}
}
@GetMapping("/admin/settlements")
public String list(Model model) {
model.addAttribute("settlements", settlementService.findAll());
return "admin/settlements";
}
@GetMapping("/admin/settlements/{id}")
public String detail(@PathVariable Long id, Model model) {
VendorSettlement s = settlementService.findById(id);
model.addAttribute("settlement", s);
model.addAttribute("trips", settlementService.tripsFor(s));
return "admin/settlement-detail";
}
@PostMapping("/admin/settlements/{id}/status")
public String status(@PathVariable Long id, @RequestParam SettlementStatus status, RedirectAttributes ra) {
settlementService.updateStatus(id, status);
ra.addFlashAttribute("success", "Settlement set to " + status);
return "redirect:/admin/settlements/" + id;
}
@GetMapping("/admin/settlements/{id}/pdf")
public ResponseEntity<byte[]> pdf(@PathVariable Long id) {
VendorSettlement s = settlementService.findById(id);
byte[] pdf = pdfService.render("pdf/settlement", Map.of(
"settlement", s, "trips", settlementService.tripsFor(s),
"orgName", configService.getOrganization().getName()));
return ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF)
.header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"settlement-" + s.getId() + ".pdf\"")
.body(pdf);
}
@GetMapping("/admin/settlements/{id}/excel")
public ResponseEntity<byte[]> excel(@PathVariable Long id) {
VendorSettlement s = settlementService.findById(id);
byte[] xlsx = excelService.settlementWorkbook(s, settlementService.tripsFor(s));
return ResponseEntity.ok().contentType(XLSX)
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"settlement-" + s.getId() + ".xlsx\"")
.body(xlsx);
}
}
@@ -0,0 +1,33 @@
package com.mvrent.carrental.controller;
import com.mvrent.carrental.service.TelematicsService;
import com.mvrent.carrental.web.VehicleLocationDto;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import java.util.List;
@Controller
@RequestMapping("/admin/tracking")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_FLEET')")
public class AdminTelematicsController {
private final TelematicsService telematicsService;
@GetMapping
public String page(Model model) {
return "admin/tracking";
}
@GetMapping(value = "/data", produces = "application/json")
@ResponseBody
public List<VehicleLocationDto> data() {
return telematicsService.findAll().stream().map(VehicleLocationDto::from).toList();
}
}
@@ -0,0 +1,27 @@
package com.mvrent.carrental.controller;
import com.mvrent.carrental.service.FileStorageService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.Map;
/** Generic single-file upload — stores the file and returns its public URL. */
@RestController
@RequestMapping("/admin/api/upload")
@RequiredArgsConstructor
public class AdminUploadApiController {
private final FileStorageService fileStorage;
@PostMapping
public ResponseEntity<?> upload(@RequestParam MultipartFile file) {
String url = fileStorage.store(file);
if (url == null) {
return ResponseEntity.badRequest().body(Map.of("ok", false, "message", "No file uploaded"));
}
return ResponseEntity.ok(Map.of("ok", true, "url", url));
}
}
@@ -0,0 +1,76 @@
package com.mvrent.carrental.controller;
import com.mvrent.carrental.service.UserService;
import com.mvrent.carrental.web.StaffDto;
import com.mvrent.carrental.web.StaffForm;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/** JSON API backing the staff / user management screen. */
@RestController
@RequestMapping("/admin/api/users")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_USERS')")
public class AdminUserApiController {
private final UserService userService;
@GetMapping
public List<StaffDto> list() {
return userService.findStaff().stream().map(StaffDto::from).toList();
}
@GetMapping("/{id}")
public StaffDto get(@PathVariable Long id) {
return StaffDto.from(userService.findById(id));
}
@PostMapping
public ResponseEntity<?> save(@RequestBody StaffForm f) {
try {
var saved = (f.id() != null) ? userService.updateStaff(f) : userService.createStaff(f);
return ResponseEntity.ok(Map.of("ok", true, "id", saved.getId()));
} catch (IllegalArgumentException ex) {
return bad(ex.getMessage());
}
}
@PostMapping("/{id}/enabled")
public ResponseEntity<?> setEnabled(@PathVariable Long id, @RequestParam boolean enabled) {
try {
userService.setEnabled(id, enabled);
return ResponseEntity.ok(Map.of("ok", true));
} catch (IllegalArgumentException ex) {
return bad(ex.getMessage());
}
}
@PostMapping("/{id}/password")
public ResponseEntity<?> resetPassword(@PathVariable Long id, @RequestParam String password) {
try {
userService.resetPassword(id, password);
return ResponseEntity.ok(Map.of("ok", true));
} catch (IllegalArgumentException ex) {
return bad(ex.getMessage());
}
}
@PostMapping("/{id}/delete")
public ResponseEntity<?> delete(@PathVariable Long id) {
try {
userService.deleteStaff(id);
return ResponseEntity.ok(Map.of("ok", true));
} catch (IllegalArgumentException ex) {
return bad(ex.getMessage());
}
}
private static ResponseEntity<Map<String, Object>> bad(String m) {
return ResponseEntity.badRequest().body(Map.of("ok", false, "message", m == null ? "Operation failed" : m));
}
}
@@ -0,0 +1,24 @@
package com.mvrent.carrental.controller;
import com.mvrent.carrental.model.Permission;
import com.mvrent.carrental.model.Role;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping("/admin/users")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_USERS')")
public class AdminUserController {
@GetMapping
public String page(Model model) {
model.addAttribute("permissions", Permission.values());
model.addAttribute("roles", new Role[]{Role.STAFF, Role.ADMIN});
return "admin/users";
}
}
@@ -0,0 +1,73 @@
package com.mvrent.carrental.controller;
import com.mvrent.carrental.model.Vendor;
import com.mvrent.carrental.service.VendorService;
import com.mvrent.carrental.web.VendorDto;
import com.mvrent.carrental.web.VendorForm;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/** JSON API backing the AJAX Manage Vendors screen. */
@RestController
@RequestMapping("/admin/api/vendors")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_VENDORS')")
public class AdminVendorApiController {
private final VendorService vendorService;
@GetMapping
public List<VendorDto> list() {
return vendorService.findAll().stream().map(VendorDto::from).toList();
}
@GetMapping("/{id}")
public VendorDto get(@PathVariable Long id) {
return VendorDto.from(vendorService.findById(id));
}
@PostMapping
public ResponseEntity<?> save(@RequestBody VendorForm f) {
Map<String, String> errors = new LinkedHashMap<>();
if (isBlank(f.name())) errors.put("name", "Name is required");
if (!errors.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("ok", false, "errors", errors));
}
// Load existing on edit so we don't null out anything outside the form.
Vendor v = f.id() != null ? vendorService.findById(f.id()) : new Vendor();
v.setName(trim(f.name()));
v.setContactPerson(trim(f.contactPerson()));
v.setPhone(trim(f.phone()));
v.setEmail(trim(f.email()));
v.setAddress(trim(f.address()));
v.setGstNumber(trim(f.gstNumber()));
v.setSettlementDetails(trim(f.settlementDetails()));
v.setCommissionPercent(f.commissionPercent());
v.setActive(f.active() == null || f.active());
Vendor saved = vendorService.save(v);
return ResponseEntity.ok(Map.of("ok", true, "vendor", VendorDto.from(saved)));
}
@PostMapping("/{id}/delete")
public ResponseEntity<?> delete(@PathVariable Long id) {
try {
vendorService.delete(id);
return ResponseEntity.ok(Map.of("ok", true));
} catch (DataIntegrityViolationException ex) {
return ResponseEntity.badRequest().body(Map.of("ok", false,
"message", "Cannot delete — vendor has cars, drivers or a portal login. Deactivate instead."));
}
}
private static boolean isBlank(String s) { return s == null || s.isBlank(); }
private static String trim(String s) { return s == null ? null : s.trim(); }
}
@@ -0,0 +1,98 @@
package com.mvrent.carrental.controller;
import com.mvrent.carrental.model.Vendor;
import com.mvrent.carrental.service.SettlementService;
import com.mvrent.carrental.service.VendorService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
@Controller
@RequestMapping("/admin/vendors")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_VENDORS')")
public class AdminVendorController {
private final VendorService vendorService;
private final SettlementService settlementService;
@GetMapping
public String list(Model model) {
model.addAttribute("vendors", vendorService.findAll());
return "admin/vendors";
}
@GetMapping("/new")
public String newVendor(Model model) {
if (!model.containsAttribute("vendor")) {
model.addAttribute("vendor", new Vendor());
}
return "admin/vendor-form";
}
@PostMapping
public String save(@Valid @ModelAttribute("vendor") Vendor vendor,
BindingResult result,
RedirectAttributes ra) {
if (result.hasErrors()) {
return "admin/vendor-form";
}
Vendor saved = vendorService.save(vendor);
ra.addFlashAttribute("success", "Vendor saved");
return "redirect:/admin/vendors/" + saved.getId();
}
@GetMapping("/{id}")
public String detail(@PathVariable Long id, Model model) {
Vendor vendor = vendorService.findById(id);
model.addAttribute("vendor", vendor);
model.addAttribute("cars", vendorService.cars(id));
model.addAttribute("drivers", vendorService.drivers(id));
model.addAttribute("revenue", vendorService.revenue(id));
model.addAttribute("settlements", settlementService.findByVendor(id));
model.addAttribute("initials", initials(vendor.getName()));
return "admin/vendor-detail";
}
private static String initials(String name) {
if (name == null || name.isBlank()) return "?";
String[] p = name.trim().split("\\s+");
if (p.length >= 2 && !p[0].isEmpty() && !p[1].isEmpty()) {
return ("" + p[0].charAt(0) + p[1].charAt(0)).toUpperCase();
}
return name.substring(0, Math.min(2, name.length())).toUpperCase();
}
@GetMapping("/{id}/edit")
public String edit(@PathVariable Long id, Model model) {
model.addAttribute("vendor", vendorService.findById(id));
return "admin/vendor-form";
}
@PostMapping("/{id}/delete")
public String delete(@PathVariable Long id, RedirectAttributes ra) {
vendorService.delete(id);
ra.addFlashAttribute("success", "Vendor deleted");
return "redirect:/admin/vendors";
}
@PostMapping("/{id}/portal-user")
public String createPortalUser(@PathVariable Long id,
@RequestParam String username,
@RequestParam String email,
@RequestParam String password,
RedirectAttributes ra) {
try {
vendorService.createPortalUser(id, username, email, password);
ra.addFlashAttribute("success", "Vendor portal login created for '" + username + "'");
} catch (IllegalArgumentException ex) {
ra.addFlashAttribute("error", ex.getMessage());
}
return "redirect:/admin/vendors/" + id;
}
}
@@ -0,0 +1,60 @@
package com.mvrent.carrental.controller;
import com.mvrent.carrental.model.CategoryType;
import com.mvrent.carrental.model.Role;
import com.mvrent.carrental.model.User;
import com.mvrent.carrental.service.ConfigService;
import com.mvrent.carrental.service.UserService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
@Controller
@RequiredArgsConstructor
public class AuthController {
private final UserService userService;
private final ConfigService configService;
@GetMapping("/login")
public String login() {
return "auth/login";
}
@GetMapping("/register")
public String registerForm(Model model) {
if (!model.containsAttribute("user")) {
model.addAttribute("user", new User());
}
model.addAttribute("customerCategories", configService.categoriesOfType(CategoryType.CUSTOMER));
return "auth/register";
}
@PostMapping("/register")
public String register(@Valid @ModelAttribute("user") User user,
BindingResult result,
@RequestParam String rawPassword,
@RequestParam(required = false) Long customerCategoryId,
RedirectAttributes ra,
Model model) {
if (result.hasErrors()) {
model.addAttribute("customerCategories", configService.categoriesOfType(CategoryType.CUSTOMER));
return "auth/register";
}
try {
user.setRole(Role.CUSTOMER);
user.setCustomerCategory(configService.findCategory(customerCategoryId));
userService.register(user, rawPassword);
ra.addFlashAttribute("success", "Account created — please log in");
return "redirect:/login";
} catch (IllegalArgumentException ex) {
model.addAttribute("customerCategories", configService.categoriesOfType(CategoryType.CUSTOMER));
model.addAttribute("error", ex.getMessage());
return "auth/register";
}
}
}
@@ -0,0 +1,132 @@
package com.mvrent.carrental.controller;
import com.mvrent.carrental.model.Booking;
import com.mvrent.carrental.model.Invoice;
import com.mvrent.carrental.model.User;
import com.mvrent.carrental.service.BookingService;
import com.mvrent.carrental.service.CarService;
import com.mvrent.carrental.service.ConfigService;
import com.mvrent.carrental.service.DriverService;
import com.mvrent.carrental.service.PaymentService;
import com.mvrent.carrental.service.PdfService;
import com.mvrent.carrental.service.UserService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import java.util.Map;
@Controller
@RequiredArgsConstructor
public class BookingController {
private final BookingService bookingService;
private final CarService carService;
private final DriverService driverService;
private final UserService userService;
private final PdfService pdfService;
private final ConfigService configService;
private final PaymentService paymentService;
@GetMapping("/cars/{carId}/book")
public String newBooking(@PathVariable Long carId,
@RequestParam(required = false) Boolean withDriver,
@RequestParam(required = false)
@org.springframework.format.annotation.DateTimeFormat(iso = org.springframework.format.annotation.DateTimeFormat.ISO.DATE) java.time.LocalDate pickup,
@RequestParam(required = false)
@org.springframework.format.annotation.DateTimeFormat(iso = org.springframework.format.annotation.DateTimeFormat.ISO.DATE) java.time.LocalDate ret,
Model model) {
model.addAttribute("car", carService.findById(carId));
if (!model.containsAttribute("booking")) {
Booking booking = new Booking();
if (withDriver != null) booking.setWithDriver(withDriver);
if (pickup != null) booking.setPickupDate(pickup);
if (ret != null) booking.setReturnDate(ret);
model.addAttribute("booking", booking);
}
model.addAttribute("drivers", driverService.findAvailable());
return "bookings/form";
}
@PostMapping("/cars/{carId}/book")
public String createBooking(@PathVariable Long carId,
@Valid @ModelAttribute("booking") Booking booking,
BindingResult result,
@RequestParam(required = false) Long driverId,
@AuthenticationPrincipal UserDetails principal,
RedirectAttributes ra,
Model model) {
if (result.hasErrors()) {
model.addAttribute("car", carService.findById(carId));
model.addAttribute("drivers", driverService.findAvailable());
return "bookings/form";
}
try {
User user = userService.findByUsername(principal.getUsername());
Booking saved = bookingService.createBooking(user, carId, booking, driverId);
ra.addFlashAttribute("success", "Booking confirmed (id #" + saved.getId() + ")");
return "redirect:/my-bookings";
} catch (RuntimeException ex) {
model.addAttribute("car", carService.findById(carId));
model.addAttribute("drivers", driverService.findAvailable());
model.addAttribute("error", ex.getMessage());
return "bookings/form";
}
}
@GetMapping("/my-bookings")
public String myBookings(@AuthenticationPrincipal UserDetails principal, Model model) {
User user = userService.findByUsername(principal.getUsername());
model.addAttribute("bookings", bookingService.findByUser(user));
return "bookings/my";
}
@GetMapping("/my-bookings/{id}/invoice")
public String invoice(@PathVariable Long id,
@AuthenticationPrincipal UserDetails principal,
Model model) {
User user = userService.findByUsername(principal.getUsername());
Booking booking = bookingService.findById(id);
if (!booking.getUser().getId().equals(user.getId())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Not your booking");
}
java.math.BigDecimal paid = paymentService.totalPaid(id);
java.math.BigDecimal total = booking.getTotalAmount() != null ? booking.getTotalAmount() : java.math.BigDecimal.ZERO;
model.addAttribute("booking", booking);
model.addAttribute("invoice", bookingService.invoiceFor(id));
model.addAttribute("amountPaid", paid);
model.addAttribute("balance", total.subtract(paid));
model.addAttribute("pdfUrl", "/my-bookings/" + id + "/invoice/pdf");
return "bookings/invoice";
}
@GetMapping("/my-bookings/{id}/invoice/pdf")
public ResponseEntity<byte[]> invoicePdf(@PathVariable Long id,
@AuthenticationPrincipal UserDetails principal) {
User user = userService.findByUsername(principal.getUsername());
Booking booking = bookingService.findById(id);
if (!booking.getUser().getId().equals(user.getId())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Not your booking");
}
Invoice invoice = bookingService.invoiceFor(id);
byte[] pdf = pdfService.render("pdf/invoice", Map.of(
"booking", booking, "invoice", invoice,
"orgName", configService.getOrganization().getName()));
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_PDF)
.header(HttpHeaders.CONTENT_DISPOSITION,
"inline; filename=\"" + invoice.getInvoiceNumber() + ".pdf\"")
.body(pdf);
}
}
@@ -0,0 +1,99 @@
package com.mvrent.carrental.controller;
import com.mvrent.carrental.model.Car;
import com.mvrent.carrental.model.CarType;
import com.mvrent.carrental.model.Transmission;
import com.mvrent.carrental.service.CarService;
import com.mvrent.carrental.service.DriverService;
import com.mvrent.carrental.service.ReviewService;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.List;
@Controller
@RequestMapping("/cars")
@RequiredArgsConstructor
public class CarController {
private final CarService carService;
private final DriverService driverService;
private final ReviewService reviewService;
@GetMapping
public String list(@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate pickup,
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate ret,
@RequestParam(required = false) CarType type,
@RequestParam(required = false) Integer minSeats,
@RequestParam(required = false) Transmission transmission,
@RequestParam(required = false) BigDecimal maxPrice,
@RequestParam(required = false) Boolean withDriver,
@RequestParam(required = false) String pickupLocation,
@RequestParam(required = false) String dropLocation,
@RequestParam(required = false) String promo,
Model model) {
List<Car> cars;
Long days = null;
if (pickup != null && ret != null && ret.isAfter(pickup)) {
cars = carService.findAvailableBetween(pickup, ret);
days = Math.max(1, ChronoUnit.DAYS.between(pickup, ret));
} else {
cars = carService.findAvailable();
}
// In-memory refine by the optional attribute filters.
cars = cars.stream()
.filter(c -> type == null || c.getType() == type)
.filter(c -> minSeats == null || c.getSeats() >= minSeats)
.filter(c -> transmission == null || c.getTransmission() == transmission)
.filter(c -> maxPrice == null || c.getPricePerDay().compareTo(maxPrice) <= 0)
.toList();
model.addAttribute("cars", cars);
model.addAttribute("days", days);
model.addAttribute("availableDrivers", driverService.findAvailable().size());
// echo filters back into the form
model.addAttribute("pickup", pickup);
model.addAttribute("ret", ret);
model.addAttribute("type", type);
model.addAttribute("minSeats", minSeats);
model.addAttribute("transmission", transmission);
model.addAttribute("maxPrice", maxPrice);
model.addAttribute("withDriver", withDriver);
model.addAttribute("pickupLocation", pickupLocation);
model.addAttribute("dropLocation", dropLocation);
model.addAttribute("promo", promo);
model.addAttribute("carTypes", CarType.values());
model.addAttribute("transmissions", Transmission.values());
return "cars/list";
}
@GetMapping("/{id}")
public String detail(@PathVariable Long id,
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate pickup,
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate ret,
Model model) {
model.addAttribute("car", carService.findById(id));
Long days = (pickup != null && ret != null && ret.isAfter(pickup))
? Math.max(1, ChronoUnit.DAYS.between(pickup, ret)) : null;
model.addAttribute("days", days);
model.addAttribute("pickup", pickup);
model.addAttribute("ret", ret);
model.addAttribute("availableDrivers", driverService.findAvailable().size());
model.addAttribute("reviews", reviewService.approvedForCar(id));
model.addAttribute("avgRating", reviewService.averageForCar(id));
model.addAttribute("reviewCount", reviewService.countForCar(id));
return "cars/detail";
}
}
@@ -0,0 +1,14 @@
package com.mvrent.carrental.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
/** Dedicated login entry point for the driver portal. */
@Controller
public class DriverAuthController {
@GetMapping("/driver/login")
public String login() {
return "driver/login";
}
}
@@ -0,0 +1,110 @@
package com.mvrent.carrental.controller;
import com.mvrent.carrental.model.Booking;
import com.mvrent.carrental.model.BookingStatus;
import com.mvrent.carrental.model.Driver;
import com.mvrent.carrental.model.DriverStatus;
import com.mvrent.carrental.model.User;
import com.mvrent.carrental.service.DriverService;
import com.mvrent.carrental.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import java.time.LocalDate;
import java.util.List;
/** Self-service portal scoped to the logged-in driver's own record and trips. */
@Controller
@RequestMapping("/driver")
@RequiredArgsConstructor
public class DriverPortalController {
private final UserService userService;
private final DriverService driverService;
private Driver currentDriver(UserDetails principal) {
User user = userService.findByUsername(principal.getUsername());
Driver driver = user.getDriver();
if (driver == null) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "No driver linked to this account");
}
return driver;
}
@GetMapping
public String dashboard(@AuthenticationPrincipal UserDetails principal, Model model) {
Driver driver = currentDriver(principal);
List<Booking> trips = driverService.tripHistory(driver);
LocalDate today = LocalDate.now();
List<Booking> upcoming = trips.stream()
.filter(b -> b.getReturnDate() != null && !b.getReturnDate().isBefore(today)
&& (b.getStatus() == BookingStatus.PENDING || b.getStatus() == BookingStatus.CONFIRMED))
.toList();
model.addAttribute("driver", driver);
model.addAttribute("initials", initials(driver.getName()));
model.addAttribute("totalTrips", driverService.totalTrips(driver));
model.addAttribute("upcoming", upcoming);
model.addAttribute("recentTrips", trips.stream().limit(6).toList());
return "driver/dashboard";
}
@GetMapping("/trips")
public String trips(@AuthenticationPrincipal UserDetails principal, Model model) {
Driver driver = currentDriver(principal);
model.addAttribute("driver", driver);
model.addAttribute("initials", initials(driver.getName()));
model.addAttribute("trips", driverService.tripHistory(driver));
return "driver/trips";
}
@GetMapping("/profile")
public String profile(@AuthenticationPrincipal UserDetails principal, Model model) {
Driver driver = currentDriver(principal);
model.addAttribute("driver", driver);
model.addAttribute("initials", initials(driver.getName()));
return "driver/profile";
}
/** Driver toggles their own availability (AVAILABLE ↔ OFF). */
@PostMapping("/availability")
public String availability(@AuthenticationPrincipal UserDetails principal,
@RequestParam DriverStatus status,
RedirectAttributes ra) {
Driver driver = currentDriver(principal);
// A driver may only mark themselves available or off (not ON_TRIP).
DriverStatus target = (status == DriverStatus.OFF) ? DriverStatus.OFF : DriverStatus.AVAILABLE;
driverService.updateStatus(driver.getId(), target);
ra.addFlashAttribute("success", "You are now " + (target == DriverStatus.OFF ? "off duty" : "available"));
return "redirect:/driver";
}
@PostMapping("/password")
public String changePassword(@AuthenticationPrincipal UserDetails principal,
@RequestParam String currentPassword,
@RequestParam String newPassword,
RedirectAttributes ra) {
try {
userService.changePassword(principal.getUsername(), currentPassword, newPassword);
ra.addFlashAttribute("success", "Password updated");
} catch (IllegalArgumentException ex) {
ra.addFlashAttribute("error", ex.getMessage());
}
return "redirect:/driver/profile";
}
private static String initials(String name) {
if (name == null || name.isBlank()) return "?";
String[] p = name.trim().split("\\s+");
if (p.length >= 2 && !p[0].isEmpty() && !p[1].isEmpty()) {
return ("" + p[0].charAt(0) + p[1].charAt(0)).toUpperCase();
}
return name.substring(0, Math.min(2, name.length())).toUpperCase();
}
}
@@ -0,0 +1,31 @@
package com.mvrent.carrental.controller;
import com.mvrent.carrental.model.Car;
import com.mvrent.carrental.service.CarService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import java.math.BigDecimal;
import java.util.Comparator;
import java.util.List;
@Controller
@RequiredArgsConstructor
public class HomeController {
private final CarService carService;
@GetMapping("/")
public String home(Model model) {
List<Car> available = carService.findAvailable();
BigDecimal minRate = available.stream()
.map(Car::getPricePerDay)
.min(Comparator.naturalOrder())
.orElse(BigDecimal.valueOf(800));
model.addAttribute("featuredCars", available.stream().limit(8).toList());
model.addAttribute("minRate", minRate);
return "index";
}
}
@@ -0,0 +1,38 @@
package com.mvrent.carrental.controller;
import com.mvrent.carrental.model.User;
import com.mvrent.carrental.service.ReviewService;
import com.mvrent.carrental.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
/** Customer-facing endpoint for leaving a car review. */
@Controller
@RequiredArgsConstructor
public class ReviewController {
private final ReviewService reviewService;
private final UserService userService;
@PostMapping("/reviews")
public String submit(@RequestParam Long carId,
@RequestParam(required = false) Long bookingId,
@RequestParam int rating,
@RequestParam(required = false) String comment,
@AuthenticationPrincipal UserDetails principal,
RedirectAttributes ra) {
try {
User user = userService.findByUsername(principal.getUsername());
reviewService.addReview(user, carId, bookingId, rating, comment);
ra.addFlashAttribute("reviewSuccess", "Thanks! Your review has been posted.");
} catch (RuntimeException ex) {
ra.addFlashAttribute("reviewError", ex.getMessage());
}
return "redirect:/cars/" + carId;
}
}
@@ -0,0 +1,36 @@
package com.mvrent.carrental.controller;
import com.mvrent.carrental.service.TelematicsService;
import com.mvrent.carrental.web.TelematicsPing;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* Device-facing ingest endpoint for GPS pings. Authenticated by an API key
* header ({@code X-API-KEY}) rather than a session, so trackers can post directly.
*/
@RestController
@RequestMapping("/api/telematics")
@RequiredArgsConstructor
public class TelematicsApiController {
private final TelematicsService telematicsService;
@PostMapping("/ingest")
public ResponseEntity<?> ingest(@RequestHeader(value = "X-API-KEY", required = false) String apiKey,
@RequestBody TelematicsPing ping) {
if (!telematicsService.apiKeyValid(apiKey)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("ok", false, "message", "Invalid API key"));
}
try {
telematicsService.ingest(ping);
return ResponseEntity.ok(Map.of("ok", true));
} catch (IllegalArgumentException ex) {
return ResponseEntity.badRequest().body(Map.of("ok", false, "message", ex.getMessage()));
}
}
}
@@ -0,0 +1,14 @@
package com.mvrent.carrental.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
/** Dedicated login entry point for the vendor portal. */
@Controller
public class VendorAuthController {
@GetMapping("/vendor/login")
public String login() {
return "vendor/login";
}
}
@@ -0,0 +1,124 @@
package com.mvrent.carrental.controller;
import com.mvrent.carrental.model.User;
import com.mvrent.carrental.model.Vendor;
import com.mvrent.carrental.model.VendorSettlement;
import com.mvrent.carrental.service.ConfigService;
import com.mvrent.carrental.service.ExcelService;
import com.mvrent.carrental.service.PdfService;
import com.mvrent.carrental.service.SettlementService;
import com.mvrent.carrental.service.UserService;
import com.mvrent.carrental.service.VendorService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import java.util.Map;
/** Self-service portal scoped to the logged-in vendor's own data. */
@Controller
@RequestMapping("/vendor")
@RequiredArgsConstructor
public class VendorPortalController {
private final UserService userService;
private final VendorService vendorService;
private final SettlementService settlementService;
private final PdfService pdfService;
private final ExcelService excelService;
private final ConfigService configService;
private Vendor currentVendor(UserDetails principal) {
User user = userService.findByUsername(principal.getUsername());
Vendor vendor = user.getVendor();
if (vendor == null) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "No vendor linked to this account");
}
return vendor;
}
@GetMapping
public String dashboard(@AuthenticationPrincipal UserDetails principal, Model model) {
Vendor vendor = currentVendor(principal);
model.addAttribute("vendor", vendor);
model.addAttribute("carCount", vendorService.cars(vendor.getId()).size());
model.addAttribute("driverCount", vendorService.drivers(vendor.getId()).size());
model.addAttribute("revenue", vendorService.revenue(vendor.getId()));
model.addAttribute("recentTrips", vendorService.trips(vendor.getId()).stream().limit(5).toList());
return "vendor/dashboard";
}
@GetMapping("/cars")
public String cars(@AuthenticationPrincipal UserDetails principal, Model model) {
Vendor vendor = currentVendor(principal);
model.addAttribute("vendor", vendor);
model.addAttribute("cars", vendorService.cars(vendor.getId()));
return "vendor/cars";
}
@GetMapping("/drivers")
public String drivers(@AuthenticationPrincipal UserDetails principal, Model model) {
Vendor vendor = currentVendor(principal);
model.addAttribute("vendor", vendor);
model.addAttribute("drivers", vendorService.drivers(vendor.getId()));
return "vendor/drivers";
}
@GetMapping("/trips")
public String trips(@AuthenticationPrincipal UserDetails principal, Model model) {
Vendor vendor = currentVendor(principal);
model.addAttribute("vendor", vendor);
model.addAttribute("trips", vendorService.trips(vendor.getId()));
model.addAttribute("revenue", vendorService.revenue(vendor.getId()));
return "vendor/trips";
}
@GetMapping("/settlements")
public String settlements(@AuthenticationPrincipal UserDetails principal, Model model) {
Vendor vendor = currentVendor(principal);
model.addAttribute("vendor", vendor);
model.addAttribute("settlements", settlementService.findByVendor(vendor.getId()));
return "vendor/settlements";
}
private VendorSettlement ownSettlement(UserDetails principal, Long id) {
Vendor vendor = currentVendor(principal);
VendorSettlement s = settlementService.findById(id);
if (!s.getVendor().getId().equals(vendor.getId())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Not your settlement");
}
return s;
}
@GetMapping("/settlements/{id}/pdf")
public ResponseEntity<byte[]> settlementPdf(@AuthenticationPrincipal UserDetails principal,
@PathVariable Long id) {
VendorSettlement s = ownSettlement(principal, id);
byte[] pdf = pdfService.render("pdf/settlement", Map.of(
"settlement", s, "trips", settlementService.tripsFor(s),
"orgName", configService.getOrganization().getName()));
return ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF)
.header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"settlement-" + s.getId() + ".pdf\"")
.body(pdf);
}
@GetMapping("/settlements/{id}/excel")
public ResponseEntity<byte[]> settlementExcel(@AuthenticationPrincipal UserDetails principal,
@PathVariable Long id) {
VendorSettlement s = ownSettlement(principal, id);
byte[] xlsx = excelService.settlementWorkbook(s, settlementService.tripsFor(s));
return ResponseEntity.ok().contentType(AdminSettlementController.XLSX)
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"settlement-" + s.getId() + ".xlsx\"")
.body(xlsx);
}
}
@@ -0,0 +1,45 @@
package com.mvrent.carrental.model;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import java.time.LocalDateTime;
/** An immutable record of a significant action — who did what, when. */
@Entity
@Table(name = "audit_logs", indexes = {
@Index(name = "idx_audit_created", columnList = "createdAt"),
@Index(name = "idx_audit_username", columnList = "username")
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class AuditLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@CreationTimestamp
private LocalDateTime createdAt;
private String username;
private String role;
/** CREATE, UPDATE, DELETE, GENERATE, LOGIN_SUCCESS, LOGIN_FAILURE, … */
private String action;
/** The kind of thing acted on (Car, Driver, Booking, …). */
private String entityType;
private String entityId;
@Column(length = 500)
private String detail;
private String ip;
}
@@ -0,0 +1,98 @@
package com.mvrent.carrental.model;
import jakarta.persistence.*;
import jakarta.validation.constraints.*;
import lombok.*;
import org.hibernate.annotations.ColumnDefault;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Entity
@Table(name = "bookings")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Booking {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.EAGER, optional = false)
@JoinColumn(name = "user_id")
private User user;
@ManyToOne(fetch = FetchType.EAGER, optional = false)
@JoinColumn(name = "car_id")
private Car car;
/** Assigned driver for with-driver bookings. */
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "driver_id")
private Driver driver;
@ColumnDefault("false")
@Builder.Default
private boolean withDriver = false;
@NotNull
private LocalDate pickupDate;
@NotNull
private LocalDate returnDate;
@NotBlank
private String pickupLocation;
@NotBlank
private String dropLocation;
/* ---------- Period & charge breakdown ---------- */
@Enumerated(EnumType.STRING)
@ColumnDefault("'DAY'")
@Builder.Default
private RentalUnit rentalUnit = RentalUnit.DAY;
/** Number of billed periods (days or months). */
@ColumnDefault("1")
@Builder.Default
private int quantity = 1;
private BigDecimal vehicleCharge;
private BigDecimal driverCharge;
private BigDecimal taxPercent;
private BigDecimal taxAmount;
@Column(nullable = false)
private BigDecimal totalAmount;
/* ---------- Revenue attribution ---------- */
@Enumerated(EnumType.STRING)
private CarOwnership revenueOwner;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "vendor_id")
private Vendor vendor;
private BigDecimal commissionPercent;
private BigDecimal commissionAmount;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private BookingStatus status;
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
@PrePersist
void onCreate() {
if (createdAt == null) createdAt = LocalDateTime.now();
if (status == null) status = BookingStatus.PENDING;
}
}
@@ -0,0 +1,5 @@
package com.mvrent.carrental.model;
public enum BookingStatus {
PENDING, CONFIRMED, CANCELLED, COMPLETED
}
@@ -0,0 +1,119 @@
package com.mvrent.carrental.model;
import jakarta.persistence.*;
import jakarta.validation.constraints.*;
import lombok.*;
import org.hibernate.annotations.ColumnDefault;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@Entity
@Table(name = "cars")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Car {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank
private String make;
@NotBlank
private String model;
@Column(unique = true, nullable = false)
@NotBlank
private String registrationNumber;
/** Year the vehicle was registered, e.g. 2022. */
private Integer yearOfRegistration;
private String colour;
@Enumerated(EnumType.STRING)
@NotNull
private CarType type;
@Min(2)
@Max(15)
private int seats;
@Enumerated(EnumType.STRING)
private Transmission transmission;
@Enumerated(EnumType.STRING)
private FuelType fuelType;
/** Feature tags such as AC, GPS, Sunroof, Bluetooth. */
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "car_feature_tags", joinColumns = @JoinColumn(name = "car_id"))
@Column(name = "tag")
@Builder.Default
private Set<String> featureTags = new HashSet<>();
/* ---------- Rates ---------- */
private BigDecimal hourlyRate;
@NotNull
@DecimalMin("0.0")
private BigDecimal pricePerDay; // daily rate
private BigDecimal monthlyRate;
/* ---------- Ownership & availability ---------- */
@Enumerated(EnumType.STRING)
@ColumnDefault("'OWNED'")
@Builder.Default
private CarOwnership ownership = CarOwnership.OWNED;
/** Set when ownership = VENDOR. */
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "vendor_id")
private Vendor vendor;
/** Per-vehicle commission override; falls back to vendor/global when null. */
private BigDecimal commissionPercentOverride;
@ColumnDefault("1")
@Builder.Default
private int unitsTotal = 1;
@Enumerated(EnumType.STRING)
@ColumnDefault("'ACTIVE'")
@Builder.Default
private CarStatus status = CarStatus.ACTIVE;
private String imageUrl;
@Builder.Default
private boolean available = true;
/* ---------- Documents & photos ---------- */
@OneToMany(mappedBy = "car", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("expiryDate ASC")
@Builder.Default
private List<CarDocument> documents = new ArrayList<>();
@OneToMany(mappedBy = "car", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("sortOrder ASC")
@Builder.Default
private List<CarPhoto> photos = new ArrayList<>();
/** Bookable only when listed and operationally active. */
@Transient
public boolean isBookable() {
return available && status == CarStatus.ACTIVE;
}
}
@@ -0,0 +1,52 @@
package com.mvrent.carrental.model;
import jakarta.persistence.*;
import jakarta.validation.constraints.*;
import lombok.*;
import java.time.LocalDate;
/**
* A compliance document (RC, insurance, permit, …) for a car, with an expiry
* date so the fleet can flag documents that are expired or expiring soon.
*/
@Entity
@Table(name = "car_documents")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class CarDocument {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "car_id")
private Car car;
@Enumerated(EnumType.STRING)
@NotNull
private CarDocumentType docType;
/** Path or URL to the stored file (local filesystem path for v1). */
private String fileUrl;
private LocalDate issuedDate;
private LocalDate expiryDate;
@Transient
public boolean isExpired() {
return expiryDate != null && expiryDate.isBefore(LocalDate.now());
}
@Transient
public boolean isExpiringSoon() {
return expiryDate != null
&& !expiryDate.isBefore(LocalDate.now())
&& expiryDate.isBefore(LocalDate.now().plusDays(30));
}
}
@@ -0,0 +1,11 @@
package com.mvrent.carrental.model;
/** Type of compliance document attached to a car. */
public enum CarDocumentType {
RC,
INSURANCE,
PERMIT,
POLLUTION,
FITNESS,
OTHER
}
@@ -0,0 +1,7 @@
package com.mvrent.carrental.model;
/** Whether a car belongs to the organization or is attached by a vendor. */
public enum CarOwnership {
OWNED,
VENDOR
}
@@ -0,0 +1,29 @@
package com.mvrent.carrental.model;
import jakarta.persistence.*;
import lombok.*;
/** A gallery photo for a car (URL/path based for v1). */
@Entity
@Table(name = "car_photos")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class CarPhoto {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "car_id")
private Car car;
@Column(nullable = false)
private String url;
@Builder.Default
private int sortOrder = 0;
}
@@ -0,0 +1,8 @@
package com.mvrent.carrental.model;
/** Operational state of a car. Only ACTIVE cars can be booked. */
public enum CarStatus {
ACTIVE,
MAINTENANCE,
RETIRED
}
@@ -0,0 +1,5 @@
package com.mvrent.carrental.model;
public enum CarType {
HATCHBACK, SEDAN, SUV, MUV, LUXURY, BIKE
}
@@ -0,0 +1,39 @@
package com.mvrent.carrental.model;
import jakarta.persistence.*;
import jakarta.validation.constraints.*;
import lombok.*;
/**
* Admin-configurable master category for vehicles, drivers or customers.
* Replaces hard-coded enums going forward; consumed by the Fleet/Driver/Customer
* modules in their respective phases.
*/
@Entity
@Table(name = "categories",
uniqueConstraints = @UniqueConstraint(columnNames = {"type", "name"}))
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Category {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Enumerated(EnumType.STRING)
@NotNull
@Column(nullable = false)
private CategoryType type;
@NotBlank
@Column(nullable = false)
private String name;
private String description;
@Builder.Default
private boolean active = true;
}
@@ -0,0 +1,8 @@
package com.mvrent.carrental.model;
/** Which domain a {@link Category} master record applies to. */
public enum CategoryType {
VEHICLE,
DRIVER,
CUSTOMER
}
@@ -0,0 +1,86 @@
package com.mvrent.carrental.model;
import jakarta.persistence.*;
import jakarta.validation.constraints.*;
import lombok.*;
import org.hibernate.annotations.ColumnDefault;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "drivers")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Driver {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank
private String name;
private String phone;
@Column(unique = true, nullable = false)
@NotBlank
private String licenceNumber;
private LocalDate licenceExpiry;
@Min(0)
@ColumnDefault("0")
@Builder.Default
private int yearsExperience = 0;
/** Driver category (master record of type DRIVER). */
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "category_id")
private Category category;
@Enumerated(EnumType.STRING)
@ColumnDefault("'EMPLOYED'")
@Builder.Default
private DriverEmploymentType employmentType = DriverEmploymentType.EMPLOYED;
/** Set when employmentType = VENDOR. */
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "vendor_id")
private Vendor vendor;
/* ---------- Charges ---------- */
private BigDecimal hourlyCharge;
private BigDecimal dailyCharge;
private BigDecimal monthlyCharge;
/* ---------- Availability ---------- */
@Enumerated(EnumType.STRING)
@ColumnDefault("'AVAILABLE'")
@Builder.Default
private DriverStatus status = DriverStatus.AVAILABLE;
private String photoUrl;
@OneToMany(mappedBy = "driver", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("expiryDate ASC")
@Builder.Default
private List<DriverDocument> documents = new ArrayList<>();
@Transient
public boolean isLicenceExpired() {
return licenceExpiry != null && licenceExpiry.isBefore(LocalDate.now());
}
@Transient
public boolean isAssignable() {
return status == DriverStatus.AVAILABLE && !isLicenceExpired();
}
}
@@ -0,0 +1,48 @@
package com.mvrent.carrental.model;
import jakarta.persistence.*;
import jakarta.validation.constraints.*;
import lombok.*;
import java.time.LocalDate;
/** A document for a driver (licence, ID proof, …) with expiry tracking. */
@Entity
@Table(name = "driver_documents")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class DriverDocument {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "driver_id")
private Driver driver;
@Enumerated(EnumType.STRING)
@NotNull
private DriverDocumentType docType;
private String fileUrl;
private LocalDate issuedDate;
private LocalDate expiryDate;
@Transient
public boolean isExpired() {
return expiryDate != null && expiryDate.isBefore(LocalDate.now());
}
@Transient
public boolean isExpiringSoon() {
return expiryDate != null
&& !expiryDate.isBefore(LocalDate.now())
&& expiryDate.isBefore(LocalDate.now().plusDays(30));
}
}
@@ -0,0 +1,10 @@
package com.mvrent.carrental.model;
/** Type of document attached to a driver. */
public enum DriverDocumentType {
LICENCE,
ID_PROOF,
POLICE_VERIFICATION,
MEDICAL,
OTHER
}
@@ -0,0 +1,7 @@
package com.mvrent.carrental.model;
/** Whether a driver is employed by the organization or supplied by a vendor. */
public enum DriverEmploymentType {
EMPLOYED,
VENDOR
}
@@ -0,0 +1,8 @@
package com.mvrent.carrental.model;
/** Availability state of a driver. Only AVAILABLE drivers can be assigned. */
public enum DriverStatus {
AVAILABLE,
ON_TRIP,
OFF
}
@@ -0,0 +1,5 @@
package com.mvrent.carrental.model;
public enum FuelType {
PETROL, DIESEL, ELECTRIC, HYBRID, CNG
}
@@ -0,0 +1,8 @@
package com.mvrent.carrental.model;
/** Overall condition recorded at an inspection. */
public enum InspectionCondition {
GOOD,
MINOR_DAMAGE,
MAJOR_DAMAGE
}
@@ -0,0 +1,65 @@
package com.mvrent.carrental.model;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import java.time.LocalDate;
import java.time.LocalDateTime;
/** A pre/post-trip or periodic condition & damage inspection of a car. */
@Entity
@Table(name = "inspection_records")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class InspectionRecord {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.EAGER, optional = false)
@JoinColumn(name = "car_id")
private Car car;
/** Optional link to the trip this inspection belongs to. */
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "booking_id")
private Booking booking;
@Enumerated(EnumType.STRING)
@NotNull
private InspectionType type;
@Enumerated(EnumType.STRING)
@NotNull
@Builder.Default
private InspectionCondition condition = InspectionCondition.GOOD;
private LocalDate inspectionDate;
private String inspector;
private Integer odometer;
/** Fuel level as a percentage 0100. */
private Integer fuelLevel;
@Column(length = 1000)
private String damageNotes;
/** Path/URL to an uploaded photo of the damage / condition. */
private String photoUrl;
@CreationTimestamp
private LocalDateTime createdAt;
@Transient
public boolean hasDamage() {
return condition != null && condition != InspectionCondition.GOOD;
}
}
@@ -0,0 +1,9 @@
package com.mvrent.carrental.model;
/** When a vehicle inspection was carried out. */
public enum InspectionType {
PRE_TRIP,
POST_TRIP,
PERIODIC,
INCIDENT
}
@@ -0,0 +1,41 @@
package com.mvrent.carrental.model;
import jakarta.persistence.*;
import lombok.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** A customer invoice generated for a booking. */
@Entity
@Table(name = "invoices")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Invoice {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToOne(fetch = FetchType.EAGER, optional = false)
@JoinColumn(name = "booking_id", unique = true)
private Booking booking;
@Column(unique = true, nullable = false)
private String invoiceNumber;
@Column(nullable = false, updatable = false)
private LocalDateTime issuedAt;
private BigDecimal subtotal;
private BigDecimal taxAmount;
private BigDecimal total;
@PrePersist
void onCreate() {
if (issuedAt == null) issuedAt = LocalDateTime.now();
}
}
@@ -0,0 +1,70 @@
package com.mvrent.carrental.model;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
/** A service / repair entry in a car's maintenance history. */
@Entity
@Table(name = "maintenance_records")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class MaintenanceRecord {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.EAGER, optional = false)
@JoinColumn(name = "car_id")
private Car car;
@Enumerated(EnumType.STRING)
@NotNull
private MaintenanceType type;
@Enumerated(EnumType.STRING)
@NotNull
@Builder.Default
private MaintenanceStatus status = MaintenanceStatus.COMPLETED;
private LocalDate serviceDate;
/** Odometer reading at the time of service (km). */
private Integer odometer;
private BigDecimal cost;
/** Garage / workshop that did the work. */
private String garage;
@Column(length = 1000)
private String notes;
private LocalDate nextServiceDate;
private Integer nextServiceOdometer;
@CreationTimestamp
private LocalDateTime createdAt;
@Transient
public boolean isOpen() {
return status != MaintenanceStatus.COMPLETED;
}
@Transient
public boolean isDueSoon() {
return nextServiceDate != null
&& !nextServiceDate.isBefore(LocalDate.now())
&& nextServiceDate.isBefore(LocalDate.now().plusDays(30));
}
}
@@ -0,0 +1,8 @@
package com.mvrent.carrental.model;
/** Lifecycle of a maintenance job. An open job (SCHEDULED/IN_PROGRESS) takes the car off the road. */
public enum MaintenanceStatus {
SCHEDULED,
IN_PROGRESS,
COMPLETED
}
@@ -0,0 +1,13 @@
package com.mvrent.carrental.model;
/** Kind of work done on a vehicle. */
public enum MaintenanceType {
SERVICE,
REPAIR,
TYRE,
BATTERY,
BODY_WORK,
CLEANING,
INSURANCE_RENEWAL,
OTHER
}
@@ -0,0 +1,43 @@
package com.mvrent.carrental.model;
import jakarta.persistence.*;
import jakarta.validation.constraints.*;
import lombok.*;
/**
* The single rental company that owns this installation. One row only (id = 1).
* Branding here is applied to the customer page and exported reports.
*/
@Entity
@Table(name = "organization")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Organization {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank
private String name;
private String legalName;
private String logoUrl;
private String address;
private String gstNumber;
@Email
private String contactEmail;
private String phone;
@NotBlank
@Builder.Default
private String currencyCode = "INR";
}
@@ -0,0 +1,50 @@
package com.mvrent.carrental.model;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
/** A payment collected against a booking (counter cash, UPI, card, gateway, …). */
@Entity
@Table(name = "payments")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Payment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.EAGER, optional = false)
@JoinColumn(name = "booking_id")
private Booking booking;
@NotNull
private BigDecimal amount;
@Enumerated(EnumType.STRING)
@NotNull
@Builder.Default
private PaymentMethod method = PaymentMethod.CASH;
/** Transaction / cheque / UPI reference. */
private String reference;
private LocalDate paidAt;
private String notes;
/** Username of the staff member who recorded it. */
private String recordedBy;
@CreationTimestamp
private LocalDateTime createdAt;
}
@@ -0,0 +1,11 @@
package com.mvrent.carrental.model;
/** How a payment was collected. */
public enum PaymentMethod {
CASH,
UPI,
CARD,
BANK_TRANSFER,
ONLINE,
OTHER
}
@@ -0,0 +1,16 @@
package com.mvrent.carrental.model;
/**
* Granular capabilities assignable per staff user (see {@link User#getPermissions()}).
* ADMIN implicitly has all of these; STAFF gets a configurable subset.
*/
public enum Permission {
MANAGE_FLEET,
MANAGE_DRIVERS,
MANAGE_VENDORS,
MANAGE_BOOKINGS,
MANAGE_BILLING,
VIEW_REPORTS,
MANAGE_CONFIG,
MANAGE_USERS
}
@@ -0,0 +1,8 @@
package com.mvrent.carrental.model;
/** Billing period for a booking. HOUR is reserved for a future release. */
public enum RentalUnit {
HOUR,
DAY,
MONTH
}
@@ -0,0 +1,51 @@
package com.mvrent.carrental.model;
import jakarta.persistence.*;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import java.time.LocalDateTime;
/** A customer's star rating and comment for a car. */
@Entity
@Table(name = "reviews")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Review {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.EAGER, optional = false)
@JoinColumn(name = "car_id")
private Car car;
@ManyToOne(fetch = FetchType.EAGER, optional = false)
@JoinColumn(name = "user_id")
private User user;
/** The trip this review relates to (optional). */
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "booking_id")
private Booking booking;
@Min(1)
@Max(5)
private int rating;
@Column(length = 1000)
private String comment;
/** Published reviews are shown publicly; admins can unpublish. */
@Builder.Default
private boolean approved = true;
@CreationTimestamp
private LocalDateTime createdAt;
}
@@ -0,0 +1,14 @@
package com.mvrent.carrental.model;
/**
* Top-level account type. ADMIN/STAFF use the /admin chain, VENDOR the /vendor
* portal, DRIVER the /driver portal, CUSTOMER the public site. Fine-grained
* access for staff is controlled by {@link Permission}.
*/
public enum Role {
ADMIN,
STAFF,
VENDOR,
DRIVER,
CUSTOMER
}
@@ -0,0 +1,7 @@
package com.mvrent.carrental.model;
/** How often vendor settlements are aggregated. */
public enum SettlementCycle {
WEEKLY,
MONTHLY
}
@@ -0,0 +1,8 @@
package com.mvrent.carrental.model;
/** Lifecycle of a vendor settlement statement. */
public enum SettlementStatus {
DRAFT,
FINALISED,
PAID
}
@@ -0,0 +1,46 @@
package com.mvrent.carrental.model;
import jakarta.persistence.*;
import lombok.*;
import java.math.BigDecimal;
/**
* Master configuration singleton (id = 1): currency, tax, default charges and
* commission rules used across booking, billing and settlement calculations.
*/
@Entity
@Table(name = "system_config")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class SystemConfig {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Builder.Default
private String currencyCode = "INR";
/** Default GST/tax percent applied to bookings, e.g. 18.00 */
@Builder.Default
private BigDecimal defaultTaxPercent = new BigDecimal("18.00");
/** Default organization commission on vendor revenue, e.g. 15.00 */
@Builder.Default
private BigDecimal defaultCommissionPercent = new BigDecimal("15.00");
/** Fallback driver day charge when a driver has none set. */
@Builder.Default
private BigDecimal defaultDriverDailyCharge = new BigDecimal("800.00");
@Builder.Default
private String invoicePrefix = "INV";
@Enumerated(EnumType.STRING)
@Builder.Default
private SettlementCycle settlementCycle = SettlementCycle.MONTHLY;
}
@@ -0,0 +1,5 @@
package com.mvrent.carrental.model;
public enum Transmission {
MANUAL, AUTOMATIC
}
@@ -0,0 +1,73 @@
package com.mvrent.carrental.model;
import jakarta.persistence.*;
import jakarta.validation.constraints.*;
import lombok.*;
import java.util.HashSet;
import java.util.Set;
@Entity
@Table(name = "users")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
@NotBlank
@Size(min = 3, max = 50)
private String username;
@Column(unique = true, nullable = false)
@Email
@NotBlank
private String email;
// Set programmatically (encoded) by UserService/registration, not bound from the form.
@Column(nullable = false)
private String password;
@NotBlank
private String fullName;
private String phone;
private String licenseNumber;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Role role;
/** Set for ROLE_VENDOR portal users — links the login to its vendor. */
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "vendor_id")
private Vendor vendor;
/** Set for ROLE_DRIVER portal users — links the login to its driver record. */
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "driver_id")
private Driver driver;
/** Customer segment (master category of type CUSTOMER); optional. */
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "customer_category_id")
private Category customerCategory;
/** Granular staff capabilities; empty for customers/vendors. */
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "user_permissions", joinColumns = @JoinColumn(name = "user_id"))
@Enumerated(EnumType.STRING)
@Column(name = "permission")
@Builder.Default
private Set<Permission> permissions = new HashSet<>();
@Builder.Default
private boolean enabled = true;
}
@@ -0,0 +1,47 @@
package com.mvrent.carrental.model;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
/** The latest known GPS position / telematics state for a car (one row per car). */
@Entity
@Table(name = "vehicle_locations")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class VehicleLocation {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToOne(fetch = FetchType.EAGER, optional = false)
@JoinColumn(name = "car_id", unique = true)
private Car car;
private double latitude;
private double longitude;
/** Speed in km/h. */
private Double speedKph;
/** Heading in degrees (0360). */
private Double heading;
private Integer odometer;
private Boolean ignitionOn;
private LocalDateTime updatedAt;
/** Considered "live" if a ping arrived in the last 5 minutes. */
@Transient
public boolean isLive() {
return updatedAt != null && updatedAt.isAfter(LocalDateTime.now().minusMinutes(5));
}
}
@@ -0,0 +1,50 @@
package com.mvrent.carrental.model;
import jakarta.persistence.*;
import jakarta.validation.constraints.*;
import lombok.*;
import org.hibernate.annotations.ColumnDefault;
import java.math.BigDecimal;
/**
* A third party that attaches cars/drivers to the fleet. Vendors get a scoped
* /vendor portal login and earn revenue minus the organization's commission.
*/
@Entity
@Table(name = "vendors")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Vendor {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank
private String name;
private String contactPerson;
private String phone;
@Email
private String email;
private String address;
private String gstNumber;
/** Free-text bank / settlement details. */
private String settlementDetails;
/** Per-vendor commission percent; overrides the global default when set. */
private BigDecimal commissionPercent;
@ColumnDefault("true")
@Builder.Default
private boolean active = true;
}
@@ -0,0 +1,55 @@
package com.mvrent.carrental.model;
import jakarta.persistence.*;
import lombok.*;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* An aggregated settlement statement for a vendor over a period:
* gross rental revenue, organization commission, vendor driver charges and the
* resulting net payable.
*/
@Entity
@Table(name = "vendor_settlements")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class VendorSettlement {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.EAGER, optional = false)
@JoinColumn(name = "vendor_id")
private Vendor vendor;
private LocalDate periodStart;
private LocalDate periodEnd;
@Builder.Default
private int tripCount = 0;
private BigDecimal grossRevenue;
private BigDecimal commissionAmount;
private BigDecimal driverCharges;
private BigDecimal netPayable;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
@Builder.Default
private SettlementStatus status = SettlementStatus.DRAFT;
@Column(nullable = false, updatable = false)
private LocalDateTime generatedAt;
@PrePersist
void onCreate() {
if (generatedAt == null) generatedAt = LocalDateTime.now();
}
}
@@ -0,0 +1,12 @@
package com.mvrent.carrental.repository;
import com.mvrent.carrental.model.AuditLog;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface AuditLogRepository extends JpaRepository<AuditLog, Long> {
List<AuditLog> findAllByOrderByCreatedAtDesc(Pageable pageable);
}
@@ -0,0 +1,47 @@
package com.mvrent.carrental.repository;
import com.mvrent.carrental.model.Booking;
import com.mvrent.carrental.model.BookingStatus;
import com.mvrent.carrental.model.CarOwnership;
import com.mvrent.carrental.model.Driver;
import com.mvrent.carrental.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import java.time.LocalDate;
import java.util.List;
public interface BookingRepository extends JpaRepository<Booking, Long>,
JpaSpecificationExecutor<Booking> {
List<Booking> findByUserOrderByCreatedAtDesc(User user);
List<Booking> findByDriverOrderByCreatedAtDesc(Driver driver);
long countByDriver(Driver driver);
List<Booking> findByCarVendorIdOrderByCreatedAtDesc(Long vendorId);
long countByCarVendorId(Long vendorId);
List<Booking> findByCarVendorIdAndStatusInAndPickupDateBetweenOrderByPickupDate(
Long vendorId, List<BookingStatus> statuses, LocalDate start, LocalDate end);
List<Booking> findByRevenueOwnerAndStatusIn(CarOwnership owner, List<BookingStatus> statuses);
List<Booking> findAllByOrderByCreatedAtDesc();
boolean existsByCarIdAndStatusInAndPickupDateLessThanEqualAndReturnDateGreaterThanEqual(
Long carId,
List<BookingStatus> statuses,
LocalDate returnDate,
LocalDate pickupDate
);
boolean existsByDriverIdAndStatusInAndPickupDateLessThanEqualAndReturnDateGreaterThanEqual(
Long driverId,
List<BookingStatus> statuses,
LocalDate returnDate,
LocalDate pickupDate
);
}
@@ -0,0 +1,20 @@
package com.mvrent.carrental.repository;
import com.mvrent.carrental.model.CarDocument;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDate;
import java.util.List;
public interface CarDocumentRepository extends JpaRepository<CarDocument, Long> {
/** Documents already expired or expiring on/before the given cut-off date. */
@Query("""
SELECT d FROM CarDocument d
WHERE d.expiryDate IS NOT NULL AND d.expiryDate <= :cutoff
ORDER BY d.expiryDate ASC
""")
List<CarDocument> findExpiringOrExpired(@Param("cutoff") LocalDate cutoff);
}

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