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
30 changes: 12 additions & 18 deletions modules/billing/services/billing.webhook.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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(
Expand Down
Loading