Skip to content

Commit c5066aa

Browse files
authored
🐛 Improved checks for comped subs in member creation flow (TryGhost#27778)
ref INC-280 Fixes comped member creation so Ghost only reuses zero-value Stripe prices that are actually marked as `Complimentary`. Previously, `setComplimentarySubscription()` selected the first zero-value price on the default paid tier. If a site had another $0 price, Ghost could create the comped subscription with that price and then misclassify the member because later logic only treats `Complimentary` as comped.
1 parent f142986 commit c5066aa

2 files changed

Lines changed: 105 additions & 8 deletions

File tree

ghost/core/core/server/services/members/members-api/repositories/member-repository.js

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1954,15 +1954,15 @@ module.exports = class MemberRepository {
19541954
});
19551955
}
19561956

1957-
const zeroValuePrices = defaultProduct.stripePrices.filter((price) => {
1958-
return price.amount === 0;
1957+
const complimentaryPrices = defaultProduct.stripePrices.filter((price) => {
1958+
return price.amount === 0 && this.isComplimentaryPlanNickname(price.nickname);
19591959
});
19601960

19611961
if (activeSubscriptions.length) {
19621962
for (const subscription of activeSubscriptions) {
19631963
const price = await subscription.related('stripePrice').fetch(options);
19641964

1965-
let zeroValuePrice = zeroValuePrices.find((p) => {
1965+
let zeroValuePrice = complimentaryPrices.find((p) => {
19661966
return p.currency.toLowerCase() === price.get('currency').toLowerCase();
19671967
});
19681968

@@ -1980,9 +1980,14 @@ module.exports = class MemberRepository {
19801980
}]
19811981
}, options)).toJSON();
19821982
zeroValuePrice = product.stripePrices.find((p) => {
1983-
return p.currency.toLowerCase() === price.get('currency').toLowerCase() && p.amount === 0;
1983+
return p.currency.toLowerCase() === price.get('currency').toLowerCase() && p.amount === 0 && this.isComplimentaryPlanNickname(p.nickname);
19841984
});
1985-
zeroValuePrices.push(zeroValuePrice);
1985+
if (!zeroValuePrice) {
1986+
throw new errors.NotFoundError({
1987+
message: `Failed to locate a complimentary (zero-amount, nickname matched by isComplimentaryPlanNickname) Stripe price for currency "${price.get('currency')}" on product ${product.id} after update. Returned stripePrices: ${JSON.stringify(product.stripePrices)}`
1988+
});
1989+
}
1990+
complimentaryPrices.push(zeroValuePrice);
19861991
}
19871992

19881993
const stripeSubscription = await this._stripeAPIService.getSubscription(
@@ -2014,7 +2019,7 @@ module.exports = class MemberRepository {
20142019
name: stripeCustomer.name
20152020
}, sharedOptions);
20162021

2017-
let zeroValuePrice = zeroValuePrices[0];
2022+
let zeroValuePrice = complimentaryPrices[0];
20182023

20192024
if (!zeroValuePrice) {
20202025
const product = (await this._productRepository.update({
@@ -2030,9 +2035,14 @@ module.exports = class MemberRepository {
20302035
}]
20312036
}, sharedOptions)).toJSON();
20322037
zeroValuePrice = product.stripePrices.find((price) => {
2033-
return price.currency.toLowerCase() === 'usd' && price.amount === 0;
2038+
return price.currency.toLowerCase() === 'usd' && price.amount === 0 && this.isComplimentaryPlanNickname(price.nickname);
20342039
});
2035-
zeroValuePrices.push(zeroValuePrice);
2040+
if (!zeroValuePrice) {
2041+
throw new errors.NotFoundError({
2042+
message: `Failed to locate a complimentary (zero-amount, nickname matched by isComplimentaryPlanNickname) Stripe price for currency "USD" on product ${product.id} after update. Returned stripePrices: ${JSON.stringify(product.stripePrices)}`
2043+
});
2044+
}
2045+
complimentaryPrices.push(zeroValuePrice);
20362046
}
20372047

20382048
const subscription = await this._stripeAPIService.createSubscription(

ghost/core/test/unit/server/services/members/members-api/repositories/member-repository.test.js

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,93 @@ describe('MemberRepository', function () {
143143
assert.equal(err.message, 'Could not find Product "default"');
144144
}
145145
});
146+
147+
it('ignores non-complimentary zero-value prices when creating a complimentary subscription', async function () {
148+
const member = {
149+
id: 'member_id_123',
150+
get: sinon.stub().withArgs('email').returns('member@example.com'),
151+
related: () => {
152+
return {
153+
fetch: sinon.stub().resolves({
154+
models: []
155+
})
156+
};
157+
}
158+
};
159+
Member.findOne.resolves(member);
160+
161+
const activeSubscriptionPrice = {
162+
stripe_price_id: 'price_active_subscription',
163+
nickname: 'Active Subscription',
164+
currency: 'usd',
165+
amount: 0
166+
};
167+
const complimentaryPrice = {
168+
stripe_price_id: 'price_complimentary',
169+
nickname: 'Complimentary',
170+
currency: 'usd',
171+
amount: 0
172+
};
173+
174+
productRepository = {
175+
getDefaultProduct: sinon.stub().resolves({
176+
toJSON: () => {
177+
return {
178+
id: 'product_id_123',
179+
name: 'Default tier',
180+
description: null,
181+
stripePrices: [activeSubscriptionPrice]
182+
};
183+
}
184+
}),
185+
update: sinon.stub().resolves({
186+
toJSON: () => {
187+
return {
188+
stripePrices: [
189+
activeSubscriptionPrice,
190+
complimentaryPrice
191+
]
192+
};
193+
}
194+
})
195+
};
196+
197+
const stripeAPIService = {
198+
configured: true,
199+
createCustomer: sinon.stub().resolves({
200+
id: 'cus_123',
201+
email: 'member@example.com',
202+
name: null
203+
}),
204+
createSubscription: sinon.stub().resolves({
205+
id: 'sub_123',
206+
customer: 'cus_123'
207+
})
208+
};
209+
210+
const StripeCustomer = {
211+
upsert: sinon.stub().resolves()
212+
};
213+
214+
const repo = new MemberRepository({
215+
Member,
216+
StripeCustomer,
217+
stripeAPIService,
218+
productRepository,
219+
OfferRedemption: mockOfferRedemption
220+
});
221+
sinon.stub(repo, 'linkSubscription').resolves();
222+
223+
await repo.setComplimentarySubscription({
224+
id: 'member_id_123'
225+
}, {
226+
transacting: true
227+
});
228+
229+
sinon.assert.calledOnce(productRepository.update);
230+
sinon.assert.calledWith(stripeAPIService.createSubscription, 'cus_123', 'price_complimentary');
231+
sinon.assert.neverCalledWith(stripeAPIService.createSubscription, 'cus_123', 'price_active_subscription');
232+
});
146233
});
147234

148235
describe('newsletter subscriptions', function () {

0 commit comments

Comments
 (0)