Subscription Revenue Tracker

v1.1.0

SaaS and subscription business revenue intelligence. Track MRR/ARR, calculate churn rate, net revenue retention (NRR), customer lifetime value (LTV), cohort...

0· 178·1 current·1 all-time

Install

OpenClaw Prompt Flow

Install with OpenClaw

Best for remote or guided setup. Copy the exact prompt, then paste it into OpenClaw for samledger67-dotcom/subscription-revenue-tracker.

Previewing Install & Setup.
Prompt PreviewInstall & Setup
Install the skill "Subscription Revenue Tracker" (samledger67-dotcom/subscription-revenue-tracker) from ClawHub.
Skill page: https://clawhub.ai/samledger67-dotcom/subscription-revenue-tracker
Keep the work scoped to this skill only.
After install, inspect the skill metadata and help me finish setup.
Use only the metadata you can verify from ClawHub; do not invent missing requirements.
Ask before making any broader environment changes.

Command Line

CLI Commands

Use the direct CLI path if you want to install manually and keep every step visible.

OpenClaw CLI

Bare skill slug

openclaw skills install subscription-revenue-tracker

ClawHub CLI

Package manager switcher

npx clawhub@latest install subscription-revenue-tracker
Security Scan
VirusTotalVirusTotal
Benign
View report →
OpenClawOpenClaw
Benign
medium confidence
Purpose & Capability
The name/description (MRR/ARR, churn, cohorts, QBO journaling) matches the runtime instructions: Stripe/Chargebee API calls, CSV handling, and Python/CLI examples. Optional binaries and APIs listed in metadata (stripe, python3, jq, curl; Stripe/Chargebee/QBO) are appropriate for the stated purpose.
Instruction Scope
Instructions stay within the domain of revenue/KPI computation and GL posting. However, examples show direct use of secret keys (e.g. sk_live_YOUR_KEY) embedded in curl and Python snippets; there is no guidance in the SKILL.md about minimizing exposure (use restricted/read-only keys, environment variables, or token-scoped credentials) or avoiding commit/logging of secrets.
Install Mechanism
No install spec and no code files — instruction-only skill. This is lower-risk because nothing is downloaded or written to disk by the skill itself.
Credentials
The skill does not declare required environment variables or a primary credential, but the runtime examples clearly expect Stripe/Chargebee API keys. This is proportionate to the purpose, but the lack of declared credential handling and the use of inline live-key examples increases the chance a user will accidentally expose sensitive credentials.
Persistence & Privilege
always is false and the skill has no install or persistent components. Autonomous invocation is allowed (platform default) but there is no extra persistence or system-wide configuration requested.
Assessment
This skill appears to do what it claims (compute MRR/ARR, churn, cohorts, and prepare GL entries), but it uses examples that inline secret keys. Before installing or using: (1) Never paste live secret keys into commands or source files — use environment variables or a secrets store and prefer restricted/read-only API keys with minimal scope. (2) Test with test/sandbox credentials (Stripe test keys) rather than sk_live keys. (3) Be careful when running CLI commands that may be saved in shell history or logs; consider using --api-key-from-env patterns. (4) Review any generated QBO journal entries before posting to your production accounting system. (5) Because the skill can be invoked autonomously by default, only grant it access to financial credentials if you trust it and monitor usage; if you need more assurance, ask the publisher for provenance or a repository link before providing credentials.

Like a lobster shell, security has layers — review code before you run it.

latestvk971ey8fvbynzw2b1hxaemcm218377f9
178downloads
0stars
2versions
Updated 1mo ago
v1.1.0
MIT-0

Subscription Revenue Tracker

Track MRR/ARR, churn, NRR, cohort retention, and LTV for SaaS and subscription businesses. Produces investor-grade metrics and clean GL entries.


Core Metrics Defined

MetricFormulaWhy It Matters
MRRSum of all active recurring monthly revenuePulse of the business
ARRMRR × 12Annualized scale metric for investors
New MRRRevenue from new customers this monthGrowth engine
Expansion MRRUpgrades / upsells from existing customersEfficiency signal
Contraction MRRDowngrades from existing customersNegative signal
Churned MRRRevenue lost from cancellationsRetention health
Net New MRRNew + Expansion − Contraction − ChurnedNet growth
Gross Churn RateChurned MRR / Beginning MRRRevenue decay rate
Net Revenue Retention (NRR)(Beginning + Expansion − Contraction − Churned) / BeginningGrowth from existing base
LTVARPU / Gross Churn RateCustomer economic value
CACSales + Marketing Spend / New CustomersAcquisition cost
LTV:CACLTV / CACUnit economics health (target: >3x)
Payback PeriodCAC / (ARPU × Gross Margin)Months to recover acquisition cost

Workflows

1. Pull MRR from Stripe

# List all active subscriptions with their amounts
stripe subscriptions list \
  --status=active \
  --limit=100 \
  --expand[]=data.items.data \
  2>&1 | jq '
    .data[] | {
      id: .id,
      customer: .customer,
      status: .status,
      current_period_start: (.current_period_start | strftime("%Y-%m-%d")),
      mrr: (.items.data[0].price.unit_amount / 100 * 
            (if .items.data[0].price.recurring.interval == "year" then 1/12 else 1 end))
    }
  '

Get MRR summary via Stripe API (no CLI):

curl "https://api.stripe.com/v1/subscriptions?status=active&limit=100&expand[]=data.items.data" \
  -u sk_live_YOUR_KEY: | jq '
  [.data[] | 
    (.items.data[0].price.unit_amount / 100) * 
    (if .items.data[0].price.recurring.interval == "year" then 1/12 else 1 end)
  ] | add
  '

Python: Full MRR waterfall from Stripe events:

import stripe
from datetime import datetime, timezone
from collections import defaultdict
from dateutil.relativedelta import relativedelta

stripe.api_key = "sk_live_YOUR_KEY"

def get_mrr_waterfall(year: int, month: int) -> dict:
    """
    Calculate MRR waterfall for a given month.
    Returns: new, expansion, contraction, churned, net_new MRR.
    """
    # Period boundaries
    period_start = datetime(year, month, 1, tzinfo=timezone.utc)
    period_end = period_start + relativedelta(months=1)
    prev_start = period_start - relativedelta(months=1)

    # Get subscriptions active at start of period (denominator)
    beginning_subs = _get_active_subscriptions_at(prev_start)
    ending_subs = _get_active_subscriptions_at(period_end)

    # Categorize by customer
    beginning_customers = {s.customer: _get_mrr(s) for s in beginning_subs}
    ending_customers = {s.customer: _get_mrr(s) for s in ending_subs}

    new_mrr = 0.0
    expansion_mrr = 0.0
    contraction_mrr = 0.0
    churned_mrr = 0.0

    all_customers = set(beginning_customers) | set(ending_customers)

    for cust_id in all_customers:
        begin_val = beginning_customers.get(cust_id, 0.0)
        end_val = ending_customers.get(cust_id, 0.0)
        delta = end_val - begin_val

        if begin_val == 0 and end_val > 0:
            new_mrr += end_val
        elif begin_val > 0 and end_val == 0:
            churned_mrr += begin_val
        elif delta > 0:
            expansion_mrr += delta
        elif delta < 0:
            contraction_mrr += abs(delta)

    beginning_mrr = sum(beginning_customers.values())

    return {
        "period": f"{year}-{month:02d}",
        "beginning_mrr": beginning_mrr,
        "new_mrr": new_mrr,
        "expansion_mrr": expansion_mrr,
        "contraction_mrr": contraction_mrr,
        "churned_mrr": churned_mrr,
        "net_new_mrr": new_mrr + expansion_mrr - contraction_mrr - churned_mrr,
        "ending_mrr": beginning_mrr + new_mrr + expansion_mrr - contraction_mrr - churned_mrr,
        "gross_churn_rate": churned_mrr / beginning_mrr if beginning_mrr else 0,
        "nrr": (beginning_mrr + expansion_mrr - contraction_mrr - churned_mrr) / beginning_mrr if beginning_mrr else 0,
    }

def _get_mrr(subscription) -> float:
    """Extract normalized monthly value from a Stripe subscription."""
    item = subscription.get("items", {}).get("data", [{}])[0]
    price = item.get("price", {})
    amount = price.get("unit_amount", 0) / 100
    qty = item.get("quantity", 1)
    interval = price.get("recurring", {}).get("interval", "month")
    
    if interval == "year":
        return (amount * qty) / 12
    elif interval == "week":
        return (amount * qty) * 4.333
    return amount * qty

def _get_active_subscriptions_at(timestamp: datetime) -> list:
    """Get subscriptions that were active at a given timestamp."""
    ts = int(timestamp.timestamp())
    subs = stripe.Subscription.list(
        status="all",
        created={"lte": ts},
        limit=100
    )
    return [
        s for s in subs.auto_paging_iter()
        if s.current_period_start <= ts <= (s.canceled_at or ts + 1)
    ]

2. Cohort Analysis

Track retention by signup cohort — the gold standard for understanding retention quality:

import pandas as pd
import numpy as np

def build_cohort_table(subscription_events: pd.DataFrame) -> pd.DataFrame:
    """
    Build monthly cohort retention table.
    
    Input columns: customer_id, event_type (started/churned), event_month (YYYY-MM)
    Output: matrix of cohort × months_since_start → retention percentage
    
    Example output:
    cohort    | M+0  | M+1  | M+2  | M+3  | M+6  | M+12
    2025-01   | 100% | 87%  | 79%  | 74%  | 65%  | 54%
    2025-02   | 100% | 91%  | 83%  | 78%  | --   | --
    """
    # Assign cohort (month of first subscription)
    first_sub = (subscription_events[subscription_events.event_type == "started"]
                 .groupby("customer_id")["event_month"]
                 .min()
                 .reset_index()
                 .rename(columns={"event_month": "cohort"}))
    
    df = subscription_events.merge(first_sub, on="customer_id")
    df["cohort"] = pd.to_datetime(df["cohort"])
    df["event_month"] = pd.to_datetime(df["event_month"])
    df["months_since_start"] = (
        (df["event_month"].dt.year - df["cohort"].dt.year) * 12 +
        (df["event_month"].dt.month - df["cohort"].dt.month)
    )
    
    # Active customers per cohort per month
    active = (df[df.event_type != "churned"]
              .groupby(["cohort", "months_since_start"])["customer_id"]
              .nunique()
              .reset_index()
              .rename(columns={"customer_id": "active_customers"}))
    
    cohort_table = active.pivot(
        index="cohort", 
        columns="months_since_start", 
        values="active_customers"
    )
    
    # Normalize to cohort size (M+0 = 100%)
    cohort_sizes = cohort_table[0]
    retention_table = cohort_table.divide(cohort_sizes, axis=0) * 100
    
    return retention_table.round(1)


def average_retention_curve(cohort_table: pd.DataFrame, min_cohorts: int = 3) -> pd.Series:
    """
    Compute average retention curve across cohorts with enough data.
    Used for LTV projection.
    """
    # Only include cohorts with at least min_cohorts data points per period
    valid_cols = cohort_table.columns[cohort_table.notna().sum() >= min_cohorts]
    return cohort_table[valid_cols].mean()

3. LTV and Unit Economics

def calculate_ltv(arpu: float, gross_margin: float, monthly_churn_rate: float) -> dict:
    """
    Calculate Customer Lifetime Value and payback metrics.
    
    Args:
        arpu: Average Revenue Per User per month
        gross_margin: Gross margin % (0.0-1.0)
        monthly_churn_rate: Monthly revenue churn rate (0.0-1.0)
    
    Returns:
        LTV, gross profit LTV, and key benchmarks
    """
    if monthly_churn_rate <= 0:
        raise ValueError("Churn rate must be > 0 for LTV calculation")
    
    avg_customer_lifetime_months = 1 / monthly_churn_rate
    ltv_revenue = arpu * avg_customer_lifetime_months
    ltv_gross_profit = ltv_revenue * gross_margin
    
    return {
        "arpu_monthly": arpu,
        "arpu_annual": arpu * 12,
        "monthly_churn_rate": monthly_churn_rate,
        "avg_lifetime_months": avg_customer_lifetime_months,
        "ltv_revenue": ltv_revenue,
        "ltv_gross_profit": ltv_gross_profit,
        "benchmarks": {
            "saas_target_ltv_cac": ">3x",
            "saas_target_payback": "<12 months",
            "saas_target_nrr": ">100%",
        }
    }


def payback_period(cac: float, arpu: float, gross_margin: float) -> dict:
    """
    Calculate CAC payback period in months.
    
    Healthy SaaS: <12 months
    Great SaaS: <6 months
    Struggling: >18 months
    """
    monthly_gross_profit_per_customer = arpu * gross_margin
    if monthly_gross_profit_per_customer <= 0:
        return {"payback_months": float("inf"), "status": "never — negative gross margin"}
    
    months = cac / monthly_gross_profit_per_customer
    
    if months < 6:
        status = "excellent"
    elif months < 12:
        status = "healthy"
    elif months < 18:
        status = "acceptable"
    else:
        status = "concerning"
    
    return {
        "cac": cac,
        "arpu_monthly": arpu,
        "gross_margin": gross_margin,
        "payback_months": round(months, 1),
        "payback_status": status,
    }

4. CSV Import (Non-Stripe Businesses)

For businesses without Stripe — import from any billing system:

# Expected CSV format:
# customer_id, plan_name, mrr, start_date, end_date (blank if active), currency

import pandas as pd
from datetime import datetime

def load_subscriptions_from_csv(path: str, as_of_date: str = None) -> pd.DataFrame:
    """
    Load subscription data from a CSV export.
    Handles Chargebee, Recurly, Zuora, or manual exports.
    
    Required columns: customer_id, mrr, start_date
    Optional: end_date, plan_name, currency
    """
    df = pd.read_csv(path)
    df["start_date"] = pd.to_datetime(df["start_date"])
    if "end_date" in df.columns:
        df["end_date"] = pd.to_datetime(df["end_date"], errors="coerce")
    
    if as_of_date:
        cutoff = pd.Timestamp(as_of_date)
        # Active = started before cutoff AND (not ended OR ended after cutoff)
        df = df[
            (df["start_date"] <= cutoff) &
            (df.get("end_date", pd.NaT).isna() | (df.get("end_date", pd.NaT) > cutoff))
        ]
    
    # Standardize MRR (handle annual → monthly)
    if "billing_interval" in df.columns:
        df.loc[df.billing_interval == "annual", "mrr"] /= 12
    
    return df


def compute_metrics_from_csv(csv_path: str, period: str) -> dict:
    """
    Full metrics computation from CSV for a given YYYY-MM period.
    """
    year, month = map(int, period.split("-"))
    
    # Current and previous month active subs
    period_end = datetime(year, month, 28)  # safe month-end
    prev_period_end = datetime(year, month - 1 if month > 1 else 12, 28)
    
    current = load_subscriptions_from_csv(csv_path, period_end.strftime("%Y-%m-%d"))
    previous = load_subscriptions_from_csv(csv_path, prev_period_end.strftime("%Y-%m-%d"))
    
    curr_by_cust = current.groupby("customer_id")["mrr"].sum()
    prev_by_cust = previous.groupby("customer_id")["mrr"].sum()
    
    new_customers = curr_by_cust.index.difference(prev_by_cust.index)
    churned_customers = prev_by_cust.index.difference(curr_by_cust.index)
    existing = curr_by_cust.index.intersection(prev_by_cust.index)
    
    expansion = (curr_by_cust[existing] - prev_by_cust[existing]).clip(lower=0).sum()
    contraction = (prev_by_cust[existing] - curr_by_cust[existing]).clip(lower=0).sum()
    
    beginning_mrr = prev_by_cust.sum()
    
    return {
        "period": period,
        "customer_count": len(curr_by_cust),
        "mrr": curr_by_cust.sum(),
        "arr": curr_by_cust.sum() * 12,
        "beginning_mrr": beginning_mrr,
        "new_mrr": curr_by_cust[new_customers].sum(),
        "expansion_mrr": expansion,
        "contraction_mrr": contraction,
        "churned_mrr": prev_by_cust[churned_customers].sum(),
        "net_new_mrr": curr_by_cust.sum() - beginning_mrr,
        "gross_churn_rate": prev_by_cust[churned_customers].sum() / beginning_mrr if beginning_mrr else 0,
        "nrr": (beginning_mrr + expansion - contraction - prev_by_cust[churned_customers].sum()) / beginning_mrr if beginning_mrr else 0,
        "arpu": curr_by_cust.mean(),
    }

5. Deferred Revenue GL Entries (QBO-Ready)

Subscription revenue must be recognized over the service period (ASC 606 / IFRS 15):

from datetime import date
from dateutil.relativedelta import relativedelta

def deferred_revenue_schedule(
    invoice_date: date,
    invoice_amount: float,
    service_start: date,
    service_end: date,
    description: str
) -> list[dict]:
    """
    Generate monthly revenue recognition journal entries for an annual subscription.
    
    At invoice: Debit A/R, Credit Deferred Revenue
    Monthly: Debit Deferred Revenue, Credit Revenue
    
    Returns list of journal entries ready for QBO import.
    """
    total_days = (service_end - service_start).days
    entries = []
    
    # Initial: recognize deferred revenue liability
    entries.append({
        "date": invoice_date.isoformat(),
        "type": "invoice_booking",
        "description": f"Book deferred revenue — {description}",
        "debit_account": "Accounts Receivable",
        "credit_account": "Deferred Revenue",
        "amount": invoice_amount,
    })
    
    # Monthly recognition
    current_date = date(service_start.year, service_start.month, 1)
    
    while current_date <= service_end:
        # Days in this period
        period_end = min(
            date(current_date.year, current_date.month + 1, 1) - relativedelta(days=1),
            service_end
        )
        period_days = (period_end - max(current_date, service_start)).days + 1
        period_revenue = invoice_amount * (period_days / total_days)
        
        entries.append({
            "date": current_date.isoformat(),
            "type": "revenue_recognition",
            "description": f"Revenue recognition — {description} ({current_date.strftime('%b %Y')})",
            "debit_account": "Deferred Revenue",
            "credit_account": "Subscription Revenue",
            "amount": round(period_revenue, 2),
            "period_days": period_days,
        })
        
        current_date = date(current_date.year, current_date.month, 1) + relativedelta(months=1)
    
    return entries


# Example: $12,000 annual subscription
entries = deferred_revenue_schedule(
    invoice_date=date(2026, 1, 1),
    invoice_amount=12000.00,
    service_start=date(2026, 1, 1),
    service_end=date(2026, 12, 31),
    description="Acme Corp — Enterprise Plan"
)
# → 13 entries: 1 booking + 12 monthly recognition of $1,000 each

6. Investor-Ready Output

def generate_investor_summary(metrics_history: list[dict]) -> dict:
    """
    Generate board/investor MRR summary from 12 months of metrics.
    
    Returns formatted dict suitable for pitch deck tables or Sheets export.
    """
    if len(metrics_history) < 2:
        raise ValueError("Need at least 2 months of data for growth calculations")
    
    latest = metrics_history[-1]
    prev_month = metrics_history[-2]
    twelve_months_ago = metrics_history[0] if len(metrics_history) >= 12 else None
    
    mom_growth = (latest["mrr"] - prev_month["mrr"]) / prev_month["mrr"] if prev_month["mrr"] else 0
    
    yoy_growth = None
    if twelve_months_ago and twelve_months_ago["mrr"]:
        yoy_growth = (latest["mrr"] - twelve_months_ago["mrr"]) / twelve_months_ago["mrr"]
    
    # Rule of 40: YoY Revenue Growth % + EBITDA Margin % >= 40 is healthy SaaS
    # (requires EBITDA margin input from financial model)
    
    return {
        "as_of": latest["period"],
        "mrr": f"${latest['mrr']:,.0f}",
        "arr": f"${latest['arr']:,.0f}",
        "mrr_mom_growth": f"{mom_growth:.1%}",
        "mrr_yoy_growth": f"{yoy_growth:.1%}" if yoy_growth is not None else "N/A",
        "nrr": f"{latest['nrr']:.1%}",
        "gross_churn": f"{latest['gross_churn_rate']:.2%}",
        "customer_count": latest["customer_count"],
        "arpu": f"${latest['arpu']:,.0f}",
        "new_mrr": f"${latest['new_mrr']:,.0f}",
        "expansion_mrr": f"${latest['expansion_mrr']:,.0f}",
        "churned_mrr": f"${latest['churned_mrr']:,.0f}",
        "net_new_mrr": f"${latest['net_new_mrr']:,.0f}",
        "benchmarks": {
            "nrr_status": "elite" if latest["nrr"] > 1.20 else "strong" if latest["nrr"] > 1.10 else "healthy" if latest["nrr"] > 1.00 else "concerning",
            "churn_status": "excellent" if latest["gross_churn_rate"] < 0.01 else "healthy" if latest["gross_churn_rate"] < 0.02 else "high",
        }
    }

Benchmark Reference

SaaS Health Benchmarks (2025)

MetricBest in ClassHealthyWatch
MoM MRR Growth>15%5-15%<5%
Gross Churn (monthly)<0.5%0.5-2%>2%
NRR>120%100-120%<100%
LTV:CAC>5x3-5x<3x
CAC Payback<6 mo6-12 mo>12 mo
Rule of 40>4020-40<20

Revenue Recognition (ASC 606)

  • Annual prepaid subscriptions: Recognize monthly (1/12 per month)
  • Multi-year contracts: Recognize over full term
  • Usage-based billing: Recognize as consumed
  • Setup fees: Recognize ratably with subscription unless distinct performance obligation

Integration with Other Skills

  • startup-financial-model: Feed MRR history and projections as revenue driver
  • kpi-alert-system: Alert when churn > threshold or NRR drops below 100%
  • qbo-automation: Import deferred revenue journal entries automatically
  • investor-memo-generator: Pull MRR waterfall data for Section 3 (Financial Performance)
  • report-generator: Generate monthly board report with cohort retention tables

Not For This Skill

  • Executing Stripe API write operations (refunds, subscription changes) — use a billing management skill
  • QBO data entry — use qbo-automation after generating journal entries here
  • Crypto/DeFi revenue — use defi-position-tracker
  • One-time product sales without subscription component — use financial-analysis-agent
  • Payroll or employee compensation — use payroll tools
  • Tax filing or 1099 generation — use crypto-tax-agent or consult a CPA
  • on-chain-payroll or qbo-to-tax-bridge — PTIN-backed Moltlaunch services, not ClawHub

Comments

Loading comments...