You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix(billing): resolve Stripe plan via the priceId map in admin sync and reconcile (#3969)
resolveStripePlan in billing.admin.service.js and billing.reconcile.service.js
read only price/plan.metadata.planId and fell back to 'free' — the exact defect
config.stripe.prices, never propagated here. Real Stripe Price objects carry no
metadata.planId, so:
- syncOrgFromStripe (a DB write) could silently downgrade a paying org to free
on the very tool ops runs to resolve a billing divergence.
- runReconciliation (log-only) emitted a false planMismatch alert for every
paid org, drowning real divergences.
Extracted the webhook's resolvePlan/buildPriceIdToPlanMap into a shared
modules/billing/lib/billing.planResolver.js so all three call sites resolve via
ONE implementation. The shared resolver returns null (never 'free') when
unresolved; the admin path now ABORTS the write (409) instead of guessing, and
the reconcile path skips the plan comparison instead of alerting.
Replaced the unrealistic metadata.planId-only test fixtures with priceId-based
ones matching real Stripe payloads, and added regression coverage: a paid
subscription with no metadata.planId resolves correctly in both admin and
reconcile, and admin sync never writes 'free' for it.
Closes#3964
Claude-Session: https://claude.ai/code/session_01WfNC8bt1TgL4AsiYgCEGup
Copy file name to clipboardExpand all lines: ERRORS.md
+1Lines changed: 1 addition & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -31,3 +31,4 @@ Use this file as a compact memory of recurring AI mistakes.
31
31
-[2026-06-04] repository: top-level `const Foo = mongoose.model('Foo')` in a repository file -> this is evaluated at import time; safe in an HTTP server (loadModels() runs first) but silently crashes standalone scripts (crons, migrations) with `MissingSchemaError` when import order differs; tests miss it because jest mocks intercept the module entirely; fix = lazy getter `const Foo = () => mongoose.model('Foo')` (call sites: `Foo().find(...)`) or dynamic import after `loadModels()` in the entrypoint; see pierreb-devkit/Node#3789
32
32
-[2026-06-15] deps/audit: leaving `npm audit` advisories unaddressed on the assumption they need a major bump -> run `npm audit fix` (never `--force`) first; the runtime-tree DoS/ReDoS items (`qs`, `path-to-regexp`, `brace-expansion`) all fixed via in-range bumps, no residual. These are DoS-class but NOT attacker-reachable in this stack: Express route patterns are static (no user-controlled `path-to-regexp` input) and `qs`/`brace-expansion` only parse server-side query strings under fixed code paths — still bump them to keep the tree clean and avoid scanner noise.
33
33
-[2026-07-16] security: `users.repository.js findByIdAndUpdatePopulated()` does `.populate()` with no `.select()`, so `organizations.controller.js switchOrganization` serialized the raw doc (password hash + OAuth tokens + reset/verification tokens) straight to the client; `users.account.controller.js me()` separately forwarded `providerData` (OAuth tokens) verbatim -> any endpoint returning a Mongoose user doc must go through `UserService.removeSensitive()` (whitelist, `modules/users/utils/sanitizeUser.js`) at the response boundary, never serialize `req.user`/a populated doc directly; a local-signup fixture's `providerData` defaults to `{}` so a naive falsy check won't catch this — seed a fake OAuth token in tests to prove the leak is actually closed; see pierreb-devkit/Node#3963
34
+
-[2026-07-16] billing/stripe: the #3742 priceId-map fix (`resolvePlan`/`buildPriceIdToPlanMap`) was applied only to the webhook handler; `billing.admin.service.js``resolveStripePlan` (admin force-sync, a DB WRITE path) and `billing.reconcile.service.js``resolveStripePlan` (LOG-ONLY divergence check) kept their own copies reading only `metadata.planId` -> a paid org's Stripe subscription (which never carries `metadata.planId`) resolved to `'free'` on admin sync (silently downgrading a paying org) and produced a false `planMismatch` alert on every reconcile run; fix = one shared resolver (`modules/billing/lib/billing.planResolver.js`) used by all three call sites, and a null-unresolved sentinel (never guess `'free'`) so the admin write path ABORTS instead of downgrading and the reconcile log path skips the comparison instead of alerting; see pierreb-devkit/Node#3964
0 commit comments