| """ |
| mrr_transform.py - Job 2: MRR / ARR / LTV Calculations |
| ---------------------------------------------------------- |
| Computes Monthly Recurring Revenue, Annual Recurring Revenue, |
| and Lifetime Value per tenant using Spark SQL window functions. |
| |
| Run: |
| python src/transforms/mrr_transform.py |
| """ |
|
|
| import sys |
| import os |
| sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) |
|
|
| try: |
| from pyspark.sql import functions as F, Window |
| except ImportError: |
| F = None |
| Window = None |
|
|
| from utils.spark_session import get_spark |
| from utils.filesystem import recreate_dir |
| from utils.paths import data_path |
|
|
|
|
| def compute_mrr(spark): |
| """ |
| MRR = sum of all successful invoice_paid amounts per tenant per month. |
| Uses window functions to compute: |
| - monthly MRR |
| - MRR growth % vs prior month |
| - cumulative MRR (running total) |
| - 3-month rolling average MRR |
| """ |
| events = spark.read.parquet(data_path("processed", "billing_events")) |
| tenants = spark.read.parquet(data_path("processed", "tenants")) |
|
|
| |
| monthly_revenue = ( |
| events |
| .filter(F.col("event_type") == "invoice_paid") |
| .filter(F.col("status") == "success") |
| .groupBy("tenant_id", "event_year", "event_month") |
| .agg( |
| F.sum("amount").alias("mrr"), |
| F.count("invoice_id").alias("invoice_count"), |
| F.avg("seats").alias("avg_seats") |
| ) |
| .withColumn( |
| "period", |
| F.concat(F.col("event_year"), F.lit("-"), F.lpad(F.col("event_month"), 2, "0")) |
| ) |
| ) |
|
|
| |
| tenant_window = Window.partitionBy("tenant_id").orderBy("event_year", "event_month") |
| tenant_window_unbounded = ( |
| Window.partitionBy("tenant_id") |
| .orderBy("event_year", "event_month") |
| .rowsBetween(Window.unboundedPreceding, Window.currentRow) |
| ) |
| tenant_window_3m = ( |
| Window.partitionBy("tenant_id") |
| .orderBy("event_year", "event_month") |
| .rowsBetween(-2, 0) |
| ) |
|
|
| mrr_enriched = ( |
| monthly_revenue |
| .withColumn("prev_mrr", F.lag("mrr", 1).over(tenant_window)) |
| .withColumn( |
| "mrr_growth_pct", |
| F.round( |
| F.when( |
| (F.col("prev_mrr").isNotNull()) & (F.col("prev_mrr") != 0), |
| (F.col("mrr") - F.col("prev_mrr")) / F.col("prev_mrr") * 100 |
| ).otherwise(F.lit(None)), |
| 2, |
| ) |
| ) |
| .withColumn("cumulative_mrr", F.sum("mrr").over(tenant_window_unbounded)) |
| .withColumn("rolling_3m_mrr", F.avg("mrr").over(tenant_window_3m)) |
| .withColumn("arr", F.col("mrr") * 12) |
| ) |
|
|
| return mrr_enriched, tenants |
|
|
|
|
| def compute_ltv(mrr_df, tenants_df): |
| """ |
| LTV = Average MRR x average customer lifetime in months. |
| Simple LTV model: LTV = ARPU / Churn Rate |
| """ |
| avg_mrr_per_tenant = ( |
| mrr_df |
| .groupBy("tenant_id") |
| .agg( |
| F.avg("mrr").alias("avg_monthly_mrr"), |
| F.count("period").alias("active_months"), |
| F.sum("mrr").alias("total_revenue") |
| ) |
| ) |
|
|
| ltv = ( |
| avg_mrr_per_tenant |
| .join(tenants_df.select("tenant_id", "company_name", "industry", "plan", "country"), "tenant_id") |
| .withColumn("estimated_ltv", F.col("avg_monthly_mrr") * F.col("active_months")) |
| ) |
| return ltv |
|
|
|
|
| def run_pandas(): |
| print("\n[JOB 2] MRR / ARR / LTV Computation (Pandas Fallback Engine)\n") |
| import pandas as pd |
| import numpy as np |
|
|
| events_path = data_path("processed", "billing_events") |
| tenants_path = data_path("processed", "tenants") |
|
|
| if not os.path.exists(events_path) or not os.path.exists(tenants_path): |
| print(" [ERROR] Processed data missing from Job 1. Please run etl_ingest.py first.") |
| return |
|
|
| |
| events = pd.read_parquet(events_path) |
| events["event_year"] = events["event_year"].astype(int) |
| events["event_month"] = events["event_month"].astype(int) |
| tenants = pd.read_parquet(tenants_path) |
|
|
| |
| invoice_paid = events[(events["event_type"] == "invoice_paid") & (events["status"] == "success")] |
|
|
| monthly_rev = ( |
| invoice_paid |
| .groupby(["tenant_id", "event_year", "event_month"]) |
| .agg( |
| mrr=("amount", "sum"), |
| invoice_count=("invoice_id", "count"), |
| avg_seats=("seats", "mean") |
| ) |
| .reset_index() |
| ) |
|
|
| monthly_rev["period"] = ( |
| monthly_rev["event_year"].astype(str) + "-" + |
| monthly_rev["event_month"].astype(str).str.zfill(2) |
| ) |
|
|
| |
| |
| monthly_rev = monthly_rev.sort_values(["tenant_id", "event_year", "event_month"]).reset_index(drop=True) |
|
|
| |
| monthly_rev["prev_mrr"] = monthly_rev.groupby("tenant_id")["mrr"].shift(1) |
|
|
| |
| monthly_rev["mrr_growth_pct"] = np.where( |
| (monthly_rev["prev_mrr"].notna()) & (monthly_rev["prev_mrr"] != 0), |
| ((monthly_rev["mrr"] - monthly_rev["prev_mrr"]) / monthly_rev["prev_mrr"] * 100).round(2), |
| np.nan |
| ) |
|
|
| |
| monthly_rev["cumulative_mrr"] = monthly_rev.groupby("tenant_id")["mrr"].cumsum() |
|
|
| |
| |
| monthly_rev["rolling_3m_mrr"] = ( |
| monthly_rev.groupby("tenant_id")["mrr"] |
| .rolling(window=3, min_periods=1) |
| .mean() |
| .reset_index(level=0, drop=True) |
| ) |
|
|
| |
| monthly_rev["arr"] = monthly_rev["mrr"] * 12 |
|
|
| |
| avg_mrr_per_tenant = ( |
| monthly_rev |
| .groupby("tenant_id") |
| .agg( |
| avg_monthly_mrr=("mrr", "mean"), |
| active_months=("period", "count"), |
| total_revenue=("mrr", "sum") |
| ) |
| .reset_index() |
| ) |
|
|
| |
| tenants_select = tenants[["tenant_id", "company_name", "industry", "plan", "country"]] |
| ltv = pd.merge(avg_mrr_per_tenant, tenants_select, on="tenant_id", how="inner") |
| ltv["estimated_ltv"] = ltv["avg_monthly_mrr"] * ltv["active_months"] |
|
|
| |
| global_mrr = ( |
| monthly_rev |
| .groupby(["event_year", "event_month", "period"]) |
| .agg( |
| total_mrr=("mrr", "sum"), |
| total_arr=("arr", "sum"), |
| paying_tenants=("tenant_id", "nunique"), |
| arpu=("mrr", "mean") |
| ) |
| .reset_index() |
| .sort_values(["event_year", "event_month"]) |
| ) |
|
|
| print(" Global MRR by Month (last 6 periods):") |
| print(global_mrr.sort_values("period", ascending=False).head(6).to_string(index=False)) |
| print("\n Top 10 Tenants by Estimated LTV:") |
| print(ltv.sort_values("estimated_ltv", ascending=False).head(10).to_string(index=False)) |
|
|
| |
| |
| for path in [ |
| data_path("processed", "mrr_by_tenant_month"), |
| data_path("processed", "global_mrr_monthly"), |
| data_path("processed", "tenant_ltv"), |
| ]: |
| recreate_dir(path) |
|
|
| monthly_rev.to_parquet(os.path.join(data_path("processed", "mrr_by_tenant_month"), "part.parquet"), index=False) |
| global_mrr.to_parquet(os.path.join(data_path("processed", "global_mrr_monthly"), "part.parquet"), index=False) |
| ltv.to_parquet(os.path.join(data_path("processed", "tenant_ltv"), "part.parquet"), index=False) |
|
|
| print("\n [OK] MRR data written -> data/processed/mrr_by_tenant_month/") |
| print(" [OK] Global MRR written -> data/processed/global_mrr_monthly/") |
| print(" [OK] LTV data written -> data/processed/tenant_ltv/") |
| print("\n[OK] Job 2 complete.\n") |
|
|
|
|
| def run(): |
| spark = get_spark("MRR-Transform") |
| if spark is None: |
| run_pandas() |
| return |
|
|
| print("\n[JOB 2] MRR / ARR / LTV Computation\n") |
|
|
| mrr_df, tenants_df = compute_mrr(spark) |
|
|
| |
| global_mrr = ( |
| mrr_df |
| .groupBy("event_year", "event_month", "period") |
| .agg( |
| F.sum("mrr").alias("total_mrr"), |
| F.sum("arr").alias("total_arr"), |
| F.countDistinct("tenant_id").alias("paying_tenants"), |
| F.avg("mrr").alias("arpu") |
| ) |
| .orderBy("event_year", "event_month") |
| ) |
|
|
| print(" Global MRR by Month (last 6 periods):") |
| global_mrr.orderBy(F.desc("period")).show(6, truncate=False) |
|
|
| |
| ltv_df = compute_ltv(mrr_df, tenants_df) |
| print(" Top 10 Tenants by Estimated LTV:") |
| ltv_df.orderBy(F.desc("estimated_ltv")).show(10, truncate=False) |
|
|
| |
| mrr_df.write.mode("overwrite").parquet(data_path("processed", "mrr_by_tenant_month")) |
| global_mrr.write.mode("overwrite").parquet(data_path("processed", "global_mrr_monthly")) |
| ltv_df.write.mode("overwrite").parquet(data_path("processed", "tenant_ltv")) |
|
|
| print("\n [OK] MRR data written -> data/processed/mrr_by_tenant_month/") |
| print(" [OK] Global MRR written -> data/processed/global_mrr_monthly/") |
| print(" [OK] LTV data written -> data/processed/tenant_ltv/") |
| print("\n[OK] Job 2 complete.\n") |
| spark.stop() |
|
|
|
|
| if __name__ == "__main__": |
| run() |
|
|