mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-28 14:03:48 +03:00
Merged v5.59.4 into main
This commit is contained in:
commit
cebe8d7db6
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ghost-admin",
|
||||
"version": "5.59.3",
|
||||
"version": "5.59.4",
|
||||
"description": "Ember.js admin client for Ghost",
|
||||
"author": "Ghost Foundation",
|
||||
"homepage": "http://ghost.org",
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ghost",
|
||||
"version": "5.59.3",
|
||||
"version": "5.59.4",
|
||||
"description": "The professional publishing platform",
|
||||
"author": "Ghost Foundation",
|
||||
"homepage": "https://ghost.org",
|
||||
|
File diff suppressed because it is too large
Load Diff
818
ghost/core/test/e2e-api/admin/members-edit-subscriptions.test.js
Normal file
818
ghost/core/test/e2e-api/admin/members-edit-subscriptions.test.js
Normal file
@ -0,0 +1,818 @@
|
||||
const {agentProvider, mockManager, fixtureManager, matchers} = require('../../utils/e2e-framework');
|
||||
const {anyContentVersion, anyEtag, anyObjectId, anyUuid, anyISODateTime, anyString, anyArray} = matchers;
|
||||
const testUtils = require('../../utils');
|
||||
const assert = require('assert/strict');
|
||||
const models = require('../../../core/server/models');
|
||||
const {stripeMocker} = require('../../utils/e2e-framework-mock-manager');
|
||||
const DomainEvents = require('@tryghost/domain-events/lib/DomainEvents');
|
||||
|
||||
const subscriptionSnapshot = {
|
||||
id: anyString,
|
||||
start_date: anyString,
|
||||
current_period_end: anyString,
|
||||
price: {
|
||||
id: anyString,
|
||||
price_id: anyObjectId,
|
||||
tier: {
|
||||
id: anyString,
|
||||
tier_id: anyObjectId
|
||||
}
|
||||
},
|
||||
plan: {
|
||||
id: anyString
|
||||
},
|
||||
customer: {
|
||||
id: anyString
|
||||
}
|
||||
};
|
||||
|
||||
const tierSnapshot = {
|
||||
id: anyObjectId,
|
||||
created_at: anyISODateTime,
|
||||
updated_at: anyISODateTime,
|
||||
monthly_price_id: anyString,
|
||||
yearly_price_id: anyString
|
||||
};
|
||||
|
||||
const subscriptionSnapshotWithTier = {
|
||||
...subscriptionSnapshot,
|
||||
tier: tierSnapshot
|
||||
};
|
||||
|
||||
describe('Members API: edit subscriptions', function () {
|
||||
let agent;
|
||||
|
||||
before(async function () {
|
||||
agent = await agentProvider.getAdminAPIAgent();
|
||||
await fixtureManager.init('posts', 'members', 'tiers:extra');
|
||||
await agent.loginAsOwner();
|
||||
});
|
||||
|
||||
beforeEach(function () {
|
||||
mockManager.mockStripe();
|
||||
mockManager.mockMail();
|
||||
});
|
||||
|
||||
afterEach(async function () {
|
||||
await mockManager.restore();
|
||||
});
|
||||
|
||||
it('Can cancel a subscription', async function () {
|
||||
const memberId = testUtils.DataGenerator.Content.members[1].id;
|
||||
|
||||
// Get the stripe price ID of the default price for month
|
||||
const price = await stripeMocker.getPriceForTier('default-product', 'year');
|
||||
|
||||
const res = await agent
|
||||
.post(`/members/${memberId}/subscriptions/`)
|
||||
.body({
|
||||
stripe_price_id: price.id
|
||||
})
|
||||
.expectStatus(200)
|
||||
.matchBodySnapshot({
|
||||
members: new Array(1).fill({
|
||||
id: anyObjectId,
|
||||
uuid: anyUuid,
|
||||
created_at: anyISODateTime,
|
||||
updated_at: anyISODateTime,
|
||||
labels: anyArray,
|
||||
subscriptions: [subscriptionSnapshotWithTier],
|
||||
newsletters: anyArray,
|
||||
tiers: [tierSnapshot]
|
||||
})
|
||||
})
|
||||
.matchHeaderSnapshot({
|
||||
'content-version': anyContentVersion,
|
||||
etag: anyEtag
|
||||
});
|
||||
|
||||
const subscriptionId = res.body.members[0].subscriptions[0].id;
|
||||
|
||||
const editRes = await agent
|
||||
.put(`/members/${memberId}/subscriptions/${subscriptionId}`)
|
||||
.body({
|
||||
status: 'canceled'
|
||||
})
|
||||
.expectStatus(200)
|
||||
.matchBodySnapshot({
|
||||
members: new Array(1).fill({
|
||||
id: anyObjectId,
|
||||
uuid: anyUuid,
|
||||
created_at: anyISODateTime,
|
||||
updated_at: anyISODateTime,
|
||||
labels: anyArray,
|
||||
subscriptions: [subscriptionSnapshot],
|
||||
newsletters: anyArray,
|
||||
tiers: []
|
||||
})
|
||||
})
|
||||
.matchHeaderSnapshot({
|
||||
'content-version': anyContentVersion,
|
||||
etag: anyEtag
|
||||
});
|
||||
|
||||
assert.equal('canceled', editRes.body.members[0].subscriptions[0].status);
|
||||
});
|
||||
|
||||
it('Can cancel a subscription for a member with both comped and paid subscriptions', async function () {
|
||||
const email = 'comped-paid-combination@example.com';
|
||||
|
||||
// Create this member with a comped product
|
||||
let member = await models.Member.add({
|
||||
email,
|
||||
email_disabled: false,
|
||||
products: [
|
||||
{
|
||||
slug: 'gold'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
member = await models.Member.findOne({email}, {require: true, withRelated: ['products', 'stripeCustomers', 'stripeSubscriptions']});
|
||||
|
||||
assert.equal(member.related('stripeCustomers').length, 0);
|
||||
assert.equal(member.related('stripeSubscriptions').length, 0);
|
||||
assert.equal(member.related('products').length, 1, 'This member should have one product');
|
||||
|
||||
// Subscribe this to a paid product
|
||||
const customer1 = stripeMocker.createCustomer({
|
||||
email
|
||||
});
|
||||
const price1 = await stripeMocker.getPriceForTier('default-product', 'month');
|
||||
const subscription1 = await stripeMocker.createSubscription({
|
||||
customer: customer1,
|
||||
price: price1
|
||||
});
|
||||
await DomainEvents.allSettled();
|
||||
|
||||
member = await models.Member.findOne({email}, {require: true, withRelated: ['products', 'stripeCustomers', 'stripeSubscriptions']});
|
||||
|
||||
// Assert this member is subscribed to two products
|
||||
assert.equal(member.related('stripeCustomers').length, 1);
|
||||
assert.equal(member.related('stripeSubscriptions').length, 1);
|
||||
assert.equal(member.related('products').length, 2, 'This member should have two products');
|
||||
assert.deepEqual(member.related('products').models.map(m => m.get('slug')).sort(), ['default-product', 'gold']);
|
||||
|
||||
// Cancel the paid subscription at period end
|
||||
// Now update one of those subscriptions immediately
|
||||
await agent
|
||||
.put(`/members/${member.id}/subscriptions/${subscription1.id}`)
|
||||
.body({
|
||||
cancel_at_period_end: true // = just an update, the subscription should remain active until it is ended
|
||||
})
|
||||
.expectStatus(200)
|
||||
.matchBodySnapshot({
|
||||
members: new Array(1).fill({
|
||||
id: anyObjectId,
|
||||
uuid: anyUuid,
|
||||
created_at: anyISODateTime,
|
||||
updated_at: anyISODateTime,
|
||||
labels: anyArray,
|
||||
subscriptions: anyArray,
|
||||
newsletters: anyArray,
|
||||
tiers: anyArray
|
||||
})
|
||||
})
|
||||
.matchHeaderSnapshot({
|
||||
'content-version': anyContentVersion,
|
||||
etag: anyEtag
|
||||
});
|
||||
|
||||
// Assert products didn't change
|
||||
member = await models.Member.findOne({email}, {require: true, withRelated: ['products', 'stripeCustomers', 'stripeSubscriptions']});
|
||||
assert.equal(member.related('stripeCustomers').length, 1);
|
||||
assert.equal(member.related('stripeSubscriptions').length, 1);
|
||||
assert.equal(member.related('products').length, 2, 'This member should have two products');
|
||||
assert.deepEqual(member.related('products').models.map(m => m.get('slug')).sort(), ['default-product', 'gold']);
|
||||
|
||||
// Now cancel for real
|
||||
await agent
|
||||
.put(`/members/${member.id}/subscriptions/${subscription1.id}`)
|
||||
.body({
|
||||
status: 'canceled'
|
||||
})
|
||||
.expectStatus(200)
|
||||
.matchBodySnapshot({
|
||||
members: new Array(1).fill({
|
||||
id: anyObjectId,
|
||||
uuid: anyUuid,
|
||||
created_at: anyISODateTime,
|
||||
updated_at: anyISODateTime,
|
||||
labels: anyArray,
|
||||
subscriptions: anyArray,
|
||||
newsletters: anyArray,
|
||||
tiers: anyArray
|
||||
})
|
||||
})
|
||||
.matchHeaderSnapshot({
|
||||
'content-version': anyContentVersion,
|
||||
etag: anyEtag
|
||||
});
|
||||
|
||||
// Assert product is removed, but comped is maintained
|
||||
member = await models.Member.findOne({email}, {require: true, withRelated: ['products', 'stripeCustomers', 'stripeSubscriptions']});
|
||||
assert.equal(member.related('stripeCustomers').length, 1);
|
||||
assert.equal(member.related('stripeSubscriptions').length, 1);
|
||||
assert.equal(member.related('products').length, 1, 'This member should have one product');
|
||||
assert.deepEqual(member.related('products').models.map(m => m.get('slug')).sort(), ['gold']);
|
||||
});
|
||||
|
||||
it('Can cancel a subscription for a member with duplicate customers', async function () {
|
||||
const email = 'duplicate-customers-test@example.com';
|
||||
|
||||
// We create duplicate customers to mimick a situation where a member is connected to two customers
|
||||
const customer1 = stripeMocker.createCustomer({
|
||||
email
|
||||
});
|
||||
|
||||
const customer2 = stripeMocker.createCustomer({
|
||||
email
|
||||
});
|
||||
|
||||
const price1 = await stripeMocker.getPriceForTier('default-product', 'month');
|
||||
const price2 = await stripeMocker.getPriceForTier('gold', 'year');
|
||||
|
||||
const subscription1 = await stripeMocker.createSubscription({
|
||||
customer: customer1,
|
||||
price: price1
|
||||
});
|
||||
|
||||
const subscription2 = await stripeMocker.createSubscription({
|
||||
customer: customer2,
|
||||
price: price2
|
||||
});
|
||||
|
||||
await DomainEvents.allSettled();
|
||||
|
||||
let member = await models.Member.findOne({email}, {require: true, withRelated: ['products', 'stripeCustomers', 'stripeSubscriptions']});
|
||||
|
||||
// Assert this member is subscribed to two products
|
||||
assert.equal(member.related('stripeCustomers').length, 2);
|
||||
assert.equal(member.related('stripeSubscriptions').length, 2);
|
||||
assert.equal(member.related('products').length, 2, 'This member should have two products');
|
||||
|
||||
// Now cancel one of those subscriptions immediately
|
||||
await agent
|
||||
.put(`/members/${member.id}/subscriptions/${subscription2.id}`)
|
||||
.body({
|
||||
status: 'canceled'
|
||||
})
|
||||
.expectStatus(200)
|
||||
.matchBodySnapshot({
|
||||
members: new Array(1).fill({
|
||||
id: anyObjectId,
|
||||
uuid: anyUuid,
|
||||
created_at: anyISODateTime,
|
||||
updated_at: anyISODateTime,
|
||||
labels: anyArray,
|
||||
subscriptions: anyArray,
|
||||
newsletters: anyArray,
|
||||
tiers: anyArray
|
||||
})
|
||||
})
|
||||
.matchHeaderSnapshot({
|
||||
'content-version': anyContentVersion,
|
||||
etag: anyEtag
|
||||
});
|
||||
|
||||
// Update member
|
||||
member = await models.Member.findOne({email}, {require: true, withRelated: ['products', 'stripeCustomers', 'stripeSubscriptions']});
|
||||
|
||||
// Assert this member is subscribed to one products
|
||||
assert.equal(member.related('stripeCustomers').length, 2);
|
||||
assert.equal(member.related('stripeSubscriptions').length, 2);
|
||||
assert.equal(member.related('products').length, 1, 'This member should only have one remaning product');
|
||||
|
||||
// Cancel the other subscription
|
||||
await agent
|
||||
.put(`/members/${member.id}/subscriptions/${subscription1.id}`)
|
||||
.body({
|
||||
status: 'canceled'
|
||||
})
|
||||
.expectStatus(200)
|
||||
.matchBodySnapshot({
|
||||
members: new Array(1).fill({
|
||||
id: anyObjectId,
|
||||
uuid: anyUuid,
|
||||
created_at: anyISODateTime,
|
||||
updated_at: anyISODateTime,
|
||||
labels: anyArray,
|
||||
subscriptions: anyArray,
|
||||
newsletters: anyArray,
|
||||
tiers: anyArray
|
||||
})
|
||||
})
|
||||
.matchHeaderSnapshot({
|
||||
'content-version': anyContentVersion,
|
||||
etag: anyEtag
|
||||
});
|
||||
|
||||
// Update member
|
||||
member = await models.Member.findOne({email}, {require: true, withRelated: ['products', 'stripeCustomers', 'stripeSubscriptions']});
|
||||
|
||||
// Assert this member is subscribed to one products
|
||||
assert.equal(member.related('stripeCustomers').length, 2);
|
||||
assert.equal(member.related('stripeSubscriptions').length, 2);
|
||||
assert.equal(member.related('products').length, 0, 'This member should only have no remaning products');
|
||||
});
|
||||
|
||||
it('Can cancel a subscription for a member with duplicate subscriptions', async function () {
|
||||
const email = 'duplicate-subscription-test@example.com';
|
||||
|
||||
// We create duplicate customers to mimick a situation where a member is connected to two customers
|
||||
const customer1 = stripeMocker.createCustomer({
|
||||
email
|
||||
});
|
||||
|
||||
const price1 = await stripeMocker.getPriceForTier('default-product', 'month');
|
||||
const price2 = await stripeMocker.getPriceForTier('gold', 'year');
|
||||
|
||||
const subscription1 = await stripeMocker.createSubscription({
|
||||
customer: customer1,
|
||||
price: price1
|
||||
});
|
||||
|
||||
const subscription2 = await stripeMocker.createSubscription({
|
||||
customer: customer1,
|
||||
price: price2
|
||||
});
|
||||
|
||||
await DomainEvents.allSettled();
|
||||
|
||||
let member = await models.Member.findOne({email}, {require: true, withRelated: ['products', 'stripeCustomers', 'stripeSubscriptions']});
|
||||
|
||||
// Assert this member is subscribed to two products
|
||||
assert.equal(member.related('stripeCustomers').length, 1);
|
||||
assert.equal(member.related('stripeSubscriptions').length, 2);
|
||||
assert.equal(member.related('products').length, 2, 'This member should have two products');
|
||||
|
||||
// Now cancel one of those subscriptions immediately
|
||||
await agent
|
||||
.put(`/members/${member.id}/subscriptions/${subscription2.id}`)
|
||||
.body({
|
||||
status: 'canceled'
|
||||
})
|
||||
.expectStatus(200)
|
||||
.matchBodySnapshot({
|
||||
members: new Array(1).fill({
|
||||
id: anyObjectId,
|
||||
uuid: anyUuid,
|
||||
created_at: anyISODateTime,
|
||||
updated_at: anyISODateTime,
|
||||
labels: anyArray,
|
||||
subscriptions: anyArray,
|
||||
newsletters: anyArray,
|
||||
tiers: anyArray
|
||||
})
|
||||
})
|
||||
.matchHeaderSnapshot({
|
||||
'content-version': anyContentVersion,
|
||||
etag: anyEtag
|
||||
});
|
||||
|
||||
// Update member
|
||||
member = await models.Member.findOne({email}, {require: true, withRelated: ['products', 'stripeCustomers', 'stripeSubscriptions']});
|
||||
|
||||
// Assert this member is subscribed to one products
|
||||
assert.equal(member.related('stripeCustomers').length, 1);
|
||||
assert.equal(member.related('stripeSubscriptions').length, 2);
|
||||
assert.equal(member.related('products').length, 1, 'This member should only have one remaning product');
|
||||
|
||||
// Cancel the other subscription
|
||||
await agent
|
||||
.put(`/members/${member.id}/subscriptions/${subscription1.id}`)
|
||||
.body({
|
||||
status: 'canceled'
|
||||
})
|
||||
.expectStatus(200)
|
||||
.matchBodySnapshot({
|
||||
members: new Array(1).fill({
|
||||
id: anyObjectId,
|
||||
uuid: anyUuid,
|
||||
created_at: anyISODateTime,
|
||||
updated_at: anyISODateTime,
|
||||
labels: anyArray,
|
||||
subscriptions: anyArray,
|
||||
newsletters: anyArray,
|
||||
tiers: anyArray
|
||||
})
|
||||
})
|
||||
.matchHeaderSnapshot({
|
||||
'content-version': anyContentVersion,
|
||||
etag: anyEtag
|
||||
});
|
||||
|
||||
// Update member
|
||||
member = await models.Member.findOne({email}, {require: true, withRelated: ['products', 'stripeCustomers', 'stripeSubscriptions']});
|
||||
|
||||
// Assert this member is subscribed to one products
|
||||
assert.equal(member.related('stripeCustomers').length, 1);
|
||||
assert.equal(member.related('stripeSubscriptions').length, 2);
|
||||
assert.equal(member.related('products').length, 0, 'This member should only have no remaning products');
|
||||
});
|
||||
|
||||
it('Can update a subscription for a member with duplicate subscriptions', async function () {
|
||||
const email = 'duplicate-subscription-edit-test@example.com';
|
||||
|
||||
// We create duplicate customers to mimick a situation where a member is connected to two customers
|
||||
const customer1 = stripeMocker.createCustomer({
|
||||
email
|
||||
});
|
||||
|
||||
const customer2 = stripeMocker.createCustomer({
|
||||
email
|
||||
});
|
||||
|
||||
const price1 = await stripeMocker.getPriceForTier('default-product', 'month');
|
||||
const price2 = await stripeMocker.getPriceForTier('gold', 'year');
|
||||
|
||||
const subscription1 = await stripeMocker.createSubscription({
|
||||
customer: customer1,
|
||||
price: price1
|
||||
});
|
||||
|
||||
const subscription2 = await stripeMocker.createSubscription({
|
||||
customer: customer2,
|
||||
price: price2
|
||||
});
|
||||
|
||||
await DomainEvents.allSettled();
|
||||
|
||||
let member = await models.Member.findOne({email}, {require: true, withRelated: ['products', 'stripeCustomers', 'stripeSubscriptions']});
|
||||
|
||||
// Assert this member is subscribed to two products
|
||||
assert.equal(member.related('stripeCustomers').length, 2);
|
||||
assert.equal(member.related('stripeSubscriptions').length, 2);
|
||||
assert.equal(member.related('products').length, 2, 'This member should have two products');
|
||||
|
||||
// Now update one of those subscriptions immediately
|
||||
await agent
|
||||
.put(`/members/${member.id}/subscriptions/${subscription2.id}`)
|
||||
.body({
|
||||
cancel_at_period_end: true // = just an update, the subscription should remain active until it is ended
|
||||
})
|
||||
.expectStatus(200)
|
||||
.matchBodySnapshot({
|
||||
members: new Array(1).fill({
|
||||
id: anyObjectId,
|
||||
uuid: anyUuid,
|
||||
created_at: anyISODateTime,
|
||||
updated_at: anyISODateTime,
|
||||
labels: anyArray,
|
||||
subscriptions: anyArray,
|
||||
newsletters: anyArray,
|
||||
tiers: anyArray
|
||||
})
|
||||
})
|
||||
.matchHeaderSnapshot({
|
||||
'content-version': anyContentVersion,
|
||||
etag: anyEtag
|
||||
});
|
||||
|
||||
// Update member
|
||||
member = await models.Member.findOne({email}, {require: true, withRelated: ['products', 'stripeCustomers', 'stripeSubscriptions']});
|
||||
|
||||
// Assert this member is subscribed to one products
|
||||
assert.equal(member.related('stripeCustomers').length, 2);
|
||||
assert.equal(member.related('stripeSubscriptions').length, 2);
|
||||
assert.equal(member.related('products').length, 2, 'This member should still have two products');
|
||||
|
||||
// Cancel the other subscription
|
||||
await agent
|
||||
.put(`/members/${member.id}/subscriptions/${subscription1.id}`)
|
||||
.body({
|
||||
cancel_at_period_end: true
|
||||
})
|
||||
.expectStatus(200)
|
||||
.matchBodySnapshot({
|
||||
members: new Array(1).fill({
|
||||
id: anyObjectId,
|
||||
uuid: anyUuid,
|
||||
created_at: anyISODateTime,
|
||||
updated_at: anyISODateTime,
|
||||
labels: anyArray,
|
||||
subscriptions: anyArray,
|
||||
newsletters: anyArray,
|
||||
tiers: anyArray
|
||||
})
|
||||
})
|
||||
.matchHeaderSnapshot({
|
||||
'content-version': anyContentVersion,
|
||||
etag: anyEtag
|
||||
});
|
||||
|
||||
// Update member
|
||||
member = await models.Member.findOne({email}, {require: true, withRelated: ['products', 'stripeCustomers', 'stripeSubscriptions']});
|
||||
|
||||
// Assert this member is subscribed to one products
|
||||
assert.equal(member.related('stripeCustomers').length, 2);
|
||||
assert.equal(member.related('stripeSubscriptions').length, 2);
|
||||
assert.equal(member.related('products').length, 2, 'This member should still have two products');
|
||||
});
|
||||
|
||||
it('Can recover member products when we cancel a subscription', async function () {
|
||||
/**
|
||||
* This tests a situation where a bug didn't set the products for a member correctly in the past when it had multiple subscriptions.
|
||||
* This tests what happens when we cancel the remaining product. To recover from this, we should set the products correctly after the cancelation.
|
||||
*/
|
||||
const email = 'duplicate-subscription-wrongfully-test@example.com';
|
||||
|
||||
// We create duplicate customers to mimick a situation where a member is connected to two customers
|
||||
const customer1 = stripeMocker.createCustomer({
|
||||
email
|
||||
});
|
||||
|
||||
const customer2 = stripeMocker.createCustomer({
|
||||
email
|
||||
});
|
||||
|
||||
const price1 = await stripeMocker.getPriceForTier('default-product', 'month');
|
||||
const price2 = await stripeMocker.getPriceForTier('gold', 'year');
|
||||
|
||||
const subscription1 = await stripeMocker.createSubscription({
|
||||
customer: customer1,
|
||||
price: price1
|
||||
});
|
||||
|
||||
const subscription2 = await stripeMocker.createSubscription({
|
||||
customer: customer2,
|
||||
price: price2
|
||||
});
|
||||
|
||||
await DomainEvents.allSettled();
|
||||
|
||||
let member = await models.Member.findOne({email}, {require: true, withRelated: ['products', 'stripeCustomers', 'stripeSubscriptions']});
|
||||
|
||||
// Assert this member is subscribed to two products
|
||||
assert.equal(member.get('status'), 'paid');
|
||||
assert.equal(member.related('stripeCustomers').length, 2);
|
||||
assert.equal(member.related('stripeSubscriptions').length, 2);
|
||||
assert.equal(member.related('products').length, 2, 'This member should have two products');
|
||||
|
||||
// Manually unlink the first product from the member, to simulate a bug from the past
|
||||
// where we didn't store the products correctly
|
||||
await models.Member.edit({products: member.related('products').models.filter(p => p.get('slug') !== 'default-product')}, {id: member.id});
|
||||
|
||||
// Assert only one product left
|
||||
member = await models.Member.findOne({email}, {require: true, withRelated: ['products', 'stripeCustomers', 'stripeSubscriptions']});
|
||||
assert.equal(member.related('products').length, 1, 'This member should have one product after the update');
|
||||
assert.equal(member.related('products').models[0].get('slug'), 'gold');
|
||||
|
||||
// Now cancel the second subscription (from the remaining product)
|
||||
await agent
|
||||
.put(`/members/${member.id}/subscriptions/${subscription2.id}`)
|
||||
.body({
|
||||
status: 'canceled'
|
||||
})
|
||||
.expectStatus(200)
|
||||
.matchBodySnapshot({
|
||||
members: new Array(1).fill({
|
||||
id: anyObjectId,
|
||||
uuid: anyUuid,
|
||||
created_at: anyISODateTime,
|
||||
updated_at: anyISODateTime,
|
||||
labels: anyArray,
|
||||
subscriptions: anyArray,
|
||||
newsletters: anyArray,
|
||||
tiers: anyArray
|
||||
})
|
||||
})
|
||||
.matchHeaderSnapshot({
|
||||
'content-version': anyContentVersion,
|
||||
etag: anyEtag
|
||||
});
|
||||
|
||||
// Update member
|
||||
member = await models.Member.findOne({email}, {require: true, withRelated: ['products', 'stripeCustomers', 'stripeSubscriptions']});
|
||||
|
||||
// Assert this member is subscribed to one products
|
||||
assert.equal(member.get('status'), 'paid');
|
||||
assert.equal(member.related('stripeCustomers').length, 2);
|
||||
assert.equal(member.related('stripeSubscriptions').length, 2);
|
||||
assert.equal(member.related('products').length, 1, 'This member should still have the other product that was wrongfully removed in the past');
|
||||
assert.equal(member.related('products').models[0].get('slug'), 'default-product', 'This member should still have the other product that was wrongfully removed in the past');
|
||||
|
||||
// Cancel the other subscription
|
||||
await agent
|
||||
.put(`/members/${member.id}/subscriptions/${subscription1.id}`)
|
||||
.body({
|
||||
status: 'canceled'
|
||||
})
|
||||
.expectStatus(200)
|
||||
.matchBodySnapshot({
|
||||
members: new Array(1).fill({
|
||||
id: anyObjectId,
|
||||
uuid: anyUuid,
|
||||
created_at: anyISODateTime,
|
||||
updated_at: anyISODateTime,
|
||||
labels: anyArray,
|
||||
subscriptions: anyArray,
|
||||
newsletters: anyArray,
|
||||
tiers: anyArray
|
||||
})
|
||||
})
|
||||
.matchHeaderSnapshot({
|
||||
'content-version': anyContentVersion,
|
||||
etag: anyEtag
|
||||
});
|
||||
|
||||
// Update member
|
||||
member = await models.Member.findOne({email}, {require: true, withRelated: ['products', 'stripeCustomers', 'stripeSubscriptions']});
|
||||
|
||||
// Assert this member is subscribed to one products
|
||||
assert.equal(member.get('status'), 'free');
|
||||
assert.equal(member.related('stripeCustomers').length, 2);
|
||||
assert.equal(member.related('stripeSubscriptions').length, 2);
|
||||
assert.equal(member.related('products').length, 0);
|
||||
});
|
||||
|
||||
it('Can recover member products when we update a subscription', async function () {
|
||||
/**
|
||||
* This tests a situation where a bug didn't set the products for a member correctly in the past when it had multiple subscriptions.
|
||||
* This tests what happens when we cancel the remaining product. To recover from this, we should set the products correctly after the cancelation.
|
||||
*/
|
||||
const email = 'duplicate-subscription-wrongfully-test2@example.com';
|
||||
|
||||
// We create duplicate customers to mimick a situation where a member is connected to two customers
|
||||
const customer1 = stripeMocker.createCustomer({
|
||||
email
|
||||
});
|
||||
|
||||
const customer2 = stripeMocker.createCustomer({
|
||||
email
|
||||
});
|
||||
|
||||
const price1 = await stripeMocker.getPriceForTier('default-product', 'month');
|
||||
const price2 = await stripeMocker.getPriceForTier('gold', 'year');
|
||||
|
||||
await stripeMocker.createSubscription({
|
||||
customer: customer1,
|
||||
price: price1
|
||||
});
|
||||
|
||||
const subscription2 = await stripeMocker.createSubscription({
|
||||
customer: customer2,
|
||||
price: price2
|
||||
});
|
||||
|
||||
await DomainEvents.allSettled();
|
||||
|
||||
let member = await models.Member.findOne({email}, {require: true, withRelated: ['products', 'stripeCustomers', 'stripeSubscriptions']});
|
||||
|
||||
// Assert this member is subscribed to two products
|
||||
assert.equal(member.get('status'), 'paid');
|
||||
assert.equal(member.related('stripeCustomers').length, 2);
|
||||
assert.equal(member.related('stripeSubscriptions').length, 2);
|
||||
assert.equal(member.related('products').length, 2, 'This member should have two products');
|
||||
|
||||
// Manually unlink the first product from the member, to simulate a bug from the past
|
||||
// where we didn't store the products correctly
|
||||
await models.Member.edit({products: member.related('products').models.filter(p => p.get('slug') !== 'default-product')}, {id: member.id});
|
||||
|
||||
// Assert only one product left
|
||||
member = await models.Member.findOne({email}, {require: true, withRelated: ['products', 'stripeCustomers', 'stripeSubscriptions']});
|
||||
assert.equal(member.get('status'), 'paid');
|
||||
assert.equal(member.related('products').length, 1, 'This member should have one product after the update');
|
||||
assert.equal(member.related('products').models[0].get('slug'), 'gold');
|
||||
|
||||
// Now cancel the second subscription (from the remaining product)
|
||||
await agent
|
||||
.put(`/members/${member.id}/subscriptions/${subscription2.id}`)
|
||||
.body({
|
||||
cancel_at_period_end: true // = just an update, the subscription should remain active until it is ended
|
||||
})
|
||||
.expectStatus(200)
|
||||
.matchBodySnapshot({
|
||||
members: new Array(1).fill({
|
||||
id: anyObjectId,
|
||||
uuid: anyUuid,
|
||||
created_at: anyISODateTime,
|
||||
updated_at: anyISODateTime,
|
||||
labels: anyArray,
|
||||
subscriptions: anyArray,
|
||||
newsletters: anyArray,
|
||||
tiers: anyArray
|
||||
})
|
||||
})
|
||||
.matchHeaderSnapshot({
|
||||
'content-version': anyContentVersion,
|
||||
etag: anyEtag
|
||||
});
|
||||
|
||||
// Update member
|
||||
member = await models.Member.findOne({email}, {require: true, withRelated: ['products', 'stripeCustomers', 'stripeSubscriptions']});
|
||||
|
||||
// Assert this member is subscribed to one products
|
||||
assert.equal(member.get('status'), 'paid');
|
||||
assert.equal(member.related('stripeCustomers').length, 2);
|
||||
assert.equal(member.related('stripeSubscriptions').length, 2);
|
||||
assert.equal(member.related('products').length, 2, 'Should readd the product that was wrongfully removed in the past');
|
||||
});
|
||||
|
||||
it('Can edit the price of a subscription directly in Stripe', async function () {
|
||||
const email = 'edit-subscription-product-in-stripe@example.com';
|
||||
|
||||
// We create duplicate customers to mimick a situation where a member is connected to two customers
|
||||
const customer1 = stripeMocker.createCustomer({
|
||||
email
|
||||
});
|
||||
|
||||
const price1 = await stripeMocker.getPriceForTier('default-product', 'month');
|
||||
const price2 = await stripeMocker.getPriceForTier('gold', 'year');
|
||||
|
||||
const subscription1 = await stripeMocker.createSubscription({
|
||||
customer: customer1,
|
||||
price: price1
|
||||
});
|
||||
|
||||
await DomainEvents.allSettled();
|
||||
|
||||
let member = await models.Member.findOne({email}, {require: true, withRelated: ['products', 'stripeCustomers', 'stripeSubscriptions']});
|
||||
|
||||
assert.equal(member.get('status'), 'paid');
|
||||
assert.equal(member.related('stripeCustomers').length, 1);
|
||||
assert.equal(member.related('stripeSubscriptions').length, 1);
|
||||
assert.equal(member.related('products').length, 1);
|
||||
assert.equal(member.related('products').models[0].get('slug'), 'default-product');
|
||||
|
||||
// Change subscription price in Stripe
|
||||
// This will send a webhook to Ghost
|
||||
await stripeMocker.updateSubscription({
|
||||
id: subscription1.id,
|
||||
items: {
|
||||
type: 'list',
|
||||
data: [
|
||||
{
|
||||
price: price2
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
member = await models.Member.findOne({email}, {require: true, withRelated: ['products', 'stripeCustomers', 'stripeSubscriptions']});
|
||||
|
||||
assert.equal(member.get('status'), 'paid');
|
||||
assert.equal(member.related('stripeCustomers').length, 1);
|
||||
assert.equal(member.related('stripeSubscriptions').length, 1);
|
||||
assert.equal(member.related('products').length, 1);
|
||||
assert.equal(member.related('products').models[0].get('slug'), 'gold');
|
||||
});
|
||||
|
||||
it('Can edit the price of a subscription directly in Stripe when having duplicate subscriptions', async function () {
|
||||
const email = 'edit-subscription-product-in-stripe-dup@example.com';
|
||||
|
||||
// We create duplicate customers to mimick a situation where a member is connected to two customers
|
||||
const customer1 = stripeMocker.createCustomer({
|
||||
email
|
||||
});
|
||||
const customer2 = stripeMocker.createCustomer({
|
||||
email
|
||||
});
|
||||
|
||||
const price1 = await stripeMocker.getPriceForTier('default-product', 'month');
|
||||
const price2 = await stripeMocker.getPriceForTier('gold', 'year');
|
||||
const price3 = await stripeMocker.getPriceForTier('silver', 'year');
|
||||
|
||||
const subscription1 = await stripeMocker.createSubscription({
|
||||
customer: customer1,
|
||||
price: price1
|
||||
});
|
||||
|
||||
await stripeMocker.createSubscription({
|
||||
customer: customer2,
|
||||
price: price2
|
||||
});
|
||||
|
||||
await DomainEvents.allSettled();
|
||||
|
||||
let member = await models.Member.findOne({email}, {require: true, withRelated: ['products', 'stripeCustomers', 'stripeSubscriptions']});
|
||||
|
||||
assert.equal(member.get('status'), 'paid');
|
||||
assert.equal(member.related('stripeCustomers').length, 2);
|
||||
assert.equal(member.related('stripeSubscriptions').length, 2);
|
||||
assert.equal(member.related('products').length, 2);
|
||||
assert.deepEqual(member.related('products').models.map(m => m.get('slug')).sort(), ['default-product', 'gold']);
|
||||
|
||||
// Change subscription price in Stripe
|
||||
// This will send a webhook to Ghost
|
||||
await stripeMocker.updateSubscription({
|
||||
id: subscription1.id,
|
||||
items: {
|
||||
type: 'list',
|
||||
data: [
|
||||
{
|
||||
price: price3
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
member = await models.Member.findOne({email}, {require: true, withRelated: ['products', 'stripeCustomers', 'stripeSubscriptions']});
|
||||
|
||||
assert.equal(member.get('status'), 'paid');
|
||||
assert.equal(member.related('stripeCustomers').length, 2);
|
||||
assert.equal(member.related('stripeSubscriptions').length, 2);
|
||||
assert.equal(member.related('products').length, 2);
|
||||
assert.deepEqual(member.related('products').models.map(m => m.get('slug')).sort(), ['gold', 'silver']);
|
||||
});
|
||||
});
|
@ -2261,63 +2261,6 @@ describe('Members API', function () {
|
||||
});
|
||||
});
|
||||
|
||||
it('Can edit a subscription', async function () {
|
||||
const memberId = testUtils.DataGenerator.Content.members[1].id;
|
||||
|
||||
// Get the stripe price ID of the default price for month
|
||||
const price = await stripeMocker.getPriceForTier('default-product', 'year');
|
||||
|
||||
const res = await agent
|
||||
.post(`/members/${memberId}/subscriptions/`)
|
||||
.body({
|
||||
stripe_price_id: price.id
|
||||
})
|
||||
.expectStatus(200)
|
||||
.matchBodySnapshot({
|
||||
members: new Array(1).fill({
|
||||
id: anyObjectId,
|
||||
uuid: anyUuid,
|
||||
created_at: anyISODateTime,
|
||||
updated_at: anyISODateTime,
|
||||
labels: anyArray,
|
||||
subscriptions: [subscriptionSnapshotWithTier],
|
||||
newsletters: anyArray,
|
||||
tiers: [tierSnapshot]
|
||||
})
|
||||
})
|
||||
.matchHeaderSnapshot({
|
||||
'content-version': anyContentVersion,
|
||||
etag: anyEtag
|
||||
});
|
||||
|
||||
const subscriptionId = res.body.members[0].subscriptions[0].id;
|
||||
|
||||
const editRes = await agent
|
||||
.put(`/members/${memberId}/subscriptions/${subscriptionId}`)
|
||||
.body({
|
||||
status: 'canceled'
|
||||
})
|
||||
.expectStatus(200)
|
||||
.matchBodySnapshot({
|
||||
members: new Array(1).fill({
|
||||
id: anyObjectId,
|
||||
uuid: anyUuid,
|
||||
created_at: anyISODateTime,
|
||||
updated_at: anyISODateTime,
|
||||
labels: anyArray,
|
||||
subscriptions: [subscriptionSnapshot],
|
||||
newsletters: anyArray,
|
||||
tiers: []
|
||||
})
|
||||
})
|
||||
.matchHeaderSnapshot({
|
||||
'content-version': anyContentVersion,
|
||||
etag: anyEtag
|
||||
});
|
||||
|
||||
assert.equal('canceled', editRes.body.members[0].subscriptions[0].status);
|
||||
});
|
||||
|
||||
// Delete a member
|
||||
|
||||
it('Can destroy', async function () {
|
||||
|
@ -487,6 +487,13 @@ const fixtures = {
|
||||
return models.Product.add(hiddenTier, context.internal);
|
||||
},
|
||||
|
||||
insertExtraTiers: async function insertExtraTiers() {
|
||||
const extraTier = DataGenerator.forKnex.createProduct({});
|
||||
const extraTier2 = DataGenerator.forKnex.createProduct({slug: 'silver', name: 'Silver'});
|
||||
await models.Product.add(extraTier, context.internal);
|
||||
await models.Product.add(extraTier2, context.internal);
|
||||
},
|
||||
|
||||
insertProducts: async function insertProducts() {
|
||||
let coreProductFixtures = fixtureManager.findModelFixtures('Product').entries;
|
||||
await Promise.all(coreProductFixtures.map(async (product) => {
|
||||
@ -822,6 +829,9 @@ const toDoList = {
|
||||
custom_theme_settings: function insertCustomThemeSettings() {
|
||||
return fixtures.insertCustomThemeSettings();
|
||||
},
|
||||
'tiers:extra': function insertExtraTiers() {
|
||||
return fixtures.insertExtraTiers();
|
||||
},
|
||||
'tiers:archived': function insertArchivedTiers() {
|
||||
return fixtures.insertArchivedTiers();
|
||||
},
|
||||
|
@ -78,6 +78,10 @@ class StripeMocker {
|
||||
*/
|
||||
async getPriceForTier(tierSlug, cadence) {
|
||||
const product = await models.Product.findOne({slug: tierSlug});
|
||||
|
||||
if (!product) {
|
||||
throw new Error('Product not found with slug ' + tierSlug);
|
||||
}
|
||||
const tier = await tiers.api.read(product.id);
|
||||
const payments = members.api.paymentsService;
|
||||
const {id} = await payments.createPriceForTierCadence(tier, cadence);
|
||||
|
@ -1138,28 +1138,32 @@ module.exports = class MemberRepository {
|
||||
status = 'paid';
|
||||
}
|
||||
|
||||
// This is an active subscription! Add the product
|
||||
if (ghostProduct) {
|
||||
// memberProducts.push(ghostProduct.toJSON());
|
||||
memberProducts = [ghostProduct.toJSON()];
|
||||
}
|
||||
if (model) {
|
||||
if (model.get('stripe_price_id') !== subscriptionData.stripe_price_id) {
|
||||
// The subscription has changed plan - we may need to update the products
|
||||
// We might need to...
|
||||
// 1. delete the previous product from the linked member products (in case an existing subscription changed product/price)
|
||||
// 2. fix the list of products linked to a member (an existing subscription doesn't have a linked product to this member)
|
||||
|
||||
const subscriptions = await member.related('stripeSubscriptions').fetch(options);
|
||||
const changedProduct = await this._productRepository.get({
|
||||
|
||||
const previousProduct = await this._productRepository.get({
|
||||
stripe_price_id: model.get('stripe_price_id')
|
||||
}, options);
|
||||
|
||||
let activeSubscriptionForChangedProduct = false;
|
||||
if (previousProduct) {
|
||||
let activeSubscriptionForPreviousProduct = false;
|
||||
|
||||
for (const subscriptionModel of subscriptions.models) {
|
||||
if (this.isActiveSubscriptionStatus(subscriptionModel.get('status'))) {
|
||||
if (this.isActiveSubscriptionStatus(subscriptionModel.get('status')) && subscriptionModel.id !== model.id) {
|
||||
try {
|
||||
const subscriptionProduct = await this._productRepository.get({stripe_price_id: subscriptionModel.get('stripe_price_id')}, options);
|
||||
if (subscriptionProduct && changedProduct && subscriptionProduct.id === changedProduct.id) {
|
||||
activeSubscriptionForChangedProduct = true;
|
||||
if (subscriptionProduct && previousProduct && subscriptionProduct.id === previousProduct.id) {
|
||||
activeSubscriptionForPreviousProduct = true;
|
||||
}
|
||||
|
||||
if (subscriptionProduct && !memberProducts.find(p => p.id === subscriptionProduct.id)) {
|
||||
// Due to a bug in the past it is possible that this subscription's product wasn't added to the member products
|
||||
// So we need to add it again
|
||||
memberProducts.push(subscriptionProduct.toJSON());
|
||||
}
|
||||
} catch (e) {
|
||||
logging.error(`Failed to attach products to member - ${data.id}`);
|
||||
@ -1168,13 +1172,21 @@ module.exports = class MemberRepository {
|
||||
}
|
||||
}
|
||||
|
||||
if (!activeSubscriptionForChangedProduct) {
|
||||
if (!activeSubscriptionForPreviousProduct) {
|
||||
// We can safely remove the product from this member because it doesn't have any other remaining active subscription for it
|
||||
memberProducts = memberProducts.filter((product) => {
|
||||
return product.id !== changedProduct.id;
|
||||
return product.id !== previousProduct.id;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ghostProduct) {
|
||||
// Note: we add the product here
|
||||
// We don't override the products because in an edge case a member can have multiple subscriptions
|
||||
// We'll need to keep all the products related to those subscriptions to avoid creating other issues
|
||||
memberProducts.push(ghostProduct.toJSON());
|
||||
}
|
||||
} else {
|
||||
const subscriptions = await member.related('stripeSubscriptions').fetch(options);
|
||||
let activeSubscriptionForGhostProduct = false;
|
||||
@ -1186,6 +1198,12 @@ module.exports = class MemberRepository {
|
||||
if (subscriptionProduct && ghostProduct && subscriptionProduct.id === ghostProduct.id) {
|
||||
activeSubscriptionForGhostProduct = true;
|
||||
}
|
||||
|
||||
if (subscriptionProduct && !memberProducts.find(p => p.id === subscriptionProduct.id)) {
|
||||
// Due to a bug in the past it is possible that this subscription's product wasn't added to the member products
|
||||
// So we need to add it again
|
||||
memberProducts.push(subscriptionProduct.toJSON());
|
||||
}
|
||||
} catch (e) {
|
||||
logging.error(`Failed to attach products to member - ${data.id}`);
|
||||
logging.error(e);
|
||||
@ -1194,12 +1212,14 @@ module.exports = class MemberRepository {
|
||||
}
|
||||
|
||||
if (!activeSubscriptionForGhostProduct) {
|
||||
// We don't have an active subscription for this product anymore, so we can safely unlink it from the member
|
||||
memberProducts = memberProducts.filter((product) => {
|
||||
return product.id !== ghostProduct.id;
|
||||
});
|
||||
}
|
||||
|
||||
if (memberProducts.length === 0) {
|
||||
// If all products were removed, set the status back to 'free'
|
||||
status = 'free';
|
||||
}
|
||||
}
|
||||
|
@ -191,14 +191,14 @@ describe('MemberRepository', function () {
|
||||
|
||||
Member = {
|
||||
findOne: sinon.stub().resolves({
|
||||
related: () => {
|
||||
related: (relation) => {
|
||||
return {
|
||||
query: sinon.stub().returns({
|
||||
fetchOne: sinon.stub().resolves({})
|
||||
}),
|
||||
toJSON: sinon.stub().returns([]),
|
||||
toJSON: sinon.stub().returns(relation === 'products' ? [] : {}),
|
||||
fetch: sinon.stub().resolves({
|
||||
toJSON: sinon.stub().returns({})
|
||||
toJSON: sinon.stub().returns(relation === 'products' ? [] : {})
|
||||
})
|
||||
};
|
||||
},
|
||||
|
Loading…
Reference in New Issue
Block a user