Merge branch 'currency-by-country'

Currency-aware formatting in the planner: UK → £, US → $, EU → €
with locale-native thousands separators.
This commit is contained in:
Deeman
2026-02-21 01:53:28 +01:00
8 changed files with 120 additions and 81 deletions

View File

@@ -23,19 +23,24 @@ def _detect_lang() -> str:
def _fmt_currency(n) -> str:
"""Format as EUR with German-style thousands separator (€50.000)."""
"""Format currency using request-context symbol and locale style."""
sym = getattr(g, "currency_sym", "\u20ac")
eu_style = getattr(g, "currency_eu_style", True)
n = round(float(n))
s = f"{abs(n):,}".replace(",", ".")
return f"-\u20ac{s}" if n < 0 else f"\u20ac{s}"
s = f"{abs(n):,}"
if eu_style:
s = s.replace(",", ".")
return f"-{sym}{s}" if n < 0 else f"{sym}{s}"
def _fmt_k(n) -> str:
"""Short currency: €50K, €1.2M, or full fmt_currency."""
"""Short currency: €50K, €1.2M, or full _fmt_currency."""
sym = getattr(g, "currency_sym", "\u20ac")
n = float(n)
if abs(n) >= 1_000_000:
return f"\u20ac{n/1_000_000:.1f}M"
return f"{sym}{n/1_000_000:.1f}M"
if abs(n) >= 1_000:
return f"\u20ac{n/1_000:.0f}K"
return f"{sym}{n/1_000:.0f}K"
return _fmt_currency(n)
@@ -50,8 +55,10 @@ def _fmt_x(n) -> str:
def _fmt_n(n) -> str:
"""Format integer with German thousands separator: 1.234."""
return f"{round(float(n)):,}".replace(",", ".")
"""Format integer with locale-aware thousands separator: 1.234 or 1,234."""
eu_style = getattr(g, "currency_eu_style", True)
s = f"{round(float(n)):,}"
return s.replace(",", ".") if eu_style else s
def _tformat(s: str, **kwargs) -> str:

View File

@@ -9,16 +9,20 @@ from pathlib import Path
from .core import fetch_one
from .i18n import get_translations
from .planner.calculator import calc, validate_state
from .planner.calculator import COUNTRY_CURRENCY, CURRENCY_DEFAULT, calc, validate_state
TEMPLATE_DIR = Path(__file__).parent / "templates" / "businessplan"
def _fmt_eur(n) -> str:
"""Format number as EUR with thousands separator."""
def _fmt_cur(n, sym: str = "\u20ac", eu_style: bool = True) -> str:
"""Format number as currency with locale-aware thousands separator."""
if n is None:
return "-"
return f"\u20ac{n:,.0f}"
v = round(float(n))
s = f"{abs(v):,}"
if eu_style:
s = s.replace(",", ".")
return f"-{sym}{s}" if v < 0 else f"{sym}{s}"
def _fmt_pct(n) -> str:
@@ -44,16 +48,20 @@ def get_plan_sections(state: dict, d: dict, language: str = "en") -> dict:
s = state
t = get_translations(language)
cur = COUNTRY_CURRENCY.get(s.get("country", "DE"), CURRENCY_DEFAULT)
sym, eu_style = cur["sym"], cur["eu_style"]
fmt = lambda n: _fmt_cur(n, sym, eu_style) # noqa: E731
venue_type = t["bp_indoor"] if s["venue"] == "indoor" else t["bp_outdoor"]
own_type = t["bp_own"] if s["own"] == "buy" else t["bp_rent"]
payback_str = _fmt_months(d["paybackIdx"], t)
irr_str = _fmt_pct(d["irr"])
total_capex_str = _fmt_eur(d["capex"])
equity_str = _fmt_eur(d["equity"])
loan_str = _fmt_eur(d["loanAmount"])
per_court_str = _fmt_eur(d["capexPerCourt"])
per_sqm_str = _fmt_eur(d["capexPerSqm"])
total_capex_str = fmt(d["capex"])
equity_str = fmt(d["equity"])
loan_str = fmt(d["loanAmount"])
per_court_str = fmt(d["capexPerCourt"])
per_sqm_str = fmt(d["capexPerSqm"])
sections = {
"lang": language,
@@ -70,8 +78,8 @@ def get_plan_sections(state: dict, d: dict, language: str = "en") -> dict:
"total_capex": total_capex_str,
"equity": equity_str,
"loan": loan_str,
"y1_revenue": _fmt_eur(d["annuals"][0]["revenue"]) if d["annuals"] else "-",
"y3_ebitda": _fmt_eur(d["stabEbitda"]),
"y1_revenue": fmt(d["annuals"][0]["revenue"]) if d["annuals"] else "-",
"y3_ebitda": fmt(d["stabEbitda"]),
"irr": irr_str,
"payback": payback_str,
},
@@ -79,7 +87,7 @@ def get_plan_sections(state: dict, d: dict, language: str = "en") -> dict:
# Investment Plan (CAPEX)
"investment": {
"heading": t["bp_investment"],
"items": d["capexItems"],
"items": [{**i, "formatted_amount": fmt(i["amount"])} for i in d["capexItems"]],
"total": total_capex_str,
"per_court": per_court_str,
"per_sqm": per_sqm_str,
@@ -88,20 +96,20 @@ def get_plan_sections(state: dict, d: dict, language: str = "en") -> dict:
# Operating Costs
"operations": {
"heading": t["bp_operations"],
"items": d["opexItems"],
"monthly_total": _fmt_eur(d["opex"]),
"annual_total": _fmt_eur(d["annualOpex"]),
"items": [{**i, "formatted_amount": fmt(i["amount"])} for i in d["opexItems"]],
"monthly_total": fmt(d["opex"]),
"annual_total": fmt(d["annualOpex"]),
},
# Revenue Model
"revenue": {
"heading": t["bp_revenue"],
"weighted_rate": _fmt_eur(d["weightedRate"]),
"weighted_rate": fmt(d["weightedRate"]),
"utilization": _fmt_pct(s["utilTarget"] / 100),
"gross_monthly": _fmt_eur(d["grossRevMonth"]),
"net_monthly": _fmt_eur(d["netRevMonth"]),
"ebitda_monthly": _fmt_eur(d["ebitdaMonth"]),
"net_cf_monthly": _fmt_eur(d["netCFMonth"]),
"gross_monthly": fmt(d["grossRevMonth"]),
"net_monthly": fmt(d["netRevMonth"]),
"ebitda_monthly": fmt(d["ebitdaMonth"]),
"net_cf_monthly": fmt(d["netCFMonth"]),
},
# 5-Year P&L
@@ -110,10 +118,10 @@ def get_plan_sections(state: dict, d: dict, language: str = "en") -> dict:
"years": [
{
"year": a["year"],
"revenue": _fmt_eur(a["revenue"]),
"ebitda": _fmt_eur(a["ebitda"]),
"debt_service": _fmt_eur(a["ds"]),
"net_cf": _fmt_eur(a["ncf"]),
"revenue": fmt(a["revenue"]),
"ebitda": fmt(a["ebitda"]),
"debt_service": fmt(a["ds"]),
"net_cf": fmt(a["ncf"]),
}
for a in d["annuals"]
],
@@ -127,8 +135,8 @@ def get_plan_sections(state: dict, d: dict, language: str = "en") -> dict:
"loan": loan_str,
"interest_rate": f"{s['interestRate']}%",
"term": t["bp_years"].format(n=s["loanTerm"]),
"monthly_payment": _fmt_eur(d["monthlyPayment"]),
"annual_debt_service": _fmt_eur(d["annualDebtService"]),
"monthly_payment": fmt(d["monthlyPayment"]),
"annual_debt_service": fmt(d["annualDebtService"]),
"ltv": _fmt_pct(d["ltv"]),
},
@@ -151,12 +159,12 @@ def get_plan_sections(state: dict, d: dict, language: str = "en") -> dict:
"months": [
{
"month": m["m"],
"revenue": _fmt_eur(m["totalRev"]),
"opex": _fmt_eur(abs(m["opex"])),
"ebitda": _fmt_eur(m["ebitda"]),
"debt": _fmt_eur(abs(m["loan"])),
"ncf": _fmt_eur(m["ncf"]),
"cumulative": _fmt_eur(m["cum"]),
"revenue": fmt(m["totalRev"]),
"opex": fmt(abs(m["opex"])),
"ebitda": fmt(m["ebitda"]),
"debt": fmt(abs(m["loan"])),
"ncf": fmt(m["ncf"]),
"cumulative": fmt(m["cum"]),
}
for m in d["months"][:12]
],
@@ -220,6 +228,7 @@ def get_plan_sections(state: dict, d: dict, language: str = "en") -> dict:
"debt": t["bp_lbl_debt"],
"cumulative": t["bp_lbl_cumulative"],
"disclaimer": t["bp_lbl_disclaimer"],
"currency_sym": sym,
},
}

View File

@@ -652,9 +652,9 @@
"sl_sqm_sgl_hall": "Hallen-m² pro Einzelplatz",
"sl_sqm_dbl_outdoor": "Grundstück-m² pro Doppelplatz",
"sl_sqm_sgl_outdoor": "Grundstück-m² pro Einzelplatz",
"sl_rate_peak": "Spitzenstundensatz ()",
"sl_rate_offpeak": "Nebenstundensatz ()",
"sl_rate_single": "Einzelplatz-Stundensatz ()",
"sl_rate_peak": "Spitzenstundensatz ({currency})",
"sl_rate_offpeak": "Nebenstundensatz ({currency})",
"sl_rate_single": "Einzelplatz-Stundensatz ({currency})",
"sl_peak_pct": "Anteil Spitzenstunden",
"sl_booking_fee": "Plattformprovision",
"sl_util_target": "Ziel-Auslastung",
@@ -668,9 +668,9 @@
"sl_retail_rev": "Einzelhandel / Platz",
"sl_court_cost_dbl": "Platzkosten — Doppel",
"sl_court_cost_sgl": "Platzkosten — Einzel",
"sl_hall_cost_sqm": "Hallenbau (/m²)",
"sl_foundation_sqm": "Fundament (/m²)",
"sl_land_price_sqm": "Grundstückspreis (/m²)",
"sl_hall_cost_sqm": "Hallenbau ({currency}/m²)",
"sl_foundation_sqm": "Fundament ({currency}/m²)",
"sl_land_price_sqm": "Grundstückspreis ({currency}/m²)",
"sl_hvac": "Lüftung & Klimaanlage",
"sl_electrical": "Elektro + Beleuchtung",
"sl_sanitary": "Sanitär / Umkleide",
@@ -680,7 +680,7 @@
"sl_hvac_upgrade": "Lüftungsausbau",
"sl_lighting_upgrade": "Beleuchtungsausbau",
"sl_fitout": "Ausbau & Empfang",
"sl_outdoor_foundation": "Beton (/m²)",
"sl_outdoor_foundation": "Beton ({currency}/m²)",
"sl_outdoor_site_work": "Erschließung",
"sl_outdoor_lighting": "Beleuchtung pro Platz",
"sl_outdoor_fencing": "Einzäunung",
@@ -688,17 +688,17 @@
"sl_working_capital": "Betriebskapital",
"sl_contingency": "Reserve",
"sl_budget_target": "Dein Budgetziel",
"sl_rent_sqm": "Miete (/m²/Monat)",
"sl_rent_sqm": "Miete ({currency}/m²/Monat)",
"sl_outdoor_rent": "Monatliche Grundstücksmiete",
"sl_property_tax": "Grundsteuer / Monat",
"sl_insurance": "Versicherung (/Monat)",
"sl_electricity": "Strom (/Monat)",
"sl_heating": "Heizung (/Monat)",
"sl_water": "Wasser (/Monat)",
"sl_maintenance": "Wartung (/Monat)",
"sl_cleaning": "Reinigung (/Monat)",
"sl_marketing": "Marketing / Sonstiges (/Monat)",
"sl_staff": "Personal (/Monat)",
"sl_insurance": "Versicherung ({currency}/Monat)",
"sl_electricity": "Strom ({currency}/Monat)",
"sl_heating": "Heizung ({currency}/Monat)",
"sl_water": "Wasser ({currency}/Monat)",
"sl_maintenance": "Wartung ({currency}/Monat)",
"sl_cleaning": "Reinigung ({currency}/Monat)",
"sl_marketing": "Marketing / Sonstiges ({currency}/Monat)",
"sl_staff": "Personal ({currency}/Monat)",
"sl_loan_pct": "Fremdkapitalquote (LTC)",
"sl_interest_rate": "Zinssatz",
"sl_loan_term": "Kreditlaufzeit",

View File

@@ -652,9 +652,9 @@
"sl_sqm_sgl_hall": "Hall m² per Single Court",
"sl_sqm_dbl_outdoor": "Land m² per Double Court",
"sl_sqm_sgl_outdoor": "Land m² per Single Court",
"sl_rate_peak": "Peak Hour Rate ()",
"sl_rate_offpeak": "Off-Peak Hour Rate ()",
"sl_rate_single": "Single Court Rate ()",
"sl_rate_peak": "Peak Hour Rate ({currency})",
"sl_rate_offpeak": "Off-Peak Hour Rate ({currency})",
"sl_rate_single": "Single Court Rate ({currency})",
"sl_peak_pct": "Peak Hours Share",
"sl_booking_fee": "Platform Fee",
"sl_util_target": "Target Utilization",
@@ -668,9 +668,9 @@
"sl_retail_rev": "Retail / Court",
"sl_court_cost_dbl": "Court Cost — Double",
"sl_court_cost_sgl": "Court Cost — Single",
"sl_hall_cost_sqm": "Hall Construction (/m²)",
"sl_foundation_sqm": "Foundation (/m²)",
"sl_land_price_sqm": "Land Price (/m²)",
"sl_hall_cost_sqm": "Hall Construction ({currency}/m²)",
"sl_foundation_sqm": "Foundation ({currency}/m²)",
"sl_land_price_sqm": "Land Price ({currency}/m²)",
"sl_hvac": "HVAC System",
"sl_electrical": "Electrical + Lighting",
"sl_sanitary": "Sanitary / Changing",
@@ -680,7 +680,7 @@
"sl_hvac_upgrade": "HVAC Upgrade",
"sl_lighting_upgrade": "Lighting Upgrade",
"sl_fitout": "Fit-Out & Reception",
"sl_outdoor_foundation": "Concrete (/m²)",
"sl_outdoor_foundation": "Concrete ({currency}/m²)",
"sl_outdoor_site_work": "Site Work",
"sl_outdoor_lighting": "Lighting per Court",
"sl_outdoor_fencing": "Fencing",
@@ -688,17 +688,17 @@
"sl_working_capital": "Working Capital",
"sl_contingency": "Contingency",
"sl_budget_target": "Your Budget Target",
"sl_rent_sqm": "Rent (/m²/month)",
"sl_rent_sqm": "Rent ({currency}/m²/month)",
"sl_outdoor_rent": "Monthly Land Rent",
"sl_property_tax": "Property Tax / month",
"sl_insurance": "Insurance (/mo)",
"sl_electricity": "Electricity (/mo)",
"sl_heating": "Heating (/mo)",
"sl_water": "Water (/mo)",
"sl_maintenance": "Maintenance (/mo)",
"sl_cleaning": "Cleaning (/mo)",
"sl_marketing": "Marketing / Misc (/mo)",
"sl_staff": "Staff (/mo)",
"sl_insurance": "Insurance ({currency}/mo)",
"sl_electricity": "Electricity ({currency}/mo)",
"sl_heating": "Heating ({currency}/mo)",
"sl_water": "Water ({currency}/mo)",
"sl_maintenance": "Maintenance ({currency}/mo)",
"sl_cleaning": "Cleaning ({currency}/mo)",
"sl_marketing": "Marketing / Misc ({currency}/mo)",
"sl_staff": "Staff ({currency}/mo)",
"sl_loan_pct": "Loan-to-Cost (LTC)",
"sl_interest_rate": "Interest Rate",
"sl_loan_term": "Loan Term",

View File

@@ -95,6 +95,20 @@ DEFAULTS = {
}
CURRENCY_DEFAULT = {"sym": "\u20ac", "eu_style": True}
COUNTRY_CURRENCY: dict[str, dict] = {
"DE": CURRENCY_DEFAULT,
"ES": CURRENCY_DEFAULT,
"IT": CURRENCY_DEFAULT,
"FR": CURRENCY_DEFAULT,
"NL": CURRENCY_DEFAULT,
"SE": CURRENCY_DEFAULT,
"UK": {"sym": "\u00a3", "eu_style": False},
"US": {"sym": "$", "eu_style": False},
}
def validate_state(s: dict) -> dict:
"""Apply defaults and coerce types. Returns a clean copy."""
out = {**DEFAULTS}
@@ -155,6 +169,7 @@ def calc(s: dict, lang: str = "en") -> dict:
derived-data dict (the `d` object from the JS version).
"""
names = get_calc_item_names(lang)
sym = COUNTRY_CURRENCY.get(s.get("country", "DE"), CURRENCY_DEFAULT)["sym"]
d: dict = {}
is_in = s["venue"] == "indoor"
is_buy = s["own"] == "buy"
@@ -198,12 +213,12 @@ def calc(s: dict, lang: str = "en") -> dict:
if is_in:
if is_buy:
ci(names["hall_construction"], d["hallSqm"] * s["hallCostSqm"],
f"{d['hallSqm']}m\u00b2 \u00d7 \u20ac{s['hallCostSqm']}/m\u00b2")
f"{d['hallSqm']}m\u00b2 \u00d7 {sym}{s['hallCostSqm']}/m\u00b2")
ci(names["foundation"], d["hallSqm"] * s["foundationSqm"],
f"{d['hallSqm']}m\u00b2 \u00d7 \u20ac{s['foundationSqm']}/m\u00b2")
f"{d['hallSqm']}m\u00b2 \u00d7 {sym}{s['foundationSqm']}/m\u00b2")
land_sqm = _round(d["hallSqm"] * 1.25)
ci(names["land_purchase"], land_sqm * s["landPriceSqm"],
f"{land_sqm}m\u00b2 \u00d7 \u20ac{s['landPriceSqm']}/m\u00b2")
f"{land_sqm}m\u00b2 \u00d7 {sym}{s['landPriceSqm']}/m\u00b2")
ci(names["transaction_costs"], _round(land_sqm * s["landPriceSqm"] * 0.1), "~10% of land")
ci(names["hvac_system"], s["hvac"])
ci(names["electrical_lighting"], s["electrical"] * light_mult)
@@ -225,7 +240,7 @@ def calc(s: dict, lang: str = "en") -> dict:
ci(names["permits_compliance"], s["permitsCompliance"])
if is_buy:
ci(names["land_purchase"], d["outdoorLandSqm"] * s["landPriceSqm"],
f"{d['outdoorLandSqm']}m\u00b2 \u00d7 \u20ac{s['landPriceSqm']}/m\u00b2")
f"{d['outdoorLandSqm']}m\u00b2 \u00d7 {sym}{s['landPriceSqm']}/m\u00b2")
ci(names["transaction_costs"], _round(d["outdoorLandSqm"] * s["landPriceSqm"] * 0.1))
ci(names["equipment"], s["equipment"] + total_courts * 300)
@@ -253,7 +268,7 @@ def calc(s: dict, lang: str = "en") -> dict:
if is_in:
rent_amount = _round(d["hallSqm"] * s["rentSqm"])
oi(names["rent"], d["hallSqm"] * s["rentSqm"],
f"{d['hallSqm']}m\u00b2 \u00d7 \u20ac{s['rentSqm']}/m\u00b2")
f"{d['hallSqm']}m\u00b2 \u00d7 {sym}{s['rentSqm']}/m\u00b2")
else:
rent_amount = s["outdoorRent"]
oi(names["rent"], s["outdoorRent"])

View File

@@ -19,7 +19,7 @@ from ..core import (
waitlist_gate,
)
from ..i18n import get_translations
from .calculator import DEFAULTS, calc, validate_state
from .calculator import COUNTRY_CURRENCY, CURRENCY_DEFAULT, DEFAULTS, calc, validate_state
bp = Blueprint(
"planner",
@@ -328,6 +328,9 @@ async def index():
lang = g.get("lang", "en")
d = calc(s, lang=lang)
augment_d(d, s, lang)
cur = COUNTRY_CURRENCY.get(s["country"], CURRENCY_DEFAULT)
g.currency_sym = cur["sym"]
g.currency_eu_style = cur["eu_style"]
return await render_template(
"planner.html",
s=s,
@@ -337,6 +340,7 @@ async def index():
active_tab="capex",
country_presets=COUNTRY_PRESETS,
defaults=DEFAULTS,
currency_sym=cur["sym"],
)
@@ -351,12 +355,16 @@ async def calculate():
active_tab = form.get("activeTab", "capex")
if active_tab not in {"capex", "operating", "cashflow", "returns", "metrics"}:
active_tab = "capex"
cur = COUNTRY_CURRENCY.get(s["country"], CURRENCY_DEFAULT)
g.currency_sym = cur["sym"]
g.currency_eu_style = cur["eu_style"]
return await render_template(
"partials/calculate_response.html",
s=s,
d=d,
active_tab=active_tab,
lang=lang,
currency_sym=cur["sym"],
)

View File

@@ -14,7 +14,7 @@
{% macro slider(name, label, min, max, step, value, tip='') %}
<div class="slider-group">
<label>
<span class="slider-group__label">{{ label }}</span>{% if tip %}<span class="ti">i<span class="tp">{{ tip }}</span></span>{% endif %}
<span class="slider-group__label">{{ label | tformat(currency=currency_sym) }}</span>{% if tip %}<span class="ti">i<span class="tp">{{ tip }}</span></span>{% endif %}
</label>
<div class="slider-combo">
<input type="range" name="{{ name }}" min="{{ min }}" max="{{ max }}" step="{{ step }}" value="{{ value }}"

View File

@@ -56,7 +56,7 @@
{% for item in s.investment.items %}
<tr>
<td>{{ item.name }}</td>
<td style="text-align:right">&euro;{{ "{:,.0f}".format(item.amount) }}</td>
<td style="text-align:right">{{ item.formatted_amount }}</td>
<td style="color:#94A3B8;font-size:8pt">{{ item.info }}</td>
</tr>
{% endfor %}
@@ -97,7 +97,7 @@
{% for item in s.operations.items %}
<tr>
<td>{{ item.name }}</td>
<td style="text-align:right">&euro;{{ "{:,.0f}".format(item.amount) }}</td>
<td style="text-align:right">{{ item.formatted_amount }}</td>
<td style="color:#94A3B8;font-size:8pt">{{ item.info }}</td>
</tr>
{% endfor %}