|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import math |
|
import numpy as np |
|
import gradio as gr |
|
import plotly.graph_objects as go |
|
|
|
|
|
|
|
|
|
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" |
|
|
|
|
|
|
|
|
|
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}, |
|
} |
|
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
|
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, |
|
} |
|
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
|
def create_app(): |
|
with gr.Blocks(css=CSS, theme=THEME) as demo: |
|
|
|
|
|
gr.HTML(""" |
|
<div class="vt-header"> |
|
<div class="title">VisaTier — Immigration ROI Simulator</div> |
|
<div class="right">Built for founders • MVP</div> |
|
</div> |
|
""") |
|
|
|
|
|
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> |
|
""") |
|
|
|
|
|
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> |
|
""") |
|
|
|
|
|
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 |
|
): |
|
|
|
if dest in COUNTRY_CONFIG: |
|
cfg = COUNTRY_CONFIG[dest] |
|
|
|
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] |
|
) |
|
|
|
|
|
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, |
|
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 %") |
|
|
|
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] |
|
) |
|
|
|
|
|
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 |
|
|
|
|
|
demo = create_app() |
|
|
|
if __name__ == "__main__": |
|
demo.launch() |
|
|