Skip to content

Commit c860b0f

Browse files
feat(billing): emit subscription_changed analytics on plan change (#3769)
- Add AnalyticsService.isConfigured() to lib/services/analytics.js - Guard analytics emit with isConfigured() — no-op on downstreams without PostHog; non-fatal try/catch protects webhook processing - New unit test: 5 cases (plan change, isDowngrade, not-configured no-op, same-plan no-emit, stale-event no-emit) Restores trawl observability lost in /update-stack #1316. Promoted to devkit: AnalyticsService is a devkit-level abstraction. Cross-ref: trawl#1315, infra plan 2026-06-01-trawl-promote-up-followups
1 parent b56d3b6 commit c860b0f

3 files changed

Lines changed: 293 additions & 0 deletions

File tree

lib/services/analytics.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,16 @@ const getFeatureFlagForRequest = async (flag, req, options) =>
235235
const isFeatureEnabledForRequest = async (flag, req, options) =>
236236
isFeatureEnabled(flag, resolveDistinctId(req), options);
237237

238+
/**
239+
* Return whether the PostHog client is currently initialised.
240+
* Use this as a cheap pre-check before building expensive analytics payloads.
241+
* The underlying `capture()` and `track()` methods are already no-ops when
242+
* the client is absent — `isConfigured()` is for callers that want to skip
243+
* payload construction entirely when analytics is not active.
244+
* @returns {boolean}
245+
*/
246+
const isConfigured = () => client !== null;
247+
238248
/**
239249
* Flush pending events and shut down the PostHog client.
240250
* Safe to call even when the client was never initialised.
@@ -249,6 +259,7 @@ const shutdown = async () => {
249259

250260
export default {
251261
init,
262+
isConfigured,
252263
track,
253264
capture,
254265
identify,

modules/billing/services/billing.webhook.service.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import billingEvents from '../lib/events.js';
1616
import { SENTINEL_PENDING } from '../lib/billing.constants.js';
1717
import { retryWithBackoff } from '../lib/billing.retry.js';
1818
import { isNonTransientStripeError } from '../lib/billing.stripe-errors.js';
19+
import AnalyticsService from '../../../lib/services/analytics.js';
1920

2021
/**
2122
* Treats a stripeSessionId as "unresolved" when absent, empty, or still the
@@ -506,6 +507,28 @@ const handleSubscriptionUpdated = async (subscription, event) => {
506507
});
507508
}
508509

510+
// Emit analytics observability event for downstreams running PostHog (no-op otherwise).
511+
// Mirrors the internal billing.plan.changed event but lands in the analytics pipeline.
512+
if (AnalyticsService.isConfigured()) {
513+
try {
514+
AnalyticsService.capture({
515+
distinctId: organizationId,
516+
event: 'subscription_changed',
517+
source: 'stripe-webhook',
518+
properties: {
519+
previousPlan,
520+
newPlan,
521+
isDowngrade,
522+
},
523+
});
524+
} catch (capErr) {
525+
logger.error('[billing.webhook] analytics capture subscription_changed failed (non-fatal)', {
526+
organizationId,
527+
error: capErr?.message ?? String(capErr),
528+
});
529+
}
530+
}
531+
509532
// Plan switch mid-cycle = refresh the active week snapshot to the new plan.
510533
// Unlike cron-driven resetWeek, this preserves meterUsed by default so a plan
511534
// change does not refund or double-charge already attributed usage.
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
/**
2+
* Module dependencies.
3+
*/
4+
import { jest, describe, test, beforeEach, afterEach, expect } from '@jest/globals';
5+
6+
/**
7+
* Unit tests for subscription_changed analytics emit in billing.webhook.service.js
8+
* - captures when plan changes and AnalyticsService is configured
9+
* - no-op when AnalyticsService.isConfigured() returns false (no PostHog)
10+
* - no capture when plan did not change
11+
*/
12+
describe('billing.webhook.service — subscription_changed analytics emit:', () => {
13+
let BillingWebhookService;
14+
let mockAnalyticsCapture;
15+
let mockAnalyticsIsConfigured;
16+
let mockSubscriptionRepository;
17+
let mockOrganizationRepository;
18+
let mockResetService;
19+
let mockEvents;
20+
let mockStripe;
21+
22+
const orgId = '507f1f77bcf86cd799439011';
23+
const subId = '607f1f77bcf86cd799439022';
24+
25+
const _mkStripeSubscription = (overrides = {}) => ({
26+
id: 'sub_456',
27+
status: 'active',
28+
customer: 'cus_abc',
29+
current_period_start: 1700000000,
30+
current_period_end: 1700000000 + 2592000,
31+
cancel_at_period_end: false,
32+
items: {
33+
data: [{
34+
price: { id: 'price_growth', metadata: { planId: 'growth' } },
35+
current_period_start: 1700000000,
36+
}],
37+
},
38+
...overrides,
39+
});
40+
41+
const _mkEvent = (overrides = {}) => ({
42+
id: 'evt_test_1',
43+
type: 'customer.subscription.updated',
44+
created: 1700000100,
45+
data: { previous_attributes: {} },
46+
...overrides,
47+
});
48+
49+
beforeEach(async () => {
50+
jest.resetModules();
51+
52+
mockAnalyticsCapture = jest.fn();
53+
mockAnalyticsIsConfigured = jest.fn().mockReturnValue(true);
54+
55+
mockSubscriptionRepository = {
56+
findByStripeSubscriptionId: jest.fn().mockResolvedValue({
57+
_id: subId,
58+
organization: orgId,
59+
plan: 'free',
60+
}),
61+
findByStripeCustomerId: jest.fn().mockResolvedValue(null),
62+
create: jest.fn().mockResolvedValue({}),
63+
updateIfEventNewer: jest.fn().mockResolvedValue({ _id: subId }),
64+
};
65+
66+
mockOrganizationRepository = {
67+
setPlan: jest.fn().mockResolvedValue({}),
68+
};
69+
70+
mockResetService = {
71+
resetWeek: jest.fn().mockResolvedValue({}),
72+
forceRotateForPlanChange: jest.fn().mockResolvedValue({}),
73+
};
74+
75+
mockEvents = { emit: jest.fn() };
76+
77+
mockStripe = {
78+
subscriptions: { retrieve: jest.fn() },
79+
};
80+
81+
jest.unstable_mockModule('../../../lib/services/analytics.js', () => ({
82+
default: {
83+
capture: mockAnalyticsCapture,
84+
isConfigured: mockAnalyticsIsConfigured,
85+
},
86+
}));
87+
88+
jest.unstable_mockModule('../repositories/billing.subscription.repository.js', () => ({
89+
default: mockSubscriptionRepository,
90+
}));
91+
92+
jest.unstable_mockModule('../repositories/billing.processedStripeEvent.repository.js', () => ({
93+
default: {
94+
wasProcessed: jest.fn().mockResolvedValue(false),
95+
tryRecord: jest.fn().mockResolvedValue({ recorded: true }),
96+
},
97+
}));
98+
99+
jest.unstable_mockModule('../../organizations/repositories/organizations.repository.js', () => ({
100+
default: mockOrganizationRepository,
101+
}));
102+
103+
jest.unstable_mockModule('../services/billing.extra.service.js', () => ({
104+
default: { creditPack: jest.fn(), refundPartial: jest.fn() },
105+
}));
106+
107+
jest.unstable_mockModule('../services/billing.reset.service.js', () => ({
108+
default: mockResetService,
109+
}));
110+
111+
jest.unstable_mockModule('../lib/stripe.js', () => ({
112+
default: jest.fn(() => mockStripe),
113+
}));
114+
115+
jest.unstable_mockModule('../lib/events.js', () => ({
116+
default: mockEvents,
117+
}));
118+
119+
jest.unstable_mockModule('../../../lib/services/logger.js', () => ({
120+
default: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
121+
}));
122+
123+
jest.unstable_mockModule('../../../config/index.js', () => ({
124+
default: {
125+
billing: {
126+
plans: ['free', 'growth', 'pro'],
127+
meterMode: true,
128+
},
129+
},
130+
}));
131+
132+
jest.unstable_mockModule('mongoose', () => ({
133+
default: {
134+
Types: { ObjectId: { isValid: (id) => /^[a-f\d]{24}$/i.test(id) } },
135+
model: () => ({}),
136+
},
137+
}));
138+
139+
const mod = await import('../services/billing.webhook.service.js');
140+
BillingWebhookService = mod.default;
141+
});
142+
143+
afterEach(() => {
144+
jest.restoreAllMocks();
145+
});
146+
147+
describe('when plan changes and AnalyticsService is configured', () => {
148+
test('captures subscription_changed with previousPlan, newPlan, isDowngrade', async () => {
149+
// existing sub has free plan, event carries growth plan → plan change
150+
await BillingWebhookService.handleSubscriptionUpdated(
151+
_mkStripeSubscription({
152+
items: {
153+
data: [{ price: { id: 'price_growth', metadata: { planId: 'growth' } }, current_period_start: 1700000000 }],
154+
},
155+
}),
156+
_mkEvent({
157+
data: {
158+
previous_attributes: {
159+
items: { data: [{ price: { id: 'price_free', metadata: { planId: 'free' } } }] },
160+
},
161+
},
162+
}),
163+
);
164+
165+
expect(mockAnalyticsIsConfigured).toHaveBeenCalled();
166+
const subscriptionChangedCalls = mockAnalyticsCapture.mock.calls.filter(
167+
([arg]) => arg.event === 'subscription_changed',
168+
);
169+
expect(subscriptionChangedCalls).toHaveLength(1);
170+
const [call] = subscriptionChangedCalls;
171+
expect(call[0].distinctId).toBe(orgId);
172+
expect(call[0].source).toBe('stripe-webhook');
173+
expect(call[0].properties).toMatchObject({
174+
previousPlan: 'free',
175+
newPlan: 'growth',
176+
isDowngrade: false,
177+
});
178+
});
179+
180+
test('marks isDowngrade=true when switching from higher to lower plan', async () => {
181+
await BillingWebhookService.handleSubscriptionUpdated(
182+
_mkStripeSubscription({
183+
items: {
184+
data: [{ price: { id: 'price_free', metadata: { planId: 'free' } }, current_period_start: 1700000000 }],
185+
},
186+
}),
187+
_mkEvent({
188+
data: {
189+
previous_attributes: {
190+
items: { data: [{ price: { id: 'price_pro', metadata: { planId: 'pro' } } }] },
191+
},
192+
},
193+
}),
194+
);
195+
196+
const subscriptionChangedCalls = mockAnalyticsCapture.mock.calls.filter(
197+
([arg]) => arg.event === 'subscription_changed',
198+
);
199+
expect(subscriptionChangedCalls).toHaveLength(1);
200+
expect(subscriptionChangedCalls[0][0].properties.isDowngrade).toBe(true);
201+
});
202+
});
203+
204+
describe('when AnalyticsService is not configured (no PostHog)', () => {
205+
test('does not capture subscription_changed (clean no-op)', async () => {
206+
mockAnalyticsIsConfigured.mockReturnValue(false);
207+
208+
await BillingWebhookService.handleSubscriptionUpdated(
209+
_mkStripeSubscription({
210+
items: {
211+
data: [{ price: { id: 'price_growth', metadata: { planId: 'growth' } }, current_period_start: 1700000000 }],
212+
},
213+
}),
214+
_mkEvent({
215+
data: {
216+
previous_attributes: {
217+
items: { data: [{ price: { id: 'price_free', metadata: { planId: 'free' } } }] },
218+
},
219+
},
220+
}),
221+
);
222+
223+
expect(mockAnalyticsCapture).not.toHaveBeenCalled();
224+
});
225+
});
226+
227+
describe('when plan did not change', () => {
228+
test('does not capture subscription_changed', async () => {
229+
// No previous_attributes.items — no plan change detected
230+
await BillingWebhookService.handleSubscriptionUpdated(
231+
_mkStripeSubscription(),
232+
_mkEvent({
233+
data: { previous_attributes: {} },
234+
}),
235+
);
236+
237+
const subscriptionChangedCalls = mockAnalyticsCapture.mock.calls.filter(
238+
([arg]) => arg.event === 'subscription_changed',
239+
);
240+
expect(subscriptionChangedCalls).toHaveLength(0);
241+
});
242+
});
243+
244+
describe('when event is stale (updateIfEventNewer returns null)', () => {
245+
test('does not capture subscription_changed', async () => {
246+
mockSubscriptionRepository.updateIfEventNewer.mockResolvedValue(null);
247+
248+
await BillingWebhookService.handleSubscriptionUpdated(
249+
_mkStripeSubscription(),
250+
_mkEvent(),
251+
);
252+
253+
const subscriptionChangedCalls = mockAnalyticsCapture.mock.calls.filter(
254+
([arg]) => arg.event === 'subscription_changed',
255+
);
256+
expect(subscriptionChangedCalls).toHaveLength(0);
257+
});
258+
});
259+
});

0 commit comments

Comments
 (0)