diff --git a/test/api/unit/libs/payments/stripe/calculate-termination-date.test.js b/test/api/unit/libs/payments/stripe/calculate-termination-date.test.js new file mode 100644 index 0000000000..7badd7e62a --- /dev/null +++ b/test/api/unit/libs/payments/stripe/calculate-termination-date.test.js @@ -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); + }); +}); diff --git a/test/api/unit/models/group.test.js b/test/api/unit/models/group.test.js index 5cb124d96f..c75d1dc7fc 100644 --- a/test/api/unit/models/group.test.js +++ b/test/api/unit/models/group.test.js @@ -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; }); }); diff --git a/test/api/v3/integration/groups/POST-groups_groupId_leave.js b/test/api/v3/integration/groups/POST-groups_groupId_leave.test.js similarity index 86% rename from test/api/v3/integration/groups/POST-groups_groupId_leave.js rename to test/api/v3/integration/groups/POST-groups_groupId_leave.test.js index c4764e3428..fea70d3176 100644 --- a/test/api/v3/integration/groups/POST-groups_groupId_leave.js +++ b/test/api/v3/integration/groups/POST-groups_groupId_leave.test.js @@ -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); + }); + }); + }); }); diff --git a/test/helpers/api-integration/v3/object-generators.js b/test/helpers/api-integration/v3/object-generators.js index e713c1084c..b48dd862a9 100644 --- a/test/helpers/api-integration/v3/object-generators.js +++ b/test/helpers/api-integration/v3/object-generators.js @@ -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, diff --git a/website/server/controllers/api-v3/groups.js b/website/server/controllers/api-v3/groups.js index f411c9db1f..bf7119c02a 100644 --- a/website/server/controllers/api-v3/groups.js +++ b/website/server/controllers/api-v3/groups.js @@ -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); }, diff --git a/website/server/libs/chat/group-chat.js b/website/server/libs/chat/group-chat.js index 1a864b0f47..dc6b0d8278 100644 --- a/website/server/libs/chat/group-chat.js +++ b/website/server/libs/chat/group-chat.js @@ -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) diff --git a/website/server/libs/invites/index.js b/website/server/libs/invites/index.js index 58474510fb..1679b1bf73 100644 --- a/website/server/libs/invites/index.js +++ b/website/server/libs/invites/index.js @@ -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, diff --git a/website/server/libs/payments/subscriptions.js b/website/server/libs/payments/subscriptions.js index fbf81b90fb..4718ab7f02 100644 --- a/website/server/libs/payments/subscriptions.js +++ b/website/server/libs/payments/subscriptions.js @@ -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; diff --git a/website/server/libs/payments/util.js b/website/server/libs/payments/util.js new file mode 100644 index 0000000000..fb6b65daaa --- /dev/null +++ b/website/server/libs/payments/util.js @@ -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(); +} diff --git a/website/server/models/group.js b/website/server/models/group.js index b1e4c439e3..f9548d994f 100644 --- a/website/server/models/group.js +++ b/website/server/models/group.js @@ -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) { diff --git a/website/server/models/user/methods.js b/website/server/models/user/methods.js index 2bf8227e3a..70821e0b95 100644 --- a/website/server/models/user/methods.js +++ b/website/server/models/user/methods.js @@ -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 () {