Comped member upgrading to the same tier can end up paid with no tier
Issue Summary
removeComplimentarySubscription() can delete a member's tier link for a tier that is backed by an active paid Stripe subscription, leaving the member paid with no tier.
Ghost calls it automatically after every comped→paid upgrade, from both subscription-event-service.js and checkout-session-event-service.js. Both call linkSubscription(), then re-read the member and call removeComplimentarySubscription({id: member.id})` — with no options, so it runs outside the transaction that just wrote the subscription.
Step 2 of that function rebuilds the member's tiers from scratch:
const currentProducts = member.related('products').toJSON();
const filteredProducts = currentProducts.filter(
product => activeSubscriptionProductIds.has(product.id)
);
if (filteredProducts.length !== currentProducts.length) {
await this._Member.edit({products: filteredProducts}, {id});
}
If activeSubscriptionProductIds comes back empty, filteredProducts is [] and the call removes every tier — including the one the member is paying for. status is not touched, so the member stays paid and nothing corrects it afterwards.
It also calls this._Member.edit() directly instead of going through update(), so it skips both the deleteProductWithActiveSubscription guard, which exists to prevent exactly this, and the members_product_events write. The removal leaves no trace in the member's activity.
The case that bites is a member comped on the same tier they then buy. For an upgrade to a different tier the filter returns a non-empty set and the function does the right thing. When the comp tier and the purchased tier match, an empty read is the difference between "no change" and "delete the tier they're paying for".
Hit this on a production site: one member, comped on a tier since 2025, bought the same tier in August, now has an active subscription and no members_products
Expected: the member keeps the tier their subscription pays for. Actual: the member is paid with no tier. Admin shows "Paid" with a blank tier, and the Admin API omits the member-level tier key while subscriptions[].price.tier still resolves.
Reachable as a unit test by having stripeSubscriptions resolve empty while products is non-empty.
Steps to Reproduce
- Create a paid tier with a Stripe price.
- Give a member a complimentary subscription on that same tier.
- Have them buy a paid subscription for it.
- If member.related('stripeSubscriptions') resolves empty when step 2 reads, the tier link is deleted.
Ghost Version
6.64.0
Node.js Version
22 LTS
How did you install Ghost?
Magic Pages
Database type
MySQL 8
Browser & OS version
No response
Relevant log / error output
Code of Conduct
- I agree to be friendly and polite to people in this repository
Source: TryGhost/Ghost