Merge branch 'bugfix-extramonths-lost-when-subscription-terminated' of https://github.com/hamboomger/habitica into hamboomger-bugfix-extramonths-lost-when-subscription-terminated

This commit is contained in:
Matteo Pagliazzi 2020-04-20 23:30:19 +02:00
commit 4fa381f153
11 changed files with 193 additions and 38 deletions

View file

@ -0,0 +1,64 @@
import moment from 'moment';
import { calculateSubscriptionTerminationDate } from '../../../../../../website/server/libs/payments/util';
import api from '../../../../../../website/server/libs/payments/payments';
describe('#calculateSubscriptionTerminationDate', () => {
let plan;
let nextBill;
beforeEach(() => {
plan = {
customerId: 'customer-id',
extraMonths: 0,
};
nextBill = moment();
});
it('should extend date to the exact amount of days left before the next bill will occur', () => {
nextBill = moment()
.add(5, 'days');
const expectedTerminationDate = moment()
.add(5, 'days');
const terminationDate = calculateSubscriptionTerminationDate(nextBill, plan, api.constants);
expect(expectedTerminationDate.diff(terminationDate, 'days')).to.eql(0);
});
it('if nextBill is null, add 30 days to termination date', () => {
nextBill = null;
const expectedTerminationDate = moment()
.add(30, 'days');
const terminationDate = calculateSubscriptionTerminationDate(nextBill, plan, api.constants);
expect(expectedTerminationDate.diff(terminationDate, 'days')).to.eql(0);
});
it('if nextBill is null and it\'s a group plan, add 2 days instead of 30', () => {
nextBill = null;
plan.customerId = api.constants.GROUP_PLAN_CUSTOMER_ID;
const expectedTerminationDate = moment()
.add(2, 'days');
const terminationDate = calculateSubscriptionTerminationDate(nextBill, plan, api.constants);
expect(expectedTerminationDate.diff(terminationDate, 'days')).to.eql(0);
});
it('should add 30.5 days for each extraMonth', () => {
plan.extraMonths = 4;
const expectedTerminationDate = moment()
.add(30.5 * 4, 'days');
const terminationDate = calculateSubscriptionTerminationDate(nextBill, plan, api.constants);
expect(expectedTerminationDate.diff(terminationDate, 'days')).to.eql(0);
});
it('should round up if total days gained by extraMonth is a decimal number', () => {
plan.extraMonths = 5;
const expectedTerminationDate = moment()
.add(Math.ceil(30.5 * 5), 'days');
const terminationDate = calculateSubscriptionTerminationDate(nextBill, plan, api.constants);
expect(expectedTerminationDate.diff(terminationDate, 'days')).to.eql(0);
});
it('behaves like extraMonths is 0 if it\'s set to a negative number', () => {
plan.extraMonths = -5;
const expectedTerminationDate = moment();
const terminationDate = calculateSubscriptionTerminationDate(nextBill, plan, api.constants);
expect(expectedTerminationDate.diff(terminationDate, 'days')).to.eql(0);
});
});

View file

@ -2402,29 +2402,29 @@ describe('Group Model', () => {
});
});
context('isSubscribed', () => {
context('hasActiveGroupPlan', () => {
it('returns false if group does not have customer id', () => {
expect(party.isSubscribed()).to.be.undefined;
expect(party.hasActiveGroupPlan()).to.be.undefined;
});
it('returns true if group does not have plan.dateTerminated', () => {
party.purchased.plan.customerId = 'test-id';
expect(party.isSubscribed()).to.be.true;
expect(party.hasActiveGroupPlan()).to.be.true;
});
it('returns true if group if plan.dateTerminated is after today', () => {
party.purchased.plan.customerId = 'test-id';
party.purchased.plan.dateTerminated = moment().add(1, 'days').toDate();
expect(party.isSubscribed()).to.be.true;
expect(party.hasActiveGroupPlan()).to.be.true;
});
it('returns false if group if plan.dateTerminated is before today', () => {
party.purchased.plan.customerId = 'test-id';
party.purchased.plan.dateTerminated = moment().subtract(1, 'days').toDate();
expect(party.isSubscribed()).to.be.false;
expect(party.hasActiveGroupPlan()).to.be.false;
});
});

View file

@ -2,6 +2,7 @@ import { v4 as generateUUID } from 'uuid';
import {
each,
} from 'lodash';
import moment from 'moment';
import {
generateChallenge,
checkExistence,
@ -12,6 +13,7 @@ import {
} from '../../../../helpers/api-integration/v3';
import { model as User } from '../../../../../website/server/models/user';
import payments from '../../../../../website/server/libs/payments/payments';
import { calculateSubscriptionTerminationDate } from '../../../../../website/server/libs/payments/util';
describe('POST /groups/:groupId/leave', () => {
const typesOfGroups = {
@ -338,4 +340,48 @@ describe('POST /groups/:groupId/leave', () => {
});
});
});
each(typesOfGroups, (groupDetails, groupType) => {
context(`Leaving a group with extraMonths left plan when the group is a ${groupType}`, () => {
const extraMonths = 12;
let groupWithPlan;
let member;
beforeEach(async () => {
const { group, members } = await createAndPopulateGroup({
groupDetails,
members: 1,
upgradeToGroupPlan: true,
});
[member] = members;
groupWithPlan = group;
await member.update({
'purchased.plan.extraMonths': extraMonths,
});
});
it('calculates dateTerminated and sets extraMonths to zero after user leaves the group', async () => {
const userBeforeLeave = await User.findById(member._id).exec();
await member.post(`/groups/${groupWithPlan._id}/leave`);
const userAfterLeave = await User.findById(member._id).exec();
const dateTerminatedBefore = userBeforeLeave.purchased.plan.dateTerminated;
const extraMonthsBefore = userBeforeLeave.purchased.plan.extraMonths;
const dateTerminatedAfter = userAfterLeave.purchased.plan.dateTerminated;
const extraMonthsAfter = userAfterLeave.purchased.plan.extraMonths;
const expectedTerminationDate = calculateSubscriptionTerminationDate(null, {
customerId: payments.constants.GROUP_PLAN_CUSTOMER_ID,
extraMonths,
}, payments.constants);
expect(extraMonthsBefore).to.gte(12);
expect(extraMonthsAfter).to.equal(0);
expect(dateTerminatedBefore).to.be.null;
expect(dateTerminatedAfter).to.exist;
expect(moment(dateTerminatedAfter).diff(expectedTerminationDate, 'days')).to.equal(0);
});
});
});
});

View file

@ -5,6 +5,8 @@ import { v4 as generateUUID } from 'uuid';
import { ApiUser, ApiGroup, ApiChallenge } from '../api-classes';
import { requester } from '../requester';
import * as Tasks from '../../../../website/server/models/task';
import payments from '../../../../website/server/libs/payments/payments';
import { model as User } from '../../../../website/server/models/user';
// Creates a new user and returns it
// If you need the user to have specific requirements,
@ -83,14 +85,35 @@ export async function generateGroup (leader, details = {}, update = {}) {
return apiGroup;
}
async function _upgradeToGroupPlan (groupLeader, group) {
const groupLeaderModel = await User.findById(groupLeader._id).exec();
// Create subscription
const paymentData = {
user: groupLeaderModel,
groupId: group._id,
sub: {
key: 'basic_3mo',
},
customerId: 'customer-id',
paymentMethod: 'Payment Method',
headers: {
'x-client': 'habitica-web',
'user-agent': '',
},
};
await payments.createSubscription(paymentData);
}
// This is generate group + the ability to create
// real users to populate it. The settings object
// takes in:
// members: Number - the number of group members to create.
// Defaults to 0. Does not include group leader.
// inivtes: Number - the number of users to create and invite to the group. Defaults to 0.
// invites: Number - the number of users to create and invite to the group. Defaults to 0.
// groupDetails: Object - how to initialize the group
// leaderDetails: Object - defaults for the leader, defaults with a gem balance so the user
// addGroupPlan: boolean - will add group plan with basic subscription. Defaults to false
// can create the group
//
// Returns an object with
@ -101,6 +124,7 @@ export async function generateGroup (leader, details = {}, update = {}) {
export async function createAndPopulateGroup (settings = {}) {
const numberOfMembers = settings.members || 0;
const numberOfInvites = settings.invites || 0;
const upgradeToGroupPlan = settings.upgradeToGroupPlan || false;
const { groupDetails } = settings;
const leaderDetails = settings.leaderDetails || { balance: 10 };
@ -130,6 +154,10 @@ export async function createAndPopulateGroup (settings = {}) {
await Promise.all(invitees.map(invitee => invitee.sync()));
if (upgradeToGroupPlan) {
await _upgradeToGroupPlan(groupLeader, group);
}
return {
groupLeader,
group,

View file

@ -863,11 +863,6 @@ api.leaveGroup = {
if (guildIndex >= 0) user.guilds.splice(guildIndex, 1);
}
const isMemberOfGroupPlan = await user.isMemberOfGroupPlan();
if (!isMemberOfGroupPlan) {
await payments.cancelGroupSubscriptionForUser(user, group);
}
if (group.hasNotCancelled()) await group.updateGroupPlan(true);
res.respond(200, {});
},
@ -1317,7 +1312,7 @@ api.getGroupPlans = {
.select('leaderOnly leader purchased name managers')
.exec();
const groupPlans = groups.filter(group => group.isSubscribed());
const groupPlans = groups.filter(group => group.hasActiveGroupPlan());
res.respond(200, groupPlans);
},

View file

@ -10,7 +10,7 @@ const questScrolls = shared.content.quests;
// @TODO: Don't use this method when the group can be saved.
export async function getGroupChat (group) {
const maxChatCount = group.isSubscribed() ? MAX_SUBBED_GROUP_CHAT_COUNT : MAX_CHAT_COUNT;
const maxChatCount = group.hasActiveGroupPlan() ? MAX_SUBBED_GROUP_CHAT_COUNT : MAX_CHAT_COUNT;
const groupChat = await Chat.find({ groupId: group._id })
.limit(maxChatCount)

View file

@ -73,7 +73,7 @@ function inviteUserToGuild (userToInvite, group, inviter, publicGuild, res) {
publicGuild,
};
if (group.isSubscribed() && !group.hasNotCancelled()) guildInvite.cancelledPlan = true;
if (group.hasActiveGroupPlan() && !group.hasNotCancelled()) guildInvite.cancelledPlan = true;
userToInvite.invitations.guilds.push(guildInvite);
}
@ -94,7 +94,7 @@ async function inviteUserToParty (userToInvite, group, inviter, res) {
}
const partyInvite = { id: group._id, name: group.name, inviter: inviter._id };
if (group.isSubscribed() && !group.hasNotCancelled()) partyInvite.cancelledPlan = true;
if (group.hasActiveGroupPlan() && !group.hasNotCancelled()) partyInvite.cancelledPlan = true;
userToInvite.invitations.parties.push(partyInvite);
userToInvite.invitations.party = partyInvite;
@ -167,7 +167,7 @@ async function inviteByEmail (invite, group, inviter, req, res) {
userReturnInfo = invite.email;
let cancelledPlan = false;
if (group.isSubscribed() && !group.hasNotCancelled()) cancelledPlan = true;
if (group.hasActiveGroupPlan() && !group.hasNotCancelled()) cancelledPlan = true;
const groupQueryString = JSON.stringify({
id: group._id,

View file

@ -17,6 +17,7 @@ import {
} from '../errors';
import shared from '../../../common';
import { sendNotification as sendPushNotification } from '../pushNotifications'; // eslint-disable-line import/no-cycle
import { calculateSubscriptionTerminationDate } from './util';
// @TODO: Abstract to shared/constant
const JOINED_GROUP_PLAN = 'joined group plan';
@ -73,6 +74,7 @@ async function createSubscription (data) {
let itemPurchased = 'Subscription';
let purchaseType = 'subscribe';
let emailType = 'subscription-begins';
let recipientIsSubscribed = recipient.isSubscribed();
// If we are buying a group subscription
if (data.groupId) {
@ -93,6 +95,7 @@ async function createSubscription (data) {
itemPurchased = 'Group-Subscription';
purchaseType = 'group-subscribe';
emailType = 'group-subscription-begins';
recipientIsSubscribed = group.hasActiveGroupPlan();
groupId = group._id;
recipient.purchased.plan.quantity = data.sub.quantity;
@ -105,7 +108,7 @@ async function createSubscription (data) {
if (plan.customerId && !plan.dateTerminated) { // User has active plan
plan.extraMonths += months;
} else {
if (!recipient.isSubscribed() || !plan.dateUpdated) plan.dateUpdated = today;
if (!recipientIsSubscribed || !plan.dateUpdated) plan.dateUpdated = today;
if (moment(plan.dateTerminated).isAfter()) {
plan.dateTerminated = moment(plan.dateTerminated).add({ months }).toDate();
} else {
@ -307,24 +310,11 @@ async function cancelSubscription (data) {
if (data.cancellationReason && data.cancellationReason === JOINED_GROUP_PLAN) sendEmail = false;
}
const now = moment();
let defaultRemainingDays = 30;
if (plan.customerId === this.constants.GROUP_PLAN_CUSTOMER_ID) {
defaultRemainingDays = 2;
sendEmail = false; // because group-member-cancel email has already been sent
}
const remaining = data.nextBill ? moment(data.nextBill).diff(new Date(), 'days', true) : defaultRemainingDays;
if (plan.extraMonths < 0) plan.extraMonths = 0;
const extraDays = Math.ceil(30.5 * plan.extraMonths);
const nowStr = `${now.format('MM')}/${now.format('DD')}/${now.format('YYYY')}`;
const nowStrFormat = 'MM/DD/YYYY';
plan.dateTerminated = moment(nowStr, nowStrFormat)
.add({ days: remaining })
.add({ days: extraDays })
.toDate();
plan.dateTerminated = calculateSubscriptionTerminationDate(data.nextBill, plan, this.constants);
// clear extra time. If they subscribe again, it'll be recalculated from p.dateTerminated
plan.extraMonths = 0;

View file

@ -0,0 +1,32 @@
import moment from 'moment';
const DEFAULT_REMAINING_DAYS = 30;
const DEFAULT_REMAINING_DAYS_FOR_GROUP_PLAN = 2;
/**
* paymentsApiConstants is provided as parameter because of a dependency cycle
* with subscriptions api which will occur if api.constants would be used directly
*/
export function calculateSubscriptionTerminationDate (
nextBill, purchasedPlan, paymentsApiConstants,
) {
const defaultRemainingDays = (
purchasedPlan.customerId === paymentsApiConstants.GROUP_PLAN_CUSTOMER_ID
) ? DEFAULT_REMAINING_DAYS_FOR_GROUP_PLAN
: DEFAULT_REMAINING_DAYS;
const now = moment();
const remaining = nextBill
? moment(nextBill).diff(new Date(), 'days', true)
: defaultRemainingDays;
const extraMonths = Math.max(purchasedPlan.extraMonths, 0);
const extraDays = Math.ceil(30.5 * extraMonths);
const nowStr = `${now.format('MM')}/${now.format('DD')}/${now.format('YYYY')}`;
const nowStrFormat = 'MM/DD/YYYY';
return moment(nowStr, nowStrFormat)
.add({ days: remaining })
.add({ days: extraDays })
.toDate();
}

View file

@ -153,7 +153,7 @@ schema.plugin(baseModel, {
noSet: ['_id', 'balance', 'quest', 'memberCount', 'chat', 'challengeCount', 'tasksOrder', 'purchased', 'managers'],
private: ['purchased.plan'],
toJSONTransform (plainObj, originalDoc) {
if (plainObj.purchased) plainObj.purchased.active = originalDoc.isSubscribed();
if (plainObj.purchased) plainObj.purchased.active = originalDoc.hasActiveGroupPlan();
},
});
@ -1701,7 +1701,7 @@ schema.methods.checkChatSpam = function groupCheckChatSpam (user) {
return false;
};
schema.methods.isSubscribed = function isSubscribed () {
schema.methods.hasActiveGroupPlan = function hasActiveGroupPlan () {
const now = new Date();
const { plan } = this.purchased;
return plan && plan.customerId
@ -1710,12 +1710,12 @@ schema.methods.isSubscribed = function isSubscribed () {
schema.methods.hasNotCancelled = function hasNotCancelled () {
const { plan } = this.purchased;
return Boolean(this.isSubscribed() && !plan.dateTerminated);
return Boolean(this.hasActiveGroupPlan() && !plan.dateTerminated);
};
schema.methods.hasCancelled = function hasNotCancelled () {
schema.methods.hasCancelled = function hasCancelled () {
const { plan } = this.purchased;
return Boolean(this.isSubscribed() && plan.dateTerminated);
return Boolean(this.hasActiveGroupPlan() && plan.dateTerminated);
};
schema.methods.updateGroupPlan = async function updateGroupPlan (removingMember) {

View file

@ -477,13 +477,13 @@ schema.methods.canGetGems = async function canObtainGems () {
const groups = await getUserGroupData(user);
return groups
.every(g => !g.isSubscribed() || g.leader === user._id || g.leaderOnly.getGems !== true);
.every(g => !g.hasActiveGroupPlan() || g.leader === user._id || g.leaderOnly.getGems !== true);
};
schema.methods.isMemberOfGroupPlan = async function isMemberOfGroupPlan () {
const groups = await getUserGroupData(this);
return groups.some(g => g.isSubscribed());
return groups.some(g => g.hasActiveGroupPlan());
};
schema.methods.isAdmin = function isAdmin () {