From 48831988dd54d7222ee9f091b8876fba3ada16c7 Mon Sep 17 00:00:00 2001 From: Jit Corn Leow Date: Mon, 9 Feb 2026 23:54:10 +0800 Subject: [PATCH 1/3] fix: consistent category labels across insurance tabs Align PoliciesTab labels with JourneyTab naming convention: Life/TPD, Hospitalization, Critical Illness, Disability, Personal Accident. Removes stale comma-separated variants and "LTC" suffix. Relates to #189 Co-Authored-By: Claude Opus 4.6 --- .../components/insurance/tabs/PoliciesTab.tsx | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/insurance/tabs/PoliciesTab.tsx b/frontend/src/components/insurance/tabs/PoliciesTab.tsx index 6ab7743f..91230c30 100644 --- a/frontend/src/components/insurance/tabs/PoliciesTab.tsx +++ b/frontend/src/components/insurance/tabs/PoliciesTab.tsx @@ -80,24 +80,24 @@ function annualizePremium(amount: number, frequency: string): number { function formatCategoryLabel(category: string, subcategory: string | null): string { const labels: Record = { - life: 'Life, TPD', + life: 'Life/TPD', health: 'Hospitalization', critical_illness: 'Critical Illness', - long_term_care: 'Long-Term Care', + long_term_care: 'Disability', personal_accident: 'Personal Accident', } if (subcategory) { const subLabels: Record = { - term_life: 'Life, TPD', - whole_life: 'Life, TPD', - ilp: 'Life, ILP', + term_life: 'Life/TPD', + whole_life: 'Life/TPD', + ilp: 'Life/ILP', isp: 'Hospitalization', medishield: 'Hospitalization', - early_ci: 'Critical Illness, Early CI', + early_ci: 'Critical Illness', late_ci: 'Critical Illness', multi_pay: 'Critical Illness', - careshield: 'Long-Term Care', - ltc_supplement: 'Long-Term Care', + careshield: 'Disability', + ltc_supplement: 'Disability', pa: 'Personal Accident', } if (subLabels[subcategory]) return subLabels[subcategory] @@ -424,10 +424,10 @@ function BeneficiaryFilter({ // Values match what AddPolicyModal stores in the DB: // life, health, critical_illness, long_term_care, personal_accident const COVERAGE_CATEGORIES = [ - { value: 'life', label: 'Life / TPD' }, + { value: 'life', label: 'Life/TPD' }, { value: 'health', label: 'Hospitalization' }, { value: 'critical_illness', label: 'Critical Illness' }, - { value: 'long_term_care', label: 'Disability / LTC' }, + { value: 'long_term_care', label: 'Disability' }, { value: 'personal_accident', label: 'Personal Accident' }, ] as const From 512fe8321b583921ae6c5dc46d906cd534b51253 Mon Sep 17 00:00:00 2001 From: Jit Corn Leow Date: Tue, 10 Feb 2026 19:49:00 +0800 Subject: [PATCH 2/3] fix: remove redundant minus sign from coverage gap display Accounting-style brackets already indicate negative values, so the extra minus sign was duplicating the negative indicator. Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/insurance/tabs/JourneyTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/insurance/tabs/JourneyTab.tsx b/frontend/src/components/insurance/tabs/JourneyTab.tsx index 7cc5cd0e..673f3618 100644 --- a/frontend/src/components/insurance/tabs/JourneyTab.tsx +++ b/frontend/src/components/insurance/tabs/JourneyTab.tsx @@ -956,7 +956,7 @@ function DarkCoverageBreakdownCard({ className={cn(T.bodyText, 'w-24 text-right')} style={{ color: perPersonGap > 0 ? DARK_PALETTE.red : DARK_PALETTE.green }} > - {perPersonGap > 0 ? `(\u2212${formatCoverageAmount(perPersonGap)})` : 'OK'} + {perPersonGap > 0 ? `(${formatCoverageAmount(perPersonGap)})` : 'OK'} ) From 7a0d87079a080c456450409b7379593211dcebe3 Mon Sep 17 00:00:00 2001 From: Jit Corn Leow Date: Tue, 10 Feb 2026 19:49:14 +0800 Subject: [PATCH 3/3] feat: add vehicle planner module with SG ownership cost calculator Adds a full vehicle planner for Singapore car ownership costs including: - COE, ARF, and registration fee calculations - Loan financing with interest breakdown - 10-year depreciation schedule (PARF vs COE rebate) - Running costs (road tax, insurance, fuel, maintenance, parking) - Scenario comparison for different vehicles - Integration into dashboard module menu Co-Authored-By: Claude Opus 4.6 --- frontend/src/app/vehicle-planner/page.tsx | 255 ++++++ .../src/components/dashboard/Dashboard.tsx | 28 +- .../dashboard/FinancialWorkspace.tsx | 29 +- .../vehicle-planner/VehiclePlannerTabs.tsx | 63 ++ .../vehicle-planner/tabs/CostBreakdownTab.tsx | 243 ++++++ .../vehicle-planner/tabs/DepreciationTab.tsx | 220 +++++ .../vehicle-planner/tabs/ScenariosTab.tsx | 298 +++++++ .../vehicle-planner/tabs/TotalCostTab.tsx | 261 ++++++ .../tabs/VehicleFinancingTab.tsx | 489 ++++++++++++ frontend/src/lib/vehicle/calculations.ts | 399 +++++++++ frontend/src/lib/vehicle/constants.ts | 127 +++ frontend/src/lib/vehicle/formatting.ts | 67 ++ frontend/src/stores/featureModulesStore.ts | 9 + frontend/src/stores/vehiclePlannerStore.ts | 240 ++++++ frontend/src/types/vehicle.ts | 132 +++ spec/vehicle-planner.md | 754 ++++++++++++++++++ 16 files changed, 3602 insertions(+), 12 deletions(-) create mode 100644 frontend/src/app/vehicle-planner/page.tsx create mode 100644 frontend/src/components/vehicle-planner/VehiclePlannerTabs.tsx create mode 100644 frontend/src/components/vehicle-planner/tabs/CostBreakdownTab.tsx create mode 100644 frontend/src/components/vehicle-planner/tabs/DepreciationTab.tsx create mode 100644 frontend/src/components/vehicle-planner/tabs/ScenariosTab.tsx create mode 100644 frontend/src/components/vehicle-planner/tabs/TotalCostTab.tsx create mode 100644 frontend/src/components/vehicle-planner/tabs/VehicleFinancingTab.tsx create mode 100644 frontend/src/lib/vehicle/calculations.ts create mode 100644 frontend/src/lib/vehicle/constants.ts create mode 100644 frontend/src/lib/vehicle/formatting.ts create mode 100644 frontend/src/stores/vehiclePlannerStore.ts create mode 100644 frontend/src/types/vehicle.ts create mode 100644 spec/vehicle-planner.md diff --git a/frontend/src/app/vehicle-planner/page.tsx b/frontend/src/app/vehicle-planner/page.tsx new file mode 100644 index 00000000..b4757e25 --- /dev/null +++ b/frontend/src/app/vehicle-planner/page.tsx @@ -0,0 +1,255 @@ +'use client' + +import { useEffect } from 'react' +import { useRouter } from 'next/navigation' +import { X } from 'lucide-react' +import clsx from 'clsx' + +import { VehiclePlannerTabs } from '@/components/vehicle-planner/VehiclePlannerTabs' +import { VehicleFinancingTab } from '@/components/vehicle-planner/tabs/VehicleFinancingTab' +import { CostBreakdownTab } from '@/components/vehicle-planner/tabs/CostBreakdownTab' +import { DepreciationTab } from '@/components/vehicle-planner/tabs/DepreciationTab' +import { TotalCostTab } from '@/components/vehicle-planner/tabs/TotalCostTab' +import { ScenariosTab } from '@/components/vehicle-planner/tabs/ScenariosTab' +import { useColorScheme } from '@/stores' +import { + useVehiclePlannerStore, + useVehiclePlannerActions, + useVehicleActiveTab, + useActiveVehicleScenario, +} from '@/stores/vehiclePlannerStore' + +// ============================================================================ +// THEME-AWARE DESIGN (reuses insurance planner theme palette) +// ============================================================================ + +const monetColors = { + bgCream: '#FAF8F5', + bgPaleBlue: '#F0F4F8', + bgWarmWhite: '#FFFEF9', + textPrimary: '#3D3D3D', + textSecondary: '#6B6B6B', + amber: '#D4A574', + amberLight: '#E8D4BC', + sunlightGoldLight: '#EDE6D8', +} + +const darkColors = { + textPrimary: '#f1f5f9', + textSecondary: '#94a3b8', + primary: '#fbbf24', + accent: '#f59e0b', +} + +const canvasTexture = `url("data:image/svg+xml,%3Csvg viewBox='0 0 400 400' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E")` + +// ─── Embedded view (Dashboard takeover) ────────────── + +export function VehiclePlannerView({ onClose }: { onClose?: () => void }) { + const activeTab = useVehicleActiveTab() + const activeScenario = useActiveVehicleScenario() + const scenarios = useVehiclePlannerStore((s) => s.scenarios) + const { setActiveTab, addScenario } = useVehiclePlannerActions() + const colorScheme = useColorScheme() + const isMonet = colorScheme === 'monet' + const colors = isMonet ? monetColors : darkColors + + // Auto-create first scenario if none exist + useEffect(() => { + if (scenarios.length === 0) { + addScenario('My Vehicle') + } + }, [scenarios.length, addScenario]) + + return ( +
+ {/* Canvas texture overlay */} +
+ + {/* Ambient light effects */} +
+ + {/* Title + Tabs */} +
+
+

+ Vehicle Planner +

+
+ {onClose && ( + + )} +
+
+ + +
+ + {/* Main Content */} +
+ {activeTab === 'vehicle' && } + {activeTab === 'costs' && } + {activeTab === 'depreciation' && } + {activeTab === 'tco' && } + {activeTab === 'scenarios' && } + {!activeScenario && scenarios.length === 0 && ( +
+

Loading...

+
+ )} +
+
+ ) +} + +// ─── Standalone page ───────────────────────────────── + +export default function VehiclePlannerPage() { + const router = useRouter() + const activeTab = useVehicleActiveTab() + const activeScenario = useActiveVehicleScenario() + const scenarios = useVehiclePlannerStore((s) => s.scenarios) + const { setActiveTab, addScenario } = useVehiclePlannerActions() + const colorScheme = useColorScheme() + const isMonet = colorScheme === 'monet' + const colors = isMonet ? monetColors : darkColors + + const handleClose = () => { + router.push('/dashboard') + } + + useEffect(() => { + if (scenarios.length === 0) { + addScenario('My Vehicle') + } + }, [scenarios.length, addScenario]) + + return ( +
+ {/* Canvas texture overlay */} +
+ + {/* Ambient light effects */} +
+
+ + {/* Title + Tabs */} +
+
+

+ Vehicle Planner +

+
+ +
+
+ + +
+ + {/* Main Content */} +
+
+ {activeTab === 'vehicle' && } + {activeTab === 'costs' && } + {activeTab === 'depreciation' && } + {activeTab === 'tco' && } + {activeTab === 'scenarios' && } + {!activeScenario && scenarios.length === 0 && ( +
+

Loading...

+
+ )} +
+
+
+ ) +} diff --git a/frontend/src/components/dashboard/Dashboard.tsx b/frontend/src/components/dashboard/Dashboard.tsx index 3d0825d4..74bdb494 100644 --- a/frontend/src/components/dashboard/Dashboard.tsx +++ b/frontend/src/components/dashboard/Dashboard.tsx @@ -37,6 +37,11 @@ const InsurancePlannerView = dynamic( { ssr: false, loading: FeatureModuleLoading } ) +const VehiclePlannerView = dynamic( + () => import('@/app/vehicle-planner/page').then(mod => ({ default: mod.VehiclePlannerView })), + { ssr: false, loading: FeatureModuleLoading } +) + import { usePersonsQuery } from '@/hooks/queries/usePersonsQuery' import { useTimeline } from '@/hooks/useTimeline' import { usePictureInPicture } from '@/hooks/usePictureInPicture' @@ -61,6 +66,8 @@ export function Dashboard() { closeCPFView, showInsurancePlanner, closeInsurancePlanner, + showVehiclePlanner, + closeVehiclePlanner, showPropertyPlanner, propertyScenarioToEdit, openPropertyPlanner, @@ -87,6 +94,8 @@ export function Dashboard() { closeCPFView: s.closeCPFView, showInsurancePlanner: s.showInsurancePlanner, closeInsurancePlanner: s.closeInsurancePlanner, + showVehiclePlanner: s.showVehiclePlanner, + closeVehiclePlanner: s.closeVehiclePlanner, showPropertyPlanner: s.showPropertyPlanner, propertyScenarioToEdit: s.propertyScenarioToEdit, openPropertyPlanner: s.openPropertyPlanner, @@ -367,6 +376,23 @@ gap-6 p-6`}>
+ ) : showVehiclePlanner ? ( + /* Vehicle Planner View - shows header + vehicle planner */ + <> +
+ +
+
+ +
+ ) : isSideBySide ? ( /* Side-by-side layout: chart-left or chart-right */ <> @@ -423,7 +449,7 @@ gap-6 p-6`}>
{/* Picture-in-Picture mini chart - disabled in side-by-side layouts */} - {showPiP && !showCPFView && !showInsurancePlanner && !isSideBySide && ( + {showPiP && !showCPFView && !showInsurancePlanner && !showVehiclePlanner && !isSideBySide && ( ({ openCPFView: s.openCPFView, openPropertyPlanner: s.openPropertyPlanner, openInsurancePlanner: s.openInsurancePlanner, + openVehiclePlanner: s.openVehiclePlanner, openLayoutModal: s.openLayoutModal, })) ) @@ -301,18 +303,23 @@ export function FinancialWorkspace({

Analyze coverage gaps and plan your protection.

- {/* Coming Soon Modules */} -
-
- - - -
-
Vehicle Purchase
-

Coming soon

-
+ {/* Vehicle Planner */} +
+
diff --git a/frontend/src/components/vehicle-planner/VehiclePlannerTabs.tsx b/frontend/src/components/vehicle-planner/VehiclePlannerTabs.tsx new file mode 100644 index 00000000..9d6dd696 --- /dev/null +++ b/frontend/src/components/vehicle-planner/VehiclePlannerTabs.tsx @@ -0,0 +1,63 @@ +'use client' + +import { Car, Receipt, TrendingDown, Calculator, GitCompareArrows } from 'lucide-react' +import { useColorScheme } from '@/stores' +import type { VehicleTabId } from '@/types/vehicle' + +interface VehiclePlannerTabsProps { + activeTab: VehicleTabId + onTabChange: (tab: VehicleTabId) => void +} + +const tabs: { id: VehicleTabId; label: string; icon: typeof Car }[] = [ + { id: 'vehicle', label: 'Vehicle & Financing', icon: Car }, + { id: 'costs', label: 'Cost Breakdown', icon: Receipt }, + { id: 'depreciation', label: 'Depreciation', icon: TrendingDown }, + { id: 'tco', label: 'Total Cost', icon: Calculator }, + { id: 'scenarios', label: 'Scenarios', icon: GitCompareArrows }, +] + +export function VehiclePlannerTabs({ activeTab, onTabChange }: VehiclePlannerTabsProps) { + const colorScheme = useColorScheme() + const isMonet = colorScheme === 'monet' + + return ( +
+ +
+ ) +} diff --git a/frontend/src/components/vehicle-planner/tabs/CostBreakdownTab.tsx b/frontend/src/components/vehicle-planner/tabs/CostBreakdownTab.tsx new file mode 100644 index 00000000..31911b58 --- /dev/null +++ b/frontend/src/components/vehicle-planner/tabs/CostBreakdownTab.tsx @@ -0,0 +1,243 @@ +'use client' + +import { useMemo } from 'react' +import { Info, TrendingDown, TrendingUp } from 'lucide-react' +import clsx from 'clsx' +import { useTheme } from '@/lib/theme' +import { formatCurrency } from '@/lib/format' +import { numericStyles } from '@/lib/utils' +import { + useActiveVehicleScenario, +} from '@/stores/vehiclePlannerStore' +import { calculateVehicle } from '@/lib/vehicle/calculations' +import { getVesBandLabel, formatFuelType } from '@/lib/vehicle/formatting' + +export function CostBreakdownTab() { + const { theme, isMonet } = useTheme() + const scenario = useActiveVehicleScenario() + + const calculation = useMemo(() => { + if (!scenario) return null + return calculateVehicle(scenario.inputs, scenario.recurringCosts) + }, [scenario]) + + if (!scenario || !calculation) { + return ( +
+

No scenario selected.

+
+ ) + } + + const { inputs } = scenario + const isUsed = inputs.condition === 'used' + + if (isUsed) { + return ( +
+
+
+ +

+ Used Vehicle +

+
+

+ For used vehicles, the registration cost breakdown is not applicable — the purchase price + is the all-in cost. Check the Depreciation and Total Cost tabs for ownership analysis. +

+
+ Purchase Price + + {formatCurrency(inputs.listPrice)} + +
+
+
+ ) + } + + // Build waterfall data + const waterfallItems = [ + { label: 'OMV', value: inputs.omv, color: isMonet ? '#7BA3C9' : '#3b82f6' }, + { label: 'Excise Duty', value: calculation.exciseDuty, color: isMonet ? '#7BA3C9' : '#60a5fa' }, + { label: 'GST', value: calculation.gst, color: isMonet ? '#9B8BB4' : '#8b5cf6' }, + { label: 'ARF', value: calculation.arf, color: isMonet ? '#E8A898' : '#f43f5e' }, + { label: 'COE', value: inputs.coePrice, color: isMonet ? '#D4A574' : '#f59e0b' }, + ...(calculation.vesAmount !== 0 + ? [{ label: `VES ${calculation.vesAmount < 0 ? 'Rebate' : 'Surcharge'}`, value: calculation.vesAmount, color: calculation.vesAmount < 0 ? '#10b981' : '#ef4444' }] + : []), + ...(calculation.eeaiRebate !== 0 + ? [{ label: 'EEAI Rebate', value: calculation.eeaiRebate, color: '#10b981' }] + : []), + { label: 'Reg Fee', value: calculation.registrationFee, color: isMonet ? '#9B9B9B' : '#64748b' }, + ] + + const maxValue = calculation.totalRegistrationCost + const positiveTotal = waterfallItems.filter(i => i.value > 0).reduce((sum, i) => sum + i.value, 0) + + // Calculate percentages for the pie-chart-style breakdown + const pieItems = waterfallItems + .filter(i => i.value > 0) + .map(i => ({ + ...i, + percentage: (i.value / positiveTotal) * 100, + })) + + const vesBandLabel = getVesBandLabel(inputs.co2EmissionsGkm, inputs.fuelType, inputs.vesPeriod) + + return ( +
+ {/* Horizontal Bar Breakdown */} +
+

+ Registration Cost Waterfall +

+ +
+ {waterfallItems.map((item) => { + const barWidth = Math.max(2, Math.abs(item.value) / maxValue * 100) + const isNegative = item.value < 0 + + return ( +
+ + {item.label} + +
+
+ {barWidth > 15 && ( + + {isNegative ? `(${formatCurrency(Math.abs(item.value))})` : formatCurrency(item.value)} + + )} +
+
+ {barWidth <= 15 && ( + + {isNegative ? `(${formatCurrency(Math.abs(item.value))})` : formatCurrency(item.value)} + + )} +
+ ) + })} + + {/* Total bar */} +
+ + Total + +
+ + {formatCurrency(calculation.totalRegistrationCost)} + +
+
+
+
+ + {/* Composition Strip */} +
+

+ Cost Composition +

+
+ {pieItems.map((item) => ( +
+ ))} +
+
+ {pieItems.map((item) => ( +
+
+ + {item.label} ({item.percentage.toFixed(0)}%) + +
+ ))} +
+
+ + {/* VES + EEAI Callouts */} +
+ {/* VES Card */} +
+
+ {calculation.vesAmount <= 0 + ? + : + } +

+ VES ({vesBandLabel}) +

+
+

+ {calculation.vesAmount === 0 + ? 'Neutral' + : calculation.vesAmount < 0 + ? `(${formatCurrency(Math.abs(calculation.vesAmount))}) rebate` + : `${formatCurrency(calculation.vesAmount)} surcharge` + } +

+

+ CO₂: {inputs.co2EmissionsGkm ?? 0} g/km · {formatFuelType(inputs.fuelType)} +

+
+ + {/* EEAI Card */} +
+
+ +

+ EEAI (EV Incentive) +

+
+ {calculation.eeaiRebate !== 0 ? ( + <> +

+ ({formatCurrency(Math.abs(calculation.eeaiRebate))}) rebate +

+

+ 45% of ARF, capped per VES period +

+ + ) : ( +

+ {inputs.fuelType === 'electric' ? 'Not available for this VES period' : 'EV only incentive'} +

+ )} +
+
+
+ ) +} diff --git a/frontend/src/components/vehicle-planner/tabs/DepreciationTab.tsx b/frontend/src/components/vehicle-planner/tabs/DepreciationTab.tsx new file mode 100644 index 00000000..2b1fb09d --- /dev/null +++ b/frontend/src/components/vehicle-planner/tabs/DepreciationTab.tsx @@ -0,0 +1,220 @@ +'use client' + +import { useMemo } from 'react' +import { Star } from 'lucide-react' +import clsx from 'clsx' +import { useTheme } from '@/lib/theme' +import { formatCurrency } from '@/lib/format' +import { numericStyles } from '@/lib/utils' +import { + useActiveVehicleScenario, +} from '@/stores/vehiclePlannerStore' +import { calculateVehicle } from '@/lib/vehicle/calculations' + +export function DepreciationTab() { + const { theme, isMonet } = useTheme() + const scenario = useActiveVehicleScenario() + + const calculation = useMemo(() => { + if (!scenario) return null + return calculateVehicle(scenario.inputs, scenario.recurringCosts) + }, [scenario]) + + if (!scenario || !calculation) { + return ( +
+

No scenario selected.

+
+ ) + } + + const { depreciationSchedule } = calculation + const startingValue = scenario.inputs.condition === 'used' + ? scenario.inputs.listPrice + : calculation.totalRegistrationCost + + // Find optimal deregistration year (smallest gap between market value and scrap value) + const optimalYear = depreciationSchedule.reduce((best, entry) => { + const gap = entry.marketValue - entry.scrapValue + const bestGap = best.marketValue - best.scrapValue + return gap < bestGap && entry.scrapValue > 0 ? entry : best + }, depreciationSchedule[0]) + + // Max value for chart scaling + const maxChartValue = startingValue + + return ( +
+ {/* Dual-line chart (bar approximation) */} +
+
+

+ Market Value vs Scrap Value +

+
+
+
+ Market Value +
+
+
+ Scrap Value +
+
+
+ +
+ {/* Year 0 */} +
+
+
+
+ 0 +
+ + {depreciationSchedule.map((entry) => { + const marketHeight = (entry.marketValue / maxChartValue) * 100 + const scrapHeight = (entry.scrapValue / maxChartValue) * 100 + const isOptimal = entry.year === optimalYear.year + + return ( +
+ {isOptimal && ( + + )} +
+
+
+
+ + {entry.year} + +
+ ) + })} +
+
+ + {/* Optimal Deregistration Callout */} +
+ +
+

+ Optimal Deregistration: Year {optimalYear.year} +

+

+ Market value {formatCurrency(optimalYear.marketValue)} vs scrap value {formatCurrency(optimalYear.scrapValue)}. + The gap is smallest at year {optimalYear.year}, making it the most cost-efficient time to deregister. +

+
+
+ + {/* Year-by-Year Table */} +
+
+

+ Depreciation Schedule +

+
+
+ + + + + + + + + + + + + + {/* Year 0 row */} + + + + + + + + + + {depreciationSchedule.map((entry) => { + const isOptimal = entry.year === optimalYear.year + return ( + + + + + + + + + + ) + })} + +
YearMarket ValuePARF RebateCOE RebateScrap ValueAnnual Dep.Cumulative Dep.
0{formatCurrency(startingValue)}
+ {entry.year} {isOptimal && '★'} + {formatCurrency(entry.marketValue)} + {entry.parfRebate > 0 ? formatCurrency(entry.parfRebate) : } + + {entry.coeRebate > 0 ? formatCurrency(entry.coeRebate) : } + + {formatCurrency(entry.scrapValue)} + + ({formatCurrency(entry.annualDepreciation)}) + + ({formatCurrency(entry.cumulativeDepreciation)}) +
+
+
+
+ ) +} diff --git a/frontend/src/components/vehicle-planner/tabs/ScenariosTab.tsx b/frontend/src/components/vehicle-planner/tabs/ScenariosTab.tsx new file mode 100644 index 00000000..45678988 --- /dev/null +++ b/frontend/src/components/vehicle-planner/tabs/ScenariosTab.tsx @@ -0,0 +1,298 @@ +'use client' + +import { useMemo } from 'react' +import { Plus, Copy, Trash2 } from 'lucide-react' +import clsx from 'clsx' +import { useTheme } from '@/lib/theme' +import { formatCurrency } from '@/lib/format' +import { numericStyles } from '@/lib/utils' +import { + useVehicleScenarios, + useActiveVehicleScenario, + useVehicleOwnershipYears, + useVehiclePlannerActions, +} from '@/stores/vehiclePlannerStore' +import { calculateVehicle } from '@/lib/vehicle/calculations' +import { formatVehicleCategoryShort, formatFuelType } from '@/lib/vehicle/formatting' + +export function ScenariosTab() { + const { theme, isMonet } = useTheme() + const scenarios = useVehicleScenarios() + const activeScenario = useActiveVehicleScenario() + const ownershipYears = useVehicleOwnershipYears() + const { + addScenario, + deleteScenario, + duplicateScenario, + setActiveScenario, + toggleIncluded, + } = useVehiclePlannerActions() + + const accentColor = isMonet ? '#D4A574' : '#fbbf24' + + // Calculate all scenarios + const scenarioCalculations = useMemo(() => { + return scenarios.map((scenario) => ({ + scenario, + calculation: calculateVehicle(scenario.inputs, scenario.recurringCosts, ownershipYears), + })) + }, [scenarios, ownershipYears]) + + + return ( +
+ {/* Scenario Cards */} +
+

+ Vehicle Scenarios ({scenarios.length}) +

+ +
+ + {/* Scenario Cards Grid */} +
+ {scenarioCalculations.map(({ scenario, calculation }) => { + const isActive = scenario.id === activeScenario?.id + const tco = calculation.totalCostOfOwnership + const isNew = scenario.inputs.condition === 'new' + + return ( +
setActiveScenario(scenario.id)} + > +
+
+

+ {scenario.name} +

+

+ {formatVehicleCategoryShort(scenario.inputs.vehicleCategory)} · {formatFuelType(scenario.inputs.fuelType)} · {isNew ? 'New' : 'Used'} +

+
+
+ + {scenarios.length > 1 && ( + + )} +
+
+ + {/* Key metrics */} +
+ + {scenario.inputs.useFinancing && ( + + )} + + + +
+ + {/* Include Toggle */} +
+ Include in planning + +
+
+ ) + })} +
+ + {/* Side-by-Side Comparison Table */} + {scenarioCalculations.length >= 2 && ( +
+
+

+ Side-by-Side Comparison +

+
+
+ + + + + {scenarioCalculations.map(({ scenario }) => ( + + ))} + + + + + scenario.inputs.condition === 'new' ? calculation.totalRegistrationCost : scenario.inputs.listPrice + )} + theme={theme} + isMonet={isMonet} + /> + calculation.monthlyInstallment)} + theme={theme} + isMonet={isMonet} + /> + calculation.annualRoadTax)} + theme={theme} + isMonet={isMonet} + /> + calculation.totalCostOfOwnership.netTotalCost)} + theme={theme} + isMonet={isMonet} + highlightLowest + /> + calculation.totalCostOfOwnership.costPerMonth)} + theme={theme} + isMonet={isMonet} + highlightLowest + /> + calculation.totalCostOfOwnership.costPerYear)} + theme={theme} + isMonet={isMonet} + highlightLowest + /> + calculation.totalCostOfOwnership.residualValue)} + theme={theme} + isMonet={isMonet} + highlightHighest + /> + +
Metric + {scenario.name} +
+
+
+ )} +
+ ) +} + +// ─── Helper Components ──────────────────────────────── + +function MetricRow({ + label, + value, + theme, + accent, +}: { + label: string + value: number + theme: ReturnType['theme'] + accent?: string +}) { + return ( +
+ {label} + + {formatCurrency(value)} + +
+ ) +} + +function ComparisonRow({ + label, + values, + theme, + highlightLowest, + highlightHighest, +}: { + label: string + values: number[] + theme: ReturnType['theme'] + isMonet?: boolean + highlightLowest?: boolean + highlightHighest?: boolean +}) { + const minValue = Math.min(...values.filter(v => v > 0)) + const maxValue = Math.max(...values) + + return ( + + {label} + {values.map((value, index) => { + const isMin = highlightLowest && value === minValue && value > 0 + const isMax = highlightHighest && value === maxValue && value > 0 + + return ( + + + {formatCurrency(value)} + + + ) + })} + + ) +} diff --git a/frontend/src/components/vehicle-planner/tabs/TotalCostTab.tsx b/frontend/src/components/vehicle-planner/tabs/TotalCostTab.tsx new file mode 100644 index 00000000..4d327ef0 --- /dev/null +++ b/frontend/src/components/vehicle-planner/tabs/TotalCostTab.tsx @@ -0,0 +1,261 @@ +'use client' + +import { useMemo } from 'react' +import { Calculator } from 'lucide-react' +import clsx from 'clsx' +import { useTheme } from '@/lib/theme' +import { formatCurrency } from '@/lib/format' +import { numericStyles } from '@/lib/utils' +import { + useActiveVehicleScenario, + useVehicleOwnershipYears, + useVehiclePlannerActions, +} from '@/stores/vehiclePlannerStore' +import { calculateVehicle } from '@/lib/vehicle/calculations' + +export function TotalCostTab() { + const { theme, isMonet } = useTheme() + const scenario = useActiveVehicleScenario() + const ownershipYears = useVehicleOwnershipYears() + const { setOwnershipYears, updateScenarioRecurringCosts } = useVehiclePlannerActions() + + const calculation = useMemo(() => { + if (!scenario) return null + return calculateVehicle(scenario.inputs, scenario.recurringCosts, ownershipYears) + }, [scenario, ownershipYears]) + + if (!scenario || !calculation) { + return ( +
+

No scenario selected.

+
+ ) + } + + const { totalCostOfOwnership: tco } = calculation + const accentColor = isMonet ? '#D4A574' : '#fbbf24' + + // Cost categories for the breakdown + const costCategories = [ + { label: 'Upfront / Purchase', value: tco.upfrontCosts, color: isMonet ? '#7BA3C9' : '#3b82f6' }, + { label: 'Loan Interest', value: tco.totalLoanInterest, color: isMonet ? '#E8A898' : '#f43f5e' }, + { label: 'Road Tax', value: tco.totalRoadTax, color: isMonet ? '#D4A574' : '#f59e0b' }, + { label: 'Insurance', value: tco.totalInsurance, color: isMonet ? '#9B8BB4' : '#8b5cf6' }, + { label: 'Fuel / Electricity', value: tco.totalFuel, color: isMonet ? '#7FB285' : '#10b981' }, + { label: 'Maintenance', value: tco.totalMaintenance, color: isMonet ? '#D4C5A9' : '#fbbf24' }, + { label: 'Parking', value: tco.totalParking, color: isMonet ? '#A887B3' : '#a78bfa' }, + { label: 'ERP / Tolls', value: tco.totalErp, color: isMonet ? '#7BA3C9' : '#06b6d4' }, + { label: 'Other', value: tco.totalOther, color: isMonet ? '#9B9B9B' : '#64748b' }, + ].filter(c => c.value > 0) + + const totalCosts = costCategories.reduce((sum, c) => sum + c.value, 0) + + return ( +
+ {/* Ownership Period Selector */} +
+
+

+ Ownership Period +

+ + {ownershipYears} {ownershipYears === 1 ? 'year' : 'years'} + +
+
+ {Array.from({ length: 10 }, (_, i) => i + 1).map((year) => ( + + ))} +
+
+ + {/* TCO Summary Card */} +
+
+ +

+ Total Cost of Ownership — {ownershipYears} {ownershipYears === 1 ? 'Year' : 'Years'} +

+
+ +
+
+

Net Total Cost

+

+ {formatCurrency(tco.netTotalCost)} +

+
+
+

Per Month

+

+ {formatCurrency(tco.costPerMonth)} +

+
+
+

Per Year

+

+ {formatCurrency(tco.costPerYear)} +

+
+
+ + {/* Residual value note */} +

+ After deducting residual (scrap) value of {formatCurrency(tco.residualValue)} at year {ownershipYears} +

+
+ + {/* Cost Breakdown Bars */} +
+

+ Cost Breakdown +

+ +
+ {costCategories.map((category) => { + const barWidth = Math.max(3, (category.value / totalCosts) * 100) + return ( +
+ + {category.label} + +
+
+ {barWidth > 20 && ( + + {formatCurrency(category.value)} + + )} +
+
+ {barWidth <= 20 && ( + {formatCurrency(category.value)} + )} +
+ ) + })} + + {/* Residual value (negative) */} + {tco.residualValue > 0 && ( +
+ + Residual Value + + + ({formatCurrency(tco.residualValue)}) + +
+ )} +
+
+ + {/* Recurring Costs Editor */} +
+

+ Recurring Costs (Editable) +

+
+ updateScenarioRecurringCosts(scenario.id, { insuranceAnnual: v })} + theme={theme} + /> + updateScenarioRecurringCosts(scenario.id, { fuelMonthly: v })} + theme={theme} + /> + updateScenarioRecurringCosts(scenario.id, { maintenanceAnnual: v })} + theme={theme} + /> + updateScenarioRecurringCosts(scenario.id, { parkingMonthly: v })} + theme={theme} + /> + updateScenarioRecurringCosts(scenario.id, { erpMonthly: v })} + theme={theme} + /> + updateScenarioRecurringCosts(scenario.id, { otherMonthly: v })} + theme={theme} + /> +
+
+
+ ) +} + +function RecurringCostInput({ + label, + value, + onChange, + theme, +}: { + label: string + value: number + onChange: (v: number) => void + theme: ReturnType['theme'] +}) { + return ( +
+ +
+ $ + onChange(Math.max(0, Number(e.target.value)))} + className="w-full rounded-xl pl-7 pr-3 py-2.5 text-sm focus:outline-none transition font-mono tabular-nums" + style={{ + background: theme.inputBg, + border: `1px solid ${theme.inputBorder}`, + color: theme.inputText, + }} + /> +
+
+ ) +} diff --git a/frontend/src/components/vehicle-planner/tabs/VehicleFinancingTab.tsx b/frontend/src/components/vehicle-planner/tabs/VehicleFinancingTab.tsx new file mode 100644 index 00000000..f63a0eda --- /dev/null +++ b/frontend/src/components/vehicle-planner/tabs/VehicleFinancingTab.tsx @@ -0,0 +1,489 @@ +'use client' + +import { useMemo } from 'react' +import { Car, Bike, Zap, Fuel, Info } from 'lucide-react' +import clsx from 'clsx' +import { useTheme } from '@/lib/theme' +import { formatCurrency } from '@/lib/format' +import { numericStyles } from '@/lib/utils' +import { + useActiveVehicleScenario, + useVehiclePlannerActions, + useVehiclePlannerStore, +} from '@/stores/vehiclePlannerStore' +import { calculateVehicle } from '@/lib/vehicle/calculations' +import { formatVehicleCategoryShort, formatFuelType, isElectric, isCar } from '@/lib/vehicle/formatting' +import { DEFAULT_RECURRING_COSTS } from '@/lib/vehicle/constants' +import type { VehicleCategory, FuelType, VehicleCondition, VesPeriod, VehicleInputs } from '@/types/vehicle' + +export function VehicleFinancingTab() { + const { theme, isMonet } = useTheme() + const scenario = useActiveVehicleScenario() + const { updateScenarioInputs, updateScenarioName } = useVehiclePlannerActions() + + const inputs = scenario?.inputs + const recurringCosts = scenario?.recurringCosts + + const calculation = useMemo(() => { + if (!inputs || !recurringCosts) return null + return calculateVehicle(inputs, recurringCosts) + }, [inputs, recurringCosts]) + + if (!scenario || !inputs || !recurringCosts) { + return ( +
+

No scenario selected.

+
+ ) + } + + const updateInput = (updates: Partial) => { + updateScenarioInputs(scenario.id, updates) + } + + const handleCategoryChange = (category: VehicleCategory) => { + const isMotorcycle = category === 'motorcycle_cat_d' + const defaults = isMotorcycle ? DEFAULT_RECURRING_COSTS.motorcycle : DEFAULT_RECURRING_COSTS.car + updateInput({ + vehicleCategory: category, + engineCapacityCc: isMotorcycle ? 150 : category === 'car_cat_a' ? 1600 : 2000, + powerKw: null, + }) + // Also update recurring cost defaults when switching vehicle type + if (isMotorcycle !== (scenario.inputs.vehicleCategory === 'motorcycle_cat_d')) { + useVehiclePlannerStore.getState().updateScenarioRecurringCosts(scenario.id, defaults) + } + } + + const categories: { value: VehicleCategory; label: string; sublabel: string; icon: typeof Car }[] = [ + { value: 'car_cat_a', label: 'Cat A', sublabel: '≤1,600cc / ≤97kW', icon: Car }, + { value: 'car_cat_b', label: 'Cat B', sublabel: '>1,600cc / >97kW', icon: Car }, + { value: 'motorcycle_cat_d', label: 'Cat D', sublabel: 'Motorcycles', icon: Bike }, + ] + + const fuelTypes: { value: FuelType; label: string }[] = [ + { value: 'petrol', label: 'Petrol' }, + { value: 'diesel', label: 'Diesel' }, + { value: 'electric', label: 'Electric' }, + { value: 'hybrid_petrol', label: 'Hybrid' }, + ] + + const vesPeriods: { value: VesPeriod; label: string }[] = [ + { value: '2024_2025', label: '2024–25' }, + { value: '2026', label: '2026' }, + { value: '2027', label: '2027' }, + ] + + const isEv = isElectric(inputs.fuelType) + const isCarType = isCar(inputs.vehicleCategory) + + return ( +
+ {/* ─── Left Panel: Input Form ───────────────────── */} +
+ {/* Scenario Name */} +
+ + updateScenarioName(scenario.id, e.target.value)} + className="w-full rounded-xl py-2.5 px-3 text-sm focus:outline-none transition" + style={{ + background: theme.inputBg, + border: `1px solid ${theme.inputBorder}`, + color: theme.inputText, + }} + /> +
+ + {/* Vehicle Category Selector */} +
+ +
+ {categories.map((cat) => { + const isActive = inputs.vehicleCategory === cat.value + const Icon = cat.icon + return ( + + ) + })} +
+
+ + {/* Fuel Type Toggle */} +
+ +
+ {fuelTypes.map((ft) => { + const isActive = inputs.fuelType === ft.value + return ( + + ) + })} +
+
+ + {/* Condition Toggle */} +
+ +
+ {(['new', 'used'] as VehicleCondition[]).map((cond) => { + const isActive = inputs.condition === cond + return ( + + ) + })} +
+
+ + {/* Used Vehicle Fields */} + {inputs.condition === 'used' && ( +
+ updateInput({ vehicleAge: v })} theme={theme} min={0} max={20} /> + updateInput({ remainingCoeMonths: v })} theme={theme} min={0} max={120} /> +
+ )} + + {/* Pricing Section */} +
+

Pricing

+
+ updateInput({ omv: v })} theme={theme} prefix="$" step={1000} /> + updateInput({ coePrice: v })} theme={theme} prefix="$" step={1000} /> +
+ {inputs.condition === 'used' && ( + updateInput({ listPrice: v })} theme={theme} prefix="$" step={1000} /> + )} + {inputs.condition === 'new' && ( + updateInput({ listPrice: v })} theme={theme} prefix="$" step={1000} /> + )} +
+ + {/* Engine / Emissions */} +
+

Vehicle Specs

+
+ {!isEv && ( + updateInput({ engineCapacityCc: v })} theme={theme} step={100} /> + )} + {(isEv || isCarType) && ( + updateInput({ powerKw: v || null })} theme={theme} step={10} /> + )} + updateInput({ co2EmissionsGkm: v })} theme={theme} /> +
+
+ +
+ {vesPeriods.map((vp) => { + const isActive = inputs.vesPeriod === vp.value + return ( + + ) + })} +
+
+
+ + {/* Financing Section */} +
+
+

Financing

+ +
+ + {inputs.useFinancing && ( +
+
+
+ updateInput({ loanAmount: v })} + theme={theme} + prefix="$" + step={1000} + /> + {calculation && ( +

+ Max LTV: {Math.round(calculation.maxLtvPercent * 100)}% ({formatCurrency(calculation.maxLoanAllowed)}) +

+ )} +
+ updateInput({ interestRateFlat: v })} theme={theme} step={0.01} /> +
+
+ + updateInput({ loanTenureYears: Number(e.target.value) })} + className="w-full accent-amber-500" + /> +
+
+ )} +
+
+ + {/* ─── Right Panel: Live Summary ────────────────── */} +
+
+
+ +

+ Cost Summary +

+ + {formatVehicleCategoryShort(inputs.vehicleCategory)} · {formatFuelType(inputs.fuelType)} + +
+ + {calculation && inputs.condition === 'new' && ( + <> + {/* Registration Cost Breakdown */} +
+

+ Registration Cost +

+ + + + + + {calculation.vesAmount !== 0 && ( + + )} + {calculation.eeaiRebate !== 0 && ( + + )} + +
+
+ Total + + {formatCurrency(calculation.totalRegistrationCost)} + +
+
+
+ + )} + + {calculation && inputs.condition === 'used' && ( +
+

+ Purchase Price +

+
+ Total + + {formatCurrency(inputs.listPrice)} + +
+
+ )} + + {/* Financing Summary */} + {calculation && inputs.useFinancing && ( +
+

+ Financing +

+ + + +
+ + + EIR: {(calculation.effectiveInterestRate * 100).toFixed(2)}% (flat: {inputs.interestRateFlat}%) + +
+
+ )} + + {/* Road Tax */} + {calculation && ( +
+

+ Road Tax +

+ + +
+ )} +
+
+
+ ) +} + +// ─── Helper Components ──────────────────────────────── + +interface NumberInputProps { + label: string + value: number + onChange: (value: number) => void + theme: ReturnType['theme'] + prefix?: string + step?: number + min?: number + max?: number +} + +function NumberInput({ label, value, onChange, theme, prefix, step = 1, min, max }: NumberInputProps) { + return ( +
+ +
+ {prefix && ( + + {prefix} + + )} + { + const numericValue = Number(e.target.value) + if (min !== undefined && numericValue < min) return + if (max !== undefined && numericValue > max) return + onChange(numericValue) + }} + step={step} + className={clsx('w-full rounded-xl py-2.5 text-sm focus:outline-none transition font-mono tabular-nums', prefix ? 'pl-7 pr-3' : 'px-3')} + style={{ + background: theme.inputBg, + border: `1px solid ${theme.inputBorder}`, + color: theme.inputText, + }} + /> +
+
+ ) +} + +interface CostLineProps { + label: string + value: number + theme: ReturnType['theme'] + highlight?: 'green' | 'red' +} + +function CostLine({ label, value, theme, highlight }: CostLineProps) { + const displayColor = highlight === 'green' + ? '#10b981' + : highlight === 'red' + ? '#ef4444' + : undefined + + return ( +
+ {label} + + {value < 0 ? `(${formatCurrency(Math.abs(value))})` : formatCurrency(value)} + +
+ ) +} diff --git a/frontend/src/lib/vehicle/calculations.ts b/frontend/src/lib/vehicle/calculations.ts new file mode 100644 index 00000000..5d50aa8c --- /dev/null +++ b/frontend/src/lib/vehicle/calculations.ts @@ -0,0 +1,399 @@ +import type { + VehicleInputs, + VehicleRecurringCosts, + VehicleCalculationResult, + VehicleCategory, + FuelType, + VesPeriod, + DepreciationPeriod, + YearlyDepreciation, + TotalCostOfOwnership, +} from '@/types/vehicle' +import { + ARF_TIERS, + ROAD_TAX_ICE_TIERS, + ROAD_TAX_EV_TIERS, + ROAD_TAX_MOTORCYCLE_TIERS, + VES_BANDS, + EEAI_SCHEDULE, + PARF_REBATE_SCHEDULE, + LTV_LIMITS, + REGISTRATION_FEE, + EXCISE_DUTY_RATE, + GST_RATE, + ROAD_TAX_REBATE_FACTOR, + EV_AFC_SIX_MONTHLY, + PARF_CAP, + COE_DURATION_MONTHS, +} from './constants' + +// ─── ARF ────────────────────────────────────────────── + +export function calculateARF(omv: number): number { + let arf = 0 + let remaining = omv + let previousLimit = 0 + + for (const tier of ARF_TIERS) { + const taxable = Math.min(remaining, tier.limit - previousLimit) + arf += taxable * tier.rate + remaining -= taxable + previousLimit = tier.limit + if (remaining <= 0) break + } + return Math.round(arf) +} + +// ─── VES ────────────────────────────────────────────── + +export function calculateVES( + co2: number | null, + fuelType: FuelType, + period: VesPeriod +): number { + if (co2 === null) return 0 + const isEv = fuelType === 'electric' + const bands = VES_BANDS[period] + + for (const band of bands) { + const bandEntry = band as { maxCo2: number; amount: number; evOnly?: boolean } + if (bandEntry.evOnly && !isEv) continue + if (co2 <= bandEntry.maxCo2) { + return bandEntry.amount + } + } + return 0 +} + +// ─── EEAI (EV Early Adoption Incentive) ────────────── + +export function calculateEEAI( + arf: number, + fuelType: FuelType, + period: VesPeriod +): number { + if (fuelType !== 'electric') return 0 + const schedule = EEAI_SCHEDULE[period] + if (schedule.rate === 0) return 0 + const rebate = arf * schedule.rate + return -Math.min(rebate, schedule.cap) +} + +// ─── Registration Cost ──────────────────────────────── + +export function calculateRegistrationCost(inputs: VehicleInputs) { + const exciseDuty = Math.round(inputs.omv * EXCISE_DUTY_RATE) + const gst = Math.round((inputs.omv + exciseDuty) * GST_RATE) + const arf = calculateARF(inputs.omv) + const vesAmount = calculateVES(inputs.co2EmissionsGkm, inputs.fuelType, inputs.vesPeriod) + const eeaiRebate = calculateEEAI(arf, inputs.fuelType, inputs.vesPeriod) + + const totalRegistrationCost = + inputs.omv + exciseDuty + gst + arf + inputs.coePrice + REGISTRATION_FEE + vesAmount + eeaiRebate + + return { + exciseDuty, + gst, + arf, + registrationFee: REGISTRATION_FEE, + vesAmount, + eeaiRebate, + totalRegistrationCost, + } +} + +// ─── Road Tax ───────────────────────────────────────── + +function calculateTieredRoadTax( + value: number, + tiers: readonly { limit: number; base: number; rate: number }[] +): number { + for (let i = 0; i < tiers.length; i++) { + if (value <= tiers[i].limit) { + const prevLimit = i === 0 ? 0 : tiers[i - 1].limit + return tiers[i].base + tiers[i].rate * (value - prevLimit) + } + } + return 0 +} + +export function calculateRoadTax( + category: VehicleCategory, + fuelType: FuelType, + engineCapacityCc: number | null, + powerKw: number | null +): { sixMonthly: number; annual: number } { + const isEv = fuelType === 'electric' + const isMotorcycle = category === 'motorcycle_cat_d' + + let baseSixMonthly: number + + if (isMotorcycle) { + baseSixMonthly = calculateTieredRoadTax(engineCapacityCc ?? 0, ROAD_TAX_MOTORCYCLE_TIERS) + } else if (isEv) { + baseSixMonthly = calculateTieredRoadTax(powerKw ?? 0, ROAD_TAX_EV_TIERS) + } else { + baseSixMonthly = calculateTieredRoadTax(engineCapacityCc ?? 0, ROAD_TAX_ICE_TIERS) + } + + let sixMonthly = baseSixMonthly * ROAD_TAX_REBATE_FACTOR + if (isEv && !isMotorcycle) { + sixMonthly += EV_AFC_SIX_MONTHLY + } + + return { + sixMonthly: Math.round(sixMonthly * 100) / 100, + annual: Math.round(sixMonthly * 2 * 100) / 100, + } +} + +// ─── Financing ──────────────────────────────────────── + +export function calculateFinancing(inputs: VehicleInputs, totalRegistrationCost: number) { + const isMotorcycle = inputs.vehicleCategory === 'motorcycle_cat_d' + + // Max LTV + let maxLtvPercent: number + if (isMotorcycle) { + maxLtvPercent = 1.0 // No LTV restriction for motorcycles + } else if (inputs.omv <= LTV_LIMITS.lowOmv.threshold) { + maxLtvPercent = LTV_LIMITS.lowOmv.maxLtv + } else { + maxLtvPercent = LTV_LIMITS.highOmv.maxLtv + } + + const purchasePrice = inputs.condition === 'used' ? inputs.listPrice : totalRegistrationCost + const maxLoanAllowed = Math.round(purchasePrice * maxLtvPercent) + + if (!inputs.useFinancing || inputs.loanAmount <= 0) { + return { + effectiveInterestRate: 0, + monthlyInstallment: 0, + totalInterestPaid: 0, + totalLoanRepayment: 0, + downpayment: purchasePrice, + maxLtvPercent, + maxLoanAllowed, + } + } + + const loanAmount = Math.min(inputs.loanAmount, maxLoanAllowed) + const downpayment = purchasePrice - loanAmount + const monthlyPayments = inputs.loanTenureYears * 12 + const flatRate = inputs.interestRateFlat / 100 + + // Exact EIR formula: 2 × n × flatRate / (n + 1) + const effectiveInterestRate = (2 * monthlyPayments * flatRate) / (monthlyPayments + 1) + + // Amortization formula + const monthlyRate = effectiveInterestRate / 12 + let monthlyInstallment: number + + if (monthlyRate === 0) { + monthlyInstallment = loanAmount / monthlyPayments + } else { + monthlyInstallment = + (loanAmount * monthlyRate) / (1 - Math.pow(1 + monthlyRate, -monthlyPayments)) + } + + const totalLoanRepayment = monthlyInstallment * monthlyPayments + const totalInterestPaid = totalLoanRepayment - loanAmount + + return { + effectiveInterestRate: Math.round(effectiveInterestRate * 10000) / 10000, + monthlyInstallment: Math.round(monthlyInstallment), + totalInterestPaid: Math.round(totalInterestPaid), + totalLoanRepayment: Math.round(totalLoanRepayment), + downpayment: Math.round(downpayment), + maxLtvPercent, + maxLoanAllowed, + } +} + +// ─── Depreciation ───────────────────────────────────── + +function getDepreciationRateForYear(year: number, periods: DepreciationPeriod[]): number { + for (const period of periods) { + if (year >= period.startYear && (period.endYear === null || year <= period.endYear)) { + return period.annualRate / 100 + } + } + return 0.05 // Fallback 5% if no period matches +} + +function getPARFRebatePercent(ageAtDereg: number): number { + for (const entry of PARF_REBATE_SCHEDULE) { + if (ageAtDereg <= entry.maxAge) { + return entry.rebatePercent + } + } + return 0 // Beyond 10 years: no PARF +} + +export function calculateDepreciation( + registrationCost: number, + arf: number, + coePrice: number, + periods: DepreciationPeriod[], + vehicleAge: number, + remainingCoeMonths: number, + isPARFEligible: boolean +): YearlyDepreciation[] { + const schedule: YearlyDepreciation[] = [] + let currentMarketValue = registrationCost + + for (let year = 1; year <= 10; year++) { + const rate = getDepreciationRateForYear(year, periods) + const annualDep = Math.round(currentMarketValue * rate) + currentMarketValue = Math.max(0, currentMarketValue - annualDep) + + // PARF rebate + const ageAtDereg = vehicleAge + year + let parfRebate = 0 + if (isPARFEligible && ageAtDereg <= 10) { + parfRebate = Math.min(Math.round(arf * getPARFRebatePercent(ageAtDereg)), PARF_CAP) + } + + // COE rebate: pro-rated by remaining months + const monthsUsed = year * 12 + const monthsRemaining = Math.max(0, remainingCoeMonths - monthsUsed) + const coeRebate = Math.round(coePrice * (monthsRemaining / COE_DURATION_MONTHS)) + + const scrapValue = parfRebate + coeRebate + const cumulativeDepreciation = registrationCost - currentMarketValue + + schedule.push({ + year, + marketValue: currentMarketValue, + scrapValue, + parfRebate, + coeRebate, + annualDepreciation: annualDep, + cumulativeDepreciation, + }) + } + + return schedule +} + +// ─── Total Cost of Ownership ────────────────────────── + +export function calculateTCO( + inputs: VehicleInputs, + recurringCosts: VehicleRecurringCosts, + calculationResult: { + totalRegistrationCost: number + totalInterestPaid: number + annualRoadTax: number + depreciationSchedule: YearlyDepreciation[] + }, + ownershipYears: number +): TotalCostOfOwnership { + const upfrontCosts = + inputs.condition === 'used' ? inputs.listPrice : calculationResult.totalRegistrationCost + + const totalRoadTax = calculationResult.annualRoadTax * ownershipYears + const totalInsurance = recurringCosts.insuranceAnnual * ownershipYears + const totalFuel = recurringCosts.fuelMonthly * 12 * ownershipYears + const totalMaintenance = recurringCosts.maintenanceAnnual * ownershipYears + const totalParking = recurringCosts.parkingMonthly * 12 * ownershipYears + const totalErp = recurringCosts.erpMonthly * 12 * ownershipYears + const totalOther = recurringCosts.otherMonthly * 12 * ownershipYears + + // Residual value at end of ownership period + const yearIndex = Math.min(ownershipYears, calculationResult.depreciationSchedule.length) - 1 + const residualValue = + yearIndex >= 0 ? calculationResult.depreciationSchedule[yearIndex].scrapValue : 0 + + // Interest paid over ownership period (may be less than full loan if loan is shorter) + const totalLoanInterest = inputs.useFinancing ? calculationResult.totalInterestPaid : 0 + + const netTotalCost = + upfrontCosts + + totalLoanInterest + + totalRoadTax + + totalInsurance + + totalFuel + + totalMaintenance + + totalParking + + totalErp + + totalOther - + residualValue + + const totalMonths = ownershipYears * 12 + + return { + years: ownershipYears, + upfrontCosts, + totalLoanInterest, + totalRoadTax, + totalInsurance, + totalFuel, + totalMaintenance, + totalParking, + totalErp, + totalOther, + residualValue, + netTotalCost: Math.round(netTotalCost), + costPerMonth: Math.round(netTotalCost / totalMonths), + costPerYear: Math.round(netTotalCost / ownershipYears), + } +} + +// ─── Master Calculation ─────────────────────────────── + +export function calculateVehicle( + inputs: VehicleInputs, + recurringCosts: VehicleRecurringCosts, + ownershipYears: number = 10 +): VehicleCalculationResult { + // Registration costs + const registration = calculateRegistrationCost(inputs) + + // Financing + const financing = calculateFinancing(inputs, registration.totalRegistrationCost) + + // Road tax + const roadTax = calculateRoadTax( + inputs.vehicleCategory, + inputs.fuelType, + inputs.engineCapacityCc, + inputs.powerKw + ) + + // Depreciation schedule + const depreciationBase = + inputs.condition === 'used' ? inputs.listPrice : registration.totalRegistrationCost + + const depreciationSchedule = calculateDepreciation( + depreciationBase, + registration.arf, + inputs.coePrice, + inputs.depreciationPeriods, + inputs.vehicleAge, + inputs.remainingCoeMonths, + inputs.isPafrEligible + ) + + // TCO + const totalCostOfOwnership = calculateTCO( + inputs, + recurringCosts, + { + totalRegistrationCost: registration.totalRegistrationCost, + totalInterestPaid: financing.totalInterestPaid, + annualRoadTax: roadTax.annual, + depreciationSchedule, + }, + ownershipYears + ) + + return { + ...registration, + ...financing, + sixMonthlyRoadTax: roadTax.sixMonthly, + annualRoadTax: roadTax.annual, + depreciationSchedule, + totalCostOfOwnership, + } +} diff --git a/frontend/src/lib/vehicle/constants.ts b/frontend/src/lib/vehicle/constants.ts new file mode 100644 index 00000000..277979a9 --- /dev/null +++ b/frontend/src/lib/vehicle/constants.ts @@ -0,0 +1,127 @@ +import type { DepreciationPeriod } from '@/types/vehicle' + +// ─── ARF Tiers (revised Feb 2023) ───────────────────── + +export const ARF_TIERS = [ + { limit: 20000, rate: 1.00 }, + { limit: 40000, rate: 1.40 }, + { limit: 60000, rate: 1.90 }, + { limit: 80000, rate: 2.50 }, + { limit: Infinity, rate: 3.20 }, +] as const + +// ─── Road Tax Tiers (6-monthly base formulas) ──────── + +export const ROAD_TAX_ICE_TIERS = [ + { limit: 600, base: 200, rate: 0 }, + { limit: 1000, base: 200, rate: 0.125 }, + { limit: 1600, base: 250, rate: 0.375 }, + { limit: 3000, base: 475, rate: 0.75 }, + { limit: Infinity, base: 1525, rate: 1.0 }, +] as const + +export const ROAD_TAX_EV_TIERS = [ + { limit: 7.5, base: 200, rate: 0 }, + { limit: 30, base: 200, rate: 2.0 }, + { limit: 230, base: 250, rate: 3.75 }, + { limit: Infinity, base: 1525, rate: 10.0 }, +] as const + +export const ROAD_TAX_MOTORCYCLE_TIERS = [ + { limit: 200, base: 40, rate: 0 }, + { limit: 1000, base: 40, rate: 0.15 }, + { limit: Infinity, base: 160, rate: 0.30 }, +] as const + +// ─── VES Bands ───────────────────────────────────────── + +export const VES_BANDS = { + '2024_2025': [ + { maxCo2: 90, amount: -25000 }, + { maxCo2: 120, amount: -5000 }, + { maxCo2: 159, amount: 0 }, + { maxCo2: 182, amount: 15000 }, + { maxCo2: Infinity, amount: 25000 }, + ], + '2026': [ + { maxCo2: 0, amount: -22500, evOnly: true }, + { maxCo2: 159, amount: 0 }, + { maxCo2: 182, amount: 7500 }, + { maxCo2: 210, amount: 22500 }, + { maxCo2: Infinity, amount: 35000 }, + ], + '2027': [ + { maxCo2: 0, amount: -20000, evOnly: true }, + { maxCo2: 159, amount: 0 }, + { maxCo2: 182, amount: 15000 }, + { maxCo2: 210, amount: 30000 }, + { maxCo2: Infinity, amount: 45000 }, + ], +} as const + +// ─── EEAI (EV Early Adoption Incentive) ────────────── + +export const EEAI_SCHEDULE = { + '2024_2025': { rate: 0.45, cap: 15000 }, + '2026': { rate: 0.45, cap: 7500 }, + '2027': { rate: 0, cap: 0 }, +} as const + +// ─── PARF Rebate Schedule ───────────────────────────── + +export const PARF_REBATE_SCHEDULE = [ + { maxAge: 5, rebatePercent: 0.75 }, + { maxAge: 6, rebatePercent: 0.70 }, + { maxAge: 7, rebatePercent: 0.65 }, + { maxAge: 8, rebatePercent: 0.60 }, + { maxAge: 9, rebatePercent: 0.55 }, + { maxAge: 10, rebatePercent: 0.50 }, +] as const + +// ─── LTV Limits (MAS Regulation) ───────────────────── + +export const LTV_LIMITS = { + lowOmv: { threshold: 20000, maxLtv: 0.70 }, + highOmv: { maxLtv: 0.60 }, +} as const + +// ─── Fixed Constants ────────────────────────────────── + +export const MAX_LOAN_TENURE_YEARS = 7 +export const REGISTRATION_FEE = 220 +export const EXCISE_DUTY_RATE = 0.20 +export const GST_RATE = 0.09 +export const ROAD_TAX_REBATE_FACTOR = 0.782 +export const EV_AFC_SIX_MONTHLY = 350 +export const PARF_CAP = 60000 +export const COE_DURATION_MONTHS = 120 + +// ─── Default Depreciation Periods ───────────────────── + +export const DEFAULT_DEPRECIATION_PERIODS: DepreciationPeriod[] = [ + { id: 'yr1', startYear: 1, endYear: 1, annualRate: 15 }, + { id: 'yr2-3', startYear: 2, endYear: 3, annualRate: 10 }, + { id: 'yr4-5', startYear: 4, endYear: 5, annualRate: 8 }, + { id: 'yr6+', startYear: 6, endYear: null, annualRate: 5 }, +] + +// ─── Default Recurring Costs ────────────────────────── + +export const DEFAULT_RECURRING_COSTS = { + car: { + maintenanceAnnual: 1500, + insuranceAnnual: 1200, + fuelMonthly: 200, + parkingMonthly: 0, + erpMonthly: 0, + otherMonthly: 0, + }, + motorcycle: { + maintenanceAnnual: 500, + insuranceAnnual: 300, + fuelMonthly: 80, + parkingMonthly: 0, + erpMonthly: 0, + otherMonthly: 0, + }, +} as const diff --git a/frontend/src/lib/vehicle/formatting.ts b/frontend/src/lib/vehicle/formatting.ts new file mode 100644 index 00000000..461f7598 --- /dev/null +++ b/frontend/src/lib/vehicle/formatting.ts @@ -0,0 +1,67 @@ +import type { VehicleCategory, FuelType, VesPeriod } from '@/types/vehicle' +import { VES_BANDS } from './constants' + +export function formatVehicleCategory(category: VehicleCategory): string { + switch (category) { + case 'car_cat_a': return 'Car (Cat A)' + case 'car_cat_b': return 'Car (Cat B)' + case 'motorcycle_cat_d': return 'Motorcycle (Cat D)' + } +} + +export function formatVehicleCategoryShort(category: VehicleCategory): string { + switch (category) { + case 'car_cat_a': return 'Cat A' + case 'car_cat_b': return 'Cat B' + case 'motorcycle_cat_d': return 'Cat D' + } +} + +export function formatFuelType(fuelType: FuelType): string { + switch (fuelType) { + case 'petrol': return 'Petrol' + case 'diesel': return 'Diesel' + case 'electric': return 'Electric' + case 'hybrid_petrol': return 'Hybrid (Petrol)' + case 'hybrid_diesel': return 'Hybrid (Diesel)' + } +} + +export function formatVesPeriod(period: VesPeriod): string { + switch (period) { + case '2024_2025': return '2024–2025' + case '2026': return '2026' + case '2027': return '2027' + } +} + +export function getVesBandLabel(co2: number | null, fuelType: FuelType, period: VesPeriod): string { + if (co2 === null) return 'N/A' + const isEv = fuelType === 'electric' + const bands = VES_BANDS[period] + + const bandLabels: Record = { + '2024_2025': ['A1', 'A2', 'B', 'C1', 'C2'], + '2026': ['A', 'B', 'C1', 'C2', 'C3'], + '2027': ['A', 'B', 'C1', 'C2', 'C3'], + } + + const labels = bandLabels[period] + + for (let i = 0; i < bands.length; i++) { + const band = bands[i] as { maxCo2: number; amount: number; evOnly?: boolean } + if (band.evOnly && !isEv) continue + if (co2 <= band.maxCo2) { + return `Band ${labels[i]}` + } + } + return 'N/A' +} + +export function isElectric(fuelType: FuelType): boolean { + return fuelType === 'electric' +} + +export function isCar(category: VehicleCategory): boolean { + return category === 'car_cat_a' || category === 'car_cat_b' +} diff --git a/frontend/src/stores/featureModulesStore.ts b/frontend/src/stores/featureModulesStore.ts index 499f0c29..10539f56 100644 --- a/frontend/src/stores/featureModulesStore.ts +++ b/frontend/src/stores/featureModulesStore.ts @@ -13,6 +13,7 @@ export interface FeatureModulesState { showCPFView: boolean showTaxPlanner: boolean showInsurancePlanner: boolean + showVehiclePlanner: boolean // Modal visibility showPropertyPlanner: boolean @@ -36,6 +37,8 @@ export interface FeatureModulesState { closeTaxPlanner: () => void openInsurancePlanner: () => void closeInsurancePlanner: () => void + openVehiclePlanner: () => void + closeVehiclePlanner: () => void // Actions - Property Planner Modal openPropertyPlanner: (scenarioId?: string) => void @@ -71,6 +74,7 @@ const initialState = { showCPFView: false, showTaxPlanner: false, showInsurancePlanner: false, + showVehiclePlanner: false, showPropertyPlanner: false, propertyScenarioToEdit: null, showLayoutModal: false, @@ -97,6 +101,9 @@ export const useFeatureModulesStore = create()( openInsurancePlanner: () => set({ showInsurancePlanner: true }), closeInsurancePlanner: () => set({ showInsurancePlanner: false }), + openVehiclePlanner: () => set({ showVehiclePlanner: true }), + closeVehiclePlanner: () => set({ showVehiclePlanner: false }), + // Property Planner Modal openPropertyPlanner: (scenarioId) => set({ @@ -153,6 +160,7 @@ export const useFeaturePanelVisibility = () => showCPFView: state.showCPFView, showTaxPlanner: state.showTaxPlanner, showInsurancePlanner: state.showInsurancePlanner, + showVehiclePlanner: state.showVehiclePlanner, })) /** @@ -163,6 +171,7 @@ export const useFeaturePanelActions = () => openCPFView: state.openCPFView, openTaxPlanner: state.openTaxPlanner, openInsurancePlanner: state.openInsurancePlanner, + openVehiclePlanner: state.openVehiclePlanner, openPropertyPlanner: state.openPropertyPlanner, openLayoutModal: state.openLayoutModal, })) diff --git a/frontend/src/stores/vehiclePlannerStore.ts b/frontend/src/stores/vehiclePlannerStore.ts new file mode 100644 index 00000000..a9016a52 --- /dev/null +++ b/frontend/src/stores/vehiclePlannerStore.ts @@ -0,0 +1,240 @@ +import { create } from 'zustand' +import { devtools, persist } from 'zustand/middleware' +import { useShallow } from 'zustand/react/shallow' +import type { + VehicleScenario, + VehicleTabId, + VehicleInputs, + VehicleRecurringCosts, +} from '@/types/vehicle' +import { generateUUID } from '@/lib/utils' +import { DEFAULT_DEPRECIATION_PERIODS, DEFAULT_RECURRING_COSTS } from '@/lib/vehicle/constants' + +// ============================================ +// TYPES +// ============================================ + +export interface VehiclePlannerState { + scenarios: VehicleScenario[] + activeScenarioId: string | null + activeTab: VehicleTabId + ownershipYears: number + + // Actions + setActiveTab: (tab: VehicleTabId) => void + setOwnershipYears: (years: number) => void + addScenario: (name?: string) => void + updateScenarioInputs: (id: string, updates: Partial) => void + updateScenarioRecurringCosts: (id: string, updates: Partial) => void + updateScenarioName: (id: string, name: string) => void + deleteScenario: (id: string) => void + setActiveScenario: (id: string | null) => void + duplicateScenario: (id: string) => void + toggleIncluded: (id: string) => void +} + +// ============================================ +// DEFAULTS +// ============================================ + +function createDefaultInputs(): VehicleInputs { + const now = new Date() + const purchaseMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}` + + return { + vehicleCategory: 'car_cat_a', + fuelType: 'petrol', + condition: 'new', + engineCapacityCc: 1600, + powerKw: null, + co2EmissionsGkm: 120, + + vehicleAge: 0, + remainingCoeMonths: 120, + isPafrEligible: true, + + omv: 20000, + listPrice: 0, + coePrice: 100000, + vesPeriod: '2026', + + useFinancing: false, + loanAmount: 0, + loanTenureYears: 7, + interestRateFlat: 2.78, + + purchaseMonth, + depreciationPeriods: [...DEFAULT_DEPRECIATION_PERIODS], + downpaymentCashAccountId: null, + } +} + +function createDefaultRecurringCosts(): VehicleRecurringCosts { + return { ...DEFAULT_RECURRING_COSTS.car } +} + +function createDefaultScenario(name?: string): VehicleScenario { + const now = Date.now() + return { + id: generateUUID(), + name: name ?? 'New Vehicle', + inputs: createDefaultInputs(), + recurringCosts: createDefaultRecurringCosts(), + isIncluded: false, + createdAt: now, + updatedAt: now, + } +} + +// ============================================ +// STORE +// ============================================ + +const initialState = { + scenarios: [] as VehicleScenario[], + activeScenarioId: null as string | null, + activeTab: 'vehicle' as VehicleTabId, + ownershipYears: 10, +} + +export const useVehiclePlannerStore = create()( + devtools( + persist( + (set) => ({ + ...initialState, + + setActiveTab: (tab) => set({ activeTab: tab }), + + setOwnershipYears: (years) => set({ ownershipYears: Math.max(1, Math.min(10, years)) }), + + addScenario: (name) => + set((state) => { + const newScenario = createDefaultScenario(name) + return { + scenarios: [...state.scenarios, newScenario], + activeScenarioId: newScenario.id, + } + }), + + updateScenarioInputs: (id, updates) => + set((state) => ({ + scenarios: state.scenarios.map((scenario) => + scenario.id === id + ? { + ...scenario, + inputs: { ...scenario.inputs, ...updates }, + updatedAt: Date.now(), + } + : scenario + ), + })), + + updateScenarioRecurringCosts: (id, updates) => + set((state) => ({ + scenarios: state.scenarios.map((scenario) => + scenario.id === id + ? { + ...scenario, + recurringCosts: { ...scenario.recurringCosts, ...updates }, + updatedAt: Date.now(), + } + : scenario + ), + })), + + updateScenarioName: (id, name) => + set((state) => ({ + scenarios: state.scenarios.map((scenario) => + scenario.id === id + ? { ...scenario, name, updatedAt: Date.now() } + : scenario + ), + })), + + deleteScenario: (id) => + set((state) => { + const remaining = state.scenarios.filter((s) => s.id !== id) + const activeScenarioId = + state.activeScenarioId === id + ? remaining[0]?.id ?? null + : state.activeScenarioId + return { scenarios: remaining, activeScenarioId } + }), + + setActiveScenario: (id) => set({ activeScenarioId: id }), + + duplicateScenario: (id) => + set((state) => { + const source = state.scenarios.find((s) => s.id === id) + if (!source) return state + const now = Date.now() + const duplicate: VehicleScenario = { + ...source, + id: generateUUID(), + name: `${source.name} (Copy)`, + isIncluded: false, + createdAt: now, + updatedAt: now, + } + return { + scenarios: [...state.scenarios, duplicate], + activeScenarioId: duplicate.id, + } + }), + + toggleIncluded: (id) => + set((state) => ({ + scenarios: state.scenarios.map((scenario) => + scenario.id === id + ? { ...scenario, isIncluded: !scenario.isIncluded, updatedAt: Date.now() } + : scenario + ), + })), + }), + { + name: 'vehicle-planner-storage', + partialize: (state) => ({ + scenarios: state.scenarios, + activeScenarioId: state.activeScenarioId, + activeTab: state.activeTab, + ownershipYears: state.ownershipYears, + }), + } + ), + { name: 'vehicle-planner-store' } + ) +) + +// ============================================ +// SELECTORS +// ============================================ + +export const useVehicleScenarios = () => + useVehiclePlannerStore((s) => s.scenarios) + +export const useActiveVehicleScenario = () => + useVehiclePlannerStore((s) => + s.scenarios.find((scenario) => scenario.id === s.activeScenarioId) ?? null + ) + +export const useVehicleActiveTab = () => + useVehiclePlannerStore((s) => s.activeTab) + +export const useVehicleOwnershipYears = () => + useVehiclePlannerStore((s) => s.ownershipYears) + +export const useVehiclePlannerActions = () => + useVehiclePlannerStore( + useShallow((s) => ({ + setActiveTab: s.setActiveTab, + setOwnershipYears: s.setOwnershipYears, + addScenario: s.addScenario, + updateScenarioInputs: s.updateScenarioInputs, + updateScenarioRecurringCosts: s.updateScenarioRecurringCosts, + updateScenarioName: s.updateScenarioName, + deleteScenario: s.deleteScenario, + setActiveScenario: s.setActiveScenario, + duplicateScenario: s.duplicateScenario, + toggleIncluded: s.toggleIncluded, + })) + ) diff --git a/frontend/src/types/vehicle.ts b/frontend/src/types/vehicle.ts new file mode 100644 index 00000000..fbc5504a --- /dev/null +++ b/frontend/src/types/vehicle.ts @@ -0,0 +1,132 @@ +// ─── Enums ─────────────────────────────────────────── + +export type VehicleCategory = 'car_cat_a' | 'car_cat_b' | 'motorcycle_cat_d' +export type FuelType = 'petrol' | 'diesel' | 'electric' | 'hybrid_petrol' | 'hybrid_diesel' +export type VehicleCondition = 'new' | 'used' +export type VesPeriod = '2024_2025' | '2026' | '2027' +export type VehicleTabId = 'vehicle' | 'costs' | 'depreciation' | 'tco' | 'scenarios' + +// ─── Main Scenario ─────────────────────────────────── + +export interface VehicleScenario { + id: string + name: string + inputs: VehicleInputs + recurringCosts: VehicleRecurringCosts + isIncluded: boolean + createdAt: number + updatedAt: number +} + +// ─── User Inputs ───────────────────────────────────── + +export interface VehicleInputs { + vehicleCategory: VehicleCategory + fuelType: FuelType + condition: VehicleCondition + engineCapacityCc: number | null + powerKw: number | null + co2EmissionsGkm: number | null + + // Age (for used vehicles) + vehicleAge: number + remainingCoeMonths: number + isPafrEligible: boolean + + // Pricing + omv: number + listPrice: number + coePrice: number + vesPeriod: VesPeriod + + // Financing + useFinancing: boolean + loanAmount: number + loanTenureYears: number + interestRateFlat: number + + // Purchase timing + purchaseMonth: string + + // Depreciation override + depreciationPeriods: DepreciationPeriod[] + + // Payment source + downpaymentCashAccountId: string | null +} + +export interface DepreciationPeriod { + id: string + startYear: number + endYear: number | null + annualRate: number +} + +// ─── Recurring Costs ───────────────────────────────── + +export interface VehicleRecurringCosts { + insuranceAnnual: number + fuelMonthly: number + maintenanceAnnual: number + parkingMonthly: number + erpMonthly: number + otherMonthly: number +} + +// ─── Calculation Results ───────────────────────────── + +export interface VehicleCalculationResult { + exciseDuty: number + gst: number + arf: number + registrationFee: number + vesAmount: number + eeaiRebate: number + totalRegistrationCost: number + + // Financing + effectiveInterestRate: number + monthlyInstallment: number + totalInterestPaid: number + totalLoanRepayment: number + downpayment: number + maxLtvPercent: number + maxLoanAllowed: number + + // Road tax + sixMonthlyRoadTax: number + annualRoadTax: number + + // Depreciation schedule (10 years) + depreciationSchedule: YearlyDepreciation[] + + // Total Cost of Ownership + totalCostOfOwnership: TotalCostOfOwnership +} + +export interface YearlyDepreciation { + year: number + marketValue: number + scrapValue: number + parfRebate: number + coeRebate: number + annualDepreciation: number + cumulativeDepreciation: number +} + +export interface TotalCostOfOwnership { + years: number + upfrontCosts: number + totalLoanInterest: number + totalRoadTax: number + totalInsurance: number + totalFuel: number + totalMaintenance: number + totalParking: number + totalErp: number + totalOther: number + residualValue: number + netTotalCost: number + costPerMonth: number + costPerYear: number +} diff --git a/spec/vehicle-planner.md b/spec/vehicle-planner.md new file mode 100644 index 00000000..28527d67 --- /dev/null +++ b/spec/vehicle-planner.md @@ -0,0 +1,754 @@ +# Vehicle Planner — Feature Specification + +## Overview + +A Singapore-specific vehicle ownership cost planner that calculates total cost of ownership including COE, ARF, PARF rebates, VES, road tax, financing, depreciation, and recurring expenses. Supports Cars (Cat A/B) and Motorcycles (Cat D), ICE and EV powertrains, new and used vehicles. + +Follows the Insurance Planner architecture: full-screen takeover with tabbed navigation, Zustand state management with localStorage persistence, and net worth integration (depreciating asset + car loan + recurring expenses). + +--- + +## Architecture + +### Pattern + +| Aspect | Choice | Rationale | +|--------|--------|-----------| +| UI pattern | Full-screen takeover | Complex multi-tab UI needs full viewport (same as Insurance Planner) | +| Vehicle types | Cars + Motorcycles | Cat A (≤1600cc/97kW), Cat B (>1600cc/97kW), Cat D (motorcycles) | +| EV support | ICE + EV from start | Different road tax (kW-based), VES rebates, AFC surcharge | +| State management | Zustand + localStorage | Same pattern as Insurance Planner; backend persistence as follow-up | +| Net worth integration | Full | Creates: depreciating asset, car loan liability, 5+ expense items | + +### File Structure + +``` +frontend/src/ +├── app/vehicle-planner/ +│ └── page.tsx # Standalone page + VehiclePlannerView export +├── components/vehicle-planner/ +│ ├── VehiclePlannerView.tsx # Main view component (full-screen takeover) +│ ├── VehiclePlannerTabs.tsx # Tab buttons (5 tabs) +│ ├── tabs/ +│ │ ├── VehicleFinancingTab.tsx # Tab 1: Vehicle details + financing inputs +│ │ ├── CostBreakdownTab.tsx # Tab 2: Waterfall/pie charts of costs +│ │ ├── DepreciationTab.tsx # Tab 3: Value curves + PARF/COE rebates +│ │ ├── TotalCostTab.tsx # Tab 4: 10-year TCO summary +│ │ └── ScenariosTab.tsx # Tab 5: Side-by-side comparison +│ ├── cards/ +│ │ ├── CostSummaryCard.tsx # Live cost breakdown sidebar +│ │ ├── FinancingSummaryCard.tsx # Loan details summary +│ │ └── DepreciationScheduleCard.tsx # Year-by-year table +│ └── shared/ +│ ├── VehicleTypeSelector.tsx # Car Cat A / Cat B / Motorcycle selector +│ ├── FuelTypeToggle.tsx # Petrol / Diesel / Electric / Hybrid +│ └── CostWaterfallChart.tsx # Waterfall visualization +├── stores/ +│ └── vehiclePlannerStore.ts # Zustand store with persist middleware +├── types/ +│ └── vehicle.ts # All TypeScript types +└── lib/ + └── vehicle/ + ├── constants.ts # All tax rates, tiers, fees + ├── calculations.ts # ARF, road tax, depreciation, TCO + └── formatting.ts # Vehicle-specific formatters +``` + +### Files to Modify + +| File | Change | +|------|--------| +| `stores/featureModulesStore.ts` | Add `showVehiclePlanner`, `openVehiclePlanner`, `closeVehiclePlanner` | +| `components/dashboard/FinancialWorkspace.tsx` | Add Vehicle Planner to Modules dropdown menu | +| `components/dashboard/Dashboard.tsx` | Dynamic import + conditional render of VehiclePlannerView | + +--- + +## 1. Singapore Vehicle Cost Model + +### 1.1 Upfront Costs + +**Registration Price = OMV + Excise Duty + GST + ARF + COE + Registration Fee ± VES ± EEAI** + +| Component | Formula | +|-----------|---------| +| OMV | User input (Open Market Value declared by customs) | +| Excise Duty | `OMV × 20%` | +| GST | `(OMV + Excise Duty) × 9%` | +| ARF | Tiered (see below) | +| COE | User input (current bidding price for category) | +| Registration Fee | $220 (fixed) | +| VES | Rebate or surcharge based on CO2 band | +| EEAI | 45% of ARF, capped (EVs only, until 2027) | + +#### ARF Tiers (revised Feb 2023) + +| OMV Range | Rate | +|-----------|------| +| First $20,000 | 100% | +| $20,001 – $40,000 | 140% | +| $40,001 – $60,000 | 190% | +| $60,001 – $80,000 | 250% | +| Above $80,000 | 320% | + +```typescript +function calculateARF(omv: number): number { + const tiers = [ + { limit: 20000, rate: 1.00 }, + { limit: 40000, rate: 1.40 }, + { limit: 60000, rate: 1.90 }, + { limit: 80000, rate: 2.50 }, + { limit: Infinity, rate: 3.20 }, + ] + let arf = 0 + let remaining = omv + let previousLimit = 0 + + for (const tier of tiers) { + const taxable = Math.min(remaining, tier.limit - previousLimit) + arf += taxable * tier.rate + remaining -= taxable + previousLimit = tier.limit + if (remaining <= 0) break + } + return arf +} +``` + +**Verification:** OMV $50,000 → `20,000×1.0 + 20,000×1.4 + 10,000×1.9` = `20,000 + 28,000 + 19,000` = **$67,000** + +#### VES Bands + +**2024–2025 Scheme:** + +| Band | CO2 (g/km) | Amount | +|------|-----------|--------| +| A1 | ≤ 90 | $25,000 rebate | +| A2 | 91–120 | $5,000 rebate | +| B | 121–159 | $0 | +| C1 | 160–182 | $15,000 surcharge | +| C2 | > 182 | $25,000 surcharge | + +**2026 Scheme (EV-focused):** + +| Band | CO2 (g/km) | Amount | +|------|-----------|--------| +| A | 0 (EV only) | $22,500 rebate | +| B | 1–159 | $0 | +| C1 | 160–182 | $7,500 surcharge | +| C2 | 183–210 | $22,500 surcharge | +| C3 | > 210 | $35,000 surcharge | + +**2027 Scheme:** + +| Band | CO2 (g/km) | Amount | +|------|-----------|--------| +| A | 0 (EV only) | $20,000 rebate | +| B | 1–159 | $0 | +| C1 | 160–182 | $15,000 surcharge | +| C2 | 183–210 | $30,000 surcharge | +| C3 | > 210 | $45,000 surcharge | + +#### EEAI (EV Early Adoption Incentive) + +| Period | Rebate | Cap | +|--------|--------|-----| +| 2024–2025 | 45% of ARF | $15,000 | +| 2026 | 45% of ARF | $7,500 | +| 2027+ | Ceased | — | + +### 1.2 Financing + +#### LTV Limits (MAS Regulation) + +| Condition | Max LTV | +|-----------|---------| +| OMV ≤ $20,000 | 70% of purchase price | +| OMV > $20,000 | 60% of purchase price | +| Motorcycles | No LTV restriction | + +**Max loan tenure:** 7 years (cars only; no restriction for motorcycles) + +#### Interest Rate Conversion + +Dealers quote a **flat rate** (e.g., 2.78%). The actual cost is the **Effective Interest Rate (EIR)**. + +``` +Approximation: EIR ≈ flatRate × 1.85 (for 7-year loans) +Exact: EIR = 2 × n × flatRate / (n + 1) where n = number of monthly payments +``` + +#### Monthly Installment (EIR-based amortization) + +``` +monthlyRate = EIR / 12 +months = tenure × 12 +monthlyPayment = loanAmount × monthlyRate / (1 - (1 + monthlyRate)^(-months)) +``` + +### 1.3 Road Tax (6-Monthly Rates) + +All formulas produce a **6-monthly** amount. Annual road tax = 6-monthly × 2. + +The `0.782` factor is the current road tax rebate applied by LTA. + +#### Cars — ICE (CC-based) + +| Engine CC | 6-Monthly Base Formula | +|-----------|----------------------| +| ≤ 600 | $200 | +| 601–1,000 | $200 + $0.125 × (CC − 600) | +| 1,001–1,600 | $250 + $0.375 × (CC − 1,000) | +| 1,601–3,000 | $475 + $0.75 × (CC − 1,600) | +| > 3,000 | $1,525 + $1.00 × (CC − 3,000) | + +**Final = Base × 0.782** (road tax rebate factor) + +**Verification:** 1,600cc → `[$250 + $0.375 × 600] × 0.782` = `$475 × 0.782` = **$371.45** per 6 months + +#### Cars — EV (Power-based, kW) + +| Power (kW) | 6-Monthly Base Formula | +|------------|----------------------| +| ≤ 7.5 | $200 | +| 7.5–30 | $200 + $2.00 × (kW − 7.5) | +| 30–230 | $250 + $3.75 × (kW − 30) | +| > 230 | $1,525 + $10.00 × (kW − 230) | + +**Final = (Base × 0.782) + AFC** + +AFC (Additional Flat Component) = $350 per 6 months — proxy for fuel excise duty on EVs. + +#### Motorcycles (CC-based) + +| Engine CC | 6-Monthly Base Formula | +|-----------|----------------------| +| ≤ 200 | $40 | +| 201–1,000 | $40 + $0.15 × (CC − 200) | +| > 1,000 | $160 + $0.30 × (CC − 1,000) | + +**Final = Base × 0.782** + +### 1.4 Depreciation & Residual Value + +#### PARF Rebate Schedule (vehicles registered from Feb 2023) + +| Age at Deregistration | PARF Rebate (% of ARF) | +|-----------------------|------------------------| +| ≤ 5 years | 75% | +| 6 years | 70% | +| 7 years | 65% | +| 8 years | 60% | +| 9 years | 55% | +| 10 years | 50% | +| > 10 years | 0% | + +**PARF cap:** Maximum $60,000 rebate regardless of ARF amount. + +#### COE Rebate + +Pro-rated by remaining months on the 10-year COE: + +``` +coeRebate = coePaid × (remainingMonths / 120) +``` + +#### Scrap Value + +``` +scrapValue = min(parfRebate, $60,000) + coeRebate +``` + +#### Market Value Depreciation + +Default depreciation curve (configurable via periods): + +| Year | Default Annual Rate | +|------|-------------------| +| 1 | 15% | +| 2–3 | 10% | +| 4–5 | 8% | +| 6–10 | 5% | + +User can override with custom `DepreciationPeriod` entries (same pattern as property planner appreciation periods). + +### 1.5 Recurring Costs + +| Cost | Frequency | Default Estimate | +|------|-----------|-----------------| +| Road Tax | Annual | Calculated from CC/kW | +| Motor Insurance | Annual | User input (varies by driver profile) | +| Petrol/Electricity | Monthly | User input | +| Maintenance/Servicing | Annual | $1,500 (car), $500 (motorcycle) | +| Parking (Season) | Monthly | $0 (user input) | +| ERP/Tolls | Monthly | $0 (user input) | + +--- + +## 2. Data Model + +```typescript +// ─── Enums ─────────────────────────────────────────── + +type VehicleCategory = 'car_cat_a' | 'car_cat_b' | 'motorcycle_cat_d' +type FuelType = 'petrol' | 'diesel' | 'electric' | 'hybrid_petrol' | 'hybrid_diesel' +type VehicleCondition = 'new' | 'used' +type VesPeriod = '2024_2025' | '2026' | '2027' + +// ─── Main Scenario ─────────────────────────────────── + +interface VehicleScenario { + id: string + name: string // e.g. "Toyota Corolla Hybrid 2026" + inputs: VehicleInputs + recurringCosts: VehicleRecurringCosts + isIncluded: boolean // Include in financial planning / net worth + createdAt: number + updatedAt: number +} + +// ─── User Inputs ───────────────────────────────────── + +interface VehicleInputs { + // Vehicle details + vehicleCategory: VehicleCategory + fuelType: FuelType + condition: VehicleCondition + engineCapacityCc: number | null // null for EVs + powerKw: number | null // required for EVs (road tax), optional for ICE + co2EmissionsGkm: number | null // for VES calculation + + // Age (for used vehicles) + vehicleAge: number // 0 for new, years for used + remainingCoeMonths: number // 120 for new, user input for used + isPafrEligible: boolean // true for new, user input for used + + // Pricing + omv: number // Open Market Value + listPrice: number // Dealer asking price (for reference/comparison) + coePrice: number // COE premium (current bidding price) + vesPeriod: VesPeriod // Which VES schedule to use + + // Financing + useFinancing: boolean + loanAmount: number // Must respect LTV limits + loanTenureYears: number // Max 7 for cars + interestRateFlat: number // Flat rate quoted by dealer (e.g. 2.78) + + // Purchase timing + purchaseMonth: string // YYYY-MM + + // Depreciation override + depreciationPeriods: DepreciationPeriod[] + + // Payment source + downpaymentCashAccountId: string | null +} + +interface DepreciationPeriod { + id: string + startYear: number + endYear: number | null // null = until end + annualRate: number // percentage, e.g. 15 for 15% +} + +// ─── Recurring Costs ───────────────────────────────── + +interface VehicleRecurringCosts { + insuranceAnnual: number + fuelMonthly: number // petrol or electricity + maintenanceAnnual: number + parkingMonthly: number + erpMonthly: number + otherMonthly: number +} + +// ─── Calculation Results ───────────────────────────── + +interface VehicleCalculationResult { + // Upfront costs breakdown + exciseDuty: number + gst: number + arf: number + registrationFee: number + vesAmount: number // positive = surcharge, negative = rebate + eeaiRebate: number // EV only, always ≤ 0 + totalRegistrationCost: number // OMV + all taxes + COE ± VES ± EEAI + + // Financing + effectiveInterestRate: number + monthlyInstallment: number + totalInterestPaid: number + totalLoanRepayment: number + downpayment: number + maxLtvPercent: number + maxLoanAllowed: number + + // Road tax + sixMonthlyRoadTax: number + annualRoadTax: number + + // Depreciation schedule (10 years) + depreciationSchedule: YearlyDepreciation[] + + // Total Cost of Ownership + totalCostOfOwnership: TotalCostOfOwnership +} + +interface YearlyDepreciation { + year: number + marketValue: number + scrapValue: number // PARF + COE rebate + parfRebate: number + coeRebate: number + annualDepreciation: number // Market value drop from previous year + cumulativeDepreciation: number // Total drop from purchase price +} + +interface TotalCostOfOwnership { + years: number // ownership period (1–10) + upfrontCosts: number // total registration cost (or purchase price for used) + totalLoanInterest: number + totalRoadTax: number + totalInsurance: number + totalFuel: number + totalMaintenance: number + totalParking: number + totalErp: number + totalOther: number + residualValue: number // Scrap/resale value at end of period + netTotalCost: number // All costs minus residual value + costPerMonth: number + costPerYear: number +} +``` + +--- + +## 3. Constants File + +All tax rates, tiers, and fees defined as constants for easy updates when LTA revises rates: + +```typescript +// constants.ts + +export const ARF_TIERS = [ + { limit: 20000, rate: 1.00 }, + { limit: 40000, rate: 1.40 }, + { limit: 60000, rate: 1.90 }, + { limit: 80000, rate: 2.50 }, + { limit: Infinity, rate: 3.20 }, +] as const + +export const ROAD_TAX_ICE_TIERS = [ + { limit: 600, base: 200, rate: 0 }, + { limit: 1000, base: 200, rate: 0.125 }, + { limit: 1600, base: 250, rate: 0.375 }, + { limit: 3000, base: 475, rate: 0.75 }, + { limit: Infinity, base: 1525, rate: 1.0 }, +] as const + +export const ROAD_TAX_EV_TIERS = [ + { limit: 7.5, base: 200, rate: 0 }, + { limit: 30, base: 200, rate: 2.0 }, + { limit: 230, base: 250, rate: 3.75 }, + { limit: Infinity, base: 1525, rate: 10.0 }, +] as const + +export const ROAD_TAX_MOTORCYCLE_TIERS = [ + { limit: 200, base: 40, rate: 0 }, + { limit: 1000, base: 40, rate: 0.15 }, + { limit: Infinity, base: 160, rate: 0.30 }, +] as const + +export const VES_BANDS = { + '2024_2025': [ + { maxCo2: 90, amount: -25000 }, + { maxCo2: 120, amount: -5000 }, + { maxCo2: 159, amount: 0 }, + { maxCo2: 182, amount: 15000 }, + { maxCo2: Infinity, amount: 25000 }, + ], + '2026': [ + { maxCo2: 0, amount: -22500, evOnly: true }, + { maxCo2: 159, amount: 0 }, + { maxCo2: 182, amount: 7500 }, + { maxCo2: 210, amount: 22500 }, + { maxCo2: Infinity, amount: 35000 }, + ], + '2027': [ + { maxCo2: 0, amount: -20000, evOnly: true }, + { maxCo2: 159, amount: 0 }, + { maxCo2: 182, amount: 15000 }, + { maxCo2: 210, amount: 30000 }, + { maxCo2: Infinity, amount: 45000 }, + ], +} as const + +export const EEAI_SCHEDULE = { + '2024_2025': { rate: 0.45, cap: 15000 }, + '2026': { rate: 0.45, cap: 7500 }, + '2027': { rate: 0, cap: 0 }, +} as const + +export const PARF_REBATE_SCHEDULE = [ + { maxAge: 5, rebatePercent: 0.75 }, + { maxAge: 6, rebatePercent: 0.70 }, + { maxAge: 7, rebatePercent: 0.65 }, + { maxAge: 8, rebatePercent: 0.60 }, + { maxAge: 9, rebatePercent: 0.55 }, + { maxAge: 10, rebatePercent: 0.50 }, +] as const + +export const LTV_LIMITS = { + lowOmv: { threshold: 20000, maxLtv: 0.70 }, + highOmv: { maxLtv: 0.60 }, +} as const + +export const MAX_LOAN_TENURE_YEARS = 7 +export const REGISTRATION_FEE = 220 +export const EXCISE_DUTY_RATE = 0.20 +export const GST_RATE = 0.09 +export const ROAD_TAX_REBATE_FACTOR = 0.782 +export const EV_AFC_SIX_MONTHLY = 350 +export const PARF_CAP = 60000 +export const COE_DURATION_MONTHS = 120 + +export const DEFAULT_DEPRECIATION_PERIODS: DepreciationPeriod[] = [ + { id: 'yr1', startYear: 1, endYear: 1, annualRate: 15 }, + { id: 'yr2-3', startYear: 2, endYear: 3, annualRate: 10 }, + { id: 'yr4-5', startYear: 4, endYear: 5, annualRate: 8 }, + { id: 'yr6+', startYear: 6, endYear: null, annualRate: 5 }, +] + +export const DEFAULT_RECURRING_COSTS = { + car: { + maintenanceAnnual: 1500, + insuranceAnnual: 1200, + fuelMonthly: 200, + parkingMonthly: 0, + erpMonthly: 0, + otherMonthly: 0, + }, + motorcycle: { + maintenanceAnnual: 500, + insuranceAnnual: 300, + fuelMonthly: 80, + parkingMonthly: 0, + erpMonthly: 0, + otherMonthly: 0, + }, +} as const +``` + +--- + +## 4. UI Structure + +### Full-Screen Takeover Layout + +Same pattern as Insurance Planner in `Dashboard.tsx`: + +```tsx +// When showVehiclePlanner is true: +
+ +
+
+ +
+``` + +### Tab Navigation (5 Tabs) + +| # | Tab ID | Label | Icon | Description | +|---|--------|-------|------|-------------| +| 1 | `vehicle` | Vehicle & Financing | `Car` | Vehicle details, pricing, loan setup | +| 2 | `costs` | Cost Breakdown | `Receipt` | Waterfall/pie charts of all cost components | +| 3 | `depreciation` | Depreciation | `TrendingDown` | Market value vs scrap value curves | +| 4 | `tco` | Total Cost | `Calculator` | 10-year TCO summary and recurring costs | +| 5 | `scenarios` | Scenarios | `GitCompare` | Side-by-side vehicle comparison | + +### Tab 1: Vehicle & Financing + +**Left Panel (Input Form):** +- Vehicle type selector: 3 cards (Car Cat A / Car Cat B / Motorcycle Cat D) + - Cat A: ≤1,600cc or ≤97kW + - Cat B: >1,600cc or >97kW + - Cat D: Motorcycles +- Fuel type toggle: Petrol | Diesel | Electric | Hybrid +- New vs Used toggle +- OMV input field +- Engine CC input (hidden for EVs) / Power kW input (shown for EVs, optional for ICE) +- CO2 emissions input (for VES) +- COE price input with auto-detected category badge +- List/dealer price input (reference only) + +**Financing Section:** +- Enable financing toggle +- Loan amount input with max LTV indicator badge +- Loan tenure slider (1–7 years) +- Flat interest rate input +- Auto-calculated: EIR, monthly installment, total interest + +**Right Panel (Live Summary Card):** +- Registration cost breakdown (OMV, excise, GST, ARF, COE, VES, EEAI, reg fee) +- Total registration price (highlighted) +- Financing summary (downpayment, monthly payment, total interest) +- Road tax (annual) + +### Tab 2: Cost Breakdown + +- **Waterfall chart:** OMV → +Excise → +GST → +ARF → +COE → ±VES → ±EEAI → +RegFee = Total +- **Pie chart:** Composition of total registration cost +- **VES callout card:** Shows band, rebate/surcharge amount, CO2 rating +- **EEAI callout card:** (if EV) Shows rebate amount and cap +- **Comparison bar:** Your vehicle vs average Cat A / Cat B cost + +### Tab 3: Depreciation & Value + +- **Dual-line chart:** Market value (declining curve) vs Scrap value (PARF + COE rebate) +- **PARF rebate timeline:** Bar chart showing PARF rebate at each year (75% → 50% → 0%) +- **COE rebate curve:** Linear decline over 10 years +- **Optimal deregistration highlight:** Year where (market value - scrap value) gap is smallest +- **Year-by-year table:** Market value, scrap value, PARF rebate, COE rebate, annual depreciation + +### Tab 4: Total Cost of Ownership + +- **10-year TCO summary card:** Net total cost, cost/month, cost/year +- **Ownership period selector:** Slider or segmented control (1–10 years) +- **Recurring costs editor:** Inline editable fields for insurance, fuel, maintenance, parking, ERP +- **Stacked area chart:** Cumulative costs over time (fuel, insurance, road tax, maintenance stacked) +- **Cost per km:** Optional estimate if user provides annual mileage + +### Tab 5: Scenarios + +- **Scenario list:** Cards showing saved vehicle scenarios +- **Add scenario button:** Creates new scenario with default values +- **Side-by-side comparison table:** + - Registration cost + - Monthly installment + - Annual road tax + - Annual depreciation + - 5-year TCO / 10-year TCO + - Cost per month +- **Delta highlighting:** Green/red badges showing which scenario is cheaper and by how much +- **Include in planning toggle:** Per-scenario toggle to include/exclude from net worth + +--- + +## 5. Used Vehicle Support + +Used vehicles change the calculation flow: + +| Factor | New Vehicle | Used Vehicle | +|--------|-------------|--------------| +| COE remaining | 120 months (full) | User input (remaining months) | +| PARF eligibility | Always eligible | Only if < 10 years old on first COE | +| ARF | Calculated + paid | Already paid (factored into purchase price) | +| Registration cost | Fully computed | = purchase price (user input) | +| Road tax | Standard formula | Same formula | +| Depreciation | From registration price | From purchase price | +| Financing LTV | Based on OMV | Same OMV-based rules apply | + +**Additional inputs for used vehicles:** +- `purchasePrice` — replaces computed registration price +- `vehicleAge` — age in years at time of purchase +- `remainingCoeMonths` — months left on COE +- `isPafrEligible` — first COE cycle? +- `originalArf` — needed for PARF rebate calculation (if PARF eligible) + +**Deregistration value (used):** +- If PARF-eligible: PARF rebate based on original ARF and age at deregistration +- COE rebate based on remaining months from original COE +- If COE was renewed (second cycle): No PARF rebate, only COE rebate on renewed COE + +--- + +## 6. Net Worth Integration + +When user saves a scenario with `isIncluded: true`, create the following entities in the financial plan: + +| Entity | Type | Category | Details | +|--------|------|----------|---------| +| `{name}` | Asset | `vehicle` | `currentValue` = registration/purchase price, depreciation via negative growth rate | +| `{name} Loan` | Liability | `vehicle_loan` | `currentBalance` = loan amount, `interestRate` = EIR, `minimumPayment` = monthly installment | +| `{name} Road Tax` | Expense | `transport` | `amount` = annual road tax, frequency = annual | +| `{name} Insurance` | Expense | `transport` | `amount` = annual insurance, frequency = annual | +| `{name} Fuel` | Expense | `transport` | `amount` = monthly fuel, frequency = monthly | +| `{name} Maintenance` | Expense | `transport` | `amount` = annual maintenance, frequency = annual | +| `{name} Parking` | Expense | `transport` | `amount` = monthly parking, frequency = monthly (if > 0) | + +**Note:** The asset's `annualGrowthRate` should be set to the negative of the first depreciation period's rate (e.g., -15% for year 1). For a more accurate projection, the asset value should be recalculated each year based on the depreciation periods schedule. + +--- + +## 7. Store Design (Zustand) + +```typescript +interface VehiclePlannerState { + // Scenarios + scenarios: VehicleScenario[] + activeScenarioId: string | null + + // UI state + activeTab: VehicleTabId + + // Actions + setActiveTab: (tab: VehicleTabId) => void + addScenario: (scenario: VehicleScenario) => void + updateScenario: (id: string, updates: Partial) => void + deleteScenario: (id: string) => void + setActiveScenario: (id: string | null) => void + duplicateScenario: (id: string) => void + toggleIncluded: (id: string) => void +} + +type VehicleTabId = 'vehicle' | 'costs' | 'depreciation' | 'tco' | 'scenarios' +``` + +Uses `persist` middleware with `localStorage` (same as Insurance Planner's `coverageGuidelinesStore`). + +--- + +## 8. Calculation Verification + +### ARF Verification + +| OMV | Expected ARF | Breakdown | +|-----|-------------|-----------| +| $15,000 | $15,000 | 15,000 × 1.0 | +| $30,000 | $34,000 | 20,000 × 1.0 + 10,000 × 1.4 | +| $50,000 | $67,000 | 20,000 × 1.0 + 20,000 × 1.4 + 10,000 × 1.9 | +| $70,000 | $111,000 | 20,000 × 1.0 + 20,000 × 1.4 + 20,000 × 1.9 + 10,000 × 2.5 | +| $100,000 | $182,000 | 20,000 × 1.0 + 20,000 × 1.4 + 20,000 × 1.9 + 20,000 × 2.5 + 20,000 × 3.2 | + +### Road Tax Verification + +| Vehicle | CC/kW | Expected 6-Monthly | +|---------|-------|-------------------| +| 1,000cc petrol | 1,000 | [$200 + $0.125 × 400] × 0.782 = $250 × 0.782 = $195.50 | +| 1,600cc petrol | 1,600 | [$250 + $0.375 × 600] × 0.782 = $475 × 0.782 = $371.45 | +| 2,000cc petrol | 2,000 | [$475 + $0.75 × 400] × 0.782 = $775 × 0.782 = $606.05 | +| 150kW EV | 150 | {[$250 + $3.75 × 120] × 0.782} + $350 = $546.90 + $350 = $896.90 | +| 200cc motorcycle | 200 | $40 × 0.782 = $31.28 | + +### LTV Verification + +| OMV | Registration Price (approx) | Max LTV | Max Loan | +|-----|---------------------------|---------|----------| +| $15,000 | ~$60,000 | 70% | ~$42,000 | +| $25,000 | ~$85,000 | 60% | ~$51,000 | +| $50,000 | ~$165,000 | 60% | ~$99,000 | + +--- + +## 9. Future Enhancements (Out of Scope) + +- Backend persistence (follow Insurance Planner's persistence pattern) +- COE bidding history charts and price predictions +- Integration with OneMotoring / SgCarMart data feeds +- Multi-vehicle fleet management +- Ride-hailing vs ownership cost comparison +- Carbon offset / environmental impact calculator +- Seasonal parking rate variations by location