Files
carmanagement/project_design/advanced-features.md
T
2026-05-06 22:58:23 -04:00

1309 lines
49 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
name: rental-car-advanced-features
description: Build advanced rental operations features for RentalDriveGo. Use this skill when the user asks about: vehicle damage diagrams/inspection (contract car layout), insurance policies and charges, second driver (additional driver) with optional charge, driver license validation (expiry check, 3-month rule, flagging), age-based pricing rules (25+ discount, license+5 surcharge), gasoline/fuel policies (FULL_TO_FULL etc.), billing period reporting (weekly/monthly/yearly export for accountants), or the platform admin panel (managing tenants, staff, roles, permissions). This skill works alongside document-service.md and schema.md.
---
# Skill: RentalDriveGo Advanced Features
## Features Covered
1. Vehicle Damage Inspection Map (contract car diagram)
2. Insurance Policies & Per-Reservation Charges
3. Second / Additional Driver
4. Driver License Validation (expiry + 3-month rule + flagging)
5. Age-Based Pricing Rules (25+ discount, license+5 surcharge)
6. Fuel/Gasoline Policy (structured types)
7. Billing Period Reporting (accountant export)
8. Platform Admin Panel (tenant + staff + role management)
---
## 1. Vehicle Damage Inspection Map
### What it is
An interactive SVG car diagram embedded in the check-in/check-out workflow (dashboard + printable on contract). Staff marks pre-existing damage zones before handing over the vehicle. At return, the same diagram shows original vs new damage.
### Data model
```prisma
// Damage report: one per check-in, one per check-out
model DamageReport {
id String @id @default(cuid())
reservationId String
reservation Reservation @relation(fields: [reservationId], references: [id], onDelete: Cascade)
companyId String // denormalized for tenant scoping
type DamageReportType // CHECKIN | CHECKOUT
// Array of marked damage zones
// Each zone: { zone: string, severity: string, note: string }
// zone values: "hood", "roof", "trunk", "front-left-door", "front-right-door",
// "rear-left-door", "rear-right-door", "front-left-fender", "front-right-fender",
// "rear-left-fender", "rear-right-fender", "front-bumper", "rear-bumper",
// "windshield", "rear-window", "left-mirror", "right-mirror",
// "front-left-wheel", "front-right-wheel", "rear-left-wheel", "rear-right-wheel"
// severity: "SCRATCH" | "DENT" | "CRACK" | "MISSING" | "OTHER"
damages Json // DamageZone[]
// Photos of damage (Cloudinary URLs, optional)
photos String[]
// Fuel level at this inspection point
fuelLevel FuelLevel
// Odometer reading
mileage Int?
// Captured at
inspectedAt DateTime @default(now())
inspectedBy String? // Employee name who did the inspection
// Customer acknowledgement
customerSignedAt DateTime?
customerName String? // printed name of who acknowledged
createdAt DateTime @default(now())
@@unique([reservationId, type])
@@index([companyId])
@@map("damage_reports")
}
enum DamageReportType { CHECKIN CHECKOUT }
enum FuelLevel { FULL THREE_QUARTERS HALF QUARTER EMPTY }
```
Add to `Reservation` model:
```prisma
damageReports DamageReport[]
// Remove old checkInFuelLevel/checkOutFuelLevel String? — replaced by DamageReport.fuelLevel
```
### Damage zone TypeScript type
```typescript
// types/damage.ts
export interface DamageZone {
zone: VehicleZone
severity: 'SCRATCH' | 'DENT' | 'CRACK' | 'MISSING' | 'OTHER'
note?: string
}
export type VehicleZone =
| 'hood' | 'roof' | 'trunk'
| 'front-left-door' | 'front-right-door'
| 'rear-left-door' | 'rear-right-door'
| 'front-left-fender' | 'front-right-fender'
| 'rear-left-fender' | 'rear-right-fender'
| 'front-bumper' | 'rear-bumper'
| 'windshield' | 'rear-window'
| 'left-mirror' | 'right-mirror'
| 'front-left-wheel' | 'front-right-wheel'
| 'rear-left-wheel' | 'rear-right-wheel'
| 'interior' | 'under-hood' | 'undercarriage'
// Coordinates on the SVG diagram for each zone
// Used by the interactive damage map UI and the PDF render
export const DAMAGE_ZONE_COORDS: Record<VehicleZone, { x: number; y: number; w: number; h: number }> = {
'hood': { x: 48, y: 8, w: 64, h: 28 },
'roof': { x: 48, y: 52, w: 64, h: 56 },
'trunk': { x: 48, y: 124, w: 64, h: 28 },
'front-bumper': { x: 48, y: 0, w: 64, h: 10 },
'rear-bumper': { x: 48, y: 150, w: 64, h: 10 },
'front-left-fender': { x: 20, y: 10, w: 30, h: 35 },
'front-right-fender': { x: 110, y: 10, w: 30, h: 35 },
'front-left-door': { x: 20, y: 52, w: 30, h: 30 },
'front-right-door': { x: 110, y: 52, w: 30, h: 30 },
'rear-left-door': { x: 20, y: 82, w: 30, h: 30 },
'rear-right-door': { x: 110, y: 82, w: 30, h: 30 },
'rear-left-fender': { x: 20, y: 112, w: 30, h: 35 },
'rear-right-fender': { x: 110, y: 112, w: 30, h: 35 },
'windshield': { x: 52, y: 38, w: 56, h: 18 },
'rear-window': { x: 52, y: 104, w: 56, h: 18 },
'left-mirror': { x: 8, y: 50, w: 14, h: 10 },
'right-mirror': { x: 138, y: 50, w: 14, h: 10 },
'front-left-wheel': { x: 10, y: 24, w: 22, h: 26 },
'front-right-wheel': { x: 128, y: 24, w: 22, h: 26 },
'rear-left-wheel': { x: 10, y: 110, w: 22, h: 26 },
'rear-right-wheel': { x: 128, y: 110, w: 22, h: 26 },
'interior': { x: 55, y: 58, w: 50, h: 44 },
'under-hood': { x: 52, y: 10, w: 56, h: 20 },
'undercarriage': { x: 52, y: 130, w: 56, h: 20 },
}
// Severity colors for the SVG overlay
export const SEVERITY_COLORS = {
SCRATCH: '#F59E0B', // amber
DENT: '#EF4444', // red
CRACK: '#8B5CF6', // purple
MISSING: '#374151', // dark gray
OTHER: '#6B7280', // gray
}
```
### Interactive Damage Map UI (React Component)
```tsx
// components/reservations/DamageMap.tsx
import { useState } from 'react'
import { DAMAGE_ZONE_COORDS, SEVERITY_COLORS, VehicleZone, DamageZone } from '@/types/damage'
interface DamageMapProps {
damages: DamageZone[]
onChange?: (damages: DamageZone[]) => void // undefined = read-only
readonly?: boolean
primaryColor?: string
}
export function DamageMap({ damages, onChange, readonly, primaryColor = '#1A56DB' }: DamageMapProps) {
const [selected, setSelected] = useState<VehicleZone | null>(null)
const [severity, setSeverity] = useState<DamageZone['severity']>('SCRATCH')
const [note, setNote] = useState('')
const getDamageForZone = (zone: VehicleZone) =>
damages.find(d => d.zone === zone)
const handleZoneClick = (zone: VehicleZone) => {
if (readonly || !onChange) return
setSelected(zone)
const existing = getDamageForZone(zone)
if (existing) {
setSeverity(existing.severity)
setNote(existing.note ?? '')
} else {
setSeverity('SCRATCH')
setNote('')
}
}
const applyDamage = () => {
if (!selected || !onChange) return
const updated = damages.filter(d => d.zone !== selected)
updated.push({ zone: selected, severity, note })
onChange(updated)
setSelected(null)
}
const clearZone = (zone: VehicleZone) => {
if (!onChange) return
onChange(damages.filter(d => d.zone !== zone))
}
return (
<div className="flex flex-col items-center gap-4">
{/* SVG Car Diagram — top-down view */}
<div className="relative">
<svg viewBox="0 0 160 165" width={280} height={290} className="select-none">
{/* Car body — top-down silhouette */}
<CarTopDownSVG primaryColor={primaryColor} />
{/* Damage zone hit areas — transparent clickable rects */}
{(Object.entries(DAMAGE_ZONE_COORDS) as [VehicleZone, any][]).map(([zone, coords]) => {
const damage = getDamageForZone(zone)
return (
<g key={zone} onClick={() => handleZoneClick(zone)} className={readonly ? '' : 'cursor-pointer'}>
<rect
x={coords.x} y={coords.y} width={coords.w} height={coords.h}
rx={3}
fill={damage ? SEVERITY_COLORS[damage.severity] : 'transparent'}
fillOpacity={damage ? 0.45 : 0}
stroke={selected === zone ? '#1A56DB' : damage ? SEVERITY_COLORS[damage.severity] : 'transparent'}
strokeWidth={selected === zone ? 2 : 1}
className={readonly ? '' : 'hover:fill-blue-500/20 hover:stroke-blue-500'}
/>
{/* Damage indicator dot */}
{damage && (
<circle
cx={coords.x + coords.w / 2}
cy={coords.y + coords.h / 2}
r={4}
fill={SEVERITY_COLORS[damage.severity]}
stroke="white"
strokeWidth={1}
/>
)}
</g>
)
})}
</svg>
{/* Direction labels */}
<div className="absolute top-1 left-1/2 -translate-x-1/2 text-[10px] text-gray-400 font-medium">FRONT</div>
<div className="absolute bottom-1 left-1/2 -translate-x-1/2 text-[10px] text-gray-400 font-medium">REAR</div>
</div>
{/* Severity legend */}
<div className="flex flex-wrap gap-2 justify-center">
{(Object.entries(SEVERITY_COLORS) as [DamageZone['severity'], string][]).map(([s, color]) => (
<span key={s} className="flex items-center gap-1 text-xs text-gray-600">
<span className="w-3 h-3 rounded-full inline-block" style={{ backgroundColor: color }} />
{s}
</span>
))}
</div>
{/* Zone editor (appears when zone is clicked, in edit mode) */}
{selected && !readonly && (
<div className="w-full border border-blue-200 bg-blue-50 rounded-lg p-3">
<p className="text-sm font-medium text-blue-900 mb-2 capitalize">
{selected.replace(/-/g, ' ')}
</p>
<div className="flex gap-2 flex-wrap mb-2">
{(['SCRATCH', 'DENT', 'CRACK', 'MISSING', 'OTHER'] as const).map(s => (
<button key={s} onClick={() => setSeverity(s)}
className={`px-2 py-1 text-xs rounded font-medium border
${severity === s ? 'text-white border-transparent' : 'bg-white border-gray-200 text-gray-600'}`}
style={severity === s ? { backgroundColor: SEVERITY_COLORS[s], borderColor: SEVERITY_COLORS[s] } : {}}>
{s}
</button>
))}
</div>
<input type="text" placeholder="Note (optional)" value={note}
onChange={e => setNote(e.target.value)}
className="w-full text-sm border border-gray-200 rounded px-2 py-1 mb-2" />
<div className="flex gap-2">
<button onClick={applyDamage}
className="btn-primary text-xs flex-1">Mark Damage</button>
<button onClick={() => { clearZone(selected); setSelected(null) }}
className="btn-ghost text-xs">Clear Zone</button>
<button onClick={() => setSelected(null)}
className="btn-ghost text-xs text-gray-400">Cancel</button>
</div>
</div>
)}
{/* Damage list */}
{damages.length > 0 && (
<div className="w-full">
<p className="text-xs font-medium text-gray-500 mb-1">Marked damages ({damages.length})</p>
<div className="space-y-1">
{damages.map(d => (
<div key={d.zone} className="flex items-center justify-between text-xs bg-gray-50 rounded px-2 py-1">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: SEVERITY_COLORS[d.severity] }} />
<span className="capitalize font-medium">{d.zone.replace(/-/g, ' ')}</span>
<span className="text-gray-400"> {d.severity}</span>
{d.note && <span className="text-gray-400 italic">"{d.note}"</span>}
</div>
{!readonly && (
<button onClick={() => clearZone(d.zone)} className="text-red-400 hover:text-red-600 ml-2"></button>
)}
</div>
))}
</div>
</div>
)}
</div>
)
}
// Simplified SVG path for top-down car silhouette
function CarTopDownSVG({ primaryColor }: { primaryColor: string }) {
return (
<g>
{/* Outer car body */}
<rect x={30} y={4} width={100} height={157} rx={18} fill="#F3F4F6" stroke="#D1D5DB" strokeWidth={1.5} />
{/* Hood section */}
<rect x={38} y={8} width={84} height={32} rx={8} fill="#E5E7EB" />
{/* Windshield */}
<rect x={44} y={38} width={72} height={18} rx={4} fill="#BFDBFE" fillOpacity={0.8} />
{/* Roof / cabin */}
<rect x={36} y={54} width={88} height={58} rx={4} fill={primaryColor} fillOpacity={0.08} stroke={primaryColor} strokeWidth={0.5} strokeDasharray="3,2" />
{/* Rear window */}
<rect x={44} y={108} width={72} height={18} rx={4} fill="#BFDBFE" fillOpacity={0.8} />
{/* Trunk */}
<rect x={38} y={124} width={84} height={30} rx={8} fill="#E5E7EB" />
{/* Rear bumper */}
<rect x={42} y={152} width={76} height={9} rx={4} fill="#D1D5DB" />
{/* Front bumper */}
<rect x={42} y={4} width={76} height={9} rx={4} fill="#D1D5DB" />
{/* Mirrors */}
<rect x={16} y={52} width={16} height={10} rx={3} fill="#E5E7EB" stroke="#D1D5DB" strokeWidth={1} />
<rect x={128} y={52} width={16} height={10} rx={3} fill="#E5E7EB" stroke="#D1D5DB" strokeWidth={1} />
{/* Wheels */}
<rect x={10} y={24} width={22} height={28} rx={5} fill="#374151" />
<rect x={128} y={24} width={22} height={28} rx={5} fill="#374151" />
<rect x={10} y={110} width={22} height={28} rx={5} fill="#374151" />
<rect x={128} y={110} width={22} height={28} rx={5} fill="#374151" />
{/* Center line */}
<line x1={80} y1={54} x2={80} y2={112} stroke="#E5E7EB" strokeWidth={1} strokeDasharray="4,3" />
</g>
)
}
```
### Damage Map in the PDF Contract
In `RentalContractPDF.tsx`, add a damage section after the vehicle block using `@react-pdf/renderer` SVG primitives (the interactive React SVG won't work in the PDF renderer — use static `Svg`, `Rect`, `Circle`, `Line` elements from `@react-pdf/renderer`):
```tsx
// In RentalContractPDF.tsx — add after vehicle section
import { Svg, Rect, Circle, Line, Path, G, Text as SvgText } from '@react-pdf/renderer'
function DamageMapPDF({ damages, label }: { damages: DamageZone[]; label: string }) {
return (
<View>
<Text style={styles.sectionLabel}>{label}</Text>
<View style={{ flexDirection: 'row', gap: 20 }}>
{/* SVG car diagram — static top-down view */}
<Svg viewBox="0 0 160 165" width={100} height={105}>
{/* Repeat the CarTopDownSVG shapes using Svg primitives */}
<Rect x={30} y={4} width={100} height={157} rx={18} fill="#F3F4F6" stroke="#D1D5DB" strokeWidth={1.5} />
<Rect x={38} y={8} width={84} height={32} rx={8} fill="#E5E7EB" />
<Rect x={44} y={38} width={72} height={18} rx={4} fill="#BFDBFE" fillOpacity={0.6} />
<Rect x={38} y={54} width={84} height={58} rx={4} fill="#F9FAFB" stroke="#E5E7EB" strokeWidth={0.5} />
<Rect x={44} y={108} width={72} height={18} rx={4} fill="#BFDBFE" fillOpacity={0.6} />
<Rect x={38} y={124} width={84} height={30} rx={8} fill="#E5E7EB" />
<Rect x={10} y={24} width={22} height={28} rx={5} fill="#374151" />
<Rect x={128} y={24} width={22} height={28} rx={5} fill="#374151" />
<Rect x={10} y={110} width={22} height={28} rx={5} fill="#374151" />
<Rect x={128} y={110} width={22} height={28} rx={5} fill="#374151" />
{/* Damage markers */}
{damages.map((d, i) => {
const coords = DAMAGE_ZONE_COORDS[d.zone]
return (
<G key={i}>
<Rect x={coords.x} y={coords.y} width={coords.w} height={coords.h}
fill={SEVERITY_COLORS[d.severity]} fillOpacity={0.35}
stroke={SEVERITY_COLORS[d.severity]} strokeWidth={0.8} />
<Circle cx={coords.x + coords.w / 2} cy={coords.y + coords.h / 2}
r={3.5} fill={SEVERITY_COLORS[d.severity]} stroke="white" strokeWidth={0.7} />
</G>
)
})}
</Svg>
{/* Damage list */}
<View style={{ flex: 1 }}>
{damages.length === 0
? <Text style={{ fontSize: 8, color: '#10B981' }}> No damage recorded</Text>
: damages.map((d, i) => (
<View key={i} style={{ flexDirection: 'row', marginBottom: 2.5 }}>
<View style={{
width: 7, height: 7, borderRadius: 3.5, marginRight: 4, marginTop: 1,
backgroundColor: SEVERITY_COLORS[d.severity]
}} />
<Text style={{ fontSize: 7.5, flex: 1, color: '#374151' }}>
<Text style={{ fontWeight: 700 }}>{d.zone.replace(/-/g, ' ')}</Text>
{`${d.severity}${d.note ? ` (${d.note})` : ''}`}
</Text>
</View>
))
}
</View>
</View>
</View>
)
}
// Usage in the contract — two damage sections:
// 1. At check-in: <DamageMapPDF damages={checkInReport?.damages ?? []} label="Vehicle Condition at Pickup" />
// 2. At checkout: <DamageMapPDF damages={checkOutReport?.damages ?? []} label="Vehicle Condition at Return" />
// Both are shown on the contract, side by side if space, or stacked.
```
---
## 2. Insurance Policies
### Data model
```prisma
// ─── Insurance Policy ─────────────────────────────────────────
// Company configures insurance options available to renters.
// At booking, renter selects one (or NONE if all are optional).
model InsurancePolicy {
id String @id @default(cuid())
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
name String // e.g. "Basic Coverage", "Full Coverage", "CDW", "SCDW"
description String? // what it covers (shown to customer at booking)
type InsuranceType
// Pricing — one of the following applies:
chargeType InsuranceChargeType
chargeValue Int // in smallest currency unit
// Examples:
// chargeType=PER_DAY, chargeValue=3000 → 30 MAD/day
// chargeType=PER_RENTAL, chargeValue=15000 → 150 MAD flat
// chargeType=PERCENTAGE_OF_RENTAL, chargeValue=10 → 10% of base rental
isRequired Boolean @default(false) // if true, auto-applied, customer cannot opt out
isActive Boolean @default(true)
sortOrder Int @default(0) // display order at booking
reservations ReservationInsurance[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([companyId])
@@index([companyId, isActive])
@@map("insurance_policies")
}
enum InsuranceType {
CDW // Collision Damage Waiver
SCDW // Super CDW (lower excess)
THEFT // Theft protection
THIRD_PARTY // Third-party liability
FULL // Full coverage bundle
BASIC // Basic minimal coverage
ROADSIDE // Roadside assistance
PERSONAL // Personal accident insurance
CUSTOM // Company-defined name
}
enum InsuranceChargeType {
PER_DAY
PER_RENTAL // flat fee per reservation
PERCENTAGE_OF_RENTAL // % of base rental amount
}
// ─── Reservation Insurance ────────────────────────────────────
// Junction: which insurance(s) were selected for a reservation
model ReservationInsurance {
id String @id @default(cuid())
reservationId String
reservation Reservation @relation(fields: [reservationId], references: [id], onDelete: Cascade)
insurancePolicyId String
insurancePolicy InsurancePolicy @relation(fields: [insurancePolicyId], references: [id])
// Snapshot of policy at time of booking (prices may change later)
policyName String
policyType InsuranceType
chargeType InsuranceChargeType
chargeValue Int // locked at booking time
totalCharge Int // calculated total for this reservation (in smallest unit)
@@unique([reservationId, insurancePolicyId])
@@map("reservation_insurances")
}
```
Add to `Reservation` model:
```prisma
insurances ReservationInsurance[]
insuranceTotal Int @default(0) // sum of all insurance charges (cached)
```
### Insurance charge calculation
```typescript
// services/insuranceService.ts
export function calculateInsuranceCharge(
policy: InsurancePolicy,
totalDays: number,
baseRentalAmount: number // dailyRate × totalDays, in smallest unit
): number {
switch (policy.chargeType) {
case 'PER_DAY':
return policy.chargeValue * totalDays
case 'PER_RENTAL':
return policy.chargeValue
case 'PERCENTAGE_OF_RENTAL':
return Math.round(baseRentalAmount * policy.chargeValue / 100)
default:
return 0
}
}
export async function applyInsurancesToReservation(
reservationId: string,
companyId: string,
selectedPolicyIds: string[],
totalDays: number,
baseRentalAmount: number
) {
// Always include required policies
const allPolicies = await prisma.insurancePolicy.findMany({
where: { companyId, isActive: true }
})
const required = allPolicies.filter(p => p.isRequired)
const selected = allPolicies.filter(p => selectedPolicyIds.includes(p.id) && !p.isRequired)
const toApply = [...required, ...selected]
const records = toApply.map(policy => ({
reservationId,
insurancePolicyId: policy.id,
policyName: policy.name,
policyType: policy.type,
chargeType: policy.chargeType,
chargeValue: policy.chargeValue,
totalCharge: calculateInsuranceCharge(policy, totalDays, baseRentalAmount)
}))
const insuranceTotal = records.reduce((sum, r) => sum + r.totalCharge, 0)
await prisma.$transaction([
prisma.reservationInsurance.createMany({ data: records }),
prisma.reservation.update({
where: { id: reservationId },
data: { insuranceTotal }
})
])
return { records, insuranceTotal }
}
```
---
## 3. Second / Additional Driver
### Data model
```prisma
model AdditionalDriver {
id String @id @default(cuid())
reservationId String
reservation Reservation @relation(fields: [reservationId], references: [id], onDelete: Cascade)
companyId String // tenant scoping
firstName String
lastName String
email String?
phone String?
driverLicense String
licenseExpiry Date?
dateOfBirth DateTime?
nationality String?
// Charge for adding this driver
chargeType AdditionalDriverCharge @default(FREE)
chargeValue Int @default(0) // flat or per-day in smallest unit
totalCharge Int @default(0)
// Validation flags
licenseExpired Boolean @default(false)
licenseExpiringSoon Boolean @default(false) // < 3 months validity remaining
requiresApproval Boolean @default(false) // flagged for manual approval
approvedBy String? // Employee who approved a flagged license
approvedAt DateTime?
approvalNote String?
createdAt DateTime @default(now())
@@index([reservationId])
@@index([companyId])
@@map("additional_drivers")
}
enum AdditionalDriverCharge {
FREE
PER_DAY // charge per rental day
FLAT // one-time flat charge per reservation
}
```
Add to company `ContractSettings`:
```prisma
// Additional driver pricing (company sets these)
additionalDriverCharge AdditionalDriverCharge @default(FREE)
additionalDriverDailyRate Int @default(0)
additionalDriverFlatRate Int @default(0)
```
Add to `Reservation`:
```prisma
additionalDrivers AdditionalDriver[]
additionalDriverTotal Int @default(0)
```
---
## 4. Driver License Validation
### Fields on Customer model (additions)
```prisma
// Add to model Customer { ... }
licenseExpiry DateTime? // expiry date of driver's license
licenseIssuedAt DateTime? // issue date
licenseCountry String? // issuing country
licenseNumber String? // license number (may differ from driverLicense ID)
licenseCategory String? // e.g. "B", "B+E", "A" (for motorcycle)
// Validation flags
licenseExpired Boolean @default(false)
licenseExpiringSoon Boolean @default(false) // < 3 months validity
licenseValidationStatus LicenseStatus @default(PENDING)
licenseApprovedBy String? // Employee who approved/denied
licenseApprovedAt DateTime?
licenseApprovalNote String?
```
```prisma
enum LicenseStatus {
PENDING // not yet checked
VALID // checked and valid (>3 months remaining)
EXPIRING // flagged: <3 months remaining — awaiting employee decision
APPROVED // expired/expiring but manually approved by employee
DENIED // employee denied the booking due to license
EXPIRED // license has already expired
}
```
### License validation service
```typescript
// services/licenseValidationService.ts
const THREE_MONTHS_MS = 3 * 30 * 24 * 60 * 60 * 1000
export interface LicenseValidationResult {
status: 'VALID' | 'EXPIRING' | 'EXPIRED'
daysUntilExpiry: number | null
requiresApproval: boolean
message: string
}
export function validateLicense(licenseExpiry: Date | null): LicenseValidationResult {
if (!licenseExpiry) {
return {
status: 'VALID', // no expiry stored — cannot determine
daysUntilExpiry: null,
requiresApproval: false,
message: 'No expiry date on record'
}
}
const now = new Date()
const expiryMs = licenseExpiry.getTime()
const daysUntilExpiry = Math.ceil((expiryMs - now.getTime()) / (1000 * 60 * 60 * 24))
if (expiryMs <= now.getTime()) {
return {
status: 'EXPIRED',
daysUntilExpiry: daysUntilExpiry, // negative number
requiresApproval: true,
message: `License expired ${Math.abs(daysUntilExpiry)} day(s) ago`
}
}
if (expiryMs - now.getTime() < THREE_MONTHS_MS) {
return {
status: 'EXPIRING',
daysUntilExpiry,
requiresApproval: true,
message: `License expires in ${daysUntilExpiry} day(s) — approval required`
}
}
return {
status: 'VALID',
daysUntilExpiry,
requiresApproval: false,
message: `Valid — expires in ${daysUntilExpiry} day(s)`
}
}
// Called when creating/updating a customer or adding an additional driver
export async function validateAndFlagLicense(customerId: string) {
const customer = await prisma.customer.findUniqueOrThrow({
where: { id: customerId }
})
const result = validateLicense(customer.licenseExpiry)
const status: LicenseStatus =
result.status === 'EXPIRED' ? 'EXPIRED' :
result.status === 'EXPIRING' ? 'EXPIRING' : 'VALID'
await prisma.customer.update({
where: { id: customerId },
data: {
licenseExpired: result.status === 'EXPIRED',
licenseExpiringSoon: result.status === 'EXPIRING',
licenseValidationStatus: status,
}
})
return result
}
```
### License approval UI (dashboard)
When a reservation is created for a customer with `licenseValidationStatus: EXPIRING | EXPIRED`:
- Show a yellow (EXPIRING) or red (EXPIRED) banner in the reservation detail page
- Banner text: "⚠️ Customer's license expires in X days — please verify before confirming"
- Two action buttons: **[Approve and Continue]** / **[Deny Reservation]**
- Clicking Approve: `PATCH /customers/:id/license-approval { decision: 'APPROVE', note: '...' }`
- Clicking Deny: opens cancel reservation flow
---
## 5. Age-Based Pricing Rules
### Data model
```prisma
model PricingRule {
id String @id @default(cuid())
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
name String // e.g. "Young Driver Surcharge", "Senior Discount"
type PricingRuleType
condition PricingCondition
conditionValue Int // e.g. 25 for "age < 25", 70 for "age > 70"
adjustmentType PriceAdjustmentType
adjustmentValue Int // percentage or flat in smallest unit
// For LICENSE_YEARS condition: conditionValue = minimum years since license issue date
isActive Boolean @default(true)
description String? // shown to customer at booking
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([companyId])
@@map("pricing_rules")
}
enum PricingRuleType {
SURCHARGE // adds cost
DISCOUNT // reduces cost
}
enum PricingCondition {
AGE_LESS_THAN // customer age (years) < conditionValue
AGE_GREATER_THAN // customer age > conditionValue
LICENSE_YEARS_LESS_THAN // years since license issue < conditionValue
LICENSE_YEARS_GREATER_THAN
}
enum PriceAdjustmentType {
PERCENTAGE // % of base daily rate
FLAT_PER_DAY // flat amount per day in smallest unit
FLAT_TOTAL // flat amount per reservation
}
```
Add to `Reservation`:
```prisma
pricingRulesApplied Json? // Array of { ruleId, name, type, amount } — snapshot at booking
pricingRulesTotal Int @default(0) // net surcharges minus discounts
```
### Built-in rules (pre-configured defaults companies can edit)
```typescript
// The system ships with these as suggested defaults (companies can modify or delete):
const DEFAULT_PRICING_RULES = [
{
name: 'Young Driver Surcharge (Under 25)',
type: 'SURCHARGE',
condition: 'AGE_LESS_THAN',
conditionValue: 25,
adjustmentType: 'PERCENTAGE',
adjustmentValue: 15, // +15% of base rate
description: 'Additional charge for drivers under 25 years of age'
},
{
name: 'Experienced License Discount (5+ Years)',
type: 'DISCOUNT',
condition: 'LICENSE_YEARS_GREATER_THAN',
conditionValue: 5,
adjustmentType: 'PERCENTAGE',
adjustmentValue: 5, // -5% of base rate
description: 'Discount for drivers with 5+ years of driving experience'
},
]
```
### Rule application service
```typescript
// services/pricingRuleService.ts
export async function applyPricingRules(
companyId: string,
customerId: string,
additionalDrivers: { dateOfBirth?: Date | null; licenseIssuedAt?: Date | null }[],
dailyRate: number,
totalDays: number
): Promise<{ applied: any[]; total: number }> {
const rules = await prisma.pricingRule.findMany({
where: { companyId, isActive: true }
})
const customer = await prisma.customer.findUniqueOrThrow({ where: { id: customerId } })
const allDrivers = [customer, ...additionalDrivers]
const applied: any[] = []
const baseAmount = dailyRate * totalDays
for (const driver of allDrivers) {
for (const rule of rules) {
const driverAge = driver.dateOfBirth
? Math.floor((Date.now() - driver.dateOfBirth.getTime()) / (365.25 * 24 * 60 * 60 * 1000))
: null
const licenseYears = (driver as any).licenseIssuedAt
? Math.floor((Date.now() - (driver as any).licenseIssuedAt.getTime()) / (365.25 * 24 * 60 * 60 * 1000))
: null
let conditionMet = false
if (rule.condition === 'AGE_LESS_THAN' && driverAge !== null)
conditionMet = driverAge < rule.conditionValue
else if (rule.condition === 'AGE_GREATER_THAN' && driverAge !== null)
conditionMet = driverAge > rule.conditionValue
else if (rule.condition === 'LICENSE_YEARS_LESS_THAN' && licenseYears !== null)
conditionMet = licenseYears < rule.conditionValue
else if (rule.condition === 'LICENSE_YEARS_GREATER_THAN' && licenseYears !== null)
conditionMet = licenseYears > rule.conditionValue
if (!conditionMet) continue
let amount = 0
if (rule.adjustmentType === 'PERCENTAGE')
amount = Math.round(baseAmount * rule.adjustmentValue / 100)
else if (rule.adjustmentType === 'FLAT_PER_DAY')
amount = rule.adjustmentValue * totalDays
else
amount = rule.adjustmentValue
if (rule.type === 'DISCOUNT') amount = -amount
applied.push({ ruleId: rule.id, name: rule.name, type: rule.type, amount })
}
}
// Deduplicate: each rule type applied once even if multiple drivers trigger it
const deduped = applied.reduce((acc: any[], item) => {
if (!acc.find(a => a.ruleId === item.ruleId)) acc.push(item)
return acc
}, [])
const total = deduped.reduce((sum, r) => sum + r.amount, 0)
return { applied: deduped, total }
}
```
---
## 6. Fuel / Gasoline Policy (Structured)
Replace the free-text `fuelPolicy` string in `ContractSettings` with a structured enum.
```prisma
// In ContractSettings — replace fuelPolicy String with:
fuelPolicyType FuelPolicyType @default(FULL_TO_FULL)
fuelPolicyNote String? // optional extra note
prepaidFuelPrice Int? // price per liter in smallest unit (for PREPAID type)
```
```prisma
enum FuelPolicyType {
FULL_TO_FULL // Customer receives full, must return full. Extra charges if returned low.
FULL_TO_EMPTY // Customer receives full, keeps all fuel (no refund for unused fuel).
SAME_TO_SAME // Returns vehicle with same level as received.
PREPAID // Customer pays for a full tank upfront, returns at any level.
FREE // Fuel included in rental price.
}
```
```typescript
// lib/fuelPolicyLabels.ts
export const FUEL_POLICY_LABELS: Record<FuelPolicyType, Record<'en'|'fr'|'ar', string>> = {
FULL_TO_FULL: {
en: 'Full-to-Full: Return vehicle with a full tank. A refueling fee applies if returned with less fuel.',
fr: 'Plein-à-Plein: Retournez le véhicule avec le plein. Des frais de carburant s\'appliquent sinon.',
ar: 'ممتلئ إلى ممتلئ: يجب إعادة السيارة بخزان ممتلئ. رسوم إضافية في حال الإعادة بمستوى أقل.'
},
FULL_TO_EMPTY: {
en: 'Full-to-Empty: Vehicle is provided with a full tank. No refund for unused fuel upon return.',
fr: 'Plein-à-Vide: Le véhicule est fourni avec un plein. Aucun remboursement pour carburant non utilisé.',
ar: 'ممتلئ إلى فارغ: تُسلَّم السيارة بخزان ممتلئ. لا استرداد للوقود غير المستخدم.'
},
SAME_TO_SAME: {
en: 'Same-to-Same: Return vehicle with the same fuel level as at pickup.',
fr: 'Même niveau: Retournez avec le même niveau de carburant qu\'au départ.',
ar: 'نفس المستوى: أعد السيارة بنفس مستوى الوقود عند الاستلام.'
},
PREPAID: {
en: 'Prepaid Fuel: You pre-purchase a full tank at a fixed rate. Return at any fuel level.',
fr: 'Carburant prépayé: Vous prépayez un plein à tarif fixe. Retour à n\'importe quel niveau.',
ar: 'الوقود المدفوع مسبقاً: تدفع مقدماً لخزان كامل بسعر ثابت. العودة بأي مستوى.'
},
FREE: {
en: 'Fuel Included: Fuel cost is included in the rental price.',
fr: 'Carburant inclus: Le coût du carburant est inclus dans le tarif de location.',
ar: 'الوقود مشمول: تكلفة الوقود مشمولة في سعر الإيجار.'
}
}
```
---
## 7. Billing Period Reporting (Accountant Export)
Companies need to export their financial data by week, month, or year.
### Report model (optional — or generate entirely on-demand)
```prisma
// No model needed — reports are generated on-demand from Reservation + RentalPayment data
// The API query uses date range filters
```
### Report API
```typescript
// GET /api/v1/reports/financial
// Query params: period=WEEK|MONTH|YEAR, startDate, endDate, format=JSON|CSV
export async function generateFinancialReport(
companyId: string,
startDate: Date,
endDate: Date
) {
const reservations = await prisma.reservation.findMany({
where: {
companyId,
status: { in: ['CONFIRMED', 'ACTIVE', 'COMPLETED'] },
startDate: { gte: startDate },
endDate: { lte: endDate }
},
include: {
vehicle: { select: { make: true, model: true, year: true, licensePlate: true } },
customer: { select: { firstName: true, lastName: true, email: true } },
rentalPayments: { where: { status: 'SUCCEEDED' } },
insurances: true,
additionalDrivers: true,
},
orderBy: { startDate: 'asc' }
})
// Aggregate totals
const summary = {
totalReservations: reservations.length,
totalRentalRevenue: reservations.reduce((s, r) => s + r.dailyRate * r.totalDays, 0),
totalDiscounts: reservations.reduce((s, r) => s + r.discountAmount, 0),
totalInsurance: reservations.reduce((s, r) => s + r.insuranceTotal, 0),
totalAdditionalDrivers: reservations.reduce((s, r) => s + r.additionalDriverTotal, 0),
totalPricingRulesAdjustments: reservations.reduce((s, r) => s + r.pricingRulesTotal, 0),
totalDeposits: reservations.reduce((s, r) => s + r.depositAmount, 0),
totalCollected: reservations.reduce((s, r) => s + r.totalAmount, 0),
averageRentalDays: reservations.length > 0
? reservations.reduce((s, r) => s + r.totalDays, 0) / reservations.length : 0,
}
// Per-reservation rows for the table / CSV
const rows = reservations.map(r => ({
reservationId: r.id,
contractNumber: r.contractNumber ?? '—',
invoiceNumber: r.invoiceNumber ?? '—',
customerName: `${r.customer.firstName} ${r.customer.lastName}`,
vehicle: `${r.vehicle.year} ${r.vehicle.make} ${r.vehicle.model}`,
plate: r.vehicle.licensePlate,
startDate: r.startDate.toISOString().split('T')[0],
endDate: r.endDate.toISOString().split('T')[0],
days: r.totalDays,
dailyRate: r.dailyRate,
baseAmount: r.dailyRate * r.totalDays,
discount: r.discountAmount,
insurance: r.insuranceTotal,
additionalDriver: r.additionalDriverTotal,
pricingAdj: r.pricingRulesTotal,
deposit: r.depositAmount,
totalAmount: r.totalAmount,
paymentStatus: r.rentalPayments[0]?.status ?? 'UNPAID',
paymentMethod: r.rentalPayments[0]?.paymentMethod ?? '—',
source: r.source,
}))
return { summary, rows, period: { startDate, endDate } }
}
```
### CSV export
```typescript
// lib/csvExport.ts
export function toCsv(rows: Record<string, any>[]): string {
if (rows.length === 0) return ''
const headers = Object.keys(rows[0])
const lines = [
headers.join(','),
...rows.map(row =>
headers.map(h => {
const val = row[h] ?? ''
return typeof val === 'string' && val.includes(',') ? `"${val}"` : String(val)
}).join(',')
)
]
return lines.join('\n')
}
```
### Dashboard: Reports Page
Located at `/dashboard/reports` (new page).
```
┌─────────────────────────────────────────────────────┐
│ Financial Reports [Export CSV] │
│ │
│ Period: [This Week ▼] [This Month ▼] [This Year ▼] │
│ Or custom: [From date] → [To date] [Apply] │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Summary Cards (6 cards in a row) │ │
│ │ Total Bookings · Revenue · Discounts · Insurance│ │
│ │ Additional Drivers · Net Collected │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ Reservations Table (all in period) │
│ Contract# · Invoice# · Customer · Vehicle · Dates │
│ Days · Base · Discount · Insurance · AddDriver │
│ PricingAdj · Total · Payment Status · Source │
└─────────────────────────────────────────────────────┘
```
---
## 8. Platform Admin Panel
The platform administrator (RentalDriveGo team) manages all companies and their staff.
### Admin model
```prisma
model AdminUser {
id String @id @default(cuid())
email String @unique
firstName String
lastName String
passwordHash String
role AdminRole @default(SUPPORT)
isActive Boolean @default(true)
lastLoginAt DateTime?
auditLogs AdminAuditLog[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("admin_users")
}
enum AdminRole {
SUPER_ADMIN // full access: create/edit/delete companies, manage other admins
ADMIN // create/edit companies, manage staff, cannot delete or manage other admins
SUPPORT // read-only access to companies + reservations, can impersonate
}
model AdminAuditLog {
id String @id @default(cuid())
adminId String
admin AdminUser @relation(fields: [adminId], references: [id])
action String // e.g. "CREATE_COMPANY", "SUSPEND_COMPANY", "EDIT_EMPLOYEE"
targetType String // "Company" | "Employee" | "Reservation"
targetId String
before Json? // state before change
after Json? // state after change
ip String?
createdAt DateTime @default(now())
@@index([adminId])
@@index([targetType, targetId])
@@map("admin_audit_logs")
}
```
### Admin API routes
```
Base: https://admin.RentalDriveGo.com/api/v1/ (separate app)
OR: https://api.RentalDriveGo.com/api/v1/admin/ (preferred — same API, admin middleware)
Middleware: requireAdminAuth → attaches req.admin (AdminUser)
```
| Method | Path | Role | Description |
|--------|------|------|-------------|
| POST | `/admin/auth/login` | — | Admin login (email + password) |
| GET | `/admin/auth/me` | Any admin | Current admin profile |
| **Companies** | | | |
| GET | `/admin/companies` | Any | List all companies + search/filter |
| POST | `/admin/companies` | ADMIN+ | Create company (name, slug, email, plan) |
| GET | `/admin/companies/:id` | Any | Full company detail |
| PATCH | `/admin/companies/:id` | ADMIN+ | Edit company profile |
| PATCH | `/admin/companies/:id/status` | ADMIN+ | Change status (ACTIVE/SUSPENDED) |
| PATCH | `/admin/companies/:id/plan` | ADMIN+ | Change plan without payment |
| DELETE | `/admin/companies/:id` | SUPER_ADMIN | Hard delete company (irreversible) |
| POST | `/admin/companies/:id/impersonate` | ADMIN+ | Generate impersonation token |
| **Staff** | | | |
| GET | `/admin/companies/:id/employees` | Any | List employees |
| POST | `/admin/companies/:id/employees` | ADMIN+ | Add employee (name, email, role) |
| PATCH | `/admin/companies/:id/employees/:eid` | ADMIN+ | Edit employee |
| PATCH | `/admin/companies/:id/employees/:eid/role` | ADMIN+ | Change role |
| DELETE | `/admin/companies/:id/employees/:eid` | ADMIN+ | Deactivate employee |
| POST | `/admin/companies/:id/employees/:eid/reset-password` | ADMIN+ | Trigger password reset email |
| **Platform** | | | |
| GET | `/admin/metrics` | Any | MRR, ARR, churn, signups, active |
| GET | `/admin/admins` | SUPER_ADMIN | List admin users |
| POST | `/admin/admins` | SUPER_ADMIN | Create admin user |
| PATCH | `/admin/admins/:id` | SUPER_ADMIN | Edit admin |
| PATCH | `/admin/admins/:id/role` | SUPER_ADMIN | Change admin role |
| DELETE | `/admin/admins/:id` | SUPER_ADMIN | Deactivate admin |
| GET | `/admin/audit-log` | SUPER_ADMIN | Full audit log |
### Impersonation
Admins can impersonate any company employee for support purposes:
```typescript
// POST /admin/companies/:id/impersonate
// Returns a short-lived JWT that grants dashboard access as the company OWNER
// Duration: 30 minutes max
// All actions logged in AdminAuditLog
// Dashboard shows "⚠️ Impersonation Mode — RentalDriveGo Admin" banner
async function impersonateCompany(req, res) {
const company = await prisma.company.findUniqueOrThrow({
where: { id: req.params.id },
include: { employees: { where: { role: 'OWNER' } } }
})
await prisma.adminAuditLog.create({
data: {
adminId: req.admin.id,
action: 'IMPERSONATE_COMPANY',
targetType: 'Company',
targetId: company.id,
ip: req.ip
}
})
// Generate a short-lived JWT signed with JWT_SECRET
const token = jwt.sign(
{ companyId: company.id, employeeId: company.employees[0]?.id, isImpersonation: true },
process.env.JWT_SECRET!,
{ expiresIn: '30m' }
)
res.json({ data: { token, expiresIn: 1800 } })
}
```
### Admin UI (admin.RentalDriveGo.com)
Separate Next.js app with its own auth (email + password, NO Clerk).
| Page | Description |
|------|-------------|
| `/` → redirect to `/companies` | |
| `/login` | Admin login |
| `/companies` | Searchable table: name, slug, plan, status, employees count, vehicles count, MRR, created. Actions: Edit, Suspend, Impersonate |
| `/companies/new` | Create company form |
| `/companies/:id` | Company profile: overview, subscription, employees, vehicles, reservations, audit log |
| `/companies/:id/employees` | Employee list + add/edit/deactivate/change role |
| `/metrics` | Platform KPIs: MRR, ARR, churn rate, new signups chart, top companies |
| `/admins` | Admin user management (SUPER_ADMIN only) |
| `/audit-log` | Full audit log with filters |
---
## Invoice: Full Price Breakdown
The `CustomerInvoicePDF` line items now must include all charge types:
```typescript
// Full line items array for invoice generation
const lineItems = [
// 1. Base rental
{
description: `${vehicle.year} ${vehicle.make} ${vehicle.model}${totalDays} day(s) × ${fmt(dailyRate)}/day`,
qty: totalDays, unitPrice: dailyRate, total: dailyRate * totalDays,
category: 'RENTAL'
},
// 2. Insurance lines (one per policy selected)
...insurances.map(ins => ({
description: `${ins.policyName} (${insuranceChargeLabel(ins.chargeType, ins.chargeValue, totalDays, locale)})`,
qty: ins.chargeType === 'PER_DAY' ? totalDays : 1,
unitPrice: ins.chargeType === 'PER_DAY' ? ins.chargeValue : ins.totalCharge,
total: ins.totalCharge,
category: 'INSURANCE'
})),
// 3. Additional driver(s)
...additionalDrivers.filter(d => d.totalCharge > 0).map(d => ({
description: `Additional Driver — ${d.firstName} ${d.lastName}`,
qty: d.chargeType === 'PER_DAY' ? totalDays : 1,
unitPrice: d.chargeType === 'PER_DAY' ? d.chargeValue : d.totalCharge,
total: d.totalCharge,
category: 'ADDITIONAL_DRIVER'
})),
// 4. Pricing rule adjustments (surcharges as positive, discounts as negative)
...(pricingRulesApplied ?? []).map((rule: any) => ({
description: rule.name,
qty: 1, unitPrice: rule.amount, total: rule.amount,
category: rule.amount > 0 ? 'SURCHARGE' : 'DISCOUNT'
})),
// 5. Deposit (always last, marked as refundable)
...(depositAmount > 0 ? [{
description: locale === 'ar' ? 'تأمين (قابل للاسترداد)' : locale === 'fr' ? 'Caution (remboursable)' : 'Security Deposit (refundable)',
qty: 1, unitPrice: depositAmount, total: depositAmount,
category: 'DEPOSIT'
}] : []),
]
// Totals
const subtotal = dailyRate * totalDays
const discountTotal = -Math.abs(discountAmount + pricingDiscountsTotal)
const surchargeTotal = pricingSurchargesTotal + pricingRulesTotal
const insuranceTotal = insurances.reduce((s, i) => s + i.totalCharge, 0)
const additionalDriverTotal = additionalDrivers.reduce((s, d) => s + d.totalCharge, 0)
const taxAmount = showTax ? Math.round(subtotal * (taxRate ?? 0)) : 0
const grandTotal = subtotal + discountTotal + surchargeTotal + insuranceTotal + additionalDriverTotal + taxAmount + depositAmount
```
---
## Updated ContractSettings model (additions to existing)
```prisma
// ADD to model ContractSettings — replacing fuelPolicy String:
fuelPolicyType FuelPolicyType @default(FULL_TO_FULL)
fuelPolicyNote String?
// Additional driver config
additionalDriverCharge AdditionalDriverCharge @default(FREE)
additionalDriverDailyRate Int @default(0)
additionalDriverFlatRate Int @default(0)
```
## Updated Reservation model (additional fields)
```prisma
// ADD to model Reservation:
insurances ReservationInsurance[]
insuranceTotal Int @default(0)
additionalDrivers AdditionalDriver[]
additionalDriverTotal Int @default(0)
pricingRulesApplied Json? // snapshot of applied pricing rules
pricingRulesTotal Int @default(0)
damageReports DamageReport[]
```
## New Company relations (add to model Company)
```prisma
insurancePolicies InsurancePolicy[]
pricingRules PricingRule[]
```