Skip to content

Commit 7cd87b7

Browse files
feat: advanced cohort retention analytics + reliable webhook delivery (#545, #546)
Adds CohortService/RetentionCalculator with cohort tables (week/month), Day 1/7/30/60/90 retention curves, revenue vs. logo churn, plan migration flows, LTV by acquisition source, anomaly filtering, a nightly cohort_aggregation job, CSV/PDF export, and an AnalyticsDashboard upgrade with CohortChart/RetentionHeatmap/SankeyDiagram (#545). Hardens webhook delivery with a 24h idempotency dedup window, a fixed 1m/5m/15m/1h/6h retry schedule, a dead-letter queue with manual replay, signing-secret rotation with overlap, configurable burst/steady rate limits, 410 Gone auto-disable, >1MB payload truncation with a hash, plus delivery_worker/dlq_cleanup jobs, a management REST API, and a WebhookLogsScreen (#546).
1 parent a1f0795 commit 7cd87b7

31 files changed

Lines changed: 2849 additions & 62 deletions

.gitignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,14 @@ logs/
7070
coverage/
7171
.nyc_output/
7272

73+
# Test snapshots & generated test/lint artifacts
74+
**/__snapshots__/
75+
*.snap
76+
junit.xml
77+
jest-results.json
78+
*_output.txt
79+
*_output_*.txt
80+
7381
# Load test reports (generated) — keep the dir so k6 can write into it
7482
load-tests/reports/*
7583
!load-tests/reports/.gitkeep

app/screens/AnalyticsDashboard.tsx

Lines changed: 151 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,28 +7,52 @@ import {
77
ScrollView,
88
Share,
99
Alert,
10+
TouchableOpacity,
1011
} from 'react-native';
1112
import { useSubscriptionStore } from '../../src/store/subscriptionStore';
1213
import { useAnalyticsStore } from '../stores/analyticsStore';
1314
import { useSettingsStore } from '../../src/store/settingsStore';
1415
import { Card } from '../../src/components/common/Card';
1516
import { Button } from '../../src/components/common/Button';
17+
import { CohortChart } from '../../src/components/analytics/CohortChart';
18+
import { RetentionHeatmap } from '../../src/components/analytics/RetentionHeatmap';
19+
import { SankeyDiagram } from '../../src/components/analytics/SankeyDiagram';
1620
import { useThemeColors } from '../../src/hooks/useThemeColors';
1721
import { spacing, typography } from '../../src/utils/constants';
1822
import { formatCurrency } from '../../src/utils/formatting';
23+
import type { CohortGranularity } from '../../src/types/cohortAnalytics';
1924

2025
const AnalyticsDashboard: React.FC = () => {
2126
const colors = useThemeColors();
2227
const styles = useMemo(() => createStyles(colors), [colors]);
2328

2429
const { subscriptions } = useSubscriptionStore();
2530
const { preferredCurrency } = useSettingsStore();
26-
const { report, compute, exportCSV } = useAnalyticsStore();
31+
const {
32+
report,
33+
granularity,
34+
cohortBuckets,
35+
retentionCurve,
36+
churnBreakdown,
37+
planMigrationFlows,
38+
ltvBySource,
39+
revenueTrendWithAnomalies,
40+
setGranularity,
41+
compute,
42+
exportCSV,
43+
exportCohortCsv,
44+
exportCohortPdf,
45+
} = useAnalyticsStore();
2746

2847
useEffect(() => {
2948
compute(subscriptions);
3049
}, [subscriptions, compute]);
3150

51+
const handleSetGranularity = (next: CohortGranularity) => {
52+
setGranularity(next);
53+
compute(subscriptions);
54+
};
55+
3256
const handleExportCSV = async () => {
3357
try {
3458
const csv = exportCSV(subscriptions);
@@ -38,6 +62,22 @@ const AnalyticsDashboard: React.FC = () => {
3862
}
3963
};
4064

65+
const handleExportCohortCsv = async () => {
66+
try {
67+
await Share.share({ message: exportCohortCsv(), title: 'Cohort Report (CSV)' });
68+
} catch {
69+
Alert.alert('Export Failed', 'Could not export cohort report');
70+
}
71+
};
72+
73+
const handleExportCohortPdf = async () => {
74+
try {
75+
await Share.share({ message: exportCohortPdf(), title: 'Cohort Report (PDF)' });
76+
} catch {
77+
Alert.alert('Export Failed', 'Could not export cohort report');
78+
}
79+
};
80+
4181
const currency = preferredCurrency ?? 'USD';
4282

4383
if (!report) {
@@ -107,36 +147,104 @@ const AnalyticsDashboard: React.FC = () => {
107147

108148
<Card style={styles.card}>
109149
<Text style={styles.sectionTitle}>Revenue Trend (last 6 months)</Text>
110-
{report.revenueTrend.length === 0 ? (
150+
{revenueTrendWithAnomalies.length === 0 ? (
111151
<Text style={styles.emptyText}>No trend data yet</Text>
112152
) : (
113-
report.revenueTrend.map((point, index) => (
114-
<View
115-
key={point.label}
116-
style={[
117-
styles.statRow,
118-
index === report.revenueTrend.length - 1 && styles.lastRow,
119-
]}>
120-
<Text style={styles.statLabel}>{point.label}</Text>
121-
<Text style={styles.statValue}>{formatCurrency(point.mrr, currency)}</Text>
153+
revenueTrendWithAnomalies.map((point, index, arr) => (
154+
<View key={point.label} style={[styles.statRow, index === arr.length - 1 && styles.lastRow]}>
155+
<Text style={styles.statLabel}>
156+
{point.label}
157+
{point.isAnomaly ? ' ⚠️' : ''}
158+
</Text>
159+
<Text style={[styles.statValue, point.isAnomaly && styles.anomalyValue]}>
160+
{formatCurrency(point.value, currency)}
161+
</Text>
122162
</View>
123163
))
124164
)}
165+
{revenueTrendWithAnomalies.some((point) => point.isAnomaly) && (
166+
<Text style={styles.anomalyNote}>⚠️ flagged points are statistical outliers vs. the rest of the trend</Text>
167+
)}
125168
</Card>
126169

127170
<Card style={styles.card}>
128-
<Text style={styles.sectionTitle}>Cohorts</Text>
129-
{report.cohorts.length === 0 ? (
130-
<Text style={styles.emptyText}>No cohort data yet</Text>
171+
<View style={styles.rowBetween}>
172+
<Text style={styles.sectionTitle}>Cohort Retention</Text>
173+
<View style={styles.granularityToggle}>
174+
{(['week', 'month'] as CohortGranularity[]).map((option) => (
175+
<TouchableOpacity
176+
key={option}
177+
style={[styles.granularityButton, granularity === option && styles.granularityButtonActive]}
178+
onPress={() => handleSetGranularity(option)}
179+
accessibilityRole="button"
180+
accessibilityState={{ selected: granularity === option }}>
181+
<Text
182+
style={[
183+
styles.granularityButtonText,
184+
granularity === option && styles.granularityButtonTextActive,
185+
]}>
186+
{option === 'week' ? 'Week' : 'Month'}
187+
</Text>
188+
</TouchableOpacity>
189+
))}
190+
</View>
191+
</View>
192+
<CohortChart buckets={cohortBuckets} />
193+
{cohortBuckets.slice(-4).map((bucket, index, arr) => (
194+
<View key={bucket.cohortKey} style={[styles.statRow, index === arr.length - 1 && styles.lastRow]}>
195+
<Text style={styles.statLabel}>{bucket.cohortKey}</Text>
196+
<Text style={styles.statValue}>
197+
{bucket.size} signups · {(bucket.retentionRate * 100).toFixed(0)}% retained ·{' '}
198+
{formatCurrency(bucket.currentMrr, currency)}
199+
</Text>
200+
</View>
201+
))}
202+
</Card>
203+
204+
<Card style={styles.card}>
205+
<Text style={styles.sectionTitle}>Retention Curve (Day 1 / 7 / 30 / 60 / 90)</Text>
206+
<RetentionHeatmap points={retentionCurve} />
207+
</Card>
208+
209+
<Card style={styles.card}>
210+
<Text style={styles.sectionTitle}>Revenue vs. Logo Churn (last 30 days)</Text>
211+
{!churnBreakdown || churnBreakdown.isEmpty ? (
212+
<Text style={styles.emptyText}>No subscribers active at the start of this period yet.</Text>
131213
) : (
132-
report.cohorts.slice(-4).map((cohort, index, arr) => (
133-
<View
134-
key={cohort.cohort}
135-
style={[styles.statRow, index === arr.length - 1 && styles.lastRow]}>
136-
<Text style={styles.statLabel}>{cohort.cohort}</Text>
214+
<>
215+
<View style={styles.statRow}>
216+
<Text style={styles.statLabel}>Logo churn (subscribers)</Text>
217+
<Text style={styles.statValue}>
218+
{(churnBreakdown.logoChurnRate * 100).toFixed(1)}% ({churnBreakdown.churnedSubscribers}/
219+
{churnBreakdown.startingSubscribers})
220+
</Text>
221+
</View>
222+
<View style={[styles.statRow, styles.lastRow]}>
223+
<Text style={styles.statLabel}>Revenue churn (MRR)</Text>
137224
<Text style={styles.statValue}>
138-
{(cohort.retentionRate * 100).toFixed(0)}% retained ·{' '}
139-
{formatCurrency(cohort.revenue, currency)}
225+
{(churnBreakdown.revenueChurnRate * 100).toFixed(1)}% (
226+
{formatCurrency(churnBreakdown.churnedMrr, currency)})
227+
</Text>
228+
</View>
229+
</>
230+
)}
231+
</Card>
232+
233+
<Card style={styles.card}>
234+
<Text style={styles.sectionTitle}>Plan Migration</Text>
235+
<SankeyDiagram flows={planMigrationFlows} />
236+
</Card>
237+
238+
<Card style={styles.card}>
239+
<Text style={styles.sectionTitle}>LTV by Acquisition Source</Text>
240+
{ltvBySource.length === 0 ? (
241+
<Text style={styles.emptyText}>No acquisition source data yet</Text>
242+
) : (
243+
ltvBySource.map((row, index, arr) => (
244+
<View key={row.acquisitionChannel} style={[styles.statRow, index === arr.length - 1 && styles.lastRow]}>
245+
<Text style={styles.statLabel}>{row.acquisitionChannel}</Text>
246+
<Text style={styles.statValue}>
247+
{formatCurrency(row.ltv, currency)} LTV · {row.subscriberCount} subs
140248
</Text>
141249
</View>
142250
))
@@ -159,6 +267,8 @@ const AnalyticsDashboard: React.FC = () => {
159267

160268
<View style={styles.exportContainer}>
161269
<Button title="Export CSV" onPress={handleExportCSV} variant="secondary" />
270+
<Button title="Export Cohort CSV" onPress={handleExportCohortCsv} variant="secondary" />
271+
<Button title="Export Cohort PDF" onPress={handleExportCohortPdf} variant="secondary" />
162272
</View>
163273
</ScrollView>
164274
</SafeAreaView>
@@ -194,8 +304,27 @@ function createStyles(colors: ReturnType<typeof useThemeColors>) {
194304
statLabel: { ...typography.body, color: colors.textSecondary },
195305
statValue: { ...typography.body, color: colors.text.primary, fontWeight: '600' },
196306
emptyText: { ...typography.body, color: colors.textSecondary, textAlign: 'center' },
197-
exportContainer: { padding: spacing.lg, paddingTop: 0, marginBottom: spacing.xl },
307+
exportContainer: { padding: spacing.lg, paddingTop: 0, marginBottom: spacing.xl, gap: spacing.sm },
198308
loadingText: { ...typography.body, color: colors.textSecondary, padding: spacing.lg },
309+
rowBetween: {
310+
flexDirection: 'row',
311+
justifyContent: 'space-between',
312+
alignItems: 'center',
313+
marginBottom: spacing.md,
314+
},
315+
granularityToggle: { flexDirection: 'row', gap: spacing.xs },
316+
granularityButton: {
317+
paddingVertical: spacing.xs,
318+
paddingHorizontal: spacing.sm,
319+
borderRadius: 6,
320+
borderWidth: 1,
321+
borderColor: colors.border.default,
322+
},
323+
granularityButtonActive: { backgroundColor: colors.primary, borderColor: colors.primary },
324+
granularityButtonText: { ...typography.caption, color: colors.textSecondary },
325+
granularityButtonTextActive: { color: colors.text.inverse, fontWeight: '600' },
326+
anomalyValue: { color: colors.status.warning },
327+
anomalyNote: { ...typography.caption, color: colors.status.warning, marginTop: spacing.xs },
199328
});
200329
}
201330

app/stores/analyticsStore.ts

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,106 @@ import {
33
calculateSubscriptionAnalytics,
44
SubscriptionAnalyticsReport,
55
} from '../../src/services/analyticsService';
6-
import { Subscription } from '../../src/types/subscription';
6+
import { BillingCycle, Subscription } from '../../src/types/subscription';
77
import { generateCSV } from '../../src/utils/importExport';
8+
import { CohortService } from '../../backend/services/analytics/cohortService';
9+
import { cohortTableToCsv } from '../../backend/services/analytics/cohortReportExport';
10+
import { cohortTableToPdfText } from '../../src/services/cohortPdfExport';
11+
import type {
12+
ChurnBreakdown,
13+
CohortBucket,
14+
CohortGranularity,
15+
LtvSourceBreakdown,
16+
PlanMigrationFlow,
17+
RetentionCurvePoint,
18+
SubscriberRecord,
19+
AnomalyFlaggedPoint,
20+
} from '../../src/types/cohortAnalytics';
21+
22+
const DAY_MS = 24 * 60 * 60 * 1_000;
23+
24+
/**
25+
* Adapts the app's personal Subscription model into merchant-style
26+
* SubscriberRecords so CohortService (built for the merchant analytics
27+
* platform) can compute cohort/retention/churn/LTV metrics on it. Each
28+
* tracked subscription stands in for a "subscriber" of this account.
29+
*/
30+
const toSubscriberRecords = (subscriptions: Subscription[]): SubscriberRecord[] =>
31+
subscriptions.map((subscription) => ({
32+
subscriberId: subscription.id,
33+
merchantId: 'self',
34+
planId: subscription.category,
35+
planName: subscription.name,
36+
region: subscription.timezone,
37+
acquisitionChannel: subscription.isCryptoEnabled ? 'crypto' : 'card',
38+
signupAt: new Date(subscription.createdAt).getTime(),
39+
churnedAt: subscription.isActive ? undefined : new Date(subscription.updatedAt).getTime(),
40+
lastActiveAt: new Date(subscription.updatedAt).getTime(),
41+
mrr:
42+
subscription.billingCycle === BillingCycle.YEARLY
43+
? subscription.price / 12
44+
: subscription.billingCycle === BillingCycle.WEEKLY
45+
? subscription.price * 4.345
46+
: subscription.price,
47+
}));
848

949
interface AnalyticsStoreState {
1050
report: SubscriptionAnalyticsReport | null;
51+
granularity: CohortGranularity;
52+
cohortBuckets: CohortBucket[];
53+
retentionCurve: RetentionCurvePoint[];
54+
churnBreakdown: ChurnBreakdown | null;
55+
planMigrationFlows: PlanMigrationFlow[];
56+
ltvBySource: LtvSourceBreakdown[];
57+
revenueTrendWithAnomalies: AnomalyFlaggedPoint[];
58+
setGranularity: (granularity: CohortGranularity) => void;
1159
compute: (subscriptions: Subscription[]) => void;
1260
exportCSV: (subscriptions: Subscription[]) => string;
61+
exportCohortCsv: () => string;
62+
exportCohortPdf: () => string;
1363
}
1464

15-
export const useAnalyticsStore = create<AnalyticsStoreState>()((set) => ({
65+
export const useAnalyticsStore = create<AnalyticsStoreState>()((set, get) => ({
1666
report: null,
67+
granularity: 'month',
68+
cohortBuckets: [],
69+
retentionCurve: [],
70+
churnBreakdown: null,
71+
planMigrationFlows: [],
72+
ltvBySource: [],
73+
revenueTrendWithAnomalies: [],
74+
75+
setGranularity: (granularity) => {
76+
set({ granularity });
77+
// Recompute is cheap (in-memory, no I/O) — callers re-run `compute` with
78+
// the latest subscriptions list whenever granularity changes.
79+
},
1780

1881
compute: (subscriptions) => {
1982
const report = calculateSubscriptionAnalytics(subscriptions);
20-
set({ report });
83+
const records = toSubscriberRecords(subscriptions);
84+
const granularity = get().granularity;
85+
const now = Date.now();
86+
const periodStart = now - 30 * DAY_MS;
87+
88+
set({
89+
report,
90+
cohortBuckets: CohortService.buildCohortTable(records, granularity),
91+
retentionCurve: CohortService.retentionCurve(records),
92+
churnBreakdown: CohortService.revenueChurnVsLogoChurn(records, periodStart, now),
93+
planMigrationFlows: CohortService.planMigrationFlows(records, periodStart, now),
94+
ltvBySource: CohortService.ltvByAcquisitionSource(records),
95+
revenueTrendWithAnomalies: CohortService.filterAnomalousSpikes(
96+
report.revenueTrend.map((point) => ({ label: point.label, value: point.mrr }))
97+
),
98+
});
2199
},
22100

23101
exportCSV: (subscriptions) => {
24102
return generateCSV(subscriptions);
25103
},
104+
105+
exportCohortCsv: () => cohortTableToCsv(get().cohortBuckets),
106+
107+
exportCohortPdf: () => cohortTableToPdfText(get().cohortBuckets, 'Cohort Retention Report'),
26108
}));

0 commit comments

Comments
 (0)