Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
536 changes: 364 additions & 172 deletions app/screens/AnalyticsDashboard.tsx

Large diffs are not rendered by default.

101 changes: 91 additions & 10 deletions app/stores/analyticsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,15 @@ import type {

const DAY_MS = 24 * 60 * 60 * 1_000;

/**
* Adapts the app's personal Subscription model into merchant-style
* SubscriberRecords so CohortService (built for the merchant analytics
* platform) can compute cohort/retention/churn/LTV metrics on it. Each
* tracked subscription stands in for a "subscriber" of this account.
*/
export const DEFAULT_WIDGETS = [
'overview',
'revenueTrend',
'cohortHeatmap',
'churnBreakdown',
'forecast',
'planMigrations',
];

const toSubscriberRecords = (subscriptions: Subscription[]): SubscriberRecord[] =>
subscriptions.map((subscription) => ({
subscriberId: subscription.id,
Expand All @@ -49,22 +52,34 @@ const toSubscriberRecords = (subscriptions: Subscription[]): SubscriberRecord[]
interface AnalyticsStoreState {
report: SubscriptionAnalyticsReport | null;
granularity: CohortGranularity;
forecastModel: 'linear' | 'exponential';
enabledWidgets: string[];
widgetOrder: string[];
cohortBuckets: CohortBucket[];
retentionCurve: RetentionCurvePoint[];
churnBreakdown: ChurnBreakdown | null;
planMigrationFlows: PlanMigrationFlow[];
ltvBySource: LtvSourceBreakdown[];
revenueTrendWithAnomalies: AnomalyFlaggedPoint[];
setGranularity: (granularity: CohortGranularity) => void;
setForecastModel: (model: 'linear' | 'exponential') => void;
toggleWidget: (widgetId: string) => void;
reorderWidgets: (newOrder: string[]) => void;
resetWidgetConfig: () => void;
compute: (subscriptions: Subscription[]) => void;
exportCSV: (subscriptions: Subscription[]) => string;
exportCohortCsv: () => string;
exportCohortPdf: () => string;
exportSummaryCsv: () => string;
exportSummaryText: () => string;
}

export const useAnalyticsStore = create<AnalyticsStoreState>()((set, get) => ({
report: null,
granularity: 'month',
forecastModel: 'exponential',
enabledWidgets: [...DEFAULT_WIDGETS],
widgetOrder: [...DEFAULT_WIDGETS],
cohortBuckets: [],
retentionCurve: [],
churnBreakdown: null,
Expand All @@ -74,14 +89,34 @@ export const useAnalyticsStore = create<AnalyticsStoreState>()((set, get) => ({

setGranularity: (granularity) => {
set({ granularity });
// Recompute is cheap (in-memory, no I/O) — callers re-run `compute` with
// the latest subscriptions list whenever granularity changes.
},

setForecastModel: (forecastModel) => {
set({ forecastModel });
},

toggleWidget: (widgetId) => {
const { enabledWidgets } = get();
const isEnabled = enabledWidgets.includes(widgetId);
if (isEnabled && enabledWidgets.length <= 1) return; // Prevent disabling all widgets
const updated = isEnabled
? enabledWidgets.filter((id) => id !== widgetId)
: [...enabledWidgets, widgetId];
set({ enabledWidgets: updated });
},

reorderWidgets: (newOrder) => {
set({ widgetOrder: newOrder });
},

resetWidgetConfig: () => {
set({ enabledWidgets: [...DEFAULT_WIDGETS], widgetOrder: [...DEFAULT_WIDGETS] });
},

compute: (subscriptions) => {
const report = calculateSubscriptionAnalytics(subscriptions);
const { granularity, forecastModel } = get();
const report = calculateSubscriptionAnalytics(subscriptions, new Date(), forecastModel, 3);
const records = toSubscriberRecords(subscriptions);
const granularity = get().granularity;
const now = Date.now();
const periodStart = now - 30 * DAY_MS;

Expand All @@ -105,4 +140,50 @@ export const useAnalyticsStore = create<AnalyticsStoreState>()((set, get) => ({
exportCohortCsv: () => cohortTableToCsv(get().cohortBuckets),

exportCohortPdf: () => cohortTableToPdfText(get().cohortBuckets, 'Cohort Retention Report'),

exportSummaryCsv: () => {
const { report, forecastModel } = get();
if (!report) return '';
const headers = ['Metric', 'Value'];
const rows = [
['MRR', report.mrr.toFixed(2)],
['ARR', report.arr.toFixed(2)],
['MRR Growth Rate (%)', report.mrrGrowthRate.toFixed(2)],
['ARR Growth Rate (%)', report.arrGrowthRate.toFixed(2)],
['ARPU', report.arpu.toFixed(2)],
['LTV', report.ltv.toFixed(2)],
['Active Subscribers', report.subscriberCount.toString()],
['Gross Churn Rate (%)', (report.churn.grossChurnRate * 100).toFixed(2)],
['Net Churn Rate (%)', (report.churn.netChurnRate * 100).toFixed(2)],
['Forecast Model', forecastModel],
];
report.forecast.forEach((f) => {
rows.push([`Forecast ${f.label} (Expected Revenue)`, f.expectedRevenue.toFixed(2)]);
});
return [headers.join(','), ...rows.map((r) => r.join(','))].join('\n');
},

exportSummaryText: () => {
const { report, forecastModel } = get();
if (!report) return 'No analytics computed yet.';
return [
'========================================',
' SUBTRACKR ANALYTICS SUMMARY ',
'========================================',
`Active MRR: $${report.mrr.toFixed(2)} (${report.mrrGrowthRate >= 0 ? '+' : ''}${report.mrrGrowthRate.toFixed(1)}% MoM)`,
`Active ARR: $${report.arr.toFixed(2)} (${report.arrGrowthRate >= 0 ? '+' : ''}${report.arrGrowthRate.toFixed(1)}% YoY)`,
`ARPU: $${report.arpu.toFixed(2)}`,
`Customer LTV: $${report.ltv.toFixed(2)}`,
`Active Subscribers: ${report.subscriberCount}`,
`Gross Churn Rate: ${(report.churn.grossChurnRate * 100).toFixed(1)}%`,
`Net Churn Rate: ${(report.churn.netChurnRate * 100).toFixed(1)}%`,
'----------------------------------------',
`Revenue Forecast (${forecastModel.toUpperCase()} MODEL):`,
...report.forecast.map(
(f) =>
` - ${f.label}: $${f.expectedRevenue.toFixed(2)} (Range: $${f.lowerBound.toFixed(2)} - $${f.upperBound.toFixed(2)})`
),
'========================================',
].join('\n');
},
}));
73 changes: 73 additions & 0 deletions backend/services/analytics/analyticsDashboardApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,79 @@ export class AnalyticsDashboardApi {
body: ltvBreakdownToCsv(CohortService.ltvByAcquisitionSource(records)),
});
}

getMrrArrReport(merchantId: string): ApiResponse<{
mrr: number;
arr: number;
arpu: number;
ltv: number;
subscriberCount: number;
activeCount: number;
}> {
const records = this.repository.getByMerchant(merchantId);
const active = records.filter((r) => r.churnedAt === undefined);
const churned = records.filter((r) => r.churnedAt !== undefined);
const mrr = active.reduce((sum, r) => sum + r.mrr, 0);
const arr = mrr * 12;
const arpu = active.length > 0 ? mrr / active.length : 0;
const grossChurnRate = records.length > 0 ? churned.length / records.length : 0;
const ltv = grossChurnRate > 0 ? arpu / grossChurnRate : arpu * 24;
return ok({
mrr,
arr,
arpu,
ltv,
subscriberCount: records.length,
activeCount: active.length,
});
}

getRevenueForecast(
merchantId: string,
model: 'linear' | 'exponential' = 'exponential',
monthsAhead: number = 3
): ApiResponse<{ label: string; expectedRevenue: number; lowerBound: number; upperBound: number }[]> {
const records = this.repository.getByMerchant(merchantId);
const active = records.filter((r) => r.churnedAt === undefined);
const mrr = active.reduce((sum, r) => sum + r.mrr, 0);

const buckets = CohortService.buildCohortTable(records, 'month');
const retention = buckets.length
? buckets.reduce((sum, b) => sum + b.retentionRate, 0) / buckets.length
: 0.95;
const confidenceBand = Math.max(0.1, 1 - Math.min(records.length / 50, 0.8));

let linearSlope = 0;
if (model === 'linear' && buckets.length >= 2) {
const n = buckets.length;
const sumX = buckets.reduce((sum, _, i) => sum + i, 0);
const sumY = buckets.reduce((sum, b) => sum + b.currentMrr, 0);
const sumXY = buckets.reduce((sum, b, i) => sum + i * b.currentMrr, 0);
const sumXX = buckets.reduce((sum, _, i) => sum + i * i, 0);
const denominator = n * sumXX - sumX * sumX;
if (denominator !== 0) {
linearSlope = (n * sumXY - sumX * sumY) / denominator;
}
}

const forecast = Array.from({ length: monthsAhead }, (_, index) => {
const monthAhead = index + 1;
let expectedRevenue = 0;
if (model === 'linear') {
expectedRevenue = Math.max(0, mrr + linearSlope * monthAhead);
} else {
expectedRevenue = mrr * Math.pow(retention || 0.95, monthAhead);
}
return {
label: `M+${monthAhead}`,
expectedRevenue,
lowerBound: expectedRevenue * (1 - confidenceBand),
upperBound: expectedRevenue * (1 + confidenceBand),
};
});

return ok(forecast);
}
}

export const analyticsDashboardApi = new AnalyticsDashboardApi();
84 changes: 84 additions & 0 deletions docs/analytics-guide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# SubTrackr Subscription Analytics Guide

The SubTrackr advanced analytics suite delivers enterprise-grade SaaS telemetry, providing deep visibility into Monthly Recurring Revenue (MRR), Annual Recurring Revenue (ARR), subscriber cohort retention curves, algorithmic revenue forecasting, and channel-based LTV attribution.

---

## 1. Key Metrics & Financial Formulas

### Monthly Recurring Revenue (MRR)
MRR represents the normalized monthly revenue contribution from all currently active subscriptions:
- **Monthly Billing**: Contribution = `price`
- **Yearly Billing**: Contribution = `price / 12`
- **Weekly Billing**: Contribution = `price * 4.345` (average weeks per month)

### Annual Recurring Revenue (ARR)
ARR projects the annualized run-rate of current active subscriptions:
$$\text{ARR} = \text{MRR} \times 12$$

### Growth Rates (MoM / YoY)
Period-over-period growth rates evaluate momentum across historical billing cycles:
$$\text{MRR Growth Rate} = \left( \frac{\text{MRR}_{\text{current}} - \text{MRR}_{\text{previous}}}{\text{MRR}_{\text{previous}}} \right) \times 100$$

### Customer Lifetime Value (LTV) & ARPU
- **Average Revenue Per User (ARPU)**: $\text{MRR} / \text{Active Subscribers}$
- **Customer Lifetime Value (LTV)**:
- If Gross Churn Rate $> 0$: $\text{ARPU} / \text{Gross Churn Rate}$
- If Churn Rate $= 0$: $\text{ARPU} \times 24 \text{ months}$ (default lifetime assumption)

---

## 2. Cohort Retention Curves & Heatmaps

Subscribers are bucketed into signup cohorts based on their initial subscription timestamp (monthly or weekly granularity).
- **Retention Curve**: Evaluates active status at Day 1, Day 7, Day 30, Day 60, and Day 90 relative to cohort signup date.
- **Logo Churn vs. Revenue Churn**: Highlights divergence between subscriber count churn and monetary MRR churn (critical for identifying churn among high-value enterprise tiers).

---

## 3. Revenue Forecasting Models

SubTrackr supports dual algorithmic models for predicting revenue trajectories over the next 3 to 12 months:

### Exponential Decay Model (Default)
Assumes future revenue compounds based on historical retention and natural net expansion:
$$\text{Expected Revenue}_{M+k} = \text{MRR} \times (\text{Average Cohort Retention})^{k}$$

### Linear Regression Model
Fits a linear trend line across the last 6 months of observed MRR using ordinary least squares (OLS):
$$\text{Slope } (m) = \frac{n \sum (xy) - \sum x \sum y}{n \sum (x^2) - (\sum x)^2}$$
$$\text{Expected Revenue}_{M+k} = \max(0, \text{MRR} + m \times k)$$

---

## 4. Dashboard Customization & Widgets

The **Analytics Dashboard** is fully modular. Via the `WidgetCustomizationModal` (powered by Zustand `analyticsStore`), administrators can:
- **Toggle Visibility**: Enable/disable individual cards (`MRR & ARR Overview`, `Revenue Trend`, `Cohort Heatmap`, `Churn Breakdown`, `Revenue Forecast`, `Plan Migrations`).
- **Reorder Layout**: Move widgets up or down to customize hierarchy.
- **Switch Forecast Model**: Dynamically switch between Linear Regression and Exponential Decay across all forecasting views.

---

## 5. Multi-Format Report Exports

Users can export telemetry in multiple standard formats directly from the dashboard or via API:
1. **MRR/ARR Summary (CSV)**: Tabular KPI report with growth rates and forecast data.
2. **MRR/ARR Summary (Text/PDF)**: Formatted executive briefing text suitable for PDF rendering or sharing.
3. **Cohort Retention Report (CSV / PDF)**: Detailed breakdown of cohort sizes, starting MRR, and retention percentages.
4. **Raw Subscriptions (CSV)**: Full export of underlying subscriber records.

---

## 6. REST API Reference (`AnalyticsDashboardApi`)

The backend service exposes standard `ApiResponse<T>` endpoints for programmatic access:

| Method | Endpoint / Method Name | Description |
| :--- | :--- | :--- |
| `GET` | `getCohortTable(merchantId, granularity)` | Serves nightly cohort table (cached or live compute). |
| `GET` | `getRetentionCurve(merchantId)` | Returns Day 1/7/30/60/90 retention milestones. |
| `GET` | `getMrrArrReport(merchantId)` | Returns active MRR, ARR, ARPU, LTV, and subscriber counts. |
| `GET` | `getRevenueForecast(merchantId, model, monthsAhead)` | Calculates linear or exponential predictive trajectories. |
| `GET` | `getChurnBreakdown(merchantId, start, end)` | Computes logo churn rate vs. revenue churn rate. |
| `GET` | `exportCohortReport(merchantId, granularity, format)` | Returns CSV or PDF report buffer and headers. |
Loading