diff --git a/lib/services/analytics.js b/lib/services/analytics.js index 69d73ebe9..acdb91722 100644 --- a/lib/services/analytics.js +++ b/lib/services/analytics.js @@ -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. @@ -249,6 +259,7 @@ const shutdown = async () => { export default { init, + isConfigured, track, capture, identify, diff --git a/modules/billing/services/billing.webhook.service.js b/modules/billing/services/billing.webhook.service.js index cdbe741b0..6aa3c283f 100644 --- a/modules/billing/services/billing.webhook.service.js +++ b/modules/billing/services/billing.webhook.service.js @@ -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 @@ -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. + 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), + }); + } + } + // 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. diff --git a/modules/billing/tests/billing.webhook.subscription-changed-analytics.unit.tests.js b/modules/billing/tests/billing.webhook.subscription-changed-analytics.unit.tests.js new file mode 100644 index 000000000..c8dfc1a66 --- /dev/null +++ b/modules/billing/tests/billing.webhook.subscription-changed-analytics.unit.tests.js @@ -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); + }); + }); + + 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); + }); + }); +});