import pandas as pd import gradio as gr from fpdf import FPDF from fpdf import enums import tempfile import os import matplotlib.pyplot as plt import matplotlib.dates as mdates # --- PDF Generation Helper Function (Unchanged) --- def create_pdf_report(text_content): """ Generates a PDF file from a given text string. """ try: temp_dir = tempfile.gettempdir() pdf_path = os.path.join( temp_dir, next(tempfile._get_candidate_names()) + ".pdf" ) pdf = FPDF() pdf.add_page() pdf.set_font("Courier", size=10) pdf.set_font("Courier", "B", 16) pdf.cell( 0, 10, "SaaS Metrics Analysis Report", 0, new_x=enums.XPos.LMARGIN, new_y=enums.YPos.NEXT, ) pdf.ln(10) pdf.set_font("Courier", size=10) encoded_text = text_content.encode("latin-1", "replace").decode("latin-1") pdf.multi_cell(0, 5, text=encoded_text) pdf.output(pdf_path) return pdf_path except Exception as e: print(f"Error creating PDF: {e}") return None # --- Visualization Helper Function --- def create_visualizations(df): """ Generates matplotlib plots from the dataframe. """ try: # Ensure plots are closed to prevent memory issues in long-running apps plt.close("all") # --- Plot 1: MRR Trend --- fig1, ax1 = plt.subplots(figsize=(10, 5)) ax1.plot(df["Date"], df["MRR_End"], marker="o", linestyle="-", color="#1E88E5") ax1.set_title("Monthly Recurring Revenue (MRR) Trend", fontsize=14) ax1.set_xlabel("Date", fontsize=12) ax1.set_ylabel("MRR ($)", fontsize=12) ax1.grid(True, which="both", linestyle="--", linewidth=0.5) ax1.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m")) ax1.tick_params(axis="x", rotation=45) fig1.tight_layout() # --- Plot 2: Customer Growth --- fig2, ax2 = plt.subplots(figsize=(10, 5)) ax2.plot( df["Date"], df["Total_Customers_End"], marker="o", linestyle="-", color="#43A047", ) ax2.set_title("Customer Growth Trend", fontsize=14) ax2.set_xlabel("Date", fontsize=12) ax2.set_ylabel("Total Customers", fontsize=12) ax2.grid(True, which="both", linestyle="--", linewidth=0.5) ax2.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m")) ax2.tick_params(axis="x", rotation=45) fig2.tight_layout() # --- Plot 3: LTV vs CAC (Last Month) --- last_month = df.iloc[-1] mrr_now = last_month["MRR_End"] active_accounts = last_month["Total_Customers_End"] arpa_monthly = calculate_arpa(mrr_now, active_accounts) # customer_churn_rate_monthly = calculate_customer_churn_rate( # last_month["Churned_Customers"], last_month["Total_Customers_Start"] # ) gross_rev_churn_rate_monthly = calculate_gross_revenue_churn_rate( last_month["Churned_Revenue"], last_month["MRR_Start"] ) gross_margin_monthly = calculate_gross_margin( last_month["Total_Revenue"], last_month["COGS"] ) ltv = calculate_ltv( arpa_monthly, gross_margin_monthly, gross_rev_churn_rate_monthly ) cac_monthly = calculate_cac( last_month["Sales_And_Marketing_Spend"], last_month["New_Customers"] ) fig3, ax3 = plt.subplots(figsize=(8, 5)) metrics = ["LTV (Lifetime Value)", "CAC (Acquisition Cost)"] values = [ltv, cac_monthly] bars = ax3.bar(metrics, values, color=["#43A047", "#E53935"]) ax3.set_title( f"LTV vs. CAC for {last_month['Date'].strftime('%Y-%m')}", fontsize=14 ) ax3.set_ylabel("Value ($)", fontsize=12) # Add value labels on top of bars for bar in bars: yval = bar.get_height() ax3.text( bar.get_x() + bar.get_width() / 2.0, yval, f"${yval:,.0f}", va="bottom", ha="center", ) fig3.tight_layout() # --- Plot 4: Net Revenue Retention (NRR) Trend --- # Calculate NRR for each row if it's not already there df["NRR"] = df.apply( lambda row: calculate_nrr( row["MRR_Start"], row["Expansion_Revenue"], row["Churned_Revenue"] ), axis=1, ) fig4, ax4 = plt.subplots(figsize=(10, 5)) ax4.plot(df["Date"], df["NRR"], marker="o", linestyle="-", color="#8E24AA") ax4.axhline(y=1.0, color="grey", linestyle="--", label="100% Benchmark") ax4.set_title("Net Revenue Retention (NRR) Trend", fontsize=14) ax4.set_xlabel("Date", fontsize=12) ax4.set_ylabel("NRR", fontsize=12) ax4.yaxis.set_major_formatter(plt.FuncFormatter(lambda y, _: f"{y:.0%}")) ax4.grid(True, which="both", linestyle="--", linewidth=0.5) ax4.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m")) ax4.tick_params(axis="x", rotation=45) ax4.legend() fig4.tight_layout() return fig1, fig2, fig3, fig4 except Exception as e: print(f"Error creating visualizations: {e}") return None, None, None, None # --- Core SaaS Metrics Functions (Unchanged) --- def calculate_arr(mrr): return mrr * 12 def calculate_yoy_growth(current, prior): return (current - prior) / prior if prior > 0 else 0 def calculate_sde(revenue, cogs, op_ex, owner_comp): return revenue - cogs - op_ex + owner_comp def calculate_valuation_revenue(arr, multiple): return arr * multiple def calculate_valuation_sde(sde, multiple): return sde * multiple def calculate_valuation_ebitda(ebitda, multiple): return ebitda * multiple def calculate_rule_of_40(growth_percent, margin_percent): return growth_percent + margin_percent def calculate_arpa(mrr, customers): return mrr / customers if customers > 0 else 0 def calculate_customer_churn_rate(churned, start): return churned / start if start > 0 else 0 def calculate_gross_revenue_churn_rate(churned_rev, mrr_start): return churned_rev / mrr_start if mrr_start > 0 else 0 def calculate_net_revenue_churn_rate(churned_rev, expansion_rev, mrr_start): return (churned_rev - expansion_rev) / mrr_start if mrr_start > 0 else 0 def calculate_nrr(mrr_start, expansion, churned): return (mrr_start + expansion - churned) / mrr_start if mrr_start > 0 else 0 def calculate_cac(sm_spend, new): return sm_spend / new if new > 0 else 0 def calculate_gross_margin(rev, cogs): return (rev - cogs) / rev if rev > 0 else 0 def calculate_customer_lifetime(churn_rate): return 1 / churn_rate if churn_rate > 0 else 0 def calculate_ltv(arpa, margin, rev_churn): return arpa * margin / rev_churn if rev_churn > 0 else 0 def calculate_ltv_cac_ratio(ltv, cac): return ltv / cac if cac > 0 else 0 def calculate_cac_payback_period(cac, arpa, margin): return cac / (arpa * margin) if arpa * margin > 0 else 0 # --- Modified Main Analysis Function --- def analyze_csv(file, revenue_multiple=6.0, sde_multiple=4.0, ebitda_multiple=5.5): """ Analyzes the uploaded CSV and returns a text summary, a PDF, and plots. """ if file is None: return "Please upload a CSV file.", None, None, None, None, None try: df = pd.read_csv(file) df["Date"] = pd.to_datetime(df["Date"]) if len(df) < 13: return ( "Insufficient data in CSV. Need at least 13 months for full analysis.", None, None, None, None, None, ) # --- Generate Visualizations --- plot1, plot2, plot3, plot4 = create_visualizations(df) # --- Set Analysis Period and Assumptions --- last_month = df.iloc[-1] last_12_months = df.iloc[-13:-1] prior_12_months = df.iloc[:12] if len(df) >= 24 else df.iloc[:-13] output = [] # --- Calculate Annual Metrics --- output.append("=" * 50) output.append( f"ANALYSIS FOR LAST 12 MONTHS ({last_12_months['Date'].min().strftime('%Y-%m')} to {last_12_months['Date'].max().strftime('%Y-%m')})" ) output.append("=" * 50) total_revenue_last_12m = last_12_months["Total_Revenue"].sum() total_cogs_last_12m = last_12_months["COGS"].sum() total_opex_last_12m = last_12_months["OpEx"].sum() total_owner_comp_last_12m = last_12_months["Owner_Compensation"].sum() total_sm_spend_last_12m = last_12_months["Sales_And_Marketing_Spend"].sum() mrr_end_of_year = last_12_months.iloc[-1]["MRR_End"] arr_current = calculate_arr(mrr_end_of_year) arr_prior = calculate_arr(prior_12_months.iloc[-1]["MRR_End"]) yoy_growth = calculate_yoy_growth(arr_current, arr_prior) output.append(f"Annual Recurring Revenue (ARR): ${arr_current:,.2f}") output.append(f"YoY ARR Growth: {yoy_growth:.2%}") sde_annual = calculate_sde( total_revenue_last_12m, total_cogs_last_12m, (total_opex_last_12m + total_sm_spend_last_12m), total_owner_comp_last_12m, ) ebitda_annual = ( total_revenue_last_12m - total_cogs_last_12m - total_opex_last_12m - total_sm_spend_last_12m - total_owner_comp_last_12m ) output.append(f"Seller's Discretionary Earnings (SDE): ${sde_annual:,.2f}") output.append(f"EBITDA: ${ebitda_annual:,.2f}") output.append("\n--- Valuations ---") output.append( f"Revenue-Based Valuation ({revenue_multiple:.1f}x ARR): ${calculate_valuation_revenue(arr_current, revenue_multiple):,.2f}" ) output.append( f"SDE-Based Valuation ({sde_multiple:.1f}x SDE): ${calculate_valuation_sde(sde_annual, sde_multiple):,.2f}" ) output.append( f"EBITDA-Based Valuation ({ebitda_multiple:.1f}x EBITDA): ${calculate_valuation_ebitda(ebitda_annual, ebitda_multiple):,.2f}" ) ebitda_margin_annual = ( ebitda_annual / total_revenue_last_12m if total_revenue_last_12m > 0 else 0 ) rule_of_40_score = calculate_rule_of_40( yoy_growth * 100, ebitda_margin_annual * 100 ) output.append("\n--- Health Metrics ---") output.append(f"EBITDA Margin: {ebitda_margin_annual:.2%}") output.append( f"Rule of 40 Score: {rule_of_40_score:.2f} (Target > 40 is healthy)" ) # --- Calculate Monthly Metrics --- output.append("\n" + "=" * 50) output.append( f"ANALYSIS FOR LATEST MONTH ({last_month['Date'].strftime('%Y-%m')})" ) output.append("=" * 50) mrr_now = last_month["MRR_End"] arpa_monthly = calculate_arpa(mrr_now, last_month["Total_Customers_End"]) customer_churn_rate_monthly = calculate_customer_churn_rate( last_month["Churned_Customers"], last_month["Total_Customers_Start"] ) gross_rev_churn_rate_monthly = calculate_gross_revenue_churn_rate( last_month["Churned_Revenue"], last_month["MRR_Start"] ) net_rev_churn_rate_monthly = calculate_net_revenue_churn_rate( last_month["Churned_Revenue"], last_month["Expansion_Revenue"], last_month["MRR_Start"], ) nrr_monthly = calculate_nrr( last_month["MRR_Start"], last_month["Expansion_Revenue"], last_month["Churned_Revenue"], ) output.append("--- Revenue & Churn ---") output.append(f"Average Revenue Per Account (ARPA): ${arpa_monthly:,.2f}") output.append(f"Customer Churn Rate: {customer_churn_rate_monthly:.2%}") output.append(f"Gross Revenue Churn Rate: {gross_rev_churn_rate_monthly:.2%}") output.append(f"Net Revenue Churn Rate: {net_rev_churn_rate_monthly:.2%}") output.append(f"Net Revenue Retention (NRR): {nrr_monthly:.2%}") cac_monthly = calculate_cac( last_month["Sales_And_Marketing_Spend"], last_month["New_Customers"] ) gross_margin_monthly = calculate_gross_margin( last_month["Total_Revenue"], last_month["COGS"] ) customer_lifetime_months = calculate_customer_lifetime( customer_churn_rate_monthly ) ltv = calculate_ltv( arpa_monthly, gross_margin_monthly, gross_rev_churn_rate_monthly ) ltv_cac_ratio = calculate_ltv_cac_ratio(ltv, cac_monthly) payback_period_months = calculate_cac_payback_period( cac_monthly, arpa_monthly, gross_margin_monthly ) output.append("\n--- Unit Economics ---") output.append(f"Gross Margin: {gross_margin_monthly:.2%}") output.append(f"Customer Acquisition Cost (CAC): ${cac_monthly:,.2f}") output.append(f"Customer Lifetime: {customer_lifetime_months:.1f} months") output.append(f"Customer Lifetime Value (LTV): ${ltv:,.2f}") output.append(f"LTV:CAC Ratio: {ltv_cac_ratio:.2f}:1 (Target > 3:1 is healthy)") output.append( f"CAC Payback Period: {payback_period_months:.1f} months (Target < 12 is healthy)" ) analysis_text = "\n".join(output) pdf_file_path = create_pdf_report(analysis_text) return analysis_text, pdf_file_path, plot1, plot2, plot3, plot4 except Exception as e: return f"Error processing file: {str(e)}", None, None, None, None, None # --- Updated Gradio Interface --- demo = gr.Interface( fn=analyze_csv, inputs=[ gr.File(label="Upload SaaS Metrics CSV File", file_types=[".csv"]), gr.Number(label="Revenue Multiple", value=6.0), gr.Number(label="SDE Multiple", value=4.0), gr.Number(label="EBITDA Multiple", value=5.5), ], outputs=[ gr.Textbox(label="Analysis Results", lines=20), gr.File(label="Download PDF Report"), gr.Plot(label="MRR Trend"), gr.Plot(label="Customer Growth Trend"), gr.Plot(label="LTV vs. CAC (Last Month)"), gr.Plot(label="Net Revenue Retention (NRR) Trend"), ], title="SaaS Metrics Analyzer with Visualizations", description="Upload a CSV file with SaaS metrics data. The app will analyze the last 12 months, the latest month, generate key visualizations, and produce a downloadable PDF report.", allow_flagging="never", examples=[["demo.csv", 6.0, 4.0, 5.5]], ) if __name__ == "__main__": demo.launch()