| import os |
| import sys |
| import json |
| import time |
| import subprocess |
| import threading |
| from typing import Optional |
| from fastapi import FastAPI, BackgroundTasks, HTTPException |
| from fastapi.responses import HTMLResponse, JSONResponse |
| from fastapi.middleware.cors import CORSMiddleware |
| from pydantic import BaseModel, Field |
| import pandas as pd |
| import numpy as np |
|
|
| from src.utils.paths import data_dir, data_path |
|
|
| app = FastAPI(title="Subscription Intelligence Dashboard API") |
|
|
| |
| app.add_middleware( |
| CORSMiddleware, |
| allow_origins=["*"], |
| allow_credentials=True, |
| allow_methods=["*"], |
| allow_headers=["*"], |
| ) |
|
|
| |
| BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) |
| DATA_DIR = data_dir() |
| PROCESSED_DIR = os.path.join(DATA_DIR, "processed") |
| RAW_DIR = os.path.join(DATA_DIR, "raw") |
| DOCS_DIR = os.path.join(BASE_DIR, "docs") |
| LOG_FILE_PATH = data_path("pipeline_run.log") |
|
|
| |
| pipeline_status = { |
| "status": "IDLE", |
| "progress": 0, |
| "step": "Not started", |
| "start_time": None, |
| "end_time": None, |
| "elapsed": 0, |
| "records": 50000 |
| } |
|
|
| pipeline_lock = threading.Lock() |
|
|
|
|
| class RevenueSimulationRequest(BaseModel): |
| starting_tenants: int = Field(500, ge=1, le=100_000) |
| monthly_arpu: float = Field(50_000, ge=0, le=10_000_000) |
| monthly_growth_pct: float = Field(4.0, ge=-100, le=200) |
| monthly_churn_pct: float = Field(8.0, ge=0, le=100) |
| months: int = Field(12, ge=1, le=36) |
|
|
| @app.get("/health") |
| def health_check(): |
| return {"status": "ok", "service": "subscription-intelligence-dashboard"} |
|
|
| def load_parquet(path: str) -> Optional[pd.DataFrame]: |
| normalized_path = path.replace("\\", "/") |
| path_parts = normalized_path.split("/") |
| if path_parts and path_parts[0] == "data": |
| path_parts = path_parts[1:] |
| full_path = data_path(*path_parts) |
| if not os.path.exists(full_path): |
| return None |
| try: |
| return pd.read_parquet(full_path) |
| except Exception as e: |
| print(f"Error loading parquet at {full_path}: {e}") |
| return None |
|
|
| def format_inr(val) -> str: |
| """Formats values into Indian Rupees (Lakhs/Crores/Standard)""" |
| if val is None or np.isnan(val): |
| return "INR 0" |
|
|
| val = float(val) |
| if val >= 10_000_000: |
| return f"INR {val / 10_000_000:.2f}Cr" |
| elif val >= 100_000: |
| return f"INR {val / 100_000:.2f}L" |
| elif val >= 1_000: |
| return f"INR {val / 1_000:.1f}k" |
| else: |
| return f"INR {val:.0f}" |
|
|
| def format_trend(pct) -> dict: |
| """Generates standard trend indicator styling details""" |
| if pct is None or np.isnan(pct): |
| return {"text": "-", "class": "neutral", "up": False} |
| pct = float(pct) |
| if pct > 0: |
| return {"text": f"+{pct:.1f}%", "class": "up", "up": True} |
| elif pct < 0: |
| return {"text": f"-{abs(pct):.1f}%", "class": "dn", "up": False} |
| else: |
| return {"text": "0.0%", "class": "neutral", "up": False} |
|
|
| |
| def run_pipeline_worker(records: int): |
| global pipeline_status |
|
|
| with pipeline_lock: |
| pipeline_status["status"] = "RUNNING" |
| pipeline_status["progress"] = 5 |
| pipeline_status["step"] = "1/5: Generating Data" |
| pipeline_status["start_time"] = time.time() |
| pipeline_status["end_time"] = None |
| pipeline_status["elapsed"] = 0 |
| pipeline_status["records"] = records |
|
|
| python_exe = sys.executable |
|
|
| |
| os.makedirs(os.path.dirname(LOG_FILE_PATH), exist_ok=True) |
| with open(LOG_FILE_PATH, "w", encoding="utf-8") as log_file: |
| log_file.write(f"=== Subscription Intelligence Pipeline Run Started (Records: {records}) ===\n") |
| log_file.flush() |
|
|
| steps = [ |
| ("Generating Data", [python_exe, "src/ingestion/generate_data.py", "--records", str(records)], 25), |
| ("Ingest -> Parquet", [python_exe, "src/ingestion/etl_ingest.py"], 50), |
| ("MRR / ARR / LTV", [python_exe, "src/transforms/mrr_transform.py"], 75), |
| ("Churn + Cohorts", [python_exe, "src/transforms/churn_cohort.py"], 90), |
| ("Benchmark", [python_exe, "src/analytics/benchmark.py"], 100), |
| ] |
|
|
| env = os.environ.copy() |
| env["PYTHONIOENCODING"] = "utf-8" |
|
|
| success = True |
| for i, (name, cmd, prog) in enumerate(steps): |
| with pipeline_lock: |
| pipeline_status["step"] = f"{i+1}/5: {name}" |
| pipeline_status["progress"] = max(pipeline_status["progress"], int(prog - 15)) |
|
|
| log_file.write(f"\n>>> Running Step {i+1}: {name}...\n") |
| log_file.write(f"Command: {' '.join(cmd)}\n") |
| log_file.flush() |
|
|
| proc = subprocess.Popen( |
| cmd, |
| stdout=subprocess.PIPE, |
| stderr=subprocess.STDOUT, |
| cwd=BASE_DIR, |
| env=env, |
| text=True |
| ) |
|
|
| while True: |
| line = proc.stdout.readline() |
| if not line: |
| break |
| log_file.write(line) |
| log_file.flush() |
|
|
| |
| with pipeline_lock: |
| pipeline_status["elapsed"] = round(time.time() - pipeline_status["start_time"], 1) |
|
|
| proc.wait() |
| if proc.returncode != 0: |
| success = False |
| log_file.write(f"\n[ERROR] Step '{name}' failed with exit code {proc.returncode}\n") |
| log_file.flush() |
| break |
|
|
| with pipeline_lock: |
| pipeline_status["progress"] = prog |
| pipeline_status["elapsed"] = round(time.time() - pipeline_status["start_time"], 1) |
|
|
| with pipeline_lock: |
| pipeline_status["end_time"] = time.time() |
| pipeline_status["elapsed"] = round(pipeline_status["end_time"] - pipeline_status["start_time"], 1) |
| if success: |
| pipeline_status["status"] = "SUCCESS" |
| pipeline_status["progress"] = 100 |
| pipeline_status["step"] = "Completed successfully" |
| log_file.write("\n=== Pipeline completed successfully! ===\n") |
| else: |
| pipeline_status["status"] = "FAILED" |
| pipeline_status["step"] = "Pipeline failed" |
| log_file.write("\n=== Pipeline execution failed! ===\n") |
| log_file.flush() |
|
|
| @app.post("/api/pipeline/run") |
| def trigger_pipeline(background_tasks: BackgroundTasks, records: int = 50000): |
| global pipeline_status |
| with pipeline_lock: |
| if pipeline_status["status"] == "RUNNING": |
| raise HTTPException(status_code=400, detail="Pipeline is already running.") |
|
|
| background_tasks.add_task(run_pipeline_worker, records) |
| return {"message": "Pipeline run triggered", "status": "RUNNING"} |
|
|
| @app.get("/api/pipeline/status") |
| def get_pipeline_status(): |
| global pipeline_status |
| with pipeline_lock: |
| if pipeline_status["status"] == "RUNNING": |
| pipeline_status["elapsed"] = round(time.time() - pipeline_status["start_time"], 1) |
| return pipeline_status |
|
|
| @app.get("/api/pipeline/logs") |
| def get_pipeline_logs(): |
| if not os.path.exists(LOG_FILE_PATH): |
| return {"logs": "No pipeline run logs available yet. Trigger the pipeline first."} |
| try: |
| with open(LOG_FILE_PATH, "r", encoding="utf-8") as f: |
| return {"logs": f.read()} |
| except Exception as e: |
| return {"logs": f"Error reading logs: {str(e)}"} |
|
|
| @app.get("/api/overview") |
| def get_overview(period_filter: str = "1y"): |
| """ |
| Returns KPIs, MRR monthly trend, Revenue by Plan, and Top Tenants |
| """ |
| mrr_summary_df = load_parquet("data/processed/global_mrr_monthly") |
| tenant_ltv_df = load_parquet("data/processed/tenant_ltv") |
| mrr_tenant_df = load_parquet("data/processed/mrr_by_tenant_month") |
|
|
| if mrr_summary_df is None or len(mrr_summary_df) == 0: |
| return JSONResponse(status_code=404, content={"detail": "No processed data found. Please run the ETL pipeline."}) |
|
|
| mrr_summary_df = mrr_summary_df.sort_values(["event_year", "event_month"]).reset_index(drop=True) |
|
|
| |
| if period_filter == "6m": |
| filtered_summary = mrr_summary_df.tail(6) |
| elif period_filter == "1y": |
| filtered_summary = mrr_summary_df.tail(12) |
| else: |
| filtered_summary = mrr_summary_df |
|
|
| |
| latest_month = mrr_summary_df.iloc[-1] |
| prev_month = mrr_summary_df.iloc[-2] if len(mrr_summary_df) > 1 else latest_month |
|
|
| total_mrr = latest_month["total_mrr"] |
| total_arr = latest_month["total_arr"] |
| paying_tenants = latest_month["paying_tenants"] |
|
|
| |
| mrr_pct = ((total_mrr - prev_month["total_mrr"]) / prev_month["total_mrr"] * 100) if prev_month["total_mrr"] > 0 else 0 |
| arr_pct = ((total_arr - prev_month["total_arr"]) / prev_month["total_arr"] * 100) if prev_month["total_arr"] > 0 else 0 |
| tenants_diff = paying_tenants - prev_month["paying_tenants"] |
|
|
| |
| churn_df = load_parquet("data/processed/churn_by_plan_month") |
| latest_churn_pct = 0.0 |
| churn_trend = {"text": "-", "class": "neutral"} |
|
|
| if churn_df is not None and len(churn_df) > 0: |
| latest_period = latest_month["period"] |
| latest_churn_rows = churn_df[churn_df["period"] == latest_period] |
| if len(latest_churn_rows) > 0: |
| total_active = latest_churn_rows["active_tenants"].sum() |
| total_churned = latest_churn_rows["churned_tenants"].sum() |
| latest_churn_pct = (total_churned / total_active * 100) if total_active > 0 else 0.0 |
|
|
| |
| prev_period = prev_month["period"] |
| prev_churn_rows = churn_df[churn_df["period"] == prev_period] |
| if len(prev_churn_rows) > 0: |
| prev_active = prev_churn_rows["active_tenants"].sum() |
| prev_churned = prev_churn_rows["churned_tenants"].sum() |
| prev_churn_pct = (prev_churned / prev_active * 100) if prev_active > 0 else 0.0 |
| churn_diff = latest_churn_pct - prev_churn_pct |
| if churn_diff > 0: |
| churn_trend = {"text": f"+{churn_diff:.1f}% vs last mo", "class": "dn"} |
| elif churn_diff < 0: |
| churn_trend = {"text": f"-{abs(churn_diff):.1f}% vs last mo", "class": "up"} |
| else: |
| churn_trend = {"text": "No change", "class": "neutral"} |
|
|
| |
| kpis = [ |
| { |
| "label": "Total MRR", |
| "val": format_inr(total_mrr), |
| "trend_text": format_trend(mrr_pct)["text"], |
| "trend_class": format_trend(mrr_pct)["class"] |
| }, |
| { |
| "label": "ARR", |
| "val": format_inr(total_arr), |
| "trend_text": format_trend(arr_pct)["text"], |
| "trend_class": format_trend(arr_pct)["class"] |
| }, |
| { |
| "label": "Churn Rate", |
| "val": f"{latest_churn_pct:.1f}%", |
| "trend_text": churn_trend["text"], |
| "trend_class": churn_trend["class"] |
| }, |
| { |
| "label": "Active Tenants", |
| "val": str(int(paying_tenants)), |
| "trend_text": f"+{int(tenants_diff)} this month" if tenants_diff >= 0 else f"-{int(abs(tenants_diff))} this month", |
| "trend_class": "up" if tenants_diff >= 0 else "dn" |
| } |
| ] |
|
|
| |
| mrr_trend = [] |
| max_mrr = filtered_summary["total_mrr"].max() if len(filtered_summary) > 0 else 1 |
| for _, row in filtered_summary.iterrows(): |
| pct_height = int((row["total_mrr"] / max_mrr) * 90) |
| |
| color = "#B5D4F4" |
| if pct_height > 75: |
| color = "#0C447C" |
| elif pct_height > 50: |
| color = "#185FA5" |
| elif pct_height > 30: |
| color = "#378ADD" |
| elif pct_height > 15: |
| color = "#85B7EB" |
|
|
| mrr_trend.append({ |
| "period": row["period"], |
| "month_name": pd.to_datetime(row["period"] + "-01").strftime("%b"), |
| "mrr": float(row["total_mrr"]), |
| "mrr_formatted": format_inr(row["total_mrr"]), |
| "pct_height": pct_height, |
| "color": color |
| }) |
|
|
| |
| plan_revenue = [] |
| if mrr_tenant_df is not None and len(mrr_tenant_df) > 0: |
| latest_period = latest_month["period"] |
| latest_tenant_mrr = mrr_tenant_df[mrr_tenant_df["period"] == latest_period] |
|
|
| |
| if tenant_ltv_df is not None and len(tenant_ltv_df) > 0: |
| joined = pd.merge(latest_tenant_mrr, tenant_ltv_df[["tenant_id", "plan"]], on="tenant_id", how="inner") |
| plan_grouped = joined.groupby("plan")["mrr"].sum().reset_index() |
|
|
| |
| plan_colors = { |
| "enterprise": "#3C3489", |
| "business": "#185FA5", |
| "growth": "#0F6E56", |
| "starter": "#633806" |
| } |
|
|
| total_plan_rev = plan_grouped["mrr"].sum() |
| for _, row in plan_grouped.iterrows(): |
| p = row["plan"] |
| plan_revenue.append({ |
| "plan": p.capitalize(), |
| "raw_mrr": float(row["mrr"]), |
| "mrr": format_inr(row["mrr"]), |
| "pct": int((row["mrr"] / total_plan_rev * 100)) if total_plan_rev > 0 else 0, |
| "color": plan_colors.get(p.lower(), "#185FA5") |
| }) |
| |
| tier_order = {"Enterprise": 4, "Business": 3, "Growth": 2, "Starter": 1} |
| plan_revenue.sort(key=lambda x: tier_order.get(x["plan"], 0), reverse=True) |
|
|
| |
| if not plan_revenue: |
| plan_revenue = [ |
| {"plan": "Enterprise", "mrr": "INR 0", "pct": 0, "color": "#3C3489"}, |
| {"plan": "Business", "mrr": "INR 0", "pct": 0, "color": "#185FA5"}, |
| {"plan": "Growth", "mrr": "INR 0", "pct": 0, "color": "#0F6E56"}, |
| {"plan": "Starter", "mrr": "INR 0", "pct": 0, "color": "#633806"} |
| ] |
|
|
| |
| top_tenants = [] |
| if tenant_ltv_df is not None and len(tenant_ltv_df) > 0: |
| sorted_ltv = tenant_ltv_df.sort_values("estimated_ltv", ascending=False).head(5) |
| for _, row in sorted_ltv.iterrows(): |
| |
| tid = row["tenant_id"] |
| latest_mrr = 0.0 |
| mom_growth = 0.0 |
|
|
| if mrr_tenant_df is not None: |
| t_history = mrr_tenant_df[mrr_tenant_df["tenant_id"] == tid].sort_values(["event_year", "event_month"]) |
| if len(t_history) > 0: |
| latest_mrr = t_history.iloc[-1]["mrr"] |
| mom_growth = t_history.iloc[-1]["mrr_growth_pct"] |
|
|
| top_tenants.append({ |
| "company_name": row["company_name"], |
| "plan": row["plan"].capitalize(), |
| "ltv": format_inr(row["estimated_ltv"]), |
| "mrr": format_inr(latest_mrr), |
| "mom_growth": format_trend(mom_growth)["text"], |
| "mom_class": format_trend(mom_growth)["class"] |
| }) |
|
|
| return { |
| "kpis": kpis, |
| "mrr_trend": mrr_trend, |
| "plan_revenue": plan_revenue, |
| "top_tenants": top_tenants |
| } |
|
|
| @app.get("/api/tenants") |
| def get_tenants(search: str = "", plan: str = "all", page: int = 1, limit: int = 15): |
| tenant_ltv_df = load_parquet("data/processed/tenant_ltv") |
| mrr_tenant_df = load_parquet("data/processed/mrr_by_tenant_month") |
|
|
| if tenant_ltv_df is None or len(tenant_ltv_df) == 0: |
| return {"tenants": [], "total_count": 0, "active_count": 0, "avg_ltv": "INR 0", "arpu": "INR 0"} |
|
|
| |
| total_count = len(tenant_ltv_df) |
| avg_ltv = tenant_ltv_df["estimated_ltv"].mean() |
|
|
| |
| |
| latest_period = "2025-04" |
| if mrr_tenant_df is not None and len(mrr_tenant_df) > 0: |
| latest_period = mrr_tenant_df["period"].max() |
| active_ids = mrr_tenant_df[mrr_tenant_df["period"] == latest_period]["tenant_id"].unique() |
| tenant_ltv_df["status"] = np.where(tenant_ltv_df["tenant_id"].isin(active_ids), "Active", "Churned") |
| else: |
| tenant_ltv_df["status"] = "Active" |
|
|
| active_count = len(tenant_ltv_df[tenant_ltv_df["status"] == "Active"]) |
| avg_mrr = tenant_ltv_df["avg_monthly_mrr"].mean() |
|
|
| |
| filtered_df = tenant_ltv_df.copy() |
| if search: |
| filtered_df = filtered_df[ |
| filtered_df["company_name"].str.contains(search, case=False, na=False) | |
| filtered_df["tenant_id"].str.contains(search, case=False, na=False) |
| ] |
|
|
| |
| if plan != "all": |
| filtered_df = filtered_df[filtered_df["plan"].str.lower() == plan.lower()] |
|
|
| |
| total_filtered = len(filtered_df) |
| start_idx = (page - 1) * limit |
| end_idx = start_idx + limit |
| paginated_df = filtered_df.sort_values("estimated_ltv", ascending=False).iloc[start_idx:end_idx] |
|
|
| tenants_list = [] |
| for _, row in paginated_df.iterrows(): |
| |
| tid = row["tenant_id"] |
| latest_mrr = 0.0 |
| if mrr_tenant_df is not None and len(mrr_tenant_df) > 0: |
| latest_rows = mrr_tenant_df[(mrr_tenant_df["tenant_id"] == tid) & (mrr_tenant_df["period"] == latest_period)] |
| if len(latest_rows) > 0: |
| latest_mrr = latest_rows.iloc[0]["mrr"] |
|
|
| tenants_list.append({ |
| "tenant_id": row["tenant_id"], |
| "company_name": row["company_name"], |
| "plan": row["plan"].capitalize(), |
| "mrr": format_inr(latest_mrr if latest_mrr > 0 else row["avg_monthly_mrr"]), |
| "active_months": int(row["active_months"]), |
| "ltv": format_inr(row["estimated_ltv"]), |
| "status": row["status"] |
| }) |
|
|
| return { |
| "tenants": tenants_list, |
| "total_count": total_count, |
| "active_count": active_count, |
| "avg_ltv": format_inr(avg_ltv), |
| "arpu": format_inr(avg_mrr), |
| "total_filtered": total_filtered, |
| "page": page, |
| "limit": limit |
| } |
|
|
| @app.get("/api/cohorts") |
| def get_cohorts(): |
| cohort_df = load_parquet("data/processed/cohort_retention") |
| cohort_sizes_df = load_parquet("data/processed/cohort_sizes") |
|
|
| if cohort_df is None or len(cohort_df) == 0: |
| return {"cohorts": [], "months": []} |
|
|
| cohort_df["months_since_start"] = cohort_df["months_since_start"].astype(int) |
|
|
| |
| pivot = cohort_df.pivot(index="cohort", columns="months_since_start", values="retention_pct").reset_index() |
| pivot = pivot.sort_values("cohort", ascending=True) |
|
|
| |
| if cohort_sizes_df is not None: |
| pivot = pd.merge(pivot, cohort_sizes_df, on="cohort", how="left") |
| else: |
| pivot["cohort_size"] = 25 |
|
|
| cohorts_list = [] |
| for _, row in pivot.iterrows(): |
| retention_months = [] |
| for m in range(12): |
| val = row.get(m, None) |
| retention_months.append(float(val) if pd.notna(val) else None) |
|
|
| cohorts_list.append({ |
| "cohort": row["cohort"], |
| "cohort_size": int(row["cohort_size"]) if pd.notna(row["cohort_size"]) else 25, |
| "retention": retention_months |
| }) |
|
|
| return { |
| "cohorts": cohorts_list, |
| "months": [f"M{m}" for m in range(12)] |
| } |
|
|
| @app.get("/api/churn") |
| def get_churn(): |
| churn_df = load_parquet("data/processed/churn_by_plan_month") |
| events_df = load_parquet("data/processed/billing_events") |
| tenants_df = load_parquet("data/processed/tenant_ltv") |
|
|
| if churn_df is None or len(churn_df) == 0: |
| return {"kpis": [], "plan_churn": [], "churn_risk": []} |
|
|
| churn_df = churn_df.copy() |
| churn_df["churned_tenants"] = churn_df["churned_tenants"].fillna(0) |
| churn_df["active_tenants"] = churn_df["active_tenants"].fillna(0) |
| churn_df["churn_rate_pct"] = churn_df["churn_rate_pct"].fillna(0) |
|
|
| period_summary = ( |
| churn_df |
| .groupby("period", as_index=False) |
| .agg( |
| active_tenants=("active_tenants", "sum"), |
| churned_tenants=("churned_tenants", "sum") |
| ) |
| .sort_values("period") |
| ) |
| active_churn_periods = period_summary[period_summary["churned_tenants"] > 0] |
| selected_period = ( |
| active_churn_periods.iloc[-1]["period"] |
| if len(active_churn_periods) > 0 |
| else period_summary.iloc[-1]["period"] |
| ) |
| latest_churn = churn_df[churn_df["period"] == selected_period] |
|
|
| total_active = latest_churn["active_tenants"].sum() |
| total_churned = latest_churn["churned_tenants"].sum() |
| overall_churn_rate = (total_churned / total_active * 100) if total_active > 0 else 0.0 |
|
|
| |
| total_historical_churned = 0 |
| if events_df is not None: |
| total_historical_churned = events_df[events_df["event_type"] == "subscription_cancelled"]["tenant_id"].nunique() |
|
|
| if len(latest_churn) > 0: |
| highest_plan = latest_churn.sort_values("churn_rate_pct", ascending=False).iloc[0] |
| lowest_plan = latest_churn.sort_values("churn_rate_pct", ascending=True).iloc[0] |
| highest_plan_name = str(highest_plan["plan"]).capitalize() |
| lowest_plan_name = str(lowest_plan["plan"]).capitalize() |
| highest_plan_churn = float(highest_plan["churn_rate_pct"]) |
| lowest_plan_churn = float(lowest_plan["churn_rate_pct"]) |
| else: |
| highest_plan_name = lowest_plan_name = "No plan data" |
| highest_plan_churn = lowest_plan_churn = 0.0 |
|
|
| kpis = [ |
| {"label": "Observed Churn", "val": f"{overall_churn_rate:.1f}%", "sub": f"{selected_period} latest churn activity"}, |
| {"label": "Highest Plan Churn", "val": f"{highest_plan_churn:.1f}%", "sub": highest_plan_name}, |
| {"label": "Lowest Plan Churn", "val": f"{lowest_plan_churn:.1f}%", "sub": lowest_plan_name}, |
| {"label": "Churned This Period", "val": str(int(total_churned)), "sub": f"{total_historical_churned} all-time cancelled"} |
| ] |
|
|
| plan_churn = [] |
| plan_colors = { |
| "starter": "#E24B4A", |
| "growth": "#EF9F27", |
| "business": "#85B7EB", |
| "enterprise": "#1D9E75" |
| } |
|
|
| for plan_name in ["starter", "growth", "business", "enterprise"]: |
| p_row = latest_churn[latest_churn["plan"] == plan_name] |
| pct = float(p_row.iloc[0]["churn_rate_pct"]) if len(p_row) > 0 else 0.0 |
| plan_churn.append({ |
| "plan": plan_name.capitalize(), |
| "rate": f"{pct:.1f}%", |
| "bar_width": int(min(pct * 10, 100)), |
| "color": plan_colors.get(plan_name, "#85B7EB") |
| }) |
|
|
| |
| churn_risk = [] |
| if events_df is not None and tenants_df is not None: |
| events_df = events_df.copy() |
| events_df["event_date"] = pd.to_datetime(events_df["event_date"], errors="coerce") |
| latest_event_date = events_df["event_date"].max() |
| cutoff_date = latest_event_date - pd.Timedelta(days=90) if pd.notna(latest_event_date) else None |
|
|
| failed_events = events_df[events_df["event_type"] == "invoice_failed"] |
| if cutoff_date is not None: |
| failed_events = failed_events[failed_events["event_date"] >= cutoff_date] |
|
|
| failure_counts = ( |
| failed_events |
| .groupby("tenant_id") |
| .size() |
| .reset_index(name="failure_count") |
| .sort_values("failure_count", ascending=False) |
| .head(4) |
| ) |
|
|
| for _, failed_row in failure_counts.iterrows(): |
| tid = failed_row["tenant_id"] |
| t_row = tenants_df[tenants_df["tenant_id"] == tid] |
| if len(t_row) > 0: |
| failure_count = int(failed_row["failure_count"]) |
| reason = ( |
| f"{failure_count} failed invoices in last 90d" |
| if failure_count > 1 |
| else "1 failed invoice in last 90d" |
| ) |
|
|
| churn_risk.append({ |
| "company_name": t_row.iloc[0]["company_name"], |
| "reason": reason, |
| "color": "var(--color-text-danger)" if failure_count >= 3 else "var(--color-text-warning)" |
| }) |
|
|
| if not churn_risk: |
| churn_risk = [ |
| { |
| "company_name": "No high-risk accounts found", |
| "reason": "No recent failed invoices in processed data", |
| "color": "var(--color-text-tertiary)" |
| } |
| ] |
|
|
| return { |
| "kpis": kpis, |
| "plan_churn": plan_churn, |
| "churn_risk": churn_risk, |
| "selected_period": selected_period |
| } |
|
|
|
|
| @app.post("/api/simulator/revenue") |
| def simulate_revenue(request: RevenueSimulationRequest): |
| tenants = float(request.starting_tenants) |
| arpu = float(request.monthly_arpu) |
| growth_rate = request.monthly_growth_pct / 100 |
| churn_rate = request.monthly_churn_pct / 100 |
|
|
| rows = [] |
| starting_mrr = tenants * arpu |
|
|
| for month in range(1, request.months + 1): |
| starting_tenants = tenants |
| added_tenants = max(starting_tenants * growth_rate, -starting_tenants) |
| churned_tenants = starting_tenants * churn_rate |
| tenants = max(starting_tenants + added_tenants - churned_tenants, 0) |
| mrr = tenants * arpu |
|
|
| rows.append({ |
| "month": month, |
| "starting_tenants": round(starting_tenants, 1), |
| "added_tenants": round(added_tenants, 1), |
| "churned_tenants": round(churned_tenants, 1), |
| "ending_tenants": round(tenants, 1), |
| "mrr": round(mrr, 2), |
| "mrr_formatted": format_inr(mrr), |
| "arr_formatted": format_inr(mrr * 12) |
| }) |
|
|
| ending_mrr = rows[-1]["mrr"] if rows else starting_mrr |
| mrr_delta = ending_mrr - starting_mrr |
| request_payload = request.model_dump() if hasattr(request, "model_dump") else request.dict() |
|
|
| return { |
| "inputs": request_payload, |
| "summary": { |
| "starting_mrr": format_inr(starting_mrr), |
| "ending_mrr": format_inr(ending_mrr), |
| "ending_arr": format_inr(ending_mrr * 12), |
| "mrr_change": format_inr(mrr_delta), |
| "mrr_change_pct": round((mrr_delta / starting_mrr * 100), 1) if starting_mrr else 0.0, |
| "ending_tenants": round(rows[-1]["ending_tenants"], 1) if rows else round(tenants, 1) |
| }, |
| "projection": rows |
| } |
|
|
|
|
| @app.get("/api/pipeline") |
| def get_pipeline_details(): |
| events_df = load_parquet("data/processed/billing_events") |
| tenants_df = load_parquet("data/processed/tenant_ltv") |
| cohort_df = load_parquet("data/processed/cohort_retention") |
| rejected_df = load_parquet("data/processed/rejected") |
|
|
| total_events = len(events_df) if events_df is not None else 0 |
| paying_tenants = len(tenants_df) if tenants_df is not None else 0 |
| total_cohorts = cohort_df["cohort"].nunique() if cohort_df is not None else 0 |
| rejected_rows = len(rejected_df) if rejected_df is not None else 0 |
| data_status = "Done" if total_events else "Waiting for data" |
| data_status_class = "st-ok" if total_events else "st-idle" |
|
|
| jobs = [ |
| { |
| "name": "Job 1 - Ingest + Validate (ETL Core)", |
| "meta": f"{total_events:,} clean events available | {rejected_rows:,} rejected rows | Parquet partitioned by year/month", |
| "bar_width": 100 if total_events else 0, |
| "bar_color": "#1D9E75", |
| "status": data_status_class, |
| "status_text": data_status |
| }, |
| { |
| "name": "Job 2 - MRR / ARR / LTV Computations", |
| "meta": f"Computed dynamic window metrics for {paying_tenants} tenants | Derived ARR and lifetime estimations", |
| "bar_width": 100 if paying_tenants else 0, |
| "bar_color": "#185FA5", |
| "status": "st-ok" if paying_tenants else "st-idle", |
| "status_text": "Done" if paying_tenants else "Waiting for data" |
| }, |
| { |
| "name": "Job 3 - Churn + Cohort Retention Grid", |
| "meta": f"Tracked survival metrics across {total_cohorts} cohort month signups over a rolling 12 month timeframe", |
| "bar_width": 100 if total_cohorts else 0, |
| "bar_color": "#534AB7", |
| "status": "st-ok" if total_cohorts else "st-idle", |
| "status_text": "Done" if total_cohorts else "Waiting for data" |
| }, |
| { |
| "name": "PostgreSQL Schema + Materialized View Design", |
| "meta": "Schema includes tenant-scoped tables, indexes, materialized revenue view, and RLS policies", |
| "bar_width": 100, |
| "bar_color": "#BA7517", |
| "status": "st-ok", |
| "status_text": "Designed" |
| }, |
| { |
| "name": "Kafka Producer - Real-time event streaming topic", |
| "meta": "Producer supports live Kafka mode and dry-run event simulation; not running unless started", |
| "bar_width": 40, |
| "bar_color": "#D85A30", |
| "status": "st-idle", |
| "status_text": "Optional" |
| } |
| ] |
| return jobs |
|
|
| @app.get("/api/benchmark") |
| def get_benchmark(): |
| bench_results_path = os.path.join(DOCS_DIR, "benchmark_results.json") |
| if not os.path.exists(bench_results_path): |
| return { |
| "records": [ |
| {"records": "50,000", "pandas": "Not run", "spark": "Not run", "winner": "Run benchmark", "winner_class": "w-pandas", "p_width": 20, "s_width": 20}, |
| {"records": "100,000", "pandas": "Not run", "spark": "Not run", "winner": "Run benchmark", "winner_class": "w-pandas", "p_width": 20, "s_width": 20}, |
| {"records": "200,000", "pandas": "Not run", "spark": "Not run", "winner": "Run benchmark", "winner_class": "w-pandas", "p_width": 20, "s_width": 20} |
| ] |
| } |
|
|
| try: |
| with open(bench_results_path, "r", encoding="utf-8") as f: |
| data = json.load(f) |
|
|
| formatted_records = [] |
| for row in data: |
| recs = row["records"] |
| p_val = row["pandas_seconds"] |
| s_val = row["spark_seconds"] |
|
|
| p_str = f"{p_val:.2f}s" if isinstance(p_val, (int, float)) else str(p_val) |
| s_str = f"{s_val:.2f}s" if isinstance(s_val, (int, float)) else str(s_val) |
|
|
| |
| p_width = int(min(float(p_val) * 30, 120)) if isinstance(p_val, (int, float)) else 20 |
| s_width = int(min(float(s_val) * 30, 120)) if isinstance(s_val, (int, float)) else 80 |
|
|
| winner = row["winner"] |
| w_class = "w-pandas" if "pandas" in winner.lower() else "w-spark" |
|
|
| formatted_records.append({ |
| "records": f"{recs:,}", |
| "pandas": p_str, |
| "spark": s_str, |
| "winner": winner, |
| "winner_class": w_class, |
| "p_width": p_width, |
| "s_width": s_width |
| }) |
| return {"records": formatted_records} |
| except Exception as e: |
| print(f"Error loading benchmark: {e}") |
| return { |
| "records": [ |
| {"records": "50,000", "pandas": "Error", "spark": "Error", "winner": "Check benchmark file", "p_width": 20, "s_width": 20, "winner_class": "w-pandas"}, |
| {"records": "100,000", "pandas": "Error", "spark": "Error", "winner": "Check benchmark file", "p_width": 20, "s_width": 20, "winner_class": "w-pandas"}, |
| {"records": "200,000", "pandas": "Error", "spark": "Error", "winner": "Check benchmark file", "p_width": 20, "s_width": 20, "winner_class": "w-pandas"} |
| ] |
| } |
|
|
| @app.get("/", response_class=HTMLResponse) |
| def serve_index(): |
| index_path = os.path.join(BASE_DIR, "src", "web", "templates", "index.html") |
| if not os.path.exists(index_path): |
| return HTMLResponse("<h2>Error: src/web/templates/index.html not found!</h2>", status_code=404) |
|
|
| with open(index_path, "r", encoding="utf-8") as f: |
| return HTMLResponse(content=f.read()) |
|
|
| if __name__ == "__main__": |
| import uvicorn |
| uvicorn.run(app, host="127.0.0.1", port=8000) |
|
|