multi-currency support in roi calculator

This commit is contained in:
Tobias Brunner 2025-07-23 14:50:53 +02:00
parent adc3a6b905
commit 5cc6b779c5
Signed by: tobru
SSH key fingerprint: SHA256:kOXg1R6c11XW3/Pt9dbLdQvOJGFAy+B2K6v6PtRWBGQ
7 changed files with 231 additions and 45 deletions

View file

@ -82,6 +82,10 @@ class ROICalculator {
const coreRevenueElement = document.getElementById('core-service-revenue');
const coreServiceRevenue = coreRevenueElement ? parseFloat(coreRevenueElement.value) || 0 : 0;
// Get currency with validation
const currencyElement = document.getElementById('currency');
const currency = currencyElement ? currencyElement.value || 'CHF' : 'CHF';
return {
investmentAmount,
timeframe,
@ -90,7 +94,8 @@ class ROICalculator {
revenuePerInstance,
coreServiceRevenue,
servalaShare,
gracePeriod
gracePeriod,
currency
};
} catch (error) {
console.error('Error getting input values:', error);
@ -103,6 +108,7 @@ class ROICalculator {
revenuePerInstance: 50,
coreServiceRevenue: 0,
servalaShare: 0.25,
currency: 'CHF',
gracePeriod: 6
};
}

View file

@ -131,7 +131,7 @@ class ExportManager {
doc.setFontSize(12);
doc.setTextColor(...colors.dark);
doc.text(`Investment Amount: ${this.formatCHF(inputs.investmentAmount)}`, pageWidth/2, boxY + 22, { align: 'center' });
doc.text(`Investment Amount: ${this.formatCurrency(inputs.investmentAmount)}`, pageWidth/2, boxY + 22, { align: 'center' });
doc.text(`Analysis Period: ${inputs.timeframe} years`, pageWidth/2, boxY + 32, { align: 'center' });
// Generated date
@ -191,7 +191,7 @@ class ExportManager {
doc.setFont('helvetica', 'normal');
doc.setFontSize(10);
doc.text(`Average ROI: ${this.uiManager.formatPercentage(avgDirectROI)}`, margin + 5, yPos + 16);
doc.text(`Average Net Profit: ${this.formatCHF(avgDirectNetPos)}`, margin + 5, yPos + 24);
doc.text(`Average Net Profit: ${this.formatCurrency(avgDirectNetPos)}`, margin + 5, yPos + 24);
doc.text('Performance-based with bonuses', margin + 5, yPos + 32);
// Loan Model Summary
@ -208,7 +208,7 @@ class ExportManager {
doc.setFont('helvetica', 'normal');
doc.setFontSize(10);
doc.text(`Average ROI: ${this.uiManager.formatPercentage(avgLoanROI)}`, loanBoxX + 5, yPos + 16);
doc.text(`Average Net Profit: ${this.formatCHF(avgLoanNetPos)}`, loanBoxX + 5, yPos + 24);
doc.text(`Average Net Profit: ${this.formatCurrency(avgLoanNetPos)}`, loanBoxX + 5, yPos + 24);
doc.text('Fixed returns, guaranteed', loanBoxX + 5, yPos + 32);
yPos += 45;
@ -228,11 +228,11 @@ class ExportManager {
// Create parameter table
const params = [
['Investment Amount', this.formatCHF(inputs.investmentAmount)],
['Investment Amount', this.formatCurrency(inputs.investmentAmount)],
['Investment Timeframe', `${inputs.timeframe} years`],
['Service Revenue per Instance', `${this.formatCHF(inputs.revenuePerInstance)} / month`],
['Core Service Revenue per Instance', `${this.formatCHF(inputs.coreServiceRevenue)} / month`],
['Total Revenue per Instance', `${this.formatCHF(inputs.revenuePerInstance + inputs.coreServiceRevenue)} / month`],
['Service Revenue per Instance', `${this.formatCurrency(inputs.revenuePerInstance)} / month`],
['Core Service Revenue per Instance', `${this.formatCurrency(inputs.coreServiceRevenue)} / month`],
['Total Revenue per Instance', `${this.formatCurrency(inputs.revenuePerInstance + inputs.coreServiceRevenue)} / month`],
['Loan Interest Rate', `${(inputs.loanInterestRate * 100).toFixed(1)}%`],
['Direct Investment Share', `${(inputs.servalaShare * 100).toFixed(0)}% to Servala`],
['Grace Period', `${inputs.gracePeriod} months`]
@ -271,9 +271,9 @@ class ExportManager {
if (directResult && loanResult) {
tableData.push([
scenarioName,
this.formatCHF(directResult.netPosition),
this.formatCurrency(directResult.netPosition),
this.uiManager.formatPercentage(directResult.roi),
this.formatCHF(loanResult.netPosition),
this.formatCurrency(loanResult.netPosition),
this.uiManager.formatPercentage(loanResult.roi)
]);
}
@ -339,7 +339,7 @@ class ExportManager {
tableData.push([
result.scenario,
result.investmentModel === 'direct' ? 'Direct' : 'Loan',
this.formatCHF(result.netPosition),
this.formatCurrency(result.netPosition),
this.uiManager.formatPercentage(result.roi),
result.breakEvenMonth ? `${result.breakEvenMonth} months` : 'N/A'
]);
@ -498,17 +498,24 @@ class ExportManager {
});
}
formatCHF(amount) {
formatCurrency(amount) {
try {
// Consistent CHF formatting: CHF in front, no decimals for whole numbers
return new Intl.NumberFormat('de-CH', {
// Get current currency from the page
const currencyElement = document.getElementById('currency');
const currency = currencyElement ? currencyElement.value : 'CHF';
// Determine locale based on currency
const locale = currency === 'EUR' ? 'de-DE' : 'de-CH';
// Consistent currency formatting: currency in front, no decimals for whole numbers
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: 'CHF',
currency: currency,
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(amount);
} catch (error) {
console.error('Error formatting CHF:', error);
console.error('Error formatting currency:', error);
return `CHF ${Math.round(amount).toLocaleString()}`;
}
}
@ -522,15 +529,16 @@ class ExportManager {
// Add input parameters
csvContent += 'INPUT PARAMETERS\n';
const inputs = this.calculator.getInputValues();
csvContent += `Currency,${inputs.currency}\n`;
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 += `Service Revenue per Instance,${inputs.revenuePerInstance}\n`;
csvContent += `Core Service Revenue per Instance,${inputs.coreServiceRevenue}\n`;
csvContent += `Total Revenue per Instance,${inputs.revenuePerInstance + inputs.coreServiceRevenue}\n`;
csvContent += `Service Revenue per Instance (${inputs.currency}),${inputs.revenuePerInstance}\n`;
csvContent += `Core Service Revenue per Instance (${inputs.currency}),${inputs.coreServiceRevenue}\n`;
csvContent += `Total Revenue per Instance (${inputs.currency}),${inputs.revenuePerInstance + inputs.coreServiceRevenue}\n`;
if (inputs.investmentModel === 'direct') {
csvContent += `Servala Share (%),${(inputs.servalaShare * 100).toFixed(0)}\n`;
csvContent += `Grace Period (months),${inputs.gracePeriod}\n`;
@ -539,7 +547,7 @@ class ExportManager {
// Add scenario summary
csvContent += 'SCENARIO SUMMARY\n';
csvContent += 'Scenario,Investment Model,Final Instances,Total Revenue,CSP Revenue,Servala Revenue,ROI (%),Break-even (months)\n';
csvContent += `Scenario,Investment Model,Final Instances,Total Revenue (${inputs.currency}),CSP Revenue (${inputs.currency}),Servala Revenue (${inputs.currency}),ROI (%),Break-even (months)\n`;
Object.values(this.calculator.results).forEach(result => {
const modelText = result.investmentModel === 'loan' ? 'Loan' : 'Direct';
@ -550,7 +558,7 @@ class ExportManager {
// 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';
csvContent += `Month,Scenario,New Instances,Churned Instances,Total Instances,Service Revenue (${inputs.currency}),Core Revenue (${inputs.currency}),Total Revenue (${inputs.currency}),CSP Revenue (${inputs.currency}),Servala Revenue (${inputs.currency}),Cumulative CSP Revenue (${inputs.currency}),Cumulative Servala Revenue (${inputs.currency})\n`;
// Combine all monthly data
const allData = [];
@ -563,7 +571,7 @@ class ExportManager {
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`;
csvContent += `${data.month},${data.scenario},${data.newInstances},${data.churnedInstances},${data.totalInstances},${(data.serviceRevenue || data.monthlyRevenue || 0).toFixed(2)},${(data.coreRevenue || 0).toFixed(2)},${(data.totalRevenue || data.monthlyRevenue || 0).toFixed(2)},${data.cspRevenue.toFixed(2)},${data.servalaRevenue.toFixed(2)},${data.cumulativeCSPRevenue.toFixed(2)},${data.cumulativeServalaRevenue.toFixed(2)}\n`;
});
// Create and download file

View file

@ -3,9 +3,17 @@
* Handles input formatting, validation, and parsing
*/
class InputUtils {
static formatNumberWithCommas(num) {
static formatNumberWithCommas(num, currency = null) {
try {
return parseInt(num).toLocaleString('en-US');
// Get current currency if not provided
if (!currency) {
const currencyElement = document.getElementById('currency');
currency = currencyElement ? currencyElement.value : 'CHF';
}
// Use appropriate locale for number formatting
const locale = currency === 'EUR' ? 'de-DE' : 'de-CH';
return parseInt(num).toLocaleString(locale);
} catch (error) {
console.error('Error formatting number with commas:', error);
return String(num);

View file

@ -210,6 +210,85 @@ class ROICalculatorApp {
}
}
updateCurrency(value) {
try {
const currencyElement = document.getElementById('currency');
if (currencyElement) {
currencyElement.value = value;
}
// Update all currency-related UI labels
this.updateCurrencyLabels(value);
// Update calculations to reflect new currency formatting
this.updateCalculations();
} catch (error) {
console.error('Error updating currency:', error);
}
}
updateCurrencyLabels(currency) {
try {
// Update investment amount prefix
const investmentPrefix = document.getElementById('investment-currency-prefix');
if (investmentPrefix) {
investmentPrefix.textContent = currency;
}
// Update investment min/max labels
const investmentMinLabel = document.getElementById('investment-min-label');
if (investmentMinLabel) {
investmentMinLabel.textContent = `${currency} 100K`;
}
const investmentMaxLabel = document.getElementById('investment-max-label');
if (investmentMaxLabel) {
investmentMaxLabel.textContent = `${currency} 2M`;
}
// Update revenue per instance suffix
const revenueSuffix = document.getElementById('revenue-currency-suffix');
if (revenueSuffix) {
revenueSuffix.textContent = `${currency}/month`;
}
// Update core service revenue suffix (it's a direct span, not ID-based)
const coreRevenueInput = document.getElementById('core-service-revenue');
if (coreRevenueInput) {
const coreRevenueSpan = coreRevenueInput.parentElement.querySelector('.input-group-text');
if (coreRevenueSpan) {
coreRevenueSpan.textContent = `${currency}/month`;
}
}
// Update all other currency labels throughout the interface
const currencyLabels = document.querySelectorAll('.currency-label');
currencyLabels.forEach(label => {
label.textContent = currency;
});
// Update range slider labels with currency
const revenueLabel = document.querySelector('label[for="revenue-per-instance"]');
if (revenueLabel) {
revenueLabel.innerHTML = revenueLabel.innerHTML.replace(/(CHF|EUR)/, currency);
}
const coreRevenueLabel = document.querySelector('label[for="core-service-revenue"]');
if (coreRevenueLabel) {
coreRevenueLabel.innerHTML = coreRevenueLabel.innerHTML.replace(/(CHF|EUR)/, currency);
}
// Update investment amount field display if it has a value
const investmentInput = document.getElementById('investment-amount');
if (investmentInput && investmentInput.getAttribute('data-value')) {
const currentValue = investmentInput.getAttribute('data-value');
investmentInput.value = InputUtils.formatNumberWithCommas(currentValue, currency);
}
} catch (error) {
console.error('Error updating currency labels:', error);
}
}
updateScenarioChurn(scenarioKey, churnRate) {
try {
if (this.calculator && this.calculator.scenarios[scenarioKey]) {

View file

@ -203,43 +203,61 @@ class UIManager {
}
}
formatCurrency(amount) {
formatCurrency(amount, currency = null) {
try {
// Get current currency if not provided
if (!currency) {
const currencyElement = document.getElementById('currency');
currency = currencyElement ? currencyElement.value : 'CHF';
}
// Determine locale based on currency
const locale = currency === 'EUR' ? 'de-DE' : 'de-CH';
// Use compact notation for large numbers in metric cards
if (amount >= 1000000) {
return new Intl.NumberFormat('de-CH', {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: 'CHF',
currency: currency,
notation: 'compact',
minimumFractionDigits: 0,
maximumFractionDigits: 1
}).format(amount);
} else {
return new Intl.NumberFormat('de-CH', {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: 'CHF',
currency: currency,
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(amount);
}
} catch (error) {
console.error('Error formatting currency:', error);
return `CHF ${amount.toFixed(0)}`;
return `${currency || 'CHF'} ${amount.toFixed(0)}`;
}
}
formatCurrencyDetailed(amount) {
formatCurrencyDetailed(amount, currency = null) {
try {
// Get current currency if not provided
if (!currency) {
const currencyElement = document.getElementById('currency');
currency = currencyElement ? currencyElement.value : 'CHF';
}
// Determine locale based on currency
const locale = currency === 'EUR' ? 'de-DE' : 'de-CH';
// Use full formatting for detailed views (tables, exports)
return new Intl.NumberFormat('de-CH', {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: 'CHF',
currency: currency,
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(amount);
} catch (error) {
console.error('Error formatting detailed currency:', error);
return `CHF ${amount.toFixed(0)}`;
return `${currency || 'CHF'} ${amount.toFixed(0)}`;
}
}