49 KiB
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
- Vehicle Damage Inspection Map (contract car diagram)
- Insurance Policies & Per-Reservation Charges
- Second / Additional Driver
- Driver License Validation (expiry + 3-month rule + flagging)
- Age-Based Pricing Rules (25+ discount, license+5 surcharge)
- Fuel/Gasoline Policy (structured types)
- Billing Period Reporting (accountant export)
- 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
// 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:
damageReports DamageReport[]
// Remove old checkInFuelLevel/checkOutFuelLevel String? — replaced by DamageReport.fuelLevel
Damage zone TypeScript type
// 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)
// 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):
// 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
// ─── 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:
insurances ReservationInsurance[]
insuranceTotal Int @default(0) // sum of all insurance charges (cached)
Insurance charge calculation
// 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
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:
// Additional driver pricing (company sets these)
additionalDriverCharge AdditionalDriverCharge @default(FREE)
additionalDriverDailyRate Int @default(0)
additionalDriverFlatRate Int @default(0)
Add to Reservation:
additionalDrivers AdditionalDriver[]
additionalDriverTotal Int @default(0)
4. Driver License Validation
Fields on Customer model (additions)
// 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?
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
// 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
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:
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)
// 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
// 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.
// 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)
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.
}
// 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)
// No model needed — reports are generated on-demand from Reservation + RentalPayment data
// The API query uses date range filters
Report API
// 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
// 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
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:
// 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:
// 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)
// 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)
// 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)
insurancePolicies InsurancePolicy[]
pricingRules PricingRule[]