From 5cc6b779c5d284b94f9d656c2c98746e35769b54 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Wed, 23 Jul 2025 14:50:53 +0200 Subject: [PATCH] multi-currency support in roi calculator --- .../js/roi-calculator/calculator-core.js | 8 +- .../js/roi-calculator/export-manager.js | 50 +++++++----- .../static/js/roi-calculator/input-utils.js | 12 ++- .../js/roi-calculator/roi-calculator-app.js | 79 +++++++++++++++++++ .../static/js/roi-calculator/ui-manager.js | 38 ++++++--- .../calculator/csp_roi_calculator.html | 28 +++++-- .../calculator/roi_calculator_help.html | 61 +++++++++++++- 7 files changed, 231 insertions(+), 45 deletions(-) diff --git a/hub/services/static/js/roi-calculator/calculator-core.js b/hub/services/static/js/roi-calculator/calculator-core.js index 02981fe..e96cd8e 100644 --- a/hub/services/static/js/roi-calculator/calculator-core.js +++ b/hub/services/static/js/roi-calculator/calculator-core.js @@ -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 }; } diff --git a/hub/services/static/js/roi-calculator/export-manager.js b/hub/services/static/js/roi-calculator/export-manager.js index 9cbc0f8..2f00f87 100644 --- a/hub/services/static/js/roi-calculator/export-manager.js +++ b/hub/services/static/js/roi-calculator/export-manager.js @@ -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 diff --git a/hub/services/static/js/roi-calculator/input-utils.js b/hub/services/static/js/roi-calculator/input-utils.js index 94df482..19a543b 100644 --- a/hub/services/static/js/roi-calculator/input-utils.js +++ b/hub/services/static/js/roi-calculator/input-utils.js @@ -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); diff --git a/hub/services/static/js/roi-calculator/roi-calculator-app.js b/hub/services/static/js/roi-calculator/roi-calculator-app.js index 003bd86..bdaaf2c 100644 --- a/hub/services/static/js/roi-calculator/roi-calculator-app.js +++ b/hub/services/static/js/roi-calculator/roi-calculator-app.js @@ -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]) { diff --git a/hub/services/static/js/roi-calculator/ui-manager.js b/hub/services/static/js/roi-calculator/ui-manager.js index c6f4a3a..f420d2e 100644 --- a/hub/services/static/js/roi-calculator/ui-manager.js +++ b/hub/services/static/js/roi-calculator/ui-manager.js @@ -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)}`; } } diff --git a/hub/services/templates/calculator/csp_roi_calculator.html b/hub/services/templates/calculator/csp_roi_calculator.html index 133ba32..740d456 100644 --- a/hub/services/templates/calculator/csp_roi_calculator.html +++ b/hub/services/templates/calculator/csp_roi_calculator.html @@ -35,6 +35,11 @@ function updateServalaShare(value) { window.ROICalculatorApp?.updateServalaShare function updateGracePeriod(value) { window.ROICalculatorApp?.updateGracePeriod(value); } function updateLoanRate(value) { window.ROICalculatorApp?.updateLoanRate(value); } function updateCoreServiceRevenue(value) { window.ROICalculatorApp?.updateCoreServiceRevenue(value); } +function updateCurrency() { + const currencyElement = document.getElementById('currency'); + const value = currencyElement ? currencyElement.value : 'CHF'; + window.ROICalculatorApp?.updateCurrency(value); +} function updateScenarioChurn(scenarioKey, churnRate) { window.ROICalculatorApp?.updateScenarioChurn(scenarioKey, churnRate); } function updateScenarioPhase(scenarioKey, phaseIndex, newInstancesPerMonth) { window.ROICalculatorApp?.updateScenarioPhase(scenarioKey, phaseIndex, newInstancesPerMonth); } function resetAdvancedParameters() { window.ROICalculatorApp?.resetAdvancedParameters(); } @@ -137,7 +142,7 @@ document.addEventListener('DOMContentLoaded', function() {
- CHF + CHF
- CHF 100K - CHF 2M + CHF 100K + CHF 2M
- +
-
+
+ + +
+
- CHF/month + CHF/month
- CHF 20 - CHF 200 + CHF 20 + CHF 200
diff --git a/hub/services/templates/calculator/roi_calculator_help.html b/hub/services/templates/calculator/roi_calculator_help.html index 97fd010..2ef01ee 100644 --- a/hub/services/templates/calculator/roi_calculator_help.html +++ b/hub/services/templates/calculator/roi_calculator_help.html @@ -114,6 +114,9 @@ html { Using the Calculator + + Currency Support + Growth Scenarios @@ -153,6 +156,58 @@ html {
+ +
+

Currency Support

+

The ROI Calculator supports multiple currencies to accommodate different regional markets and business requirements.

+ +

Supported Currencies

+
+
+
+
Swiss Franc (CHF)
+

Default Currency

+
    +
  • Swiss locale formatting (de-CH)
  • +
  • Standard decimal separators
  • +
  • Traditional Swiss business format
  • +
+
+
+
+
+
Euro (EUR)
+

European Markets

+
    +
  • European locale formatting (de-DE)
  • +
  • Standard European decimal separators
  • +
  • EU business format compliance
  • +
+
+
+
+ +

How Currency Selection Works

+

Currency can be selected in the main configuration section of the calculator. When you change currency:

+ + +

Important Notes

+
+
Currency Display Only
+

The calculator displays amounts in your selected currency but does not perform currency conversion. All input values should be entered in your chosen currency. For example, if you select EUR, enter your investment amounts in Euros.

+
+ +
+
Consistency Important
+

Ensure all your inputs (investment amount, revenue per instance, etc.) are in the same currency for accurate calculations. Mixing currencies will produce incorrect results.

+
+
+

Loan Model (3-7% Returns)

@@ -162,7 +217,7 @@ html {

Key Features