Skip to content

fix(billing): resolve plan via priceId map, not price.metadata.planId (#1250)#3743

Merged
PierreBrisorgueil merged 2 commits into
masterfrom
feat/resolveplan-priceid-map
May 31, 2026
Merged

fix(billing): resolve plan via priceId map, not price.metadata.planId (#1250)#3743
PierreBrisorgueil merged 2 commits into
masterfrom
feat/resolveplan-priceid-map

Conversation

@PierreBrisorgueil
Copy link
Copy Markdown
Contributor

Problem (P0 — silent prod downgrade affects every devkit downstream)

customer.subscription.updated was reading price.metadata.planId to resolve the plan. price.metadata.planId is empty in real Stripe webhook payloads — Stripe only populates metadata on the Product object, not on the Price surfaced through subscription update events.

Result: every paid org's plan is silently reset to free on every customer.subscription.updated event. Pierreb, comes, montaine, and ism are all affected in production today (trawl fixed it in comes-io/trawl_node#1250).

Fix

Port trawl's #1250 fix upstream so every downstream gets it via /update-stack:

  • buildPriceIdToPlanMap() — builds Map<priceId, planName> from config.stripe.prices at boot (monthly + annual per plan)
  • Rewritten resolvePlan() — priceId map first → price.metadata.planId legacy fallback → free with warn log
  • cancelAt/cancelAtPeriodEnd sync on customer.subscription.updated — writes fields added in feat(billing): subscription cancelAt + cancelAtPeriodEnd lifecycle fields #3741 (gate task C.1) so UI shows pending cancellation dates; unix-seconds → JS Date conversion
  • previousPlan detection in plan change block now also uses the priceId map
  • Strips trawl-specific analytics.capture('subscription_changed') calls (not generic)

retryWithBackoff + isNonTransientStripeError lib files were already in devkit — unchanged.

Tests

20 new unit tests in billing.webhook.priceid-map.unit.tests.js covering:

  • priceId map resolves monthly + annual variants
  • Unknown priceId → falls back to free
  • Legacy price.metadata.planId fallback still works (test fixtures)
  • priceId map takes priority over metadata
  • cancelAt unix→Date conversion exact value
  • cancelAt=null clears the field
  • Absent cancel_at → no field written
  • handleSubscriptionCreated: existing doc path, stale event, Dashboard/Payment Link path, race E11000 idempotency, no customer mapping skip

All 982 billing unit tests pass (51 suites).

References

Self-review checklist

  • resolvePlan reads from priceId map, NOT from price.metadata.planId
  • buildPriceIdToPlanMap iterates monthly + annual per plan
  • handleSubscriptionCreated race-safe (E11000 idempotency guard)
  • cancelAt/cancelAtPeriodEnd sync writes to fields, converts unix-seconds to Date
  • ZERO trawl-specific analytics calls in the final file
  • ZERO trawl-specific issue numbers in comments
  • 20 new tests covering all new code paths
  • 982/982 billing unit tests pass (51 suites)

…#3742)

Port trawl's #1250 fix upstream: `customer.subscription.updated` was reading
`price.metadata.planId` which is empty in real Stripe webhook payloads (planId
lives on the Product, not the Price). Every paid org's plan was silently reset
to `free` on every Stripe subscription update event.

Fix:
- Add `buildPriceIdToPlanMap()` — builds `Map<priceId, planName>` from
  `config.stripe.prices` at boot (monthly + annual per plan)
- Rewrite `resolvePlan()` — looks up plan via priceId map first; falls back to
  `price.metadata.planId` for legacy test fixtures; final fallback `free` + warn
- Sync `cancelAt`/`cancelAtPeriodEnd` on `customer.subscription.updated` — writes
  the fields added by #3741 so the UI can show pending cancellation dates
- Use priceId map in `previousPlan` detection (plan change block in updated handler)
- Strip trawl-specific `analytics.capture('subscription_changed')` calls (not ported)

Also ports `retryWithBackoff` + `isNonTransientStripeError` patterns (already
in devkit from a prior PR — kept identical to trawl).

Adds 20 unit tests covering: priceId map resolution, annual/monthly variants,
unknown priceId fallback to free, legacy metadata fallback, priority of map over
metadata, cancelAt unix→Date conversion, null cancel clear, absent field guard,
handleSubscriptionCreated race safety (E11000 idempotency), and Dashboard
subscription creation path.

Closes #3742
Copilot AI review requested due to automatic review settings May 31, 2026 17:46
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 31, 2026

Warning

Review limit reached

@PierreBrisorgueil, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 10 minutes and 56 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9395fcc7-39b0-4eb5-a3d4-bcf297766f8f

📥 Commits

Reviewing files that changed from the base of the PR and between 889626a and 49718f9.

📒 Files selected for processing (3)
  • ERRORS.md
  • modules/billing/services/billing.webhook.service.js
  • modules/billing/tests/billing.webhook.priceid-map.unit.tests.js
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/resolveplan-priceid-map

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 31, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 89.96%. Comparing base (889626a) to head (49718f9).

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #3743      +/-   ##
==========================================
+ Coverage   89.92%   89.96%   +0.04%     
==========================================
  Files         148      148              
  Lines        4865     4885      +20     
  Branches     1533     1542       +9     
==========================================
+ Hits         4375     4395      +20     
  Misses        385      385              
  Partials      105      105              
Flag Coverage Δ
integration 60.06% <86.95%> (+0.10%) ⬆️
unit 68.37% <100.00%> (+0.74%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.


Continue to review full report in Codecov by Sentry.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 889626a...49718f9. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR addresses a P0 billing regression where customer.subscription.updated could silently downgrade paid orgs to free because price.metadata.planId is typically absent in real Stripe webhook payloads. It introduces a boot-time priceId→plan lookup from config.stripe.prices, updates webhook handling to use it, and adds unit tests for the new behavior and edge cases.

Changes:

  • Build a priceId→plan reverse lookup from config.stripe.prices and use it in resolvePlan() (with legacy metadata fallback).
  • Sync cancelAt / cancelAtPeriodEnd from Stripe customer.subscription.updated into the subscription record.
  • Add a customer.subscription.created “safety-net” handler and a new unit test suite covering the new/modified paths.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.

File Description
modules/billing/services/billing.webhook.service.js Adds priceId-based plan resolution and extends subscription update handling to persist cancellation scheduling fields; updates plan-change detection to consider priceId.
modules/billing/tests/billing.webhook.priceid-map.unit.tests.js Adds unit coverage for priceId plan resolution, cancelAt conversions, and the subscription.created safety-net logic (including E11000 race handling).

Comment thread modules/billing/services/billing.webhook.service.js
Comment thread modules/billing/services/billing.webhook.service.js
Comment thread modules/billing/services/billing.webhook.service.js
Comment thread modules/billing/services/billing.webhook.service.js
Comment thread modules/billing/tests/billing.webhook.priceid-map.unit.tests.js
@PierreBrisorgueil PierreBrisorgueil merged commit 0693e49 into master May 31, 2026
8 checks passed
PierreBrisorgueil added a commit that referenced this pull request Jun 1, 2026
…+ 4 hardening) (#3758)

* fix(billing): re-apply lost C.2 review nits (warn-on-unknown-priceId + 4 hardening)

5 Copilot review findings from PR #3743 (#1250 P0) were silently lost because
a controller-side fix commit was never pushed to origin before the squash-merge.

Re-applied fixes:
1. JSDoc for buildPriceIdToPlanMap: accurate description with @returns tag
2. resolvePlan: warn log when priceId not in map and metadata empty (operationally
   critical — same silent-free-downgrade shape as the P0 it was meant to prevent)
3. cancel_at truthy check → typeof === 'number' (prevents cancel_at=0 being skipped)
4. previousPlan: validatePlan() before comparison (rejects stale/invalid metadata values)
5. Test comment: corrects misleading "retryWithBackoff setTimeout suppression" claim

Refs: #3743, plan 2026-05-30-trawl-devkit-perfect-alignment.md

* fix(billing): gate resolvePlan warn to metadata-absent case only

Copilot review finding on PR #3758: the previous warn fired whenever
priceId was present and metadata didn't validate, but the log message said
"metadata empty" — misleading when metadata IS present but invalid
(validatePlan already warns in that case → double-warn).

Gate: only emit the warn when rawMeta is also absent (!rawMeta), and update
the message from "metadata empty" to "no metadata" to be precise.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(billing): resolve plan via priceId map, not price.metadata.planId (P0 — silent prod downgrade)

2 participants