CatherineYefi's picture
Update app.py
11433ac verified
# app.py
# VisaTier — Immigration ROI Simulator (Gradio)
# • Header + Methodology + Notes (always visible)
# • Main simulator (inputs, KPI tiles, cashflow chart, CTA)
# • Country comparison block (Dubai / UK / USA / Spain / Ireland)
# • Footer
#
# Assumptions are illustrative; users can tweak via inputs.
import math
import numpy as np
import gradio as gr
import plotly.graph_objects as go
# =========================
# LOOK & FEEL
# =========================
CSS = """
:root { --vt-primary:#2563EB; --vt-accent:#10B981; --vt-danger:#EF4444; --vt-ink:#0F172A; --vt-muted:#64748B; --radius:16px; }
.gradio-container { max-width: 1160px !important; margin: 0 auto; }
/* HEADER + FOOTER */
.vt-header { background:#0F172A; color:#E2E8F0; padding:14px 18px; border-radius:12px; display:flex; align-items:center; justify-content:space-between; margin-bottom:14px; }
.vt-header .title { font-weight:800; font-size:18px; letter-spacing:0.2px; }
.vt-header .right { font-size:12px; color:#CBD5E1; }
.vt-footer { margin-top:24px; color:#94A3B8; font-size:12px; text-align:center; padding:12px; border-top:1px solid #E2E8F0; }
/* HERO / BADGES */
.vt-hero { border-radius: var(--radius); padding: 18px 20px; background: linear-gradient(135deg, rgba(37,99,235,.10), rgba(16,185,129,.10)); border: 1px solid #E2E8F0; margin-bottom:16px; }
.vt-badge { display:inline-block; font-size:12px; padding:4px 8px; border-radius:9999px; background:#E2E8F0; color:#334155; margin-right:6px; }
/* KPI */
.vt-kpi { border-radius: var(--radius); padding:16px; background:#FFFFFF; border:1px solid #E2E8F0; margin-bottom:10px; }
.vt-kpi .label { font-size:12px; color:#64748B; }
.vt-kpi .value { font-size:28px; font-weight:800; color:#0F172A; line-height:1.1; }
.vt-kpi.good .value { color: var(--vt-accent); }
.vt-kpi.warn .value { color: var(--vt-danger); }
.vt-note { font-size:12px; color:#64748B; }
/* METHOD block */
.vt-method { border-radius:12px; border:1px solid #E2E8F0; background:#FFFFFF; padding:12px 14px; margin-bottom:10px; }
/* CTA button as link */
.vt-cta { display:inline-block; background:#2563EB; color:#FFFFFF!important; padding:10px 14px; border-radius:10px; font-weight:700; text-decoration:none; margin-top:10px; }
.vt-cta:hover { background:#1D4ED8; }
.table-like { border:1px solid #E2E8F0; border-radius:12px; overflow:hidden; }
.table-like table { width:100%; border-collapse:collapse; font-size:14px; }
.table-like th, .table-like td { padding:10px 12px; border-bottom:1px solid #E2E8F0; text-align:left; }
.table-like th { background:#F8FAFC; color:#0F172A; font-weight:700; }
.table-like tr:last-child td { border-bottom: none; }
"""
THEME = gr.themes.Soft(
primary_hue="blue",
neutral_hue="slate"
).set(
body_background_fill="#F8FAFC",
body_text_color="#0F172A",
button_primary_background_fill="#2563EB",
button_primary_background_fill_hover="#1D4ED8",
)
BOOK_LINK = "https://calendly.com/your-team/diagnostic" # <-- поставь свою ссылку
# =========================
# COUNTRY TEMPLATES (assumptions)
# =========================
COUNTRIES = ["UAE (Dubai)", "UK", "USA (FL/TX/NV)", "Spain", "Ireland"]
COUNTRY_CONFIG = {
"UAE (Dubai)": {"corp_tax": 0.09, "pers_tax": 0.00, "rev_mult": 3.0, "margin_delta_pp": 5.0, "living_month": 9000.0, "ongoing_month": 1500.0, "setup_once": 35000.0},
"UK": {"corp_tax": 0.25, "pers_tax": 0.27, "rev_mult": 1.5, "margin_delta_pp": 2.0, "living_month": 6200.0, "ongoing_month": 1100.0, "setup_once": 18000.0},
"USA (FL/TX/NV)":{"corp_tax": 0.21, "pers_tax": 0.22, "rev_mult": 1.8, "margin_delta_pp": 4.0, "living_month": 7000.0, "ongoing_month": 1300.0, "setup_once": 20000.0},
"Spain": {"corp_tax": 0.25, "pers_tax": 0.24, "rev_mult": 1.4, "margin_delta_pp": 2.0, "living_month": 5000.0, "ongoing_month": 1000.0, "setup_once": 20000.0},
"Ireland": {"corp_tax": 0.125, "pers_tax": 0.22, "rev_mult": 1.6, "margin_delta_pp": 3.0, "living_month": 6500.0, "ongoing_month": 1200.0, "setup_once": 25000.0},
}
# =========================
# FINANCE HELPERS
# =========================
def clamp(x, lo, hi): return max(lo, min(hi, x))
def monthly_rate(annual_rate): return (1.0 + annual_rate) ** (1.0 / 12.0) - 1.0
def irr_bisection(cash, lo=-0.99, hi=5.0, iters=100, tol=1e-7):
def npv(rate): return sum(cf / ((1 + rate) ** t) for t, cf in enumerate(cash))
f_lo, f_hi = npv(lo), npv(hi)
if f_lo * f_hi > 0: return None
for _ in range(iters):
mid = (lo + hi) / 2
v = npv(mid)
if abs(v) < tol: return mid
if v > 0: lo = mid
else: hi = mid
return (lo + hi) / 2
def ramp_factor(m): return 0.6 if m <= 6 else (0.8 if m <= 12 else 1.0)
# =========================
# CORE MODEL (monthly)
# =========================
def compute_monthly_delta_cashflow(
rev0, margin0_pct, corp0_pct, pers0_pct, living0, ongoing0,
dest, rev_mult, margin_delta_pp, corp1_pct, pers1_pct, living1, ongoing1,
capex_once, horizon_m, discount_annual_pct, success_pct
):
m0 = clamp(margin0_pct / 100.0, 0.0, 0.9)
ct0 = clamp(corp0_pct / 100.0, 0.0, 0.6)
pt0 = clamp(pers0_pct / 100.0, 0.0, 0.6)
mult = max(0.0, rev_mult)
mdelta = margin_delta_pp / 100.0
ct1 = clamp(corp1_pct / 100.0, 0.0, 0.6)
pt1 = clamp(pers1_pct / 100.0, 0.0, 0.6)
p = clamp(success_pct / 100.0, 0.01, 1.0)
mr = monthly_rate(discount_annual_pct / 100.0)
base_profit0 = rev0 * m0
after_tax0 = base_profit0 * (1 - ct0) * (1 - pt0) - living0 - ongoing0
rev1 = rev0 * mult
m1 = clamp(m0 + mdelta, 0.01, 0.9)
base_profit1 = rev1 * m1
after_tax1 = base_profit1 * (1 - ct1) * (1 - pt1) - living1 - ongoing1
delta_monthly = after_tax1 - after_tax0
cash = [-capex_once]
cum = -capex_once
months = [0]
cum_series = [cum]
payback_m = math.inf
for m in range(1, horizon_m + 1):
cf = delta_monthly * ramp_factor(m) * p
cash.append(cf)
cum += cf
months.append(m)
cum_series.append(cum)
if math.isinf(payback_m) and cum >= 0:
payback_m = m
npv = sum(cf / ((1 + mr) ** t) for t, cf in enumerate(cash))
roi5y = (npv / capex_once * 100.0) if capex_once > 0 else (0.0 if npv <= 0 else math.inf)
irr_m = irr_bisection(cash)
irr_annual = ((1 + irr_m) ** 12 - 1) * 100.0 if irr_m is not None else 0.0
return {
"npv": npv,
"total_5yr_roi": roi5y,
"payback_months": payback_m,
"payback_years": (payback_m / 12.0) if not math.isinf(payback_m) else float("inf"),
"irr_annual_pct": irr_annual,
"months": months,
"cum_values": cum_series,
"delta_monthly": delta_monthly,
}
# =========================
# PLOTS
# =========================
def build_cashflow_chart(months, cum_values, payback_month=None):
fig = go.Figure()
fig.add_trace(go.Scatter(
x=months, y=cum_values, mode="lines",
line=dict(width=3, color="#2563EB"),
fill='tozeroy', fillcolor="rgba(37,99,235,0.08)",
name="Cumulative ΔCF"
))
fig.add_hline(y=0, line_width=1, line_dash="dot", line_color="#94A3B8")
if payback_month not in (None, float("inf")):
fig.add_vline(
x=payback_month, line_width=1, line_dash="dot", line_color="#2563EB",
annotation_text="Break-even", annotation_position="top right"
)
fig.update_layout(
margin=dict(l=20, r=20, t=30, b=20),
xaxis_title="Month",
yaxis_title="Cumulative €",
plot_bgcolor="#FFFFFF",
paper_bgcolor="#FFFFFF",
showlegend=False,
height=380
)
return fig
def build_comparison_bar(names, values, title, ytitle):
fig = go.Figure()
fig.add_bar(x=names, y=values, marker_color="#2563EB")
fig.update_layout(
title=title,
xaxis_title="Destination",
yaxis_title=ytitle,
margin=dict(l=20, r=20, t=50, b=40),
plot_bgcolor="#FFFFFF",
paper_bgcolor="#FFFFFF",
height=380
)
return fig
# =========================
# UI HELPERS
# =========================
def render_kpis(result):
payback_years = "Never" if result["payback_years"] == float("inf") else f'{result["payback_years"]:.1f} years'
roi_str = f'{result["total_5yr_roi"]:.1f}%'
irr_str = f'{result["irr_annual_pct"]:.1f}%'
npv_str = f'€{result["npv"]:,.0f}'
k1 = f"""<div class="vt-kpi {'good' if result['payback_years']<=1.0 else ''}">
<div class="label">Payback</div><div class="value">{payback_years}</div>
<div class="vt-note">Time to break even</div></div>"""
k2 = f"""<div class="vt-kpi {'good' if result['total_5yr_roi']>=100 else ''}">
<div class="label">ROI (5y)</div><div class="value">{roi_str}</div>
<div class="vt-note">Risk-adjusted</div></div>"""
k3 = f"""<div class="vt-kpi">
<div class="label">IRR (annual)</div><div class="value">{irr_str}</div>
<div class="vt-note">From monthly cashflows</div></div>"""
k4 = f"""<div class="vt-kpi">
<div class="label">NPV</div><div class="value">{npv_str}</div>
<div class="vt-note">Discounted at your rate</div></div>"""
return k1, k2, k3, k4
# =========================
# APP LAYOUT
# =========================
def create_app():
with gr.Blocks(css=CSS, theme=THEME) as demo:
# HEADER
gr.HTML("""
<div class="vt-header">
<div class="title">VisaTier — Immigration ROI Simulator</div>
<div class="right">Built for founders • MVP</div>
</div>
""")
# Methodology + Notes (always visible)
gr.HTML("""
<div class="vt-method">
<b>Methodology</b><br/>
How we calculate (simplified):<br/>
• Baseline owner cashflow = after corporate & personal taxes – living – ongoing.<br/>
• Scenario = revenue × multiplier, margin delta, destination taxes & living.<br/>
• Monthly uplift ΔCF → cumulative cashflow → Payback, ROI, IRR, NPV.<br/>
• Risk‑adjusted (success probability), discounted at your chosen rate.
</div>
""")
gr.HTML("""
<div class="vt-note"><b>Notes:</b> Illustrative model, not tax or legal advice. Results depend on execution and personal circumstances.</div>
""")
# HERO
gr.HTML("""
<div class="vt-hero">
<h2 style="margin:0; font-size:22px; font-weight:800; color:#0F172A;">Model the financial impact of relocation</h2>
<p style="margin:6px 0 10px; color:#334155;">Simulate revenue lift, taxes and living costs — get a risk‑adjusted payback date in minutes.</p>
<span class="vt-badge">MVP</span>
<span class="vt-badge">Founders</span>
<span class="vt-badge">Free to try</span>
</div>
""")
# ===== MAIN SIMULATOR =====
with gr.Row():
with gr.Column(scale=5):
dest = gr.Dropdown(COUNTRIES, value="UAE (Dubai)", label="Destination template")
with gr.Row():
rev0 = gr.Number(value=30000, label="Baseline revenue (€/month)")
margin0 = gr.Slider(value=25, minimum=1, maximum=70, step=1, label="Baseline EBITDA margin (%)")
with gr.Row():
corp0 = gr.Slider(value=20, minimum=0, maximum=50, step=1, label="Baseline corporate tax (%)")
pers0 = gr.Slider(value=10, minimum=0, maximum=50, step=1, label="Baseline personal tax (%)")
with gr.Row():
living0 = gr.Number(value=4000, label="Baseline living cost (€/month)")
ongoing0 = gr.Number(value=0, label="Other ongoing (€/month)")
gr.Markdown("**Destination adjustments**")
with gr.Row():
rev_mult = gr.Slider(value=3.0, minimum=0.5, maximum=5.0, step=0.1, label="Revenue multiplier (×)")
margin_delta = gr.Slider(value=5.0, minimum=-20, maximum=30, step=0.5, label="Margin delta (pp)")
with gr.Row():
corp1 = gr.Slider(value=9, minimum=0, maximum=50, step=1, label="Destination corporate tax (%)")
pers1 = gr.Slider(value=0, minimum=0, maximum=50, step=1, label="Destination personal tax (%)")
with gr.Row():
living1 = gr.Number(value=9000, label="Destination living cost (€/month)")
ongoing1 = gr.Number(value=1500, label="Other ongoing (€/month)")
with gr.Accordion("Investments & risk", open=False):
with gr.Row():
capex_once = gr.Number(value=35000, label="One-time setup (€)")
horizon_m = gr.Slider(value=36, minimum=6, maximum=60, step=1, label="Horizon (months)")
with gr.Row():
discount_a = gr.Slider(value=12, minimum=0, maximum=40, step=1, label="Annual discount rate (%)")
success = gr.Slider(value=75, minimum=10, maximum=100, step=1, label="Success probability (%)")
estimate_btn = gr.Button("Estimate", variant="primary")
with gr.Column(scale=7):
gr.Markdown("**Results (teaser)** — first 2 KPIs.")
kpi1 = gr.HTML("<div class='vt-kpi'><div class='label'>Payback</div><div class='value'>—</div><div class='vt-note'>Time to break even</div></div>")
kpi2 = gr.HTML("<div class='vt-kpi'><div class='label'>ROI (5y)</div><div class='value'>—</div><div class='vt-note'>Risk-adjusted</div></div>")
kpi3 = gr.HTML("<div class='vt-kpi'><div class='label'>IRR (annual)</div><div class='value'>—</div><div class='vt-note'>From monthly cashflows</div></div>", visible=False)
kpi4 = gr.HTML("<div class='vt-kpi'><div class='label'>NPV</div><div class='value'>—</div><div class='vt-note'>Discounted at your rate</div></div>", visible=False)
chart = gr.Plot(visible=False)
cta_btn = gr.HTML(f'<a class="vt-cta" href="{BOOK_LINK}" target="_blank">Book diagnostic</a>', visible=False)
state_result = gr.State(value=None)
state_unlocked = gr.State(value=False)
def on_estimate(
dest, rev0, margin0, corp0, pers0, living0, ongoing0,
rev_mult, margin_delta, corp1, pers1, living1, ongoing1,
capex_once, horizon_m, discount_a, success
):
# Apply template defaults if user left UAE defaults
if dest in COUNTRY_CONFIG:
cfg = COUNTRY_CONFIG[dest]
# if user did not change from UAE defaults, swap to chosen
if dest != "UAE (Dubai)":
if rev_mult == 3.0: rev_mult = cfg["rev_mult"]
if margin_delta == 5.0: margin_delta = cfg["margin_delta_pp"]
if corp1 == 9: corp1 = cfg["corp_tax"] * 100.0
if pers1 == 0: pers1 = cfg["pers_tax"] * 100.0
if living1 == 9000: living1 = cfg["living_month"]
if ongoing1 == 1500: ongoing1 = cfg["ongoing_month"]
if capex_once == 35000: capex_once = cfg["setup_once"]
result = compute_monthly_delta_cashflow(
rev0, margin0, corp0, pers0, living0, ongoing0,
dest, rev_mult, margin_delta, corp1, pers1, living1, ongoing1,
capex_once, int(horizon_m), discount_a, success
)
k1, k2, k3, k4 = render_kpis(result)
return (
k1, k2, k3, k4,
build_cashflow_chart(result["months"], result["cum_values"], result["payback_months"]),
result, True
)
estimate_btn.click(
on_estimate,
inputs=[dest, rev0, margin0, corp0, pers0, living0, ongoing0,
rev_mult, margin_delta, corp1, pers1, living1, ongoing1,
capex_once, horizon_m, discount_a, success],
outputs=[kpi1, kpi2, kpi3, kpi4, chart, state_result, cta_btn]
)
# ===== COMPARISON BLOCK =====
gr.Markdown("## Compare destinations — Dubai / UK / USA / Spain / Ireland")
with gr.Row():
with gr.Column(scale=5):
gr.Markdown("**Assumptions shared across comparison** (you can tweak):")
rev0_c = gr.Number(value=30000, label="Baseline revenue (€/month)")
margin0_c = gr.Slider(value=25, minimum=1, maximum=70, step=1, label="Baseline EBITDA margin (%)")
corp0_c = gr.Slider(value=20, minimum=0, maximum=50, step=1, label="Baseline corporate tax (%)")
pers0_c = gr.Slider(value=10, minimum=0, maximum=50, step=1, label="Baseline personal tax (%)")
living0_c = gr.Number(value=4000, label="Baseline living cost (€/month)")
ongoing0_c = gr.Number(value=0, label="Other ongoing (€/month)")
with gr.Row():
capex_c = gr.Number(value=30000, label="One-time setup (€)")
horizon_c = gr.Slider(value=36, minimum=6, maximum=60, step=1, label="Horizon (months)")
with gr.Row():
discount_c = gr.Slider(value=12, minimum=0, maximum=40, step=1, label="Annual discount rate (%)")
success_c = gr.Slider(value=75, minimum=10, maximum=100, step=1, label="Success probability (%)")
compare_btn = gr.Button("Run comparison", variant="primary")
with gr.Column(scale=7):
comp_kpis = gr.HTML("<div class='table-like'><table><tr><th>Destination</th><th>Payback (yrs)</th><th>ROI (5y)</th><th>IRR (annual)</th><th>NPV (€)</th></tr></table></div>")
comp_bar_roi = gr.Plot()
comp_bar_payback = gr.Plot()
def run_comparison(rev0, margin0, corp0, pers0, living0, ongoing0, capex, horizon, discount, success):
rows = []
roi_vals = []
payback_vals = []
for name in COUNTRIES:
cfg = COUNTRY_CONFIG[name]
res = compute_monthly_delta_cashflow(
rev0, margin0, corp0, pers0, living0, ongoing0,
name, cfg["rev_mult"], cfg["margin_delta_pp"], cfg["corp_tax"]*100.0, cfg["pers_tax"]*100.0,
cfg["living_month"], cfg["ongoing_month"],
cfg["setup_once"] if capex == 30000 else capex, # если не трогали — можно оставить шаблонный setup
int(horizon), discount, success
)
pb = "Never" if res["payback_years"] == float("inf") else f"{res['payback_years']:.1f}"
rows.append(f"<tr><td><b>{name}</b></td><td>{pb}</td><td>{res['total_5yr_roi']:.1f}%</td><td>{res['irr_annual_pct']:.1f}%</td><td>€{res['npv']:,.0f}</td></tr>")
roi_vals.append(res["total_5yr_roi"])
payback_vals.append(res["payback_years"] if res["payback_years"] != float("inf") else None)
table_html = "<div class='table-like'><table><tr><th>Destination</th><th>Payback (yrs)</th><th>ROI (5y)</th><th>IRR (annual)</th><th>NPV (€)</th></tr>" + "".join(rows) + "</table></div>"
bar_roi = build_comparison_bar(COUNTRIES, roi_vals, "ROI (5y) by destination", "ROI %")
# For payback: set large number for None to visualize; but better leave None & Plotly will skip
pb_plot_vals = [v if v is not None else None for v in payback_vals]
bar_payback = build_comparison_bar(COUNTRIES, pb_plot_vals, "Payback (years) by destination", "Years")
return table_html, bar_roi, bar_payback
compare_btn.click(
run_comparison,
inputs=[rev0_c, margin0_c, corp0_c, pers0_c, living0_c, ongoing0_c, capex_c, horizon_c, discount_c, success_c],
outputs=[comp_kpis, comp_bar_roi, comp_bar_payback]
)
# FOOTER
gr.HTML("""<div class="vt-footer">© 2025 VisaTier — Immigration & Investment Advisory • <a href="https://visatier.com" target="_blank">visatier.com</a> • <a href='{}' target='_blank'>Book diagnostic</a></div>""".format(BOOK_LINK))
return demo
# HF expects a global `demo`
demo = create_app()
if __name__ == "__main__":
demo.launch()