fix(billing): validate webhook plan metadata#3284
Conversation
The checkout.session.completed webhook was trusting metadata.plan blindly, which caused 500s when Stripe product IDs leaked into that field. Add validatePlan() helper that checks against config.billing.plans and apply it to both handleCheckoutCompleted and resolvePlan. Invalid values now fall back to 'free' instead of propagating to the database. Also adds 14 integration tests covering all four webhook handlers.
WalkthroughAdded a config-driven plan validation whitelist to billing webhook handlers. Updated plan resolution for checkout completion and subscription sync to validate plan identifiers against allowed plans and fall back to Changes
Estimated code review effort🎯 2 (Simple) | ⏱️ ~12 minutes Possibly related PRs
Suggested labels
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
modules/billing/services/billing.webhook.service.js (1)
117-125: 🧹 Nitpick | 🔵 TrivialConsider validating
previousPlanfor consistency.Unlike
newPlanwhich is validated viaresolvePlan,previousPlanis used raw fromprevious_attributes. If Stripe sends an invalid value like'prod_ABC123'as the previous plan, it will be emitted in theplan.changedevent withisDowngrade: null.This won't cause 500 errors, but downstream event consumers may receive unexpected plan values.
♻️ Optional: Validate previousPlan
const previousPlan = previousItems[0]?.price?.metadata?.planId || previousItems[0]?.plan?.metadata?.planId || null; - if (previousPlan && previousPlan !== newPlan) { + const validatedPreviousPlan = validatePlan(previousPlan); + if (validatedPreviousPlan && validatedPreviousPlan !== newPlan) { const prevRank = planRanks[previousPlan];🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@modules/billing/services/billing.webhook.service.js` around lines 117 - 125, Validate the raw previousPlan before using it: extract previousPlan from event.data.previous_attributes.items.data as you already do, then call the same resolver (e.g., resolvePlan) or check planRanks to map/normalize it (create previousResolvedPlan or similar), and use that normalized value in the downgrade logic (prevRank = planRanks[previousResolvedPlan]) so invalid values like 'prod_ABC123' become null/ignored rather than emitted downstream; update any uses of previousPlan, prevRank and isDowngrade to rely on the resolved/validated value.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@modules/billing/tests/billing.webhook.integration.tests.js`:
- Around line 61-66: Add a test that calls
WebhookService.handleSubscriptionUpdated with a subscription payload whose
current items.price.metadata.planId differs from
previous_attributes.items.price.metadata.planId, mocking
mockSubscriptionRepository.findByStripeSubscriptionId and update as needed, then
import the events module (../lib/events.js) and assert its default.emit was
called with 'plan.changed' and a payload containing organizationId (from the
found subscription), previousPlan, newPlan, and isDowngrade boolean; reference
the handleSubscriptionUpdated entrypoint,
mockSubscriptionRepository.findByStripeSubscriptionId/mockSubscriptionRepository.update,
and the events module's default.emit when adding the test.
---
Outside diff comments:
In `@modules/billing/services/billing.webhook.service.js`:
- Around line 117-125: Validate the raw previousPlan before using it: extract
previousPlan from event.data.previous_attributes.items.data as you already do,
then call the same resolver (e.g., resolvePlan) or check planRanks to
map/normalize it (create previousResolvedPlan or similar), and use that
normalized value in the downgrade logic (prevRank =
planRanks[previousResolvedPlan]) so invalid values like 'prod_ABC123' become
null/ignored rather than emitted downstream; update any uses of previousPlan,
prevRank and isDowngrade to rely on the resolved/validated value.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 58a2b2aa-8357-4e02-bce3-da6a7c5eaf6e
📒 Files selected for processing (2)
modules/billing/services/billing.webhook.service.jsmodules/billing/tests/billing.webhook.integration.tests.js
Summary
validatePlan()helper that checks plan values againstconfig.billing.plansbefore using themhandleCheckoutCompleted(metadata.plan) andresolvePlan(subscription items metadata)prod_ABC123) now fall back to'free'instead of causing 500 errorshandleCheckoutCompleted,handleSubscriptionUpdated,handleSubscriptionDeleted,handleInvoicePaymentFailed)Context
QA testing found that
checkout.session.completedreturned 500 whenmetadata.plancontained Stripe product IDs instead of plan enum values. This was fixed upstream by addingmetadata.planIdto Stripe Products, but the webhook code needs to be resilient to invalid values.Test plan
Summary by CodeRabbit
Bug Fixes
Chores