website/hub/services/static/js/roi-calculator.js
Tobias Brunner 51d80364c0
fix critical issues in ROI calculator
- Replace hardcoded Django template tags in JavaScript with runtime CSRF token retrieval
- Add comprehensive error handling for Chart.js dependencies and missing DOM elements
- Enhance input validation with safe fallbacks for malformed number parsing
- Add graceful degradation when external libraries fail to load

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-22 08:33:54 +02:00

1142 lines
44 KiB
JavaScript

class ROICalculator {
constructor() {
this.scenarios = {
conservative: {
name: 'Conservative',
enabled: true,
churnRate: 0.02,
phases: [
{ months: 6, newInstancesPerMonth: 50 },
{ months: 6, newInstancesPerMonth: 75 },
{ months: 12, newInstancesPerMonth: 100 },
{ months: 12, newInstancesPerMonth: 150 }
]
},
moderate: {
name: 'Moderate',
enabled: true,
churnRate: 0.03,
phases: [
{ months: 6, newInstancesPerMonth: 100 },
{ months: 6, newInstancesPerMonth: 200 },
{ months: 12, newInstancesPerMonth: 300 },
{ months: 12, newInstancesPerMonth: 400 }
]
},
aggressive: {
name: 'Aggressive',
enabled: true,
churnRate: 0.05,
phases: [
{ months: 6, newInstancesPerMonth: 200 },
{ months: 6, newInstancesPerMonth: 400 },
{ months: 12, newInstancesPerMonth: 600 },
{ months: 12, newInstancesPerMonth: 800 }
]
}
};
this.charts = {};
this.monthlyData = {};
this.results = {};
// Initialize charts
this.initializeCharts();
// Initial calculation
this.updateCalculations();
}
getInputValues() {
try {
// Get investment model with fallback
const investmentModelElement = document.querySelector('input[name="investment-model"]:checked');
const investmentModel = investmentModelElement ? investmentModelElement.value : 'direct';
// Get investment amount with validation
const investmentAmountElement = document.getElementById('investment-amount');
const investmentAmountValue = investmentAmountElement ? investmentAmountElement.getAttribute('data-value') : '500000';
const investmentAmount = parseFloat(investmentAmountValue) || 500000;
// Get timeframe with validation
const timeframeElement = document.getElementById('timeframe');
const timeframe = timeframeElement ? parseInt(timeframeElement.value) || 3 : 3;
// Get loan interest rate with validation
const loanRateElement = document.getElementById('loan-interest-rate');
const loanInterestRate = loanRateElement ? (parseFloat(loanRateElement.value) || 5.0) / 100 : 0.05;
// Get revenue per instance with validation
const revenueElement = document.getElementById('revenue-per-instance');
const revenuePerInstance = revenueElement ? parseFloat(revenueElement.value) || 50 : 50;
// Get servala share with validation
const shareElement = document.getElementById('servala-share');
const servalaShare = shareElement ? (parseFloat(shareElement.value) || 25) / 100 : 0.25;
// Get grace period with validation
const graceElement = document.getElementById('grace-period');
const gracePeriod = graceElement ? parseInt(graceElement.value) || 6 : 6;
return {
investmentAmount,
timeframe,
investmentModel,
loanInterestRate,
revenuePerInstance,
servalaShare,
gracePeriod
};
} catch (error) {
console.error('Error getting input values:', error);
// Return safe default values
return {
investmentAmount: 500000,
timeframe: 3,
investmentModel: 'direct',
loanInterestRate: 0.05,
revenuePerInstance: 50,
servalaShare: 0.25,
gracePeriod: 6
};
}
}
calculateScenario(scenarioKey, inputs) {
const scenario = this.scenarios[scenarioKey];
if (!scenario.enabled) return null;
// Calculate loan payment if using loan model
let monthlyLoanPayment = 0;
if (inputs.investmentModel === 'loan') {
const monthlyRate = inputs.loanInterestRate / 12;
const numPayments = inputs.timeframe * 12;
// Calculate fixed monthly payment using amortization formula
if (monthlyRate > 0) {
monthlyLoanPayment = inputs.investmentAmount *
(monthlyRate * Math.pow(1 + monthlyRate, numPayments)) /
(Math.pow(1 + monthlyRate, numPayments) - 1);
} else {
monthlyLoanPayment = inputs.investmentAmount / numPayments;
}
}
// Calculate investment scaling factor (only for direct investment)
// Base investment of CHF 500,000 = 1.0x multiplier
// Higher investments get multiplicative benefits for instance acquisition
const baseInvestment = 500000;
const investmentScaleFactor = inputs.investmentModel === 'loan' ? 1.0 : Math.sqrt(inputs.investmentAmount / baseInvestment);
// Calculate churn reduction factor based on investment (only for direct investment)
// Higher investment = better customer success = lower churn
const churnReductionFactor = inputs.investmentModel === 'loan' ? 1.0 : Math.max(0.7, 1 - (inputs.investmentAmount - baseInvestment) / 2000000 * 0.3);
// Calculate adjusted churn rate with investment-based reduction
const adjustedChurnRate = scenario.churnRate * churnReductionFactor;
const totalMonths = inputs.timeframe * 12;
const monthlyData = [];
let currentInstances = 0;
let cumulativeCSPRevenue = 0;
let cumulativeServalaRevenue = 0;
let breakEvenMonth = null;
// Track phase progression
let currentPhase = 0;
let monthsInCurrentPhase = 0;
for (let month = 1; month <= totalMonths; month++) {
// Determine current phase
if (monthsInCurrentPhase >= scenario.phases[currentPhase].months && currentPhase < scenario.phases.length - 1) {
currentPhase++;
monthsInCurrentPhase = 0;
}
// Calculate new instances for this month with investment scaling
const baseNewInstances = scenario.phases[currentPhase].newInstancesPerMonth;
const newInstances = Math.floor(baseNewInstances * investmentScaleFactor);
// Calculate churn using the pre-calculated adjusted churn rate
const churnedInstances = Math.floor(currentInstances * adjustedChurnRate);
// Update total instances
currentInstances = currentInstances + newInstances - churnedInstances;
// Calculate revenue based on investment model
let cspRevenue, servalaRevenue, monthlyRevenue;
if (inputs.investmentModel === 'loan') {
// Loan model: CSP receives fixed monthly loan payment
cspRevenue = monthlyLoanPayment;
servalaRevenue = 0;
monthlyRevenue = monthlyLoanPayment;
} else {
// Direct investment model: Revenue based on instances
monthlyRevenue = currentInstances * inputs.revenuePerInstance;
// Determine revenue split based on grace period
if (month <= inputs.gracePeriod) {
cspRevenue = monthlyRevenue;
servalaRevenue = 0;
} else {
cspRevenue = monthlyRevenue * (1 - inputs.servalaShare);
servalaRevenue = monthlyRevenue * inputs.servalaShare;
}
}
// Update cumulative revenue
cumulativeCSPRevenue += cspRevenue;
cumulativeServalaRevenue += servalaRevenue;
// Check for break-even
if (breakEvenMonth === null && cumulativeCSPRevenue >= inputs.investmentAmount) {
breakEvenMonth = month;
}
monthlyData.push({
month,
scenario: scenario.name,
newInstances,
churnedInstances,
totalInstances: currentInstances,
monthlyRevenue,
cspRevenue,
servalaRevenue,
cumulativeCSPRevenue,
cumulativeServalaRevenue,
investmentScaleFactor: investmentScaleFactor,
adjustedChurnRate: adjustedChurnRate
});
monthsInCurrentPhase++;
}
// Calculate final metrics
const totalRevenue = cumulativeCSPRevenue + cumulativeServalaRevenue;
const roi = ((cumulativeCSPRevenue - inputs.investmentAmount) / inputs.investmentAmount) * 100;
return {
scenario: scenario.name,
investmentModel: inputs.investmentModel,
finalInstances: currentInstances,
totalRevenue,
cspRevenue: cumulativeCSPRevenue,
servalaRevenue: cumulativeServalaRevenue,
roi,
breakEvenMonth,
monthlyData,
investmentScaleFactor: investmentScaleFactor,
adjustedChurnRate: adjustedChurnRate * 100
};
}
updateCalculations() {
const inputs = this.getInputValues();
this.results = {};
this.monthlyData = {};
// Show loading spinner
document.getElementById('loading-spinner').style.display = 'block';
document.getElementById('summary-metrics').style.display = 'none';
// Calculate results for each enabled scenario
Object.keys(this.scenarios).forEach(scenarioKey => {
const result = this.calculateScenario(scenarioKey, inputs);
if (result) {
this.results[scenarioKey] = result;
this.monthlyData[scenarioKey] = result.monthlyData;
}
});
// Update UI
setTimeout(() => {
this.updateSummaryMetrics();
this.updateCharts();
this.updateComparisonTable();
this.updateMonthlyBreakdown();
// Hide loading spinner
document.getElementById('loading-spinner').style.display = 'none';
document.getElementById('summary-metrics').style.display = 'flex';
}, 500);
}
updateSummaryMetrics() {
const enabledResults = Object.values(this.results);
if (enabledResults.length === 0) {
document.getElementById('total-instances').textContent = '0';
document.getElementById('total-revenue').textContent = 'CHF 0';
document.getElementById('roi-percentage').textContent = '0%';
document.getElementById('breakeven-time').textContent = 'N/A';
return;
}
// Calculate averages across enabled scenarios
const avgInstances = Math.round(enabledResults.reduce((sum, r) => sum + r.finalInstances, 0) / enabledResults.length);
const avgRevenue = enabledResults.reduce((sum, r) => sum + r.totalRevenue, 0) / enabledResults.length;
const avgROI = enabledResults.reduce((sum, r) => sum + r.roi, 0) / enabledResults.length;
const avgBreakeven = enabledResults.filter(r => r.breakEvenMonth).reduce((sum, r) => sum + r.breakEvenMonth, 0) / enabledResults.filter(r => r.breakEvenMonth).length;
document.getElementById('total-instances').textContent = avgInstances.toLocaleString();
document.getElementById('total-revenue').textContent = this.formatCurrency(avgRevenue);
document.getElementById('roi-percentage').textContent = this.formatPercentage(avgROI);
document.getElementById('breakeven-time').textContent = isNaN(avgBreakeven) ? 'N/A' : `${Math.round(avgBreakeven)} months`;
}
initializeCharts() {
// Check if Chart.js is available
if (typeof Chart === 'undefined') {
console.error('Chart.js library not loaded. Charts will not be available.');
this.showChartError('Chart.js library failed to load. Please refresh the page.');
return;
}
try {
// Instance Growth Chart
const instanceCanvas = document.getElementById('instanceGrowthChart');
if (!instanceCanvas) {
console.error('Instance growth chart canvas not found');
return;
}
const instanceCtx = instanceCanvas.getContext('2d');
this.charts.instanceGrowth = new Chart(instanceCtx, {
type: 'line',
data: { labels: [], datasets: [] },
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'top' }
},
scales: {
y: {
beginAtZero: true,
title: { display: true, text: 'Total Instances' }
},
x: {
title: { display: true, text: 'Month' }
}
}
}
});
// Revenue Chart
const revenueCanvas = document.getElementById('revenueChart');
if (!revenueCanvas) {
console.error('Revenue chart canvas not found');
return;
}
const revenueCtx = revenueCanvas.getContext('2d');
this.charts.revenue = new Chart(revenueCtx, {
type: 'line',
data: { labels: [], datasets: [] },
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'top' }
},
scales: {
y: {
beginAtZero: true,
title: { display: true, text: 'Cumulative Revenue (CHF)' }
},
x: {
title: { display: true, text: 'Month' }
}
}
}
});
// Cash Flow Chart
const cashFlowCanvas = document.getElementById('cashFlowChart');
if (!cashFlowCanvas) {
console.error('Cash flow chart canvas not found');
return;
}
const cashFlowCtx = cashFlowCanvas.getContext('2d');
this.charts.cashFlow = new Chart(cashFlowCtx, {
type: 'bar',
data: { labels: [], datasets: [] },
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'top' }
},
scales: {
y: {
title: { display: true, text: 'Monthly Cash Flow (CHF)' }
},
x: {
title: { display: true, text: 'Month' }
}
}
}
});
} catch (error) {
console.error('Error initializing charts:', error);
this.showChartError('Failed to initialize charts. Please refresh the page.');
}
}
showChartError(message) {
// Show error message in place of charts
const chartContainers = ['instanceGrowthChart', 'revenueChart', 'cashFlowChart'];
chartContainers.forEach(containerId => {
const container = document.getElementById(containerId);
if (container) {
container.style.display = 'flex';
container.style.justifyContent = 'center';
container.style.alignItems = 'center';
container.style.minHeight = '300px';
container.style.backgroundColor = '#f8f9fa';
container.style.border = '1px solid #dee2e6';
container.style.borderRadius = '4px';
container.innerHTML = `<div class="text-muted text-center"><i class="bi bi-exclamation-triangle"></i><br>${message}</div>`;
}
});
}
updateCharts() {
const scenarios = Object.keys(this.results);
if (scenarios.length === 0 || !this.charts.instanceGrowth) return;
const colors = {
conservative: '#28a745',
moderate: '#ffc107',
aggressive: '#dc3545'
};
// Get month labels
const maxMonths = Math.max(...scenarios.map(s => this.monthlyData[s].length));
const monthLabels = Array.from({ length: maxMonths }, (_, i) => `M${i + 1}`);
// Update Instance Growth Chart
this.charts.instanceGrowth.data.labels = monthLabels;
this.charts.instanceGrowth.data.datasets = scenarios.map(scenario => ({
label: this.scenarios[scenario].name,
data: this.monthlyData[scenario].map(d => d.totalInstances),
borderColor: colors[scenario],
backgroundColor: colors[scenario] + '20',
tension: 0.4
}));
this.charts.instanceGrowth.update();
// Update Revenue Chart
this.charts.revenue.data.labels = monthLabels;
this.charts.revenue.data.datasets = scenarios.map(scenario => ({
label: this.scenarios[scenario].name + ' (CSP)',
data: this.monthlyData[scenario].map(d => d.cumulativeCSPRevenue),
borderColor: colors[scenario],
backgroundColor: colors[scenario] + '20',
tension: 0.4
}));
this.charts.revenue.update();
// Update Cash Flow Chart (show average across scenarios)
const avgCashFlow = monthLabels.map((_, monthIndex) => {
const monthData = scenarios.map(scenario =>
this.monthlyData[scenario][monthIndex]?.cspRevenue || 0
);
return monthData.reduce((sum, val) => sum + val, 0) / monthData.length;
});
this.charts.cashFlow.data.labels = monthLabels;
this.charts.cashFlow.data.datasets = [{
label: 'Average Monthly CSP Revenue',
data: avgCashFlow,
backgroundColor: '#007bff'
}];
this.charts.cashFlow.update();
}
updateComparisonTable() {
const tbody = document.getElementById('comparison-tbody');
tbody.innerHTML = '';
Object.values(this.results).forEach(result => {
const modelLabel = result.investmentModel === 'loan' ?
'<span class="badge bg-warning">Loan</span>' :
'<span class="badge bg-success">Direct</span>';
const row = tbody.insertRow();
row.innerHTML = `
<td><strong>${result.scenario}</strong></td>
<td>${modelLabel}</td>
<td>${result.finalInstances.toLocaleString()}</td>
<td>${this.formatCurrencyDetailed(result.totalRevenue)}</td>
<td>${this.formatCurrencyDetailed(result.cspRevenue)}</td>
<td>${this.formatCurrencyDetailed(result.servalaRevenue)}</td>
<td class="${result.roi >= 0 ? 'text-success' : 'text-danger'}">${this.formatPercentage(result.roi)}</td>
<td>${result.breakEvenMonth ? result.breakEvenMonth + ' months' : 'N/A'}</td>
`;
});
}
updateMonthlyBreakdown() {
const tbody = document.getElementById('monthly-tbody');
tbody.innerHTML = '';
// Combine all monthly data and sort by month and scenario
const allData = [];
Object.keys(this.monthlyData).forEach(scenario => {
this.monthlyData[scenario].forEach(monthData => {
allData.push(monthData);
});
});
allData.sort((a, b) => a.month - b.month || a.scenario.localeCompare(b.scenario));
allData.forEach(data => {
const row = tbody.insertRow();
row.innerHTML = `
<td>${data.month}</td>
<td><span class="badge bg-secondary">${data.scenario}</span></td>
<td>${data.newInstances}</td>
<td>${data.churnedInstances}</td>
<td>${data.totalInstances.toLocaleString()}</td>
<td>${this.formatCurrencyDetailed(data.monthlyRevenue)}</td>
<td>${this.formatCurrencyDetailed(data.cspRevenue)}</td>
<td>${this.formatCurrencyDetailed(data.servalaRevenue)}</td>
<td>${this.formatCurrencyDetailed(data.cumulativeCSPRevenue)}</td>
`;
});
}
formatCurrency(amount) {
// Use compact notation for large numbers in metric cards
if (amount >= 1000000) {
return new Intl.NumberFormat('de-CH', {
style: 'currency',
currency: 'CHF',
notation: 'compact',
minimumFractionDigits: 0,
maximumFractionDigits: 1
}).format(amount);
} else {
return new Intl.NumberFormat('de-CH', {
style: 'currency',
currency: 'CHF',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(amount);
}
}
formatCurrencyDetailed(amount) {
// Use full formatting for detailed views (tables, exports)
return new Intl.NumberFormat('de-CH', {
style: 'currency',
currency: 'CHF',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(amount);
}
formatPercentage(value) {
return new Intl.NumberFormat('de-CH', {
style: 'percent',
minimumFractionDigits: 1,
maximumFractionDigits: 1
}).format(value / 100);
}
}
// Initialize calculator
let calculator;
document.addEventListener('DOMContentLoaded', function () {
// Initialize calculator
calculator = new ROICalculator();
// Initialize tooltips - check if Bootstrap is available
initializeTooltips();
// Check if export libraries are loaded and enable/disable buttons accordingly
checkExportLibraries();
});
// Initialize tooltips with Bootstrap (loaded directly via CDN)
function initializeTooltips() {
// Wait for Bootstrap to be available
if (typeof bootstrap !== 'undefined' && bootstrap.Tooltip) {
try {
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl);
});
} catch (error) {
console.warn('Failed to initialize Bootstrap tooltips:', error);
initializeNativeTooltips();
}
} else {
// Retry after a short delay for deferred scripts
setTimeout(initializeTooltips, 100);
}
}
function initializeNativeTooltips() {
var tooltipElements = document.querySelectorAll('[data-bs-toggle="tooltip"]');
tooltipElements.forEach(function (element) {
// Ensure the title attribute is set for native tooltips
var tooltipContent = element.getAttribute('title');
if (!tooltipContent) {
// Get tooltip content from data-bs-original-title or title attribute
tooltipContent = element.getAttribute('data-bs-original-title');
if (tooltipContent) {
element.setAttribute('title', tooltipContent);
}
}
});
}
// Check if export libraries are loaded
function checkExportLibraries() {
const pdfButton = document.querySelector('button[onclick="exportToPDF()"]');
if (typeof window.jspdf === 'undefined') {
if (pdfButton) {
pdfButton.disabled = true;
pdfButton.innerHTML = '<i class="bi bi-file-pdf"></i> Loading PDF...';
}
// Retry after a short delay
setTimeout(() => {
if (typeof window.jspdf !== 'undefined' && pdfButton) {
pdfButton.disabled = false;
pdfButton.innerHTML = '<i class="bi bi-file-pdf"></i> Export PDF Report';
}
}, 2000);
}
}
// Input update functions
function formatNumberWithCommas(num) {
return parseInt(num).toLocaleString('en-US');
}
function parseFormattedNumber(str) {
if (typeof str !== 'string') {
return 0;
}
// Remove all non-numeric characters except decimal points and commas
const cleaned = str.replace(/[^\d,.-]/g, '');
// Handle empty string
if (!cleaned) {
return 0;
}
// Remove commas and parse as float
const result = parseFloat(cleaned.replace(/,/g, ''));
// Return 0 for invalid numbers or NaN
return isNaN(result) ? 0 : result;
}
function handleInvestmentAmountInput(input) {
if (!input || typeof input.value !== 'string') {
console.error('Invalid input element provided to handleInvestmentAmountInput');
return;
}
try {
// Remove non-numeric characters except commas
let value = input.value.replace(/[^\d,]/g, '');
// Parse the numeric value
let numericValue = parseFormattedNumber(value);
// Enforce min/max limits
const minValue = 100000;
const maxValue = 2000000;
if (numericValue < minValue) {
numericValue = minValue;
} else if (numericValue > maxValue) {
numericValue = maxValue;
}
// Update the data attribute with the raw numeric value
input.setAttribute('data-value', numericValue.toString());
// Format and display the value with commas
input.value = formatNumberWithCommas(numericValue);
// Update the slider if it exists
const slider = document.getElementById('investment-slider');
if (slider) {
slider.value = numericValue;
}
// Trigger calculations
updateCalculations();
} catch (error) {
console.error('Error handling investment amount input:', error);
// Set a safe default value
input.setAttribute('data-value', '500000');
input.value = '500,000';
}
}
function updateInvestmentAmount(value) {
const input = document.getElementById('investment-amount');
input.setAttribute('data-value', value);
input.value = formatNumberWithCommas(value);
updateCalculations();
}
function updateRevenuePerInstance(value) {
document.getElementById('revenue-per-instance').value = value;
updateCalculations();
}
function updateServalaShare(value) {
document.getElementById('servala-share').value = value;
updateCalculations();
}
function updateGracePeriod(value) {
document.getElementById('grace-period').value = value;
updateCalculations();
}
function updateCalculations() {
if (calculator) {
calculator.updateCalculations();
}
}
// Advanced parameter functions
function updateScenarioChurn(scenarioKey, churnRate) {
calculator.scenarios[scenarioKey].churnRate = parseFloat(churnRate) / 100;
updateCalculations();
}
function updateScenarioPhase(scenarioKey, phaseIndex, newInstancesPerMonth) {
calculator.scenarios[scenarioKey].phases[phaseIndex].newInstancesPerMonth = parseInt(newInstancesPerMonth);
updateCalculations();
}
function resetAdvancedParameters() {
if (confirm('Reset all advanced parameters to default values?')) {
// Reset Conservative
calculator.scenarios.conservative.churnRate = 0.02;
calculator.scenarios.conservative.phases = [
{ months: 6, newInstancesPerMonth: 50 },
{ months: 6, newInstancesPerMonth: 75 },
{ months: 12, newInstancesPerMonth: 100 },
{ months: 12, newInstancesPerMonth: 150 }
];
// Reset Moderate
calculator.scenarios.moderate.churnRate = 0.03;
calculator.scenarios.moderate.phases = [
{ months: 6, newInstancesPerMonth: 100 },
{ months: 6, newInstancesPerMonth: 200 },
{ months: 12, newInstancesPerMonth: 300 },
{ months: 12, newInstancesPerMonth: 400 }
];
// Reset Aggressive
calculator.scenarios.aggressive.churnRate = 0.05;
calculator.scenarios.aggressive.phases = [
{ months: 6, newInstancesPerMonth: 200 },
{ months: 6, newInstancesPerMonth: 400 },
{ months: 12, newInstancesPerMonth: 600 },
{ months: 12, newInstancesPerMonth: 800 }
];
// Update UI inputs
document.getElementById('conservative-churn').value = '2.0';
document.getElementById('conservative-phase-0').value = '50';
document.getElementById('conservative-phase-1').value = '75';
document.getElementById('conservative-phase-2').value = '100';
document.getElementById('conservative-phase-3').value = '150';
document.getElementById('moderate-churn').value = '3.0';
document.getElementById('moderate-phase-0').value = '100';
document.getElementById('moderate-phase-1').value = '200';
document.getElementById('moderate-phase-2').value = '300';
document.getElementById('moderate-phase-3').value = '400';
document.getElementById('aggressive-churn').value = '5.0';
document.getElementById('aggressive-phase-0').value = '200';
document.getElementById('aggressive-phase-1').value = '400';
document.getElementById('aggressive-phase-2').value = '600';
document.getElementById('aggressive-phase-3').value = '800';
updateCalculations();
}
}
// Scenario management
function toggleScenario(scenarioKey) {
const enabled = document.getElementById(scenarioKey + '-enabled').checked;
calculator.scenarios[scenarioKey].enabled = enabled;
const card = document.getElementById(scenarioKey + '-card');
if (enabled) {
card.classList.add('active');
card.classList.remove('disabled');
} else {
card.classList.remove('active');
card.classList.add('disabled');
}
updateCalculations();
}
// UI utility functions
function toggleCollapsible(elementId) {
const content = document.getElementById(elementId);
const header = content.previousElementSibling;
const chevron = header.querySelector('.bi-chevron-down, .bi-chevron-up');
if (content.classList.contains('show')) {
content.classList.remove('show');
chevron.classList.remove('bi-chevron-up');
chevron.classList.add('bi-chevron-down');
} else {
content.classList.add('show');
chevron.classList.remove('bi-chevron-down');
chevron.classList.add('bi-chevron-up');
}
}
// Export functions
function exportToPDF() {
// Check if jsPDF is available
if (typeof window.jspdf === 'undefined') {
alert('PDF export library is loading. Please try again in a moment.');
return;
}
try {
const { jsPDF } = window.jspdf;
const doc = new jsPDF();
// Add header
doc.setFontSize(20);
doc.setTextColor(0, 123, 255); // Bootstrap primary blue
doc.text('CSP ROI Calculator Report', 20, 25);
// Add generation date
doc.setFontSize(10);
doc.setTextColor(100, 100, 100);
const currentDate = new Date().toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
doc.text(`Generated on: ${currentDate}`, 20, 35);
// Reset text color
doc.setTextColor(0, 0, 0);
// Add input parameters section
doc.setFontSize(16);
doc.text('Investment Parameters', 20, 50);
const inputs = calculator.getInputValues();
let yPos = 60;
doc.setFontSize(11);
const params = [
['Investment Amount:', calculator.formatCurrencyDetailed(inputs.investmentAmount)],
['Investment Timeframe:', `${inputs.timeframe} years`],
['Investment Model:', inputs.investmentModel === 'loan' ? 'Loan Model' : 'Direct Investment'],
...(inputs.investmentModel === 'loan' ? [['Loan Interest Rate:', `${(inputs.loanInterestRate * 100).toFixed(1)}%`]] : []),
['Revenue per Instance:', calculator.formatCurrencyDetailed(inputs.revenuePerInstance)],
...(inputs.investmentModel === 'direct' ? [
['Servala Revenue Share:', `${(inputs.servalaShare * 100).toFixed(0)}%`],
['Grace Period:', `${inputs.gracePeriod} months`]
] : [])
];
params.forEach(([label, value]) => {
doc.text(label, 25, yPos);
doc.text(value, 80, yPos);
yPos += 8;
});
// Add scenario results section
yPos += 10;
doc.setFontSize(16);
doc.text('Scenario Results', 20, yPos);
yPos += 10;
doc.setFontSize(11);
Object.values(calculator.results).forEach(result => {
if (yPos > 250) {
doc.addPage();
yPos = 20;
}
// Scenario header
doc.setFontSize(14);
doc.setTextColor(0, 123, 255);
doc.text(`${result.scenario} Scenario`, 25, yPos);
yPos += 10;
doc.setFontSize(11);
doc.setTextColor(0, 0, 0);
const resultData = [
['Final Instances:', result.finalInstances.toLocaleString()],
['Total Revenue:', calculator.formatCurrencyDetailed(result.totalRevenue)],
['CSP Revenue:', calculator.formatCurrencyDetailed(result.cspRevenue)],
['Servala Revenue:', calculator.formatCurrencyDetailed(result.servalaRevenue)],
['ROI:', calculator.formatPercentage(result.roi)],
['Break-even:', result.breakEvenMonth ? `${result.breakEvenMonth} months` : 'Not achieved']
];
resultData.forEach(([label, value]) => {
doc.text(label, 30, yPos);
doc.text(value, 90, yPos);
yPos += 7;
});
yPos += 8;
});
// Add summary section
if (yPos > 220) {
doc.addPage();
yPos = 20;
}
yPos += 10;
doc.setFontSize(16);
doc.text('Executive Summary', 20, yPos);
yPos += 10;
doc.setFontSize(11);
const enabledResults = Object.values(calculator.results);
if (enabledResults.length > 0) {
const avgROI = enabledResults.reduce((sum, r) => sum + r.roi, 0) / enabledResults.length;
const avgBreakeven = enabledResults.filter(r => r.breakEvenMonth).reduce((sum, r) => sum + r.breakEvenMonth, 0) / enabledResults.filter(r => r.breakEvenMonth).length;
doc.text(`This analysis evaluates ${enabledResults.length} growth scenario(s) over a ${inputs.timeframe}-year period.`, 25, yPos);
yPos += 8;
doc.text(`Average projected ROI: ${calculator.formatPercentage(avgROI)}`, 25, yPos);
yPos += 8;
if (!isNaN(avgBreakeven)) {
doc.text(`Average break-even timeline: ${Math.round(avgBreakeven)} months`, 25, yPos);
yPos += 8;
}
yPos += 5;
doc.text('Key assumptions:', 25, yPos);
yPos += 8;
doc.text('• Growth rates based on market analysis and industry benchmarks', 30, yPos);
yPos += 6;
doc.text('• Churn rates reflect typical SaaS industry standards', 30, yPos);
yPos += 6;
doc.text('• Revenue calculations include grace period provisions', 30, yPos);
}
// Add footer
const pageCount = doc.internal.getNumberOfPages();
for (let i = 1; i <= pageCount; i++) {
doc.setPage(i);
doc.setFontSize(8);
doc.setTextColor(150, 150, 150);
doc.text(`Page ${i} of ${pageCount}`, doc.internal.pageSize.getWidth() - 30, doc.internal.pageSize.getHeight() - 10);
doc.text('Generated by Servala CSP ROI Calculator', 20, doc.internal.pageSize.getHeight() - 10);
}
// Save the PDF
const filename = `servala-csp-roi-report-${new Date().toISOString().split('T')[0]}.pdf`;
doc.save(filename);
} catch (error) {
console.error('PDF Export Error:', error);
alert('An error occurred while generating the PDF. Please try again or export as CSV instead.');
}
}
function exportToCSV() {
try {
// Create comprehensive CSV with summary and detailed data
let csvContent = 'CSP ROI Calculator Export\n';
csvContent += `Generated on: ${new Date().toLocaleDateString()}\n\n`;
// Add input parameters
csvContent += 'INPUT PARAMETERS\n';
const inputs = calculator.getInputValues();
csvContent += `Investment Amount,${inputs.investmentAmount}\n`;
csvContent += `Timeframe (years),${inputs.timeframe}\n`;
csvContent += `Investment Model,${inputs.investmentModel === 'loan' ? 'Loan Model' : 'Direct Investment'}\n`;
if (inputs.investmentModel === 'loan') {
csvContent += `Loan Interest Rate (%),${(inputs.loanInterestRate * 100).toFixed(1)}\n`;
}
csvContent += `Revenue per Instance,${inputs.revenuePerInstance}\n`;
if (inputs.investmentModel === 'direct') {
csvContent += `Servala Share (%),${(inputs.servalaShare * 100).toFixed(0)}\n`;
csvContent += `Grace Period (months),${inputs.gracePeriod}\n`;
}
csvContent += '\n';
// Add scenario summary
csvContent += 'SCENARIO SUMMARY\n';
csvContent += 'Scenario,Investment Model,Final Instances,Total Revenue,CSP Revenue,Servala Revenue,ROI (%),Break-even (months)\n';
Object.values(calculator.results).forEach(result => {
const modelText = result.investmentModel === 'loan' ? 'Loan' : 'Direct';
csvContent += `${result.scenario},${modelText},${result.finalInstances},${result.totalRevenue.toFixed(2)},${result.cspRevenue.toFixed(2)},${result.servalaRevenue.toFixed(2)},${result.roi.toFixed(2)},${result.breakEvenMonth || 'N/A'}\n`;
});
csvContent += '\n';
// Add detailed monthly data
csvContent += 'MONTHLY BREAKDOWN\n';
csvContent += 'Month,Scenario,New Instances,Churned Instances,Total Instances,Monthly Revenue,CSP Revenue,Servala Revenue,Cumulative CSP Revenue,Cumulative Servala Revenue\n';
// Combine all monthly data
const allData = [];
Object.keys(calculator.monthlyData).forEach(scenario => {
calculator.monthlyData[scenario].forEach(monthData => {
allData.push(monthData);
});
});
allData.sort((a, b) => a.month - b.month || a.scenario.localeCompare(b.scenario));
allData.forEach(data => {
csvContent += `${data.month},${data.scenario},${data.newInstances},${data.churnedInstances},${data.totalInstances},${data.monthlyRevenue.toFixed(2)},${data.cspRevenue.toFixed(2)},${data.servalaRevenue.toFixed(2)},${data.cumulativeCSPRevenue.toFixed(2)},${data.cumulativeServalaRevenue.toFixed(2)}\n`;
});
// Create and download file
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
const filename = `servala-csp-roi-data-${new Date().toISOString().split('T')[0]}.csv`;
link.setAttribute('download', filename);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Clean up
URL.revokeObjectURL(url);
} catch (error) {
console.error('CSV Export Error:', error);
alert('An error occurred while generating the CSV file. Please try again.');
}
}
// Reset function
function resetCalculator() {
if (confirm('Are you sure you want to reset all parameters to default values?')) {
// Reset input values
const investmentInput = document.getElementById('investment-amount');
investmentInput.setAttribute('data-value', '500000');
investmentInput.value = '500,000';
document.getElementById('investment-slider').value = 500000;
document.getElementById('timeframe').value = 3;
document.getElementById('direct-model').checked = true;
document.getElementById('loan-interest-rate').value = 5.0;
document.getElementById('loan-rate-slider').value = 5.0;
document.getElementById('revenue-per-instance').value = 50;
document.getElementById('revenue-slider').value = 50;
document.getElementById('servala-share').value = 25;
document.getElementById('share-slider').value = 25;
document.getElementById('grace-period').value = 6;
document.getElementById('grace-slider').value = 6;
// Reset scenarios
['conservative', 'moderate', 'aggressive'].forEach(scenario => {
document.getElementById(scenario + '-enabled').checked = true;
calculator.scenarios[scenario].enabled = true;
document.getElementById(scenario + '-card').classList.add('active');
document.getElementById(scenario + '-card').classList.remove('disabled');
});
// Reset advanced parameters
resetAdvancedParameters();
// Reset investment model toggle
toggleInvestmentModel();
// Recalculate (this will be called by resetAdvancedParameters, but we ensure it happens)
updateCalculations();
}
}
// Investment model toggle functions
function toggleInvestmentModel() {
const selectedModel = document.querySelector('input[name="investment-model"]:checked').value;
const loanSection = document.getElementById('loan-rate-section');
const modelDescription = document.getElementById('model-description');
if (selectedModel === 'loan') {
loanSection.style.display = 'block';
modelDescription.textContent = 'Loan Model: Guaranteed returns with fixed monthly payments';
} else {
loanSection.style.display = 'none';
modelDescription.textContent = 'Direct Investment: Higher potential returns based on your sales performance';
}
updateCalculations();
}
function updateLoanRate(value) {
document.getElementById('loan-interest-rate').value = value;
updateCalculations();
}
// Logout function
function logout() {
if (confirm('Are you sure you want to logout?')) {
// Create a form to submit logout request
const form = document.createElement('form');
form.method = 'POST';
form.action = window.location.pathname;
// Add CSRF token from page meta tag or cookie
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrfmiddlewaretoken';
csrfInput.value = getCSRFToken();
form.appendChild(csrfInput);
// Add logout parameter
const logoutInput = document.createElement('input');
logoutInput.type = 'hidden';
logoutInput.name = 'logout';
logoutInput.value = 'true';
form.appendChild(logoutInput);
document.body.appendChild(form);
form.submit();
}
}
// Helper function to get CSRF token
function getCSRFToken() {
// Try to get CSRF token from meta tag first
const metaTag = document.querySelector('meta[name="csrf-token"]');
if (metaTag) {
return metaTag.getAttribute('content');
}
// Fallback to cookie method
const cookies = document.cookie.split(';');
for (let cookie of cookies) {
const [name, value] = cookie.trim().split('=');
if (name === 'csrftoken') {
return decodeURIComponent(value);
}
}
// If no CSRF token found, return empty string (will cause server error, but won't break JS)
console.error('CSRF token not found');
return '';
}