mirror of
https://github.com/sudoxnym/habitica-self-host.git
synced 2026-04-14 19:47:03 +00:00
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:
commit
4fa381f153
11 changed files with 193 additions and 38 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
32
website/server/libs/payments/util.js
Normal file
32
website/server/libs/payments/util.js
Normal 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();
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
Loading…
Reference in a new issue