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
1 change: 1 addition & 0 deletions ERRORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,4 @@ Use this file as a compact memory of recurring AI mistakes.
- [2026-03-15] cross-stack: changing a Node API without checking Vue E2E tests -> when modifying an endpoint Vue consumes, run Vue E2E tests before pushing
- [2026-03-15] pr scope: batching multiple unrelated fixes in one PR -> one fix = one PR to isolate blast radius and reduce iteration loops
- [2026-05-05] repository: Repository.update(doc) doing `new Model(doc).save()` rewrites the full document from in-memory state, silently clobbering any concurrent partial update that landed after the read -> always use `Model.updateOne({ _id }, { $set: ... })` or `findOneAndUpdate({ _id }, { $set: ... })` for partial updates to avoid race conditions; see comes-io/trawl_node#1115 comes-io/trawl_node#1116 comes-io/trawl_node#1118 + pierreb-devkit/Node#3605
- [2026-05-31] billing/stripe: reading `price.metadata.planId` in `customer.subscription.updated` webhook handler -> field is EMPTY in real Stripe webhook payloads (planId lives on the Product, not the Price); use a `priceId → plan` map built at boot from `config.stripe.prices` instead; see pierreb-devkit/Node#3742
52 changes: 47 additions & 5 deletions modules/billing/services/billing.webhook.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,17 +57,45 @@ const validatePlan = (plan) => {
*/
const planRanks = Object.fromEntries((config.billing?.plans || []).map((p, i) => [p, i]));

/**
* Reverse-map from Stripe price ID → plan name, built from config.stripe.prices at boot.
* config.stripe.prices = { growth: { monthly: 'price_xxx', annual: 'price_yyy' }, pro: { ... } }
* This avoids relying on price.metadata.planId (empty on Stripe webhook payloads — planId
* lives on the Product, not the Price) and avoids a Stripe API call per webhook.
*
* Fix: resolvePlan no longer reads price.metadata (always empty in real Stripe webhook payloads).
*/
const buildPriceIdToPlanMap = () => {
const map = {};
const stripePrices = config.stripe?.prices || {};
for (const [planId, intervals] of Object.entries(stripePrices)) {
if (!validPlans.has(planId)) continue;
if (intervals.monthly) map[intervals.monthly] = planId;
if (intervals.annual) map[intervals.annual] = planId;
}
return map;
};
Comment thread
PierreBrisorgueil marked this conversation as resolved.
const priceIdToPlan = buildPriceIdToPlanMap();

/**
* @description Resolve the plan name from a Stripe subscription object.
* In webhook payloads, price.product is typically a string ID (not expanded),
* so we check price.metadata first, then fall back to plan.metadata.
* Strategy (most-specific first):
* 1. config price-ID map (price_xxx → planId) — robust, no metadata dependency.
* 2. price.metadata.planId legacy fallback (works only if metadata was explicitly set).
* 3. plan.metadata.planId further legacy fallback.
* 4. 'free' when nothing resolves.
* @param {Object} subscription - Stripe subscription object
* @returns {string} plan name
*/
const resolvePlan = (subscription) => {
const item = subscription.items?.data?.[0];
const raw = item?.price?.metadata?.planId || item?.plan?.metadata?.planId;
return validatePlan(raw) || 'free';
const priceId = item?.price?.id;
if (priceId && priceIdToPlan[priceId]) {
return priceIdToPlan[priceId];
}
// Legacy fallback: price metadata set explicitly (e.g. test fixtures or manual Stripe edits)
Comment thread
PierreBrisorgueil marked this conversation as resolved.
const rawMeta = item?.price?.metadata?.planId || item?.plan?.metadata?.planId;
return validatePlan(rawMeta) || 'free';
};

/**
Expand Down Expand Up @@ -386,6 +414,18 @@ const handleSubscriptionUpdated = async (subscription, event) => {
status: subscription.status,
};
if (newPeriodStart) fields.currentPeriodStart = newPeriodStart;
// Pending cancellation — record cancel_at_period_end flag and cancel_at date
// so the UI can show the user when their plan actually ends instead of treating
// it as an immediate downgrade. Does NOT change `plan` — subscription stays on
// the current plan until cancel_at.
if (typeof subscription.cancel_at_period_end === 'boolean') {
fields.cancelAtPeriodEnd = subscription.cancel_at_period_end;
}
if (subscription.cancel_at) {
fields.cancelAt = new Date(subscription.cancel_at * 1000);
} else if (subscription.cancel_at === null) {
fields.cancelAt = null;
}
Comment thread
PierreBrisorgueil marked this conversation as resolved.

const updated = await SubscriptionRepository.updateIfEventNewer(String(existing._id), event.created, event.id, fields, 'subscription');
if (!updated) {
Expand Down Expand Up @@ -423,7 +463,9 @@ const handleSubscriptionUpdated = async (subscription, event) => {
const previousItems = event?.data?.previous_attributes?.items?.data;
let planChangeResetTriggered = false;
if (previousItems) {
const previousPlan = previousItems[0]?.price?.metadata?.planId
const previousPriceId = previousItems[0]?.price?.id;
const previousPlan = (previousPriceId && priceIdToPlan[previousPriceId])
|| previousItems[0]?.price?.metadata?.planId
|| previousItems[0]?.plan?.metadata?.planId
|| null;
if (previousPlan && previousPlan !== newPlan) {
Comment thread
PierreBrisorgueil marked this conversation as resolved.
Expand Down
Loading
Loading