|
|
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 |
|
|
|
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
|
|
|
def create_visualizations(df): |
|
|
""" |
|
|
Generates matplotlib plots from the dataframe. |
|
|
""" |
|
|
try: |
|
|
|
|
|
plt.close("all") |
|
|
|
|
|
|
|
|
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() |
|
|
|
|
|
|
|
|
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() |
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
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() |
|
|
|
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
|
|
|
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, |
|
|
) |
|
|
|
|
|
|
|
|
plot1, plot2, plot3, plot4 = create_visualizations(df) |
|
|
|
|
|
|
|
|
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 = [] |
|
|
|
|
|
|
|
|
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)" |
|
|
) |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
|
|
|
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() |
|
|
|