diff --git a/padelnomics/src/padelnomics/app.py b/padelnomics/src/padelnomics/app.py index 4cd4508..4c15688 100644 --- a/padelnomics/src/padelnomics/app.py +++ b/padelnomics/src/padelnomics/app.py @@ -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: diff --git a/padelnomics/src/padelnomics/businessplan.py b/padelnomics/src/padelnomics/businessplan.py index 1bf9f61..576bf64 100644 --- a/padelnomics/src/padelnomics/businessplan.py +++ b/padelnomics/src/padelnomics/businessplan.py @@ -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, }, } diff --git a/padelnomics/src/padelnomics/locales/de.json b/padelnomics/src/padelnomics/locales/de.json index 6a80196..d1f6545 100644 --- a/padelnomics/src/padelnomics/locales/de.json +++ b/padelnomics/src/padelnomics/locales/de.json @@ -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", diff --git a/padelnomics/src/padelnomics/locales/en.json b/padelnomics/src/padelnomics/locales/en.json index 0b6edf2..f9bab0c 100644 --- a/padelnomics/src/padelnomics/locales/en.json +++ b/padelnomics/src/padelnomics/locales/en.json @@ -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", diff --git a/padelnomics/src/padelnomics/planner/calculator.py b/padelnomics/src/padelnomics/planner/calculator.py index d133551..cf38776 100644 --- a/padelnomics/src/padelnomics/planner/calculator.py +++ b/padelnomics/src/padelnomics/planner/calculator.py @@ -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"]) diff --git a/padelnomics/src/padelnomics/planner/routes.py b/padelnomics/src/padelnomics/planner/routes.py index f662430..4d4720e 100644 --- a/padelnomics/src/padelnomics/planner/routes.py +++ b/padelnomics/src/padelnomics/planner/routes.py @@ -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"], ) diff --git a/padelnomics/src/padelnomics/planner/templates/planner.html b/padelnomics/src/padelnomics/planner/templates/planner.html index 686e021..08c3e5c 100644 --- a/padelnomics/src/padelnomics/planner/templates/planner.html +++ b/padelnomics/src/padelnomics/planner/templates/planner.html @@ -14,7 +14,7 @@ {% macro slider(name, label, min, max, step, value, tip='') %}