--- 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 = { '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(null) const [severity, setSeverity] = useState('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 (
{/* SVG Car Diagram — top-down view */}
{/* Car body — top-down silhouette */} {/* Damage zone hit areas — transparent clickable rects */} {(Object.entries(DAMAGE_ZONE_COORDS) as [VehicleZone, any][]).map(([zone, coords]) => { const damage = getDamageForZone(zone) return ( handleZoneClick(zone)} className={readonly ? '' : 'cursor-pointer'}> {/* Damage indicator dot */} {damage && ( )} ) })} {/* Direction labels */}
FRONT
REAR
{/* Severity legend */}
{(Object.entries(SEVERITY_COLORS) as [DamageZone['severity'], string][]).map(([s, color]) => ( {s} ))}
{/* Zone editor (appears when zone is clicked, in edit mode) */} {selected && !readonly && (

{selected.replace(/-/g, ' ')}

{(['SCRATCH', 'DENT', 'CRACK', 'MISSING', 'OTHER'] as const).map(s => ( ))}
setNote(e.target.value)} className="w-full text-sm border border-gray-200 rounded px-2 py-1 mb-2" />
)} {/* Damage list */} {damages.length > 0 && (

Marked damages ({damages.length})

{damages.map(d => (
{d.zone.replace(/-/g, ' ')} — {d.severity} {d.note && "{d.note}"}
{!readonly && ( )}
))}
)}
) } // Simplified SVG path for top-down car silhouette function CarTopDownSVG({ primaryColor }: { primaryColor: string }) { return ( {/* Outer car body */} {/* Hood section */} {/* Windshield */} {/* Roof / cabin */} {/* Rear window */} {/* Trunk */} {/* Rear bumper */} {/* Front bumper */} {/* Mirrors */} {/* Wheels */} {/* Center line */} ) } ``` ### 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 ( {label} {/* SVG car diagram — static top-down view */} {/* Repeat the CarTopDownSVG shapes using Svg primitives */} {/* Damage markers */} {damages.map((d, i) => { const coords = DAMAGE_ZONE_COORDS[d.zone] return ( ) })} {/* Damage list */} {damages.length === 0 ? ✓ No damage recorded : damages.map((d, i) => ( {d.zone.replace(/-/g, ' ')} {` — ${d.severity}${d.note ? ` (${d.note})` : ''}`} )) } ) } // Usage in the contract — two damage sections: // 1. At check-in: // 2. At checkout: // 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> = { 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 { 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[] ```