Skip to content
Merged
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
11 changes: 11 additions & 0 deletions lib/services/analytics.js
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,16 @@ const getFeatureFlagForRequest = async (flag, req, options) =>
const isFeatureEnabledForRequest = async (flag, req, options) =>
isFeatureEnabled(flag, resolveDistinctId(req), options);

/**
* Return whether the PostHog client is currently initialised.
* Use this as a cheap pre-check before building expensive analytics payloads.
* The underlying `capture()` and `track()` methods are already no-ops when
* the client is absent — `isConfigured()` is for callers that want to skip
* payload construction entirely when analytics is not active.
* @returns {boolean}
*/
const isConfigured = () => client !== null;

/**
* Flush pending events and shut down the PostHog client.
* Safe to call even when the client was never initialised.
Expand All @@ -249,6 +259,7 @@ const shutdown = async () => {

export default {
init,
isConfigured,
track,
capture,
identify,
Expand Down
23 changes: 23 additions & 0 deletions modules/billing/services/billing.webhook.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import billingEvents from '../lib/events.js';
import { SENTINEL_PENDING } from '../lib/billing.constants.js';
import { retryWithBackoff } from '../lib/billing.retry.js';
import { isNonTransientStripeError } from '../lib/billing.stripe-errors.js';
import AnalyticsService from '../../../lib/services/analytics.js';

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

// Emit analytics observability event for downstreams running PostHog (no-op otherwise).
// Mirrors the internal billing.plan.changed event but lands in the analytics pipeline.
Comment thread
PierreBrisorgueil marked this conversation as resolved.
if (AnalyticsService.isConfigured()) {
try {
AnalyticsService.capture({
distinctId: organizationId,
event: 'subscription_changed',
source: 'stripe-webhook',
properties: {
previousPlan,
newPlan,
isDowngrade,
},
});
} catch (capErr) {
logger.error('[billing.webhook] analytics capture subscription_changed failed (non-fatal)', {
organizationId,
error: capErr?.message ?? String(capErr),
});
}
}
Comment thread
PierreBrisorgueil marked this conversation as resolved.

// Plan switch mid-cycle = refresh the active week snapshot to the new plan.
// Unlike cron-driven resetWeek, this preserves meterUsed by default so a plan
// change does not refund or double-charge already attributed usage.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
/**
* Module dependencies.
*/
import { jest, describe, test, beforeEach, afterEach, expect } from '@jest/globals';

/**
* Unit tests for subscription_changed analytics emit in billing.webhook.service.js
* - captures when plan changes and AnalyticsService is configured
* - no-op when AnalyticsService.isConfigured() returns false (no PostHog)
* - no capture when plan did not change
*/
describe('billing.webhook.service — subscription_changed analytics emit:', () => {
let BillingWebhookService;
let mockAnalyticsCapture;
let mockAnalyticsIsConfigured;
let mockSubscriptionRepository;
let mockOrganizationRepository;
let mockResetService;
let mockEvents;
let mockStripe;

const orgId = '507f1f77bcf86cd799439011';
const subId = '607f1f77bcf86cd799439022';

const _mkStripeSubscription = (overrides = {}) => ({
id: 'sub_456',
status: 'active',
customer: 'cus_abc',
current_period_start: 1700000000,
current_period_end: 1700000000 + 2592000,
cancel_at_period_end: false,
items: {
data: [{
price: { id: 'price_growth', metadata: { planId: 'growth' } },
current_period_start: 1700000000,
}],
},
...overrides,
});

const _mkEvent = (overrides = {}) => ({
id: 'evt_test_1',
type: 'customer.subscription.updated',
created: 1700000100,
data: { previous_attributes: {} },
...overrides,
});

beforeEach(async () => {
jest.resetModules();

mockAnalyticsCapture = jest.fn();
mockAnalyticsIsConfigured = jest.fn().mockReturnValue(true);

mockSubscriptionRepository = {
findByStripeSubscriptionId: jest.fn().mockResolvedValue({
_id: subId,
organization: orgId,
plan: 'free',
}),
findByStripeCustomerId: jest.fn().mockResolvedValue(null),
create: jest.fn().mockResolvedValue({}),
updateIfEventNewer: jest.fn().mockResolvedValue({ _id: subId }),
};

mockOrganizationRepository = {
setPlan: jest.fn().mockResolvedValue({}),
};

mockResetService = {
resetWeek: jest.fn().mockResolvedValue({}),
forceRotateForPlanChange: jest.fn().mockResolvedValue({}),
};

mockEvents = { emit: jest.fn() };

mockStripe = {
subscriptions: { retrieve: jest.fn() },
};

jest.unstable_mockModule('../../../lib/services/analytics.js', () => ({
default: {
capture: mockAnalyticsCapture,
isConfigured: mockAnalyticsIsConfigured,
},
}));

jest.unstable_mockModule('../repositories/billing.subscription.repository.js', () => ({
default: mockSubscriptionRepository,
}));

jest.unstable_mockModule('../repositories/billing.processedStripeEvent.repository.js', () => ({
default: {
wasProcessed: jest.fn().mockResolvedValue(false),
tryRecord: jest.fn().mockResolvedValue({ recorded: true }),
},
}));

jest.unstable_mockModule('../../organizations/repositories/organizations.repository.js', () => ({
default: mockOrganizationRepository,
}));

jest.unstable_mockModule('../services/billing.extra.service.js', () => ({
default: { creditPack: jest.fn(), refundPartial: jest.fn() },
}));

jest.unstable_mockModule('../services/billing.reset.service.js', () => ({
default: mockResetService,
}));

jest.unstable_mockModule('../lib/stripe.js', () => ({
default: jest.fn(() => mockStripe),
}));

jest.unstable_mockModule('../lib/events.js', () => ({
default: mockEvents,
}));

jest.unstable_mockModule('../../../lib/services/logger.js', () => ({
default: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
}));

jest.unstable_mockModule('../../../config/index.js', () => ({
default: {
billing: {
plans: ['free', 'growth', 'pro'],
meterMode: true,
},
},
}));

jest.unstable_mockModule('mongoose', () => ({
default: {
Types: { ObjectId: { isValid: (id) => /^[a-f\d]{24}$/i.test(id) } },
model: () => ({}),
},
}));

const mod = await import('../services/billing.webhook.service.js');
BillingWebhookService = mod.default;
});

afterEach(() => {
jest.restoreAllMocks();
});

describe('when plan changes and AnalyticsService is configured', () => {
test('captures subscription_changed with previousPlan, newPlan, isDowngrade', async () => {
// existing sub has free plan, event carries growth plan → plan change
await BillingWebhookService.handleSubscriptionUpdated(
_mkStripeSubscription({
items: {
data: [{ price: { id: 'price_growth', metadata: { planId: 'growth' } }, current_period_start: 1700000000 }],
},
}),
_mkEvent({
data: {
previous_attributes: {
items: { data: [{ price: { id: 'price_free', metadata: { planId: 'free' } } }] },
},
},
}),
);

expect(mockAnalyticsIsConfigured).toHaveBeenCalled();
const subscriptionChangedCalls = mockAnalyticsCapture.mock.calls.filter(
([arg]) => arg.event === 'subscription_changed',
);
expect(subscriptionChangedCalls).toHaveLength(1);
const [call] = subscriptionChangedCalls;
expect(call[0].distinctId).toBe(orgId);
expect(call[0].source).toBe('stripe-webhook');
expect(call[0].properties).toMatchObject({
previousPlan: 'free',
newPlan: 'growth',
isDowngrade: false,
});
});

test('marks isDowngrade=true when switching from higher to lower plan', async () => {
await BillingWebhookService.handleSubscriptionUpdated(
_mkStripeSubscription({
items: {
data: [{ price: { id: 'price_free', metadata: { planId: 'free' } }, current_period_start: 1700000000 }],
},
}),
_mkEvent({
data: {
previous_attributes: {
items: { data: [{ price: { id: 'price_pro', metadata: { planId: 'pro' } } }] },
},
},
}),
);

const subscriptionChangedCalls = mockAnalyticsCapture.mock.calls.filter(
([arg]) => arg.event === 'subscription_changed',
);
expect(subscriptionChangedCalls).toHaveLength(1);
expect(subscriptionChangedCalls[0][0].properties.isDowngrade).toBe(true);
});
});

describe('when AnalyticsService is not configured (no PostHog)', () => {
test('does not capture subscription_changed (clean no-op)', async () => {
mockAnalyticsIsConfigured.mockReturnValue(false);

await BillingWebhookService.handleSubscriptionUpdated(
_mkStripeSubscription({
items: {
data: [{ price: { id: 'price_growth', metadata: { planId: 'growth' } }, current_period_start: 1700000000 }],
},
}),
_mkEvent({
data: {
previous_attributes: {
items: { data: [{ price: { id: 'price_free', metadata: { planId: 'free' } } }] },
},
},
}),
);

expect(mockAnalyticsCapture).not.toHaveBeenCalled();
});
});

describe('when plan did not change', () => {
test('does not capture subscription_changed', async () => {
// No previous_attributes.items — no plan change detected
await BillingWebhookService.handleSubscriptionUpdated(
_mkStripeSubscription(),
_mkEvent({
data: { previous_attributes: {} },
}),
);

const subscriptionChangedCalls = mockAnalyticsCapture.mock.calls.filter(
([arg]) => arg.event === 'subscription_changed',
);
expect(subscriptionChangedCalls).toHaveLength(0);
});
});
Comment thread
PierreBrisorgueil marked this conversation as resolved.

describe('when event is stale (updateIfEventNewer returns null)', () => {
test('does not capture subscription_changed', async () => {
mockSubscriptionRepository.updateIfEventNewer.mockResolvedValue(null);

await BillingWebhookService.handleSubscriptionUpdated(
_mkStripeSubscription(),
_mkEvent(),
);

const subscriptionChangedCalls = mockAnalyticsCapture.mock.calls.filter(
([arg]) => arg.event === 'subscription_changed',
);
expect(subscriptionChangedCalls).toHaveLength(0);
});
Comment thread
PierreBrisorgueil marked this conversation as resolved.
});
});
Loading