🐛 Fixed invalid expiry for member tier subscriptions (#16174)

refs https://github.com/TryGhost/Team/issues/2476

When upgrading from a Complimentary subscription with an expiry, to a paid Subscription of the same Tier, the Member was eventually losing access to the Tier when the complimentary subscription expires as the `expiry_at` on the mapping was not removed. This change fixes the code by setting expiry as null when a member upgrades their subscription to paid. This also adds 2 migrations to fix any side-effects on existing sites -

- Removed invalid expiry tier expiry date for paid members
- Restored missing tier mapping for paid members
This commit is contained in:
Rishabh Garg 2023-01-25 13:59:43 +05:30 committed by Daniel Lockyer
parent 1d32931d0a
commit 503a9ebe51
No known key found for this signature in database
4 changed files with 91 additions and 2 deletions

View File

@ -0,0 +1,35 @@
const logging = require('@tryghost/logging');
const {createTransactionalMigration} = require('../../utils');
module.exports = createTransactionalMigration(
async function up(knex) {
logging.info('Removing expiry dates for paid members');
try {
// Fetch all members with a paid status that have an expiry date
// Paid members should not have an expiry date
const invalidExpiryIds = await knex('members_products')
.select('members_products.id')
.leftJoin('members', 'members_products.member_id', 'members.id')
.where('members.status', '=', 'paid')
.whereNotNull('members_products.expiry_at').pluck('members_products.id');
logging.info(`Found ${invalidExpiryIds.length} paid members with expiry dates`);
if (invalidExpiryIds.length === 0) {
return;
}
logging.info(`Removing expiry dates for ${invalidExpiryIds.length} paid members`);
await knex('members_products')
.update('expiry_at', null)
.whereIn('id', invalidExpiryIds);
} catch (err) {
logging.warn('Failed to remove expiry dates for paid members');
logging.warn(err);
}
},
async function down() {
// no-op: we don't want to reintroduce the incorrect expiry dates for member tiers
}
);

View File

@ -0,0 +1,46 @@
const logging = require('@tryghost/logging');
const ObjectId = require('bson-objectid').default;
const {createTransactionalMigration} = require('../../utils');
module.exports = createTransactionalMigration(
async function up(knex) {
logging.info('Restoring member<>tier mapping for members with paid status');
try {
// fetch all members with a paid status that don't have a members_products record
// and have a members_product_events record with an action of "added"
// and fetch the product_id from the most recent record for that member
const memberWithTiers = await knex.select('m.id as member_id', 'mpe.product_id as product_id')
.from('members as m')
.leftJoin('members_products as mp', 'm.id', 'mp.member_id')
.leftJoin('members_product_events as mpe', function () {
this.on('m.id', 'mpe.member_id')
.andOn(knex.raw('mpe.created_at = (SELECT max(created_at) FROM members_product_events WHERE member_id = mpe.member_id and action = "added")'));
})
.where({'m.status': 'paid', 'mp.member_id': null, 'mpe.action': 'added'});
// create a new members_products record for each member with id, member_id and product_id
const toInsert = memberWithTiers.map((memberTier) => {
return {
...memberTier,
id: ObjectId().toHexString()
};
}).filter((memberTier) => {
// filter out any members that don't have a product_id for some reason
if (!memberTier.product_id) {
logging.warn(`Invalid record found - member_id: ${memberTier.member_id} is without product_id`);
return false;
}
return true;
});
logging.info(`Inserting ${toInsert.length} records into members_products`);
await knex.batchInsert('members_products', toInsert);
} catch (err) {
logging.warn('Failed to restore member<>tier mapping for members with paid status');
logging.warn(err);
}
},
async function down() {
// np-op: we don't want to delete the missing records we've just inserted
}
);

View File

@ -219,8 +219,8 @@ const Member = ghostBookshelf.Model.extend({
async updateTierExpiry(products = [], options = {}) {
for (const product of products) {
if (product?.expiry_at) {
const expiry = new Date(product.expiry_at);
if (product?.id) {
const expiry = product.expiry_at ? new Date(product.expiry_at) : null;
const queryOptions = _.extend({}, options, {
query: {where: {product_id: product.id}}
});

View File

@ -77,5 +77,13 @@ describe('Unit: models/member', function () {
updatePivot.calledWith({expiry_at: new Date(expiry)}, {query: {where: {product_id: '1'}}}).should.be.true();
});
it('calls updatePivot on member products to remove expiry', function () {
memberModel.updateTierExpiry([{
id: '1'
}]);
updatePivot.calledWith({expiry_at: null}, {query: {where: {product_id: '1'}}}).should.be.true();
});
});
});