diff --git a/modules/billing/services/billing.webhook.service.js b/modules/billing/services/billing.webhook.service.js index 6aa3c283f..0987252a5 100644 --- a/modules/billing/services/billing.webhook.service.js +++ b/modules/billing/services/billing.webhook.service.js @@ -508,25 +508,19 @@ 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. + // Mirrors the internal plan.changed event but lands in the analytics pipeline. + // AnalyticsService.capture() swallows its own errors — no outer try/catch needed. 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), - }); - } + AnalyticsService.capture({ + distinctId: organizationId, + event: 'subscription_changed', + source: 'stripe-webhook', + properties: { + previousPlan, + newPlan, + isDowngrade, + }, + }); } // Plan switch mid-cycle = refresh the active week snapshot to the new plan. 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 index c8dfc1a66..e2191d0cd 100644 --- a/modules/billing/tests/billing.webhook.subscription-changed-analytics.unit.tests.js +++ b/modules/billing/tests/billing.webhook.subscription-changed-analytics.unit.tests.js @@ -225,8 +225,8 @@ describe('billing.webhook.service — subscription_changed analytics emit:', () }); describe('when plan did not change', () => { - test('does not capture subscription_changed', async () => { - // No previous_attributes.items — no plan change detected + test('does not capture subscription_changed when previous_attributes.items is absent', async () => { + // No previous_attributes.items — plan-change branch is skipped entirely await BillingWebhookService.handleSubscriptionUpdated( _mkStripeSubscription(), _mkEvent({ @@ -239,15 +239,51 @@ describe('billing.webhook.service — subscription_changed analytics emit:', () ); expect(subscriptionChangedCalls).toHaveLength(0); }); + + test('does not capture subscription_changed when previousPlan === newPlan (same-plan guard)', async () => { + // previous_attributes.items IS present but the plan is the same — the + // `previousPlan !== newPlan` guard should prevent the emit. + 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_growth', metadata: { planId: 'growth' } } }] }, + }, + }, + }), + ); + + 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 () => { + // Fixture includes previous_attributes.items so the stale-event guard is what + // actually prevents capture (not the absence of a plan change). mockSubscriptionRepository.updateIfEventNewer.mockResolvedValue(null); await BillingWebhookService.handleSubscriptionUpdated( - _mkStripeSubscription(), - _mkEvent(), + _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' } } }] }, + }, + }, + }), ); const subscriptionChangedCalls = mockAnalyticsCapture.mock.calls.filter(