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>
This commit is contained in:
Tobias Brunner 2025-07-22 08:33:54 +02:00
parent 2981be12df
commit 51d80364c0
Signed by: tobru
SSH key fingerprint: SHA256:kOXg1R6c11XW3/Pt9dbLdQvOJGFAy+B2K6v6PtRWBGQ
2 changed files with 240 additions and 90 deletions

View file

@ -48,16 +48,58 @@ class ROICalculator {
}
getInputValues() {
const investmentModel = document.querySelector('input[name="investment-model"]:checked').value;
return {
investmentAmount: parseFloat(document.getElementById('investment-amount').getAttribute('data-value')),
timeframe: parseInt(document.getElementById('timeframe').value),
investmentModel: investmentModel,
loanInterestRate: parseFloat(document.getElementById('loan-interest-rate').value) / 100,
revenuePerInstance: parseFloat(document.getElementById('revenue-per-instance').value),
servalaShare: parseFloat(document.getElementById('servala-share').value) / 100,
gracePeriod: parseInt(document.getElementById('grace-period').value)
};
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) {
@ -242,78 +284,123 @@ class ROICalculator {
}
initializeCharts() {
// Instance Growth Chart
const instanceCtx = document.getElementById('instanceGrowthChart').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' }
}
}
}
});
// 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;
}
// Revenue Chart
const revenueCtx = document.getElementById('revenueChart').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' }
}
}
try {
// Instance Growth Chart
const instanceCanvas = document.getElementById('instanceGrowthChart');
if (!instanceCanvas) {
console.error('Instance growth chart canvas not found');
return;
}
});
// Cash Flow Chart
const cashFlowCtx = document.getElementById('cashFlowChart').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)' }
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' }
},
x: {
title: { display: true, text: 'Month' }
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) return;
if (scenarios.length === 0 || !this.charts.instanceGrowth) return;
const colors = {
conservative: '#28a745',
@ -530,31 +617,68 @@ function formatNumberWithCommas(num) {
}
function parseFormattedNumber(str) {
return parseFloat(str.replace(/,/g, '')) || 0;
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) {
// Remove non-numeric characters except commas
let value = input.value.replace(/[^\d,]/g, '');
if (!input || typeof input.value !== 'string') {
console.error('Invalid input element provided to handleInvestmentAmountInput');
return;
}
// Parse the numeric value
let numericValue = parseFormattedNumber(value);
try {
// Remove non-numeric characters except commas
let value = input.value.replace(/[^\d,]/g, '');
// Enforce min/max limits
if (numericValue < 100000) numericValue = 100000;
if (numericValue > 2000000) numericValue = 2000000;
// Parse the numeric value
let numericValue = parseFormattedNumber(value);
// Update the data attribute with the raw numeric value
input.setAttribute('data-value', numericValue.toString());
// Enforce min/max limits
const minValue = 100000;
const maxValue = 2000000;
// Format and display the value with commas
input.value = formatNumberWithCommas(numericValue);
if (numericValue < minValue) {
numericValue = minValue;
} else if (numericValue > maxValue) {
numericValue = maxValue;
}
// Update the slider
document.getElementById('investment-slider').value = numericValue;
// Update the data attribute with the raw numeric value
input.setAttribute('data-value', numericValue.toString());
// Trigger calculations
updateCalculations();
// 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) {
@ -974,13 +1098,13 @@ function logout() {
// Create a form to submit logout request
const form = document.createElement('form');
form.method = 'POST';
form.action = '{% url "services:csp_roi_calculator" %}';
form.action = window.location.pathname;
// Add CSRF token
// Add CSRF token from page meta tag or cookie
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrfmiddlewaretoken';
csrfInput.value = '{{ csrf_token }}';
csrfInput.value = getCSRFToken();
form.appendChild(csrfInput);
// Add logout parameter
@ -994,3 +1118,25 @@ function logout() {
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 '';
}

View file

@ -3,6 +3,10 @@
{% block title %}CSP ROI Calculator{% endblock %}
{% block extra_head %}
<meta name="csrf-token" content="{{ csrf_token }}">
{% endblock %}
{% block extra_css %}
<link rel="stylesheet" type="text/css" href='{% static "css/roi-calculator.css" %}'>
{% endblock %}