From be60fb06350123f5e5f8ea9c5d3e8b320b6f8a04 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Mon, 6 Mar 2017 15:09:50 -0700 Subject: [PATCH] Group plans subs to all (#8394) * Added subscriptions to all members when group subs * Added unsub when group cancels * Give user a subscription when they join a subbed group * Removed subscription when user leaves or is removed from group * Fixed linting issues: * Added tests for users with a subscription being upgraded to group plan * Added tests for checking if existing recurring user sub gets updated during group plan. Added better merging for plans * Added test for existing gift subscriptions * Added additional months to user when they have an existing recurring subscription and get upgraded to group sub * Adds test for user who has cancelled with date termined in the future * Added test to ensure date termined is reset * Added tests for extra months carrying over * Added test for gems bought field * Add tests to for fields that should remain when upgrading * Added test for all payment methods * Added prevention for when a user joins a second group plan * Fixed subscribing tests * Separated group plan payment tests * Added prevention of editing a user with a unlimited sub * Add tests to ensure group keeps plan if they are in two and leave one * Ensured users with two group plans do not get cancelled when on group plan is cancelled * Ensured users without group sub are untouched when group cancels * Fixed lint issues * Added new emails * Added fix for cron tests * Add restore to stubbed methods * Ensured cancelled group subscriptions are updated * Changed group plan exist check to check for date terminated * Updated you cannont delete active group message * Removed description requirement * Added upgrade group plan for Amazon payments * Fixed lint issues * Fixed broken tests * Fixed user delete tests * Fixed function calls * Hid cancel button if user has group plan * Hide difficulty from rewards * Prevented add user functions to be called when group plan is cancelled * Fixed merge issue * Correctly displayed group price * Added message when you are about to join canclled group plan * Fixed linting issues * Updated tests to have no redirect to homes * Allowed leaving a group with a canceld subscription * Fixed spelling issues * Prevented user from changing leader with active sub * Added payment details title to replace subscription title * Ensured we do not count leader when displaying upcoming cost * Prevented party tasks from being displayed twice * Prevented cancelling and already cancelled sub * Fixed styles of subscriptions * Added more specific mystery item tests * Fixed test to refer to leader * Extended test range to account for short months * Fixed merge conflicts * Updated yarn file * Added missing locales * Trigger notification * Removed yarn * Fixed locales * Fixed scope mispelling * Fixed line endings * Removed extra advanced options from rewards * Prevent group leader from leaving an active group plan * Fixed issue with extra months applied to cancelled group plan * Ensured member count is calculated when updatedGroupPlan * Updated amazon payment method constant name * Added comment to cancel sub user method * Fixed smantic issues * Added unite test for user isSubscribed and hasNotCancelled * Add tests for isSubscribed and hasNotCanceled * Changed default days remaining to 2 days for group plans * Fixed logic with adding canceled notice to group invite --- .../groups/POST-groups_invite.test.js | 22 +- .../v3/integration/groups/PUT-groups.test.js | 11 + test/api/v3/unit/libs/amazonPayments.test.js | 94 ++- test/api/v3/unit/libs/cron.test.js | 3 + test/api/v3/unit/libs/payments.test.js | 214 +----- .../group-plans/group-payments-cancel.test.js | 326 +++++++++ .../group-plans/group-payments-create.test.js | 634 ++++++++++++++++++ test/api/v3/unit/libs/stripePayments.test.js | 57 ++ test/api/v3/unit/models/group.test.js | 100 ++- test/api/v3/unit/models/user.test.js | 67 +- test/helpers/api-integration/api-classes.js | 15 +- .../js/controllers/groupPlansCtrl.js | 2 +- .../client-old/js/controllers/guildsCtrl.js | 4 + .../client-old/js/controllers/partyCtrl.js | 10 + .../js/directives/task-list.directive.js | 156 ++--- .../client-old/js/services/paymentServices.js | 8 +- website/common/locales/en/groups.json | 520 +++++++------- .../script/content/subscriptionBlocks.js | 6 + website/server/controllers/api-v3/groups.js | 29 +- website/server/controllers/api-v3/user.js | 2 +- website/server/libs/amazonPayments.js | 39 +- website/server/libs/analyticsService.js | 4 +- website/server/libs/cron.js | 31 +- website/server/libs/payments.js | 201 +++++- website/server/libs/paypalPayments.js | 30 +- website/server/libs/stripePayments.js | 49 +- website/server/models/group.js | 37 +- website/server/models/user/methods.js | 34 +- .../views/options/settings/subscription.jade | 8 +- website/views/options/social/group.jade | 2 +- .../social/groups/group-subscription.jade | 4 +- .../shared/tasks/edit/advanced_options.jade | 53 +- .../tasks/edit/dailies/repeat_options.jade | 24 +- 33 files changed, 2128 insertions(+), 668 deletions(-) create mode 100644 test/api/v3/unit/libs/payments/group-plans/group-payments-cancel.test.js create mode 100644 test/api/v3/unit/libs/payments/group-plans/group-payments-create.test.js diff --git a/test/api/v3/integration/groups/POST-groups_invite.test.js b/test/api/v3/integration/groups/POST-groups_invite.test.js index d163964c37..522fa12043 100644 --- a/test/api/v3/integration/groups/POST-groups_invite.test.js +++ b/test/api/v3/integration/groups/POST-groups_invite.test.js @@ -1,5 +1,6 @@ import { generateUser, + generateGroup, translate as t, } from '../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; @@ -12,7 +13,7 @@ describe('Post /groups/:groupId/invite', () => { let groupName = 'Test Public Guild'; beforeEach(async () => { - inviter = await generateUser({balance: 1}); + inviter = await generateUser({balance: 4}); group = await inviter.post('/groups', { name: groupName, type: 'guild', @@ -265,6 +266,25 @@ describe('Post /groups/:groupId/invite', () => { expect(invitedUser.invitations.guilds[0].id).to.equal(group._id); expect(invite).to.exist; }); + + it('invites marks invite with cancelled plan', async () => { + let cancelledPlanGroup = await generateGroup(inviter, { + type: 'guild', + name: generateUUID(), + }); + await cancelledPlanGroup.createCancelledSubscription(); + + let newUser = await generateUser(); + let invite = await inviter.post(`/groups/${cancelledPlanGroup._id}/invite`, { + uuids: [newUser._id], + emails: [{name: 'test', email: 'test@habitica.com'}], + }); + let invitedUser = await newUser.get('/user'); + + expect(invitedUser.invitations.guilds[0].id).to.equal(cancelledPlanGroup._id); + expect(invitedUser.invitations.guilds[0].cancelledPlan).to.be.true; + expect(invite).to.exist; + }); }); describe('guild invites', () => { diff --git a/test/api/v3/integration/groups/PUT-groups.test.js b/test/api/v3/integration/groups/PUT-groups.test.js index 8d581d56ca..a5e4e091cd 100644 --- a/test/api/v3/integration/groups/PUT-groups.test.js +++ b/test/api/v3/integration/groups/PUT-groups.test.js @@ -43,4 +43,15 @@ describe('PUT /group', () => { expect(updatedGroup.leader.profile.name).to.eql(leader.profile.name); expect(updatedGroup.name).to.equal(groupUpdatedName); }); + + it('allows a leader to change leaders', async () => { + let updatedGroup = await leader.put(`/groups/${groupToUpdate._id}`, { + name: groupUpdatedName, + leader: nonLeader._id, + }); + + expect(updatedGroup.leader._id).to.eql(nonLeader._id); + expect(updatedGroup.leader.profile.name).to.eql(nonLeader.profile.name); + expect(updatedGroup.name).to.equal(groupUpdatedName); + }); }); diff --git a/test/api/v3/unit/libs/amazonPayments.test.js b/test/api/v3/unit/libs/amazonPayments.test.js index 0604d3755c..e4e593e7d7 100644 --- a/test/api/v3/unit/libs/amazonPayments.test.js +++ b/test/api/v3/unit/libs/amazonPayments.test.js @@ -1,10 +1,12 @@ import moment from 'moment'; import cc from 'coupon-code'; +import uuid from 'uuid'; import { generateGroup, } from '../../../../helpers/api-unit.helper.js'; import { model as User } from '../../../../../website/server/models/user'; +import { model as Group } from '../../../../../website/server/models/group'; import { model as Coupon } from '../../../../../website/server/models/coupon'; import amzLib from '../../../../../website/server/libs/amazonPayments'; import payments from '../../../../../website/server/libs/payments'; @@ -105,7 +107,7 @@ describe('Amazon Payments', () => { expect(paymentBuyGemsStub).to.be.calledOnce; expect(paymentBuyGemsStub).to.be.calledWith({ user, - paymentMethod: amzLib.constants.PAYMENT_METHOD_AMAZON, + paymentMethod: amzLib.constants.PAYMENT_METHOD, headers, }); expectAmazonStubs(); @@ -128,7 +130,7 @@ describe('Amazon Payments', () => { expect(paymentBuyGemsStub).to.be.calledOnce; expect(paymentBuyGemsStub).to.be.calledWith({ user, - paymentMethod: amzLib.constants.PAYMENT_METHOD_AMAZON_GIFT, + paymentMethod: amzLib.constants.PAYMENT_METHOD_GIFT, headers, gift, }); @@ -153,7 +155,7 @@ describe('Amazon Payments', () => { expect(paymentCreateSubscritionStub).to.be.calledOnce; expect(paymentCreateSubscritionStub).to.be.calledWith({ user, - paymentMethod: amzLib.constants.PAYMENT_METHOD_AMAZON_GIFT, + paymentMethod: amzLib.constants.PAYMENT_METHOD_GIFT, headers, gift, }); @@ -316,7 +318,7 @@ describe('Amazon Payments', () => { expect(createSubSpy).to.be.calledWith({ user, customerId: billingAgreementId, - paymentMethod: amzLib.constants.PAYMENT_METHOD_AMAZON, + paymentMethod: amzLib.constants.PAYMENT_METHOD, sub, headers, groupId, @@ -375,7 +377,7 @@ describe('Amazon Payments', () => { expect(createSubSpy).to.be.calledWith({ user, customerId: billingAgreementId, - paymentMethod: amzLib.constants.PAYMENT_METHOD_AMAZON, + paymentMethod: amzLib.constants.PAYMENT_METHOD, sub, headers, groupId, @@ -455,7 +457,7 @@ describe('Amazon Payments', () => { user, groupId: undefined, nextBill: moment(user.purchased.plan.lastBillingDate).add({ days: subscriptionLength }), - paymentMethod: amzLib.constants.PAYMENT_METHOD_AMAZON, + paymentMethod: amzLib.constants.PAYMENT_METHOD, headers, }); expectAmazonStubs(); @@ -485,7 +487,7 @@ describe('Amazon Payments', () => { user, groupId: undefined, nextBill: moment(user.purchased.plan.lastBillingDate).add({ days: subscriptionLength }), - paymentMethod: amzLib.constants.PAYMENT_METHOD_AMAZON, + paymentMethod: amzLib.constants.PAYMENT_METHOD, headers, }); amzLib.closeBillingAgreement.restore(); @@ -523,7 +525,7 @@ describe('Amazon Payments', () => { user, groupId: group._id, nextBill: moment(group.purchased.plan.lastBillingDate).add({ days: subscriptionLength }), - paymentMethod: amzLib.constants.PAYMENT_METHOD_AMAZON, + paymentMethod: amzLib.constants.PAYMENT_METHOD, headers, }); expectAmazonStubs(); @@ -553,10 +555,84 @@ describe('Amazon Payments', () => { user, groupId: group._id, nextBill: moment(group.purchased.plan.lastBillingDate).add({ days: subscriptionLength }), - paymentMethod: amzLib.constants.PAYMENT_METHOD_AMAZON, + paymentMethod: amzLib.constants.PAYMENT_METHOD, headers, }); amzLib.closeBillingAgreement.restore(); }); }); + + describe('#upgradeGroupPlan', () => { + let spy, data, user, group, uuidString; + + beforeEach(async function () { + user = new User(); + user.profile.name = 'sender'; + + data = { + user, + sub: { + key: 'basic_3mo', // @TODO: Validate that this is group + }, + customerId: 'customer-id', + paymentMethod: 'Payment Method', + headers: { + 'x-client': 'habitica-web', + 'user-agent': '', + }, + }; + + group = generateGroup({ + name: 'test group', + type: 'guild', + privacy: 'public', + leader: user._id, + }); + await group.save(); + + spy = sinon.stub(amzLib, 'authorizeOnBillingAgreement'); + spy.returnsPromise().resolves([]); + + uuidString = 'uuid-v4'; + sinon.stub(uuid, 'v4').returns(uuidString); + + data.groupId = group._id; + data.sub.quantity = 3; + }); + + afterEach(function () { + sinon.restore(amzLib.authorizeOnBillingAgreement); + uuid.v4.restore(); + }); + + it('charges for a new member', async () => { + data.paymentMethod = amzLib.constants.PAYMENT_METHOD; + await payments.createSubscription(data); + + let updatedGroup = await Group.findById(group._id).exec(); + + updatedGroup.memberCount += 1; + await updatedGroup.save(); + + await amzLib.chargeForAdditionalGroupMember(updatedGroup); + + expect(spy.calledOnce).to.be.true; + expect(spy).to.be.calledWith({ + AmazonBillingAgreementId: updatedGroup.purchased.plan.customerId, + AuthorizationReferenceId: uuidString.substring(0, 32), + AuthorizationAmount: { + CurrencyCode: amzLib.constants.CURRENCY_CODE, + Amount: 3, + }, + SellerAuthorizationNote: amzLib.constants.SELLER_NOTE_GROUP_NEW_MEMBER, + TransactionTimeout: 0, + CaptureNow: true, + SellerNote: amzLib.constants.SELLER_NOTE_GROUP_NEW_MEMBER, + SellerOrderAttributes: { + SellerOrderId: uuidString, + StoreName: amzLib.constants.STORE_NAME, + }, + }); + }); + }); }); diff --git a/test/api/v3/unit/libs/cron.test.js b/test/api/v3/unit/libs/cron.test.js index 4e8835988a..df8becc493 100644 --- a/test/api/v3/unit/libs/cron.test.js +++ b/test/api/v3/unit/libs/cron.test.js @@ -134,7 +134,10 @@ describe('cron', () => { user.purchased.plan.dateUpdated = moment().subtract(6, 'months').toDate(); user.purchased.plan.dateTerminated = moment().subtract(3, 'months').toDate(); user.purchased.plan.consecutive.count = 5; + user.purchased.plan.consecutive.trinkets = 1; + cron({user, tasksByType, daysMissed, analytics}); + expect(user.purchased.plan.consecutive.trinkets).to.equal(1); }); diff --git a/test/api/v3/unit/libs/payments.test.js b/test/api/v3/unit/libs/payments.test.js index a1a2b19b28..56057f8c23 100644 --- a/test/api/v3/unit/libs/payments.test.js +++ b/test/api/v3/unit/libs/payments.test.js @@ -1,22 +1,18 @@ +import moment from 'moment'; + import * as sender from '../../../../../website/server/libs/email'; import * as api from '../../../../../website/server/libs/payments'; import analytics from '../../../../../website/server/libs/analyticsService'; import notifications from '../../../../../website/server/libs/pushNotifications'; import { model as User } from '../../../../../website/server/models/user'; -import { model as Group } from '../../../../../website/server/models/group'; -import stripeModule from 'stripe'; -import moment from 'moment'; import { translate as t } from '../../../../helpers/api-v3-integration.helper'; import { generateGroup, } from '../../../../helpers/api-unit.helper.js'; -import i18n from '../../../../../website/common/script/i18n'; describe('payments/index', () => { let user, group, data, plan; - let stripe = stripeModule('test'); - beforeEach(async () => { user = new User(); user.profile.name = 'sender'; @@ -319,53 +315,6 @@ describe('payments/index', () => { }); }); - context('Purchasing a subscription for group', () => { - it('creates a subscription', async () => { - expect(group.purchased.plan.planId).to.not.exist; - data.groupId = group._id; - - await api.createSubscription(data); - - let updatedGroup = await Group.findById(group._id).exec(); - - expect(updatedGroup.purchased.plan.planId).to.eql('basic_3mo'); - expect(updatedGroup.purchased.plan.customerId).to.eql('customer-id'); - expect(updatedGroup.purchased.plan.dateUpdated).to.exist; - expect(updatedGroup.purchased.plan.gemsBought).to.eql(0); - expect(updatedGroup.purchased.plan.paymentMethod).to.eql('Payment Method'); - expect(updatedGroup.purchased.plan.extraMonths).to.eql(0); - expect(updatedGroup.purchased.plan.dateTerminated).to.eql(null); - expect(updatedGroup.purchased.plan.lastBillingDate).to.not.exist; - expect(updatedGroup.purchased.plan.dateCreated).to.exist; - }); - - it('sets extraMonths if plan has dateTerminated date', async () => { - group.purchased.plan = plan; - group.purchased.plan.dateTerminated = moment(new Date()).add(2, 'months'); - await group.save(); - expect(group.purchased.plan.extraMonths).to.eql(0); - data.groupId = group._id; - - await api.createSubscription(data); - - let updatedGroup = await Group.findById(group._id).exec(); - expect(updatedGroup.purchased.plan.extraMonths).to.within(1.9, 2); - }); - - it('does not set negative extraMonths if plan has past dateTerminated date', async () => { - group.purchased.plan = plan; - group.purchased.plan.dateTerminated = moment(new Date()).subtract(2, 'months'); - await group.save(); - expect(group.purchased.plan.extraMonths).to.eql(0); - data.groupId = group._id; - - await api.createSubscription(data); - - let updatedGroup = await Group.findById(group._id).exec(); - expect(updatedGroup.purchased.plan.extraMonths).to.eql(0); - }); - }); - context('Block subscription perks', () => { it('adds block months to plan.consecutive.offset', async () => { await api.createSubscription(data); @@ -558,112 +507,6 @@ describe('payments/index', () => { expect(sender.sendTxn).to.be.calledWith(user, 'cancel-subscription'); }); }); - - context('Canceling a subscription for group', () => { - it('adds a month termination date by default', async () => { - data.groupId = group._id; - await api.cancelSubscription(data); - - let now = new Date(); - let updatedGroup = await Group.findById(group._id).exec(); - let daysTillTermination = moment(updatedGroup.purchased.plan.dateTerminated).diff(now, 'days'); - - expect(daysTillTermination).to.be.within(29, 30); // 1 month +/- 1 days - }); - - it('adds extraMonths to dateTerminated value', async () => { - group.purchased.plan.extraMonths = 2; - await group.save(); - data.groupId = group._id; - - await api.cancelSubscription(data); - - let now = new Date(); - let updatedGroup = await Group.findById(group._id).exec(); - let daysTillTermination = moment(updatedGroup.purchased.plan.dateTerminated).diff(now, 'days'); - - expect(daysTillTermination).to.be.within(89, 90); // 3 months +/- 1 days - }); - - it('handles extra month fractions', async () => { - group.purchased.plan.extraMonths = 0.3; - await group.save(); - data.groupId = group._id; - - await api.cancelSubscription(data); - - let now = new Date(); - let updatedGroup = await Group.findById(group._id).exec(); - let daysTillTermination = moment(updatedGroup.purchased.plan.dateTerminated).diff(now, 'days'); - - expect(daysTillTermination).to.be.within(38, 39); // should be about 1 month + 1/3 month - }); - - it('terminates at next billing date if it exists', async () => { - data.nextBill = moment().add({ days: 15 }); - data.groupId = group._id; - - await api.cancelSubscription(data); - - let now = new Date(); - let updatedGroup = await Group.findById(group._id).exec(); - let daysTillTermination = moment(updatedGroup.purchased.plan.dateTerminated).diff(now, 'days'); - - expect(daysTillTermination).to.be.within(13, 15); - }); - - it('resets plan.extraMonths', async () => { - group.purchased.plan.extraMonths = 5; - await group.save(); - data.groupId = group._id; - - await api.cancelSubscription(data); - - let updatedGroup = await Group.findById(group._id).exec(); - expect(updatedGroup.purchased.plan.extraMonths).to.eql(0); - }); - - it('sends an email', async () => { - data.groupId = group._id; - await api.cancelSubscription(data); - - expect(sender.sendTxn).to.be.calledOnce; - expect(sender.sendTxn).to.be.calledWith(user, 'group-cancel-subscription'); - }); - - it('prevents non group leader from manging subscription', async () => { - let groupMember = new User(); - data.user = groupMember; - data.groupId = group._id; - - await expect(api.cancelSubscription(data)) - .eventually.be.rejected.and.to.eql({ - httpCode: 401, - message: i18n.t('onlyGroupLeaderCanManageSubscription'), - name: 'NotAuthorized', - }); - }); - - it('allows old group leader to cancel if they created the subscription', async () => { - data.groupId = group._id; - data.sub = { - key: 'group_monthly', - }; - data.paymentMethod = 'Payment Method'; - await api.createSubscription(data); - - let updatedGroup = await Group.findById(group._id).exec(); - let newLeader = new User(); - updatedGroup.leader = newLeader._id; - await updatedGroup.save(); - - await api.cancelSubscription(data); - - updatedGroup = await Group.findById(group._id).exec(); - - expect(updatedGroup.purchased.plan.dateTerminated).to.exist; - }); - }); }); describe('#buyGems', () => { @@ -771,49 +614,24 @@ describe('payments/index', () => { }); }); - describe('#upgradeGroupPlan', () => { - let spy; - - beforeEach(function () { - spy = sinon.stub(stripe.subscriptions, 'update'); - spy.returnsPromise().resolves([]); + describe('addSubToGroupUser', () => { + it('adds a group subscription to a new user', async () => { + expect(group.purchased.plan.planId).to.not.exist; data.groupId = group._id; - data.sub.quantity = 3; - }); - afterEach(function () { - sinon.restore(stripe.subscriptions.update); - }); + await api.addSubToGroupUser(user, group); - it('updates a group plan quantity', async () => { - data.paymentMethod = 'Stripe'; - await api.createSubscription(data); + let updatedUser = await User.findById(user._id).exec(); - let updatedGroup = await Group.findById(group._id).exec(); - expect(updatedGroup.purchased.plan.quantity).to.eql(3); - - updatedGroup.memberCount += 1; - await updatedGroup.save(); - - await api.updateStripeGroupPlan(updatedGroup, stripe); - - expect(spy.calledOnce).to.be.true; - expect(updatedGroup.purchased.plan.quantity).to.eql(4); - }); - - it('does not update a group plan quantity that has a payment method other than stripe', async () => { - await api.createSubscription(data); - - let updatedGroup = await Group.findById(group._id).exec(); - expect(updatedGroup.purchased.plan.quantity).to.eql(3); - - updatedGroup.memberCount += 1; - await updatedGroup.save(); - - await api.updateStripeGroupPlan(updatedGroup, stripe); - - expect(spy.calledOnce).to.be.false; - expect(updatedGroup.purchased.plan.quantity).to.eql(3); + expect(updatedUser.purchased.plan.planId).to.eql('group_plan_auto'); + expect(updatedUser.purchased.plan.customerId).to.eql('group-plan'); + expect(updatedUser.purchased.plan.dateUpdated).to.exist; + expect(updatedUser.purchased.plan.gemsBought).to.eql(0); + expect(updatedUser.purchased.plan.paymentMethod).to.eql('Group Plan'); + expect(updatedUser.purchased.plan.extraMonths).to.eql(0); + expect(updatedUser.purchased.plan.dateTerminated).to.eql(null); + expect(updatedUser.purchased.plan.lastBillingDate).to.not.exist; + expect(updatedUser.purchased.plan.dateCreated).to.exist; }); }); }); diff --git a/test/api/v3/unit/libs/payments/group-plans/group-payments-cancel.test.js b/test/api/v3/unit/libs/payments/group-plans/group-payments-cancel.test.js new file mode 100644 index 0000000000..5a7dac424f --- /dev/null +++ b/test/api/v3/unit/libs/payments/group-plans/group-payments-cancel.test.js @@ -0,0 +1,326 @@ +import moment from 'moment'; + +import * as sender from '../../../../../../../website/server/libs/email'; +import * as api from '../../../../../../../website/server/libs/payments'; +import { model as User } from '../../../../../../../website/server/models/user'; +import { model as Group } from '../../../../../../../website/server/models/group'; +import { + generateGroup, +} from '../../../../../../helpers/api-unit.helper.js'; +import i18n from '../../../../../../../website/common/script/i18n'; + +describe('Canceling a subscription for group', () => { + let plan, group, user, data; + + beforeEach(async () => { + user = new User(); + user.profile.name = 'sender'; + await user.save(); + + group = generateGroup({ + name: 'test group', + type: 'guild', + privacy: 'public', + leader: user._id, + }); + await group.save(); + + data = { + user, + sub: { + key: 'basic_3mo', + }, + customerId: 'customer-id', + paymentMethod: 'Payment Method', + headers: { + 'x-client': 'habitica-web', + 'user-agent': '', + }, + }; + + plan = { + planId: 'basic_3mo', + customerId: 'customer-id', + dateUpdated: new Date(), + gemsBought: 0, + paymentMethod: 'paymentMethod', + extraMonths: 0, + dateTerminated: null, + lastBillingDate: new Date(), + dateCreated: new Date(), + mysteryItems: [], + consecutive: { + trinkets: 0, + offset: 0, + gemCapExtra: 0, + }, + }; + + sandbox.stub(sender, 'sendTxn'); + }); + + afterEach(() => { + sender.sendTxn.restore(); + }); + + it('adds a month termination date by default', async () => { + data.groupId = group._id; + await api.cancelSubscription(data); + + let now = new Date(); + let updatedGroup = await Group.findById(group._id).exec(); + let daysTillTermination = moment(updatedGroup.purchased.plan.dateTerminated).diff(now, 'days'); + + expect(daysTillTermination).to.be.within(29, 30); // 1 month +/- 1 days + }); + + it('adds extraMonths to dateTerminated value', async () => { + group.purchased.plan.extraMonths = 2; + await group.save(); + data.groupId = group._id; + + await api.cancelSubscription(data); + + let now = new Date(); + let updatedGroup = await Group.findById(group._id).exec(); + let daysTillTermination = moment(updatedGroup.purchased.plan.dateTerminated).diff(now, 'days'); + + expect(daysTillTermination).to.be.within(89, 90); // 3 months +/- 1 days + }); + + it('handles extra month fractions', async () => { + group.purchased.plan.extraMonths = 0.3; + await group.save(); + data.groupId = group._id; + + await api.cancelSubscription(data); + + let now = new Date(); + let updatedGroup = await Group.findById(group._id).exec(); + let daysTillTermination = moment(updatedGroup.purchased.plan.dateTerminated).diff(now, 'days'); + + expect(daysTillTermination).to.be.within(38, 39); // should be about 1 month + 1/3 month + }); + + it('terminates at next billing date if it exists', async () => { + data.nextBill = moment().add({ days: 15 }); + data.groupId = group._id; + + await api.cancelSubscription(data); + + let now = new Date(); + let updatedGroup = await Group.findById(group._id).exec(); + let daysTillTermination = moment(updatedGroup.purchased.plan.dateTerminated).diff(now, 'days'); + + expect(daysTillTermination).to.be.within(13, 15); + }); + + it('resets plan.extraMonths', async () => { + group.purchased.plan.extraMonths = 5; + await group.save(); + data.groupId = group._id; + + await api.cancelSubscription(data); + + let updatedGroup = await Group.findById(group._id).exec(); + expect(updatedGroup.purchased.plan.extraMonths).to.eql(0); + }); + + it('sends an email', async () => { + data.groupId = group._id; + await api.cancelSubscription(data); + + expect(sender.sendTxn).to.be.calledOnce; + expect(sender.sendTxn.firstCall.args[0]._id).to.equal(user._id); + expect(sender.sendTxn.firstCall.args[1]).to.equal('group-cancel-subscription'); + expect(sender.sendTxn.firstCall.args[2]).to.eql([ + {name: 'GROUP_NAME', content: group.name}, + ]); + }); + + it('prevents non group leader from manging subscription', async () => { + let groupMember = new User(); + data.user = groupMember; + data.groupId = group._id; + + await expect(api.cancelSubscription(data)) + .eventually.be.rejected.and.to.eql({ + httpCode: 401, + message: i18n.t('onlyGroupLeaderCanManageSubscription'), + name: 'NotAuthorized', + }); + }); + + it('allows old group leader to cancel if they created the subscription', async () => { + data.groupId = group._id; + data.sub = { + key: 'group_monthly', + }; + data.paymentMethod = 'Payment Method'; + await api.createSubscription(data); + + let updatedGroup = await Group.findById(group._id).exec(); + let newLeader = new User(); + updatedGroup.leader = newLeader._id; + await updatedGroup.save(); + + await api.cancelSubscription(data); + + updatedGroup = await Group.findById(group._id).exec(); + + expect(updatedGroup.purchased.plan.dateTerminated).to.exist; + }); + + it('cancels member subscriptions', async () => { + data = { + user, + sub: { + key: 'basic_3mo', + }, + customerId: 'customer-id', + paymentMethod: 'Payment Method', + headers: { + 'x-client': 'habitica-web', + 'user-agent': '', + }, + }; + user.guilds.push(group._id); + await user.save(); + expect(group.purchased.plan.planId).to.not.exist; + data.groupId = group._id; + await api.createSubscription(data); + + await api.cancelSubscription(data); + + let now = new Date(); + now.setHours(0, 0, 0, 0); + let updatedLeader = await User.findById(user._id).exec(); + let daysTillTermination = moment(updatedLeader.purchased.plan.dateTerminated).diff(now, 'days'); + expect(daysTillTermination).to.be.within(2, 3); // only a few days + }); + + it('sends an email to members of group', async () => { + let recipient = new User(); + recipient.profile.name = 'recipient'; + recipient.guilds.push(group._id); + await recipient.save(); + + data.groupId = group._id; + + await api.createSubscription(data); + await api.cancelSubscription(data); + + expect(sender.sendTxn).to.be.have.callCount(4); + expect(sender.sendTxn.thirdCall.args[0]._id).to.equal(recipient._id); + expect(sender.sendTxn.thirdCall.args[1]).to.equal('group-member-cancel'); + expect(sender.sendTxn.thirdCall.args[2]).to.eql([ + {name: 'LEADER', content: user.profile.name}, + {name: 'GROUP_NAME', content: group.name}, + ]); + }); + + it('does not cancel member subscriptions when member does not have a group plan sub (i.e. UNLIMITED_CUSTOMER_ID)', async () => { + plan.key = 'basic_earned'; + plan.customerId = api.constants.UNLIMITED_CUSTOMER_ID; + + let recipient = new User(); + recipient.profile.name = 'recipient'; + recipient.purchased.plan = plan; + recipient.guilds.push(group._id); + await recipient.save(); + + data.groupId = group._id; + + await api.cancelSubscription(data); + + let updatedLeader = await User.findById(user._id).exec(); + expect(updatedLeader.purchased.plan.dateTerminated).to.not.exist; + }); + + it('does not cancel a user subscription if they are still in another active group plan', async () => { + let recipient = new User(); + recipient.profile.name = 'recipient'; + plan.key = 'basic_earned'; + recipient.purchased.plan = plan; + recipient.guilds.push(group._id); + await recipient.save(); + + user.guilds.push(group._id); + await user.save(); + data.groupId = group._id; + + await api.createSubscription(data); + + let updatedUser = await User.findById(recipient._id).exec(); + let firstDateCreated = updatedUser.purchased.plan.dateCreated; + let extraMonthsBeforeSecond = updatedUser.purchased.plan.extraMonths; + + let group2 = generateGroup({ + name: 'test group2', + type: 'guild', + privacy: 'public', + leader: user._id, + }); + data.groupId = group2._id; + await group2.save(); + recipient.guilds.push(group2._id); + await recipient.save(); + + await api.createSubscription(data); + + await api.cancelSubscription(data); + + updatedUser = await User.findById(recipient._id).exec(); + + expect(updatedUser.purchased.plan.planId).to.eql('group_plan_auto'); + expect(updatedUser.purchased.plan.customerId).to.eql('group-plan'); + expect(updatedUser.purchased.plan.dateUpdated).to.exist; + expect(updatedUser.purchased.plan.gemsBought).to.eql(0); + expect(updatedUser.purchased.plan.paymentMethod).to.eql('Group Plan'); + expect(updatedUser.purchased.plan.extraMonths).to.eql(extraMonthsBeforeSecond); + expect(updatedUser.purchased.plan.dateTerminated).to.eql(null); + expect(updatedUser.purchased.plan.lastBillingDate).to.not.exist; + expect(updatedUser.purchased.plan.dateCreated).to.eql(firstDateCreated); + }); + + it('does cancel a leader subscription with two cancelled group plans', async () => { + user.guilds.push(group._id); + await user.save(); + data.groupId = group._id; + + await api.createSubscription(data); + + let updatedUser = await User.findById(user._id).exec(); + let firstDateCreated = updatedUser.purchased.plan.dateCreated; + let extraMonthsBeforeSecond = updatedUser.purchased.plan.extraMonths; + + let group2 = generateGroup({ + name: 'test group2', + type: 'guild', + privacy: 'public', + leader: user._id, + }); + user.guilds.push(group2._id); + await user.save(); + data.groupId = group2._id; + await group2.save(); + + await api.createSubscription(data); + await api.cancelSubscription(data); + + data.groupId = group._id; + await api.cancelSubscription(data); + + updatedUser = await User.findById(user._id).exec(); + + expect(updatedUser.purchased.plan.planId).to.eql('group_plan_auto'); + expect(updatedUser.purchased.plan.customerId).to.eql('group-plan'); + expect(updatedUser.purchased.plan.dateUpdated).to.exist; + expect(updatedUser.purchased.plan.gemsBought).to.eql(0); + expect(updatedUser.purchased.plan.paymentMethod).to.eql('Group Plan'); + expect(updatedUser.purchased.plan.extraMonths).to.eql(extraMonthsBeforeSecond); + expect(updatedUser.purchased.plan.dateTerminated).to.exist; + expect(updatedUser.purchased.plan.lastBillingDate).to.not.exist; + expect(updatedUser.purchased.plan.dateCreated).to.eql(firstDateCreated); + }); +}); diff --git a/test/api/v3/unit/libs/payments/group-plans/group-payments-create.test.js b/test/api/v3/unit/libs/payments/group-plans/group-payments-create.test.js new file mode 100644 index 0000000000..2aefdcabcf --- /dev/null +++ b/test/api/v3/unit/libs/payments/group-plans/group-payments-create.test.js @@ -0,0 +1,634 @@ +import moment from 'moment'; +import stripeModule from 'stripe'; + +import * as sender from '../../../../../../../website/server/libs/email'; +import * as api from '../../../../../../../website/server/libs/payments'; +import amzLib from '../../../../../../../website/server/libs/amazonPayments'; +import stripePayments from '../../../../../../../website/server/libs/stripePayments'; +import paypalPayments from '../../../../../../../website/server/libs/paypalPayments'; +import { model as User } from '../../../../../../../website/server/models/user'; +import { model as Group } from '../../../../../../../website/server/models/group'; +import { + generateGroup, +} from '../../../../../../helpers/api-unit.helper.js'; + +describe('Purchasing a subscription for group', () => { + let plan, group, user, data; + let stripe = stripeModule('test'); + + beforeEach(async () => { + user = new User(); + user.profile.name = 'sender'; + await user.save(); + + group = generateGroup({ + name: 'test group', + type: 'guild', + privacy: 'public', + leader: user._id, + }); + await group.save(); + + data = { + user, + sub: { + key: 'basic_3mo', + }, + customerId: 'customer-id', + paymentMethod: 'Payment Method', + headers: { + 'x-client': 'habitica-web', + 'user-agent': '', + }, + }; + + plan = { + planId: 'basic_3mo', + customerId: 'customer-id', + dateUpdated: new Date(), + gemsBought: 0, + paymentMethod: 'paymentMethod', + extraMonths: 0, + dateTerminated: null, + lastBillingDate: new Date(), + dateCreated: new Date(), + mysteryItems: [], + consecutive: { + trinkets: 0, + offset: 0, + gemCapExtra: 0, + }, + }; + + let subscriptionId = 'subId'; + sinon.stub(stripe.customers, 'del').returnsPromise().resolves({}); + + let currentPeriodEndTimeStamp = moment().add(3, 'months').unix(); + sinon.stub(stripe.customers, 'retrieve') + .returnsPromise().resolves({ + subscriptions: { + data: [{id: subscriptionId, current_period_end: currentPeriodEndTimeStamp}], // eslint-disable-line camelcase + }, + }); + + stripePayments.setStripeApi(stripe); + sandbox.stub(sender, 'sendTxn'); + }); + + afterEach(() => { + stripe.customers.del.restore(); + stripe.customers.retrieve.restore(); + sender.sendTxn.restore(); + }); + + it('creates a subscription', async () => { + expect(group.purchased.plan.planId).to.not.exist; + data.groupId = group._id; + + await api.createSubscription(data); + + let updatedGroup = await Group.findById(group._id).exec(); + + expect(updatedGroup.purchased.plan.planId).to.eql('basic_3mo'); + expect(updatedGroup.purchased.plan.customerId).to.eql('customer-id'); + expect(updatedGroup.purchased.plan.dateUpdated).to.exist; + expect(updatedGroup.purchased.plan.gemsBought).to.eql(0); + expect(updatedGroup.purchased.plan.paymentMethod).to.eql('Payment Method'); + expect(updatedGroup.purchased.plan.extraMonths).to.eql(0); + expect(updatedGroup.purchased.plan.dateTerminated).to.eql(null); + expect(updatedGroup.purchased.plan.lastBillingDate).to.not.exist; + expect(updatedGroup.purchased.plan.dateCreated).to.exist; + }); + + it('sends an email', async () => { + expect(group.purchased.plan.planId).to.not.exist; + data.groupId = group._id; + + await api.createSubscription(data); + + expect(sender.sendTxn).to.be.calledWith(user, 'group-subscription-begins'); + }); + + it('sets extraMonths if plan has dateTerminated date', async () => { + group.purchased.plan = plan; + group.purchased.plan.dateTerminated = moment(new Date()).add(2, 'months'); + await group.save(); + expect(group.purchased.plan.extraMonths).to.eql(0); + data.groupId = group._id; + + await api.createSubscription(data); + + let updatedGroup = await Group.findById(group._id).exec(); + expect(updatedGroup.purchased.plan.extraMonths).to.within(1.9, 2); + }); + + it('does not set negative extraMonths if plan has past dateTerminated date', async () => { + group.purchased.plan = plan; + group.purchased.plan.dateTerminated = moment(new Date()).subtract(2, 'months'); + await group.save(); + expect(group.purchased.plan.extraMonths).to.eql(0); + data.groupId = group._id; + + await api.createSubscription(data); + + let updatedGroup = await Group.findById(group._id).exec(); + expect(updatedGroup.purchased.plan.extraMonths).to.eql(0); + }); + + it('grants all members of a group a subscription', async () => { + user.guilds.push(group._id); + await user.save(); + expect(group.purchased.plan.planId).to.not.exist; + data.groupId = group._id; + + await api.createSubscription(data); + + let updatedLeader = await User.findById(user._id).exec(); + + expect(updatedLeader.purchased.plan.planId).to.eql('group_plan_auto'); + expect(updatedLeader.purchased.plan.customerId).to.eql('group-plan'); + expect(updatedLeader.purchased.plan.dateUpdated).to.exist; + expect(updatedLeader.purchased.plan.gemsBought).to.eql(0); + expect(updatedLeader.purchased.plan.paymentMethod).to.eql('Group Plan'); + expect(updatedLeader.purchased.plan.extraMonths).to.eql(0); + expect(updatedLeader.purchased.plan.dateTerminated).to.eql(null); + expect(updatedLeader.purchased.plan.lastBillingDate).to.not.exist; + expect(updatedLeader.purchased.plan.dateCreated).to.exist; + }); + + it('sends an email to members of group', async () => { + let recipient = new User(); + recipient.profile.name = 'recipient'; + recipient.guilds.push(group._id); + await recipient.save(); + + data.groupId = group._id; + + await api.createSubscription(data); + + expect(sender.sendTxn).to.be.calledTwice; + expect(sender.sendTxn.firstCall.args[0]._id).to.equal(recipient._id); + expect(sender.sendTxn.firstCall.args[1]).to.equal('group-member-joining'); + expect(sender.sendTxn.firstCall.args[2]).to.eql([ + {name: 'LEADER', content: user.profile.name}, + {name: 'GROUP_NAME', content: group.name}, + ]); + }); + + it('adds months to members with existing gift subscription', async () => { + let recipient = new User(); + recipient.profile.name = 'recipient'; + recipient.purchased.plan = plan; + recipient.guilds.push(group._id); + plan.planId = 'basic_earned'; + plan.paymentMethod = 'paymentMethod'; + data.gift = { + member: recipient, + subscription: { + key: 'basic_earned', + months: 1, + }, + }; + await api.createSubscription(data); + await recipient.save(); + + data.gift = undefined; + + user.guilds.push(group._id); + await user.save(); + data.groupId = group._id; + + await api.createSubscription(data); + + let updatedUser = await User.findById(recipient._id).exec(); + + expect(updatedUser.purchased.plan.planId).to.eql('group_plan_auto'); + expect(updatedUser.purchased.plan.customerId).to.eql('group-plan'); + expect(updatedUser.purchased.plan.dateUpdated).to.exist; + expect(updatedUser.purchased.plan.gemsBought).to.eql(0); + expect(updatedUser.purchased.plan.paymentMethod).to.eql('Group Plan'); + expect(updatedUser.purchased.plan.extraMonths).to.within(1, 3); + expect(updatedUser.purchased.plan.dateTerminated).to.eql(null); + expect(updatedUser.purchased.plan.lastBillingDate).to.not.exist; + expect(updatedUser.purchased.plan.dateCreated).to.exist; + }); + + it('adds months to members with existing multi-month gift subscription', async () => { + let recipient = new User(); + recipient.profile.name = 'recipient'; + recipient.purchased.plan = plan; + recipient.guilds.push(group._id); + data.gift = { + member: recipient, + subscription: { + key: 'basic_3mo', + months: 3, + }, + }; + await api.createSubscription(data); + await recipient.save(); + + data.gift = undefined; + + user.guilds.push(group._id); + await user.save(); + data.groupId = group._id; + + await api.createSubscription(data); + + let updatedUser = await User.findById(recipient._id).exec(); + + expect(updatedUser.purchased.plan.planId).to.eql('group_plan_auto'); + expect(updatedUser.purchased.plan.customerId).to.eql('group-plan'); + expect(updatedUser.purchased.plan.dateUpdated).to.exist; + expect(updatedUser.purchased.plan.gemsBought).to.eql(0); + expect(updatedUser.purchased.plan.paymentMethod).to.eql('Group Plan'); + expect(updatedUser.purchased.plan.extraMonths).to.within(3, 5); + expect(updatedUser.purchased.plan.dateTerminated).to.eql(null); + expect(updatedUser.purchased.plan.lastBillingDate).to.not.exist; + expect(updatedUser.purchased.plan.dateCreated).to.exist; + }); + + it('adds months to members with existing recurring subscription (Stripe)', async () => { + let recipient = new User(); + recipient.profile.name = 'recipient'; + plan.key = 'basic_earned'; + plan.paymentMethod = stripePayments.constants.PAYMENT_METHOD; + recipient.purchased.plan = plan; + recipient.guilds.push(group._id); + await recipient.save(); + + user.guilds.push(group._id); + await user.save(); + data.groupId = group._id; + + await api.createSubscription(data); + + let updatedUser = await User.findById(recipient._id).exec(); + + expect(updatedUser.purchased.plan.extraMonths).to.within(2, 3); + }); + + it('adds months to members with existing recurring subscription (Amazon)', async () => { + sinon.stub(amzLib, 'getBillingAgreementDetails') + .returnsPromise() + .resolves({ + BillingAgreementDetails: { + BillingAgreementStatus: {State: 'Closed'}, + }, + }); + + let recipient = new User(); + recipient.profile.name = 'recipient'; + plan.planId = 'basic_earned'; + plan.paymentMethod = amzLib.constants.PAYMENT_METHOD; + plan.lastBillingDate = moment().add(3, 'months'); + recipient.purchased.plan = plan; + recipient.guilds.push(group._id); + + await recipient.save(); + + user.guilds.push(group._id); + await user.save(); + data.groupId = group._id; + + await api.createSubscription(data); + + let updatedUser = await User.findById(recipient._id).exec(); + + expect(updatedUser.purchased.plan.extraMonths).to.within(3, 4); + }); + + it('adds months to members with existing recurring subscription (Paypal)', async () => { + sinon.stub(paypalPayments, 'paypalBillingAgreementCancel').returnsPromise().resolves({}); + sinon.stub(paypalPayments, 'paypalBillingAgreementGet') + .returnsPromise().resolves({ + agreement_details: { // eslint-disable-line camelcase + next_billing_date: moment().add(3, 'months').toDate(), // eslint-disable-line camelcase + cycles_completed: 1, // eslint-disable-line camelcase + }, + }); + + let recipient = new User(); + recipient.profile.name = 'recipient'; + plan.planId = 'basic_earned'; + plan.paymentMethod = paypalPayments.constants.PAYMENT_METHOD; + recipient.purchased.plan = plan; + recipient.guilds.push(group._id); + + await recipient.save(); + + user.guilds.push(group._id); + await user.save(); + data.groupId = group._id; + + await api.createSubscription(data); + + let updatedUser = await User.findById(recipient._id).exec(); + + expect(updatedUser.purchased.plan.extraMonths).to.within(2, 3); + paypalPayments.paypalBillingAgreementGet.restore(); + paypalPayments.paypalBillingAgreementCancel.restore(); + }); + + it('adds months to members with existing recurring subscription (Android)'); + it('adds months to members with existing recurring subscription (iOs)'); + + it('adds months to members who already cancelled but not yet terminated recurring subscription', async () => { + let recipient = new User(); + recipient.profile.name = 'recipient'; + plan.key = 'basic_earned'; + plan.paymentMethod = stripePayments.constants.PAYMENT_METHOD; + recipient.purchased.plan = plan; + recipient.guilds.push(group._id); + await recipient.save(); + + user.guilds.push(group._id); + await user.save(); + data.groupId = group._id; + + await recipient.cancelSubscription(); + + await api.createSubscription(data); + + let updatedUser = await User.findById(recipient._id).exec(); + + expect(updatedUser.purchased.plan.extraMonths).to.within(2, 3); + }); + + it('adds months to members who already cancelled but not yet terminated group plan subscription', async () => { + let recipient = new User(); + recipient.profile.name = 'recipient'; + plan.key = 'basic_earned'; + plan.paymentMethod = api.constants.GROUP_PLAN_PAYMENT_METHOD; + plan.extraMonths = 2.94; + recipient.purchased.plan = plan; + recipient.guilds.push(group._id); + await recipient.save(); + + user.guilds.push(group._id); + await user.save(); + data.groupId = group._id; + + await recipient.cancelSubscription(); + + await api.createSubscription(data); + + let updatedUser = await User.findById(recipient._id).exec(); + expect(updatedUser.purchased.plan.extraMonths).to.within(3, 4); + }); + + it('resets date terminated if user has old subscription', async () => { + let recipient = new User(); + recipient.profile.name = 'recipient'; + plan.key = 'basic_earned'; + plan.paymentMethod = stripePayments.constants.PAYMENT_METHOD; + plan.dateTerminated = moment().subtract(1, 'days').toDate(); + recipient.purchased.plan = plan; + recipient.guilds.push(group._id); + await recipient.save(); + + user.guilds.push(group._id); + await user.save(); + data.groupId = group._id; + + await api.createSubscription(data); + + let updatedUser = await User.findById(recipient._id).exec(); + + expect(updatedUser.purchased.plan.dateTerminated).to.not.exist; + }); + + it('adds months to members with existing recurring subscription and includes existing extraMonths', async () => { + let recipient = new User(); + recipient.profile.name = 'recipient'; + plan.key = 'basic_earned'; + plan.paymentMethod = stripePayments.constants.PAYMENT_METHOD; + plan.extraMonths = 5; + recipient.purchased.plan = plan; + recipient.guilds.push(group._id); + await recipient.save(); + + user.guilds.push(group._id); + await user.save(); + data.groupId = group._id; + + await api.createSubscription(data); + + let updatedUser = await User.findById(recipient._id).exec(); + + expect(updatedUser.purchased.plan.extraMonths).to.within(7, 8); + }); + + it('adds months to members with existing recurring subscription and ignores existing negative extraMonths', async () => { + let recipient = new User(); + recipient.profile.name = 'recipient'; + plan.key = 'basic_earned'; + plan.paymentMethod = stripePayments.constants.PAYMENT_METHOD; + plan.extraMonths = -5; + recipient.purchased.plan = plan; + recipient.guilds.push(group._id); + await recipient.save(); + + user.guilds.push(group._id); + await user.save(); + data.groupId = group._id; + + await api.createSubscription(data); + + let updatedUser = await User.findById(recipient._id).exec(); + + expect(updatedUser.purchased.plan.extraMonths).to.within(2, 3); + }); + + it('does not override gemsBought, mysteryItems, dateCreated, and consective fields', async () => { + let planCreatedDate = moment().toDate(); + let mysteryItem = {title: 'item'}; + let mysteryItems = [mysteryItem]; + let consecutive = { + trinkets: 3, + gemCapExtra: 20, + offset: 1, + count: 13, + }; + + let recipient = new User(); + recipient.profile.name = 'recipient'; + + plan.key = 'basic_earned'; + plan.gemsBought = 3; + plan.dateCreated = planCreatedDate; + plan.mysteryItems = mysteryItems; + plan.consecutive = consecutive; + + recipient.purchased.plan = plan; + recipient.guilds.push(group._id); + await recipient.save(); + + user.guilds.push(group._id); + await user.save(); + data.groupId = group._id; + + await api.createSubscription(data); + + let updatedUser = await User.findById(recipient._id).exec(); + + expect(updatedUser.purchased.plan.gemsBought).to.equal(3); + expect(updatedUser.purchased.plan.mysteryItems[0]).to.eql(mysteryItem); + expect(updatedUser.purchased.plan.consecutive.count).to.equal(consecutive.count); + expect(updatedUser.purchased.plan.consecutive.offset).to.equal(consecutive.offset); + expect(updatedUser.purchased.plan.consecutive.gemCapExtra).to.equal(consecutive.gemCapExtra); + expect(updatedUser.purchased.plan.consecutive.trinkets).to.equal(consecutive.trinkets); + expect(updatedUser.purchased.plan.dateCreated).to.eql(planCreatedDate); + }); + + it('does not modify a user with a group subscription when they join another group', async () => { + let recipient = new User(); + recipient.profile.name = 'recipient'; + plan.key = 'basic_earned'; + recipient.purchased.plan = plan; + recipient.guilds.push(group._id); + await recipient.save(); + + user.guilds.push(group._id); + await user.save(); + data.groupId = group._id; + + await api.createSubscription(data); + + let updatedUser = await User.findById(recipient._id).exec(); + let firstDateCreated = updatedUser.purchased.plan.dateCreated; + let extraMonthsBeforeSecond = updatedUser.purchased.plan.extraMonths; + + let group2 = generateGroup({ + name: 'test group2', + type: 'guild', + privacy: 'public', + leader: user._id, + }); + data.groupId = group2._id; + await group2.save(); + recipient.guilds.push(group2._id); + await recipient.save(); + + await api.createSubscription(data); + + updatedUser = await User.findById(recipient._id).exec(); + + expect(updatedUser.purchased.plan.planId).to.eql('group_plan_auto'); + expect(updatedUser.purchased.plan.customerId).to.eql('group-plan'); + expect(updatedUser.purchased.plan.dateUpdated).to.exist; + expect(updatedUser.purchased.plan.gemsBought).to.eql(0); + expect(updatedUser.purchased.plan.paymentMethod).to.eql('Group Plan'); + expect(updatedUser.purchased.plan.extraMonths).to.eql(extraMonthsBeforeSecond); + expect(updatedUser.purchased.plan.dateTerminated).to.eql(null); + expect(updatedUser.purchased.plan.lastBillingDate).to.not.exist; + expect(updatedUser.purchased.plan.dateCreated).to.eql(firstDateCreated); + }); + + it('does not remove a user who is in two groups plans and leaves one', async () => { + let recipient = new User(); + recipient.profile.name = 'recipient'; + plan.key = 'basic_earned'; + recipient.purchased.plan = plan; + recipient.guilds.push(group._id); + await recipient.save(); + + user.guilds.push(group._id); + await user.save(); + data.groupId = group._id; + + await api.createSubscription(data); + + let updatedUser = await User.findById(recipient._id).exec(); + let firstDateCreated = updatedUser.purchased.plan.dateCreated; + let extraMonthsBeforeSecond = updatedUser.purchased.plan.extraMonths; + + let group2 = generateGroup({ + name: 'test group2', + type: 'guild', + privacy: 'public', + leader: user._id, + }); + data.groupId = group2._id; + await group2.save(); + recipient.guilds.push(group2._id); + await recipient.save(); + + await api.createSubscription(data); + + let updatedGroup = await Group.findById(group._id).exec(); + await updatedGroup.leave(recipient); + + updatedUser = await User.findById(recipient._id).exec(); + + expect(updatedUser.purchased.plan.planId).to.eql('group_plan_auto'); + expect(updatedUser.purchased.plan.customerId).to.eql('group-plan'); + expect(updatedUser.purchased.plan.dateUpdated).to.exist; + expect(updatedUser.purchased.plan.gemsBought).to.eql(0); + expect(updatedUser.purchased.plan.paymentMethod).to.eql('Group Plan'); + expect(updatedUser.purchased.plan.extraMonths).to.eql(extraMonthsBeforeSecond); + expect(updatedUser.purchased.plan.dateTerminated).to.eql(null); + expect(updatedUser.purchased.plan.lastBillingDate).to.not.exist; + expect(updatedUser.purchased.plan.dateCreated).to.eql(firstDateCreated); + }); + + it('does not modify a user with an unlimited subscription', async () => { + plan.key = 'basic_earned'; + plan.customerId = api.constants.UNLIMITED_CUSTOMER_ID; + + let recipient = new User(); + recipient.profile.name = 'recipient'; + recipient.purchased.plan = plan; + recipient.guilds.push(group._id); + await recipient.save(); + + user.guilds.push(group._id); + await user.save(); + data.groupId = group._id; + + await api.createSubscription(data); + + let updatedUser = await User.findById(recipient._id).exec(); + + expect(updatedUser.purchased.plan.planId).to.eql('basic_3mo'); + expect(updatedUser.purchased.plan.customerId).to.eql(api.constants.UNLIMITED_CUSTOMER_ID); + expect(updatedUser.purchased.plan.dateUpdated).to.exist; + expect(updatedUser.purchased.plan.gemsBought).to.eql(0); + expect(updatedUser.purchased.plan.paymentMethod).to.eql('paymentMethod'); + expect(updatedUser.purchased.plan.extraMonths).to.eql(0); + expect(updatedUser.purchased.plan.dateTerminated).to.eql(null); + expect(updatedUser.purchased.plan.lastBillingDate).to.exist; + expect(updatedUser.purchased.plan.dateCreated).to.exist; + }); + + it('updates a user with a cancelled but active group subscription', async () => { + plan.key = 'basic_earned'; + plan.customerId = api.constants.GROUP_PLAN_CUSTOMER_ID; + plan.dateTerminated = moment().add(1, 'months'); + + let recipient = new User(); + recipient.profile.name = 'recipient'; + recipient.purchased.plan = plan; + recipient.guilds.push(group._id); + await recipient.save(); + + user.guilds.push(group._id); + await user.save(); + data.groupId = group._id; + + await api.createSubscription(data); + + let updatedUser = await User.findById(recipient._id).exec(); + + expect(updatedUser.purchased.plan.planId).to.eql('group_plan_auto'); + expect(updatedUser.purchased.plan.customerId).to.eql(api.constants.GROUP_PLAN_CUSTOMER_ID); + expect(updatedUser.purchased.plan.dateUpdated).to.exist; + expect(updatedUser.purchased.plan.gemsBought).to.eql(0); + expect(updatedUser.purchased.plan.paymentMethod).to.eql('Group Plan'); + expect(updatedUser.purchased.plan.extraMonths).to.within(0, 2); + expect(updatedUser.purchased.plan.dateTerminated).to.eql(null); + expect(updatedUser.purchased.plan.lastBillingDate).to.not.exist; + expect(updatedUser.purchased.plan.dateCreated).to.exist; + }); +}); diff --git a/test/api/v3/unit/libs/stripePayments.test.js b/test/api/v3/unit/libs/stripePayments.test.js index e1ff3cdf3d..26ce880715 100644 --- a/test/api/v3/unit/libs/stripePayments.test.js +++ b/test/api/v3/unit/libs/stripePayments.test.js @@ -5,6 +5,7 @@ import { generateGroup, } from '../../../../helpers/api-unit.helper.js'; import { model as User } from '../../../../../website/server/models/user'; +import { model as Group } from '../../../../../website/server/models/group'; import { model as Coupon } from '../../../../../website/server/models/coupon'; import stripePayments from '../../../../../website/server/libs/stripePayments'; import payments from '../../../../../website/server/libs/payments'; @@ -658,4 +659,60 @@ describe('Stripe Payments', () => { }); }); }); + + describe('#upgradeGroupPlan', () => { + let spy, data, user, group; + + beforeEach(async function () { + user = new User(); + user.profile.name = 'sender'; + + data = { + user, + sub: { + key: 'basic_3mo', // @TODO: Validate that this is group + }, + customerId: 'customer-id', + paymentMethod: 'Payment Method', + headers: { + 'x-client': 'habitica-web', + 'user-agent': '', + }, + }; + + group = generateGroup({ + name: 'test group', + type: 'guild', + privacy: 'public', + leader: user._id, + }); + await group.save(); + + spy = sinon.stub(stripe.subscriptions, 'update'); + spy.returnsPromise().resolves([]); + data.groupId = group._id; + data.sub.quantity = 3; + stripePayments.setStripeApi(stripe); + }); + + afterEach(function () { + sinon.restore(stripe.subscriptions.update); + }); + + it('updates a group plan quantity', async () => { + data.paymentMethod = 'Stripe'; + await payments.createSubscription(data); + + let updatedGroup = await Group.findById(group._id).exec(); + expect(updatedGroup.purchased.plan.quantity).to.eql(3); + + updatedGroup.memberCount += 1; + await updatedGroup.save(); + + await stripePayments.chargeForAdditionalGroupMember(updatedGroup); + + expect(spy.calledOnce).to.be.true; + expect(updatedGroup.purchased.plan.quantity).to.eql(4); + }); + }); }); diff --git a/test/api/v3/unit/models/group.test.js b/test/api/v3/unit/models/group.test.js index bbb656cb97..1fa1000d9c 100644 --- a/test/api/v3/unit/models/group.test.js +++ b/test/api/v3/unit/models/group.test.js @@ -1,3 +1,6 @@ +import moment from 'moment'; +import { v4 as generateUUID } from 'uuid'; +import validator from 'validator'; import { sleep } from '../../../../helpers/api-unit.helper'; import { model as Group, INVITES_LIMIT } from '../../../../../website/server/models/group'; import { model as User } from '../../../../../website/server/models/user'; @@ -7,9 +10,7 @@ import { import { quests as questScrolls } from '../../../../../website/common/script/content'; import { groupChatReceivedWebhook } from '../../../../../website/server/libs/webhook'; import * as email from '../../../../../website/server/libs/email'; -import validator from 'validator'; import { TAVERN_ID } from '../../../../../website/common/script/'; -import { v4 as generateUUID } from 'uuid'; import shared from '../../../../../website/common'; describe('Group Model', () => { @@ -667,6 +668,49 @@ describe('Group Model', () => { expect(party.memberCount).to.eql(1); }); + it('does not allow a leader to leave a group with an active subscription', async () => { + party.memberCount = 2; + party.purchased.plan.customerId = '110002222333'; + + await expect(party.leave(questLeader)) + .to.eventually.be.rejected.and.to.eql({ + name: 'NotAuthorized', + httpCode: 401, + message: shared.i18n.t('leaderCannotLeaveGroupWithActiveGroup'), + }); + + party = await Group.findOne({_id: party._id}); + expect(party).to.exist; + expect(party.memberCount).to.eql(1); + }); + + it('deletes a private group when the last member leaves and a subscription is cancelled', async () => { + let guild = new Group({ + name: 'test guild', + type: 'guild', + memberCount: 1, + }); + + let leader = new User({ + guilds: [guild._id], + }); + + guild.leader = leader._id; + + await Promise.all([ + guild.save(), + leader.save(), + ]); + + guild.purchased.plan.customerId = '110002222333'; + guild.purchased.plan.dateTerminated = new Date(); + + await guild.leave(leader); + + party = await Group.findOne({_id: guild._id}); + expect(party).to.not.exist; + }); + it('does not delete a public group when the last member leaves', async () => { party.privacy = 'public'; @@ -1545,5 +1589,57 @@ describe('Group Model', () => { expect(args.find(arg => arg[0][0].id === memberWithWebhook3.webhooks[0].id)).to.be.exist; }); }); + + context('isSubscribed', () => { + it('returns false if group does not have customer id', () => { + expect(party.isSubscribed()).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; + }); + + 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; + }); + + 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; + }); + }); + + context('hasNotCancelled', () => { + it('returns false if group does not have customer id', () => { + expect(party.hasNotCancelled()).to.be.undefined; + }); + + it('returns true if party does not have plan.dateTerminated', () => { + party.purchased.plan.customerId = 'test-id'; + + expect(party.hasNotCancelled()).to.be.true; + }); + + it('returns false if party if plan.dateTerminated is after today', () => { + party.purchased.plan.customerId = 'test-id'; + party.purchased.plan.dateTerminated = moment().add(1, 'days').toDate(); + + expect(party.hasNotCancelled()).to.be.false; + }); + + it('returns false if party if plan.dateTerminated is before today', () => { + party.purchased.plan.customerId = 'test-id'; + party.purchased.plan.dateTerminated = moment().subtract(1, 'days').toDate(); + + expect(party.hasNotCancelled()).to.be.false; + }); + }); }); }); diff --git a/test/api/v3/unit/models/user.test.js b/test/api/v3/unit/models/user.test.js index 1c2b55ce1c..0192fe42ee 100644 --- a/test/api/v3/unit/models/user.test.js +++ b/test/api/v3/unit/models/user.test.js @@ -1,6 +1,7 @@ +import Bluebird from 'bluebird'; +import moment from 'moment'; import { model as User } from '../../../../../website/server/models/user'; import common from '../../../../../website/common'; -import Bluebird from 'bluebird'; describe('User Model', () => { it('keeps user._tmp when calling .toJSON', () => { @@ -145,4 +146,68 @@ describe('User Model', () => { }); }); }); + + context('isSubscribed', () => { + let user; + beforeEach(() => { + user = new User(); + }); + + + it('returns false if user does not have customer id', () => { + expect(user.isSubscribed()).to.be.undefined; + }); + + it('returns true if user does not have plan.dateTerminated', () => { + user.purchased.plan.customerId = 'test-id'; + + expect(user.isSubscribed()).to.be.true; + }); + + it('returns true if user if plan.dateTerminated is after today', () => { + user.purchased.plan.customerId = 'test-id'; + user.purchased.plan.dateTerminated = moment().add(1, 'days').toDate(); + + expect(user.isSubscribed()).to.be.true; + }); + + it('returns false if user if plan.dateTerminated is before today', () => { + user.purchased.plan.customerId = 'test-id'; + user.purchased.plan.dateTerminated = moment().subtract(1, 'days').toDate(); + + expect(user.isSubscribed()).to.be.false; + }); + }); + + context('hasNotCancelled', () => { + let user; + beforeEach(() => { + user = new User(); + }); + + + it('returns false if user does not have customer id', () => { + expect(user.hasNotCancelled()).to.be.undefined; + }); + + it('returns true if user does not have plan.dateTerminated', () => { + user.purchased.plan.customerId = 'test-id'; + + expect(user.hasNotCancelled()).to.be.true; + }); + + it('returns false if user if plan.dateTerminated is after today', () => { + user.purchased.plan.customerId = 'test-id'; + user.purchased.plan.dateTerminated = moment().add(1, 'days').toDate(); + + expect(user.hasNotCancelled()).to.be.false; + }); + + it('returns false if user if plan.dateTerminated is before today', () => { + user.purchased.plan.customerId = 'test-id'; + user.purchased.plan.dateTerminated = moment().subtract(1, 'days').toDate(); + + expect(user.hasNotCancelled()).to.be.false; + }); + }); }); diff --git a/test/helpers/api-integration/api-classes.js b/test/helpers/api-integration/api-classes.js index f584826520..7612c88cfd 100644 --- a/test/helpers/api-integration/api-classes.js +++ b/test/helpers/api-integration/api-classes.js @@ -1,5 +1,5 @@ /* eslint-disable no-use-before-define */ - +import moment from 'moment'; import { requester } from './requester'; import { getDocument as getDocumentFromMongo, @@ -82,6 +82,19 @@ export class ApiGroup extends ApiObject { return await this.update(update); } + + async createCancelledSubscription () { + let update = { + purchased: { + plan: { + customerId: 'example-customer', + dateTerminated: moment().add(1, 'days').toDate(), + }, + }, + }; + + return await this.update(update); + } } export class ApiChallenge extends ApiObject { diff --git a/website/client-old/js/controllers/groupPlansCtrl.js b/website/client-old/js/controllers/groupPlansCtrl.js index 0ebf814aef..6fd7d96317 100644 --- a/website/client-old/js/controllers/groupPlansCtrl.js +++ b/website/client-old/js/controllers/groupPlansCtrl.js @@ -28,7 +28,7 @@ angular.module('habitrpg') }; $scope.newGroupIsReady = function () { - return $scope.newGroup.name && $scope.newGroup.description; + return !!$scope.newGroup.name; }; $scope.createGroup = function () { diff --git a/website/client-old/js/controllers/guildsCtrl.js b/website/client-old/js/controllers/guildsCtrl.js index fa8574b03c..14513217d0 100644 --- a/website/client-old/js/controllers/guildsCtrl.js +++ b/website/client-old/js/controllers/guildsCtrl.js @@ -40,6 +40,10 @@ habitrpg.controller("GuildsCtrl", ['$scope', 'Groups', 'User', 'Challenges', '$r } $scope.join = function (group) { + if (group.cancelledPlan && !confirm(window.env.t('aboutToJoinCancelledGroupPlan'))) { + return; + } + var groupId = group._id; // If we don't have the _id property, we are joining from an invitation diff --git a/website/client-old/js/controllers/partyCtrl.js b/website/client-old/js/controllers/partyCtrl.js index 342656c03d..146b12021b 100644 --- a/website/client-old/js/controllers/partyCtrl.js +++ b/website/client-old/js/controllers/partyCtrl.js @@ -65,6 +65,12 @@ habitrpg.controller("PartyCtrl", ['$rootScope','$scope','Groups','Chat','User',' }) .then(function (response) { var tasks = response.data.data; + + $scope.group['habits'] = []; + $scope.group['dailys'] = []; + $scope.group['todos'] = []; + $scope.group['rewards'] = []; + tasks.forEach(function (element, index, array) { if (!$scope.group[element.type + 's']) $scope.group[element.type + 's'] = []; $scope.group[element.type + 's'].unshift(element); @@ -119,6 +125,10 @@ habitrpg.controller("PartyCtrl", ['$rootScope','$scope','Groups','Chat','User',' }; $scope.join = function (party) { + if (party.cancelledPlan && !confirm(window.env.t('aboutToJoinCancelledGroupPlan'))) { + return; + } + Groups.Group.join(party.id) .then(function (response) { $rootScope.party = $scope.group = response.data.data; diff --git a/website/client-old/js/directives/task-list.directive.js b/website/client-old/js/directives/task-list.directive.js index 91d0a77105..8ed4d7f096 100644 --- a/website/client-old/js/directives/task-list.directive.js +++ b/website/client-old/js/directives/task-list.directive.js @@ -1,78 +1,78 @@ -'use strict'; - -(function(){ - angular - .module('habitrpg') - .directive('taskList', taskList); - - taskList.$inject = [ - '$state', - 'User', - '$rootScope', - ]; - - function taskList($state, User, $rootScope) { - return { - restrict: 'EA', - templateUrl: 'templates/task-list.html', - transclude: true, - scope: true, - // scope: { - // taskList: '=list', - // list: '=listDetails', - // obj: '=object', - // user: "=", - // }, - link: function($scope, element, attrs) { - // @TODO: The use of scope with tasks is incorrect. We need to fix all task ctrls to use directives/services - // $scope.obj = {}; - function setObj (obj, force) { - if (!force && ($scope.obj || scope.obj !== {} || !obj)) return; - $scope.obj = obj; - setUpGroupedList(); - setUpTaskWatch(); - } - - $rootScope.$on('obj-updated', function (event, obj) { - setObj(obj, true); - }); - - function setUpGroupedList () { - if (!$scope.obj) return; - $scope.groupedList = {}; - ['habit', 'daily', 'todo', 'reward'].forEach(function (listType) { - groupTasksByChallenge($scope.obj[listType + 's'], listType); - }); - } - setUpGroupedList(); - - function groupTasksByChallenge (taskList, type) { - $scope.groupedList[type] = _.groupBy(taskList, 'challenge.shortName'); - }; - - function setUpTaskWatch () { - if (!$scope.obj) return; - $scope.$watch(function () { return $scope.obj.tasksOrder; }, function () { - setUpGroupedList(); - }, true); - } - setUpTaskWatch(); - - $scope.getTaskList = function (list, taskList, obj) { - setObj(obj); - if (!$scope.obj) return []; - if (taskList) return taskList; - return $scope.obj[list.type+'s']; - }; - - $scope.showNormalList = function () { - return !$state.includes("options.social.challenges") && !User.user.preferences.tasks.groupByChallenge; - }; - - $scope.showChallengeList = function () { - return $state.includes("options.social.challenges"); - }; - } - } - } -}()); +'use strict'; + +(function(){ + angular + .module('habitrpg') + .directive('taskList', taskList); + + taskList.$inject = [ + '$state', + 'User', + '$rootScope', + ]; + + function taskList($state, User, $rootScope) { + return { + restrict: 'EA', + templateUrl: 'templates/task-list.html', + transclude: true, + scope: true, + // scope: { + // taskList: '=list', + // list: '=listDetails', + // obj: '=object', + // user: "=", + // }, + link: function($scope, element, attrs) { + // @TODO: The use of scope with tasks is incorrect. We need to fix all task ctrls to use directives/services + // $scope.obj = {}; + function setObj (obj, force) { + if (!force && ($scope.obj || $scope.obj !== {} || !obj)) return; + $scope.obj = obj; + setUpGroupedList(); + setUpTaskWatch(); + } + + $rootScope.$on('obj-updated', function (event, obj) { + setObj(obj, true); + }); + + function setUpGroupedList () { + if (!$scope.obj) return; + $scope.groupedList = {}; + ['habit', 'daily', 'todo', 'reward'].forEach(function (listType) { + groupTasksByChallenge($scope.obj[listType + 's'], listType); + }); + } + setUpGroupedList(); + + function groupTasksByChallenge (taskList, type) { + $scope.groupedList[type] = _.groupBy(taskList, 'challenge.shortName'); + }; + + function setUpTaskWatch () { + if (!$scope.obj) return; + $scope.$watch(function () { return $scope.obj.tasksOrder; }, function () { + setUpGroupedList(); + }, true); + } + setUpTaskWatch(); + + $scope.getTaskList = function (list, taskList, obj) { + setObj(obj); + if (!$scope.obj) return []; + if (taskList) return taskList; + return $scope.obj[list.type+'s']; + }; + + $scope.showNormalList = function () { + return !$state.includes("options.social.challenges") && !User.user.preferences.tasks.groupByChallenge; + }; + + $scope.showChallengeList = function () { + return $state.includes("options.social.challenges"); + }; + } + } + } +}()); diff --git a/website/client-old/js/services/paymentServices.js b/website/client-old/js/services/paymentServices.js index e64ddb1185..2262ebbcf9 100644 --- a/website/client-old/js/services/paymentServices.js +++ b/website/client-old/js/services/paymentServices.js @@ -22,10 +22,10 @@ function($rootScope, User, $http, Content) { sub = sub && Content.subscriptionBlocks[sub]; - var amount = // 500 = $5 - sub ? sub.price*100 - : data.gift && data.gift.type=='gems' ? data.gift.gems.amount/4*100 - : 500; + var amount = 500;// 500 = $5 + if (sub) amount = sub.price * 100; + if (data.gift && data.gift.type=='gems') amount = data.gift.gems.amount / 4 * 100; + if (data.group) amount = (sub.price + 3 * (data.group.memberCount - 1)) * 100; StripeCheckout.open({ key: window.env.STRIPE_PUB_KEY, diff --git a/website/common/locales/en/groups.json b/website/common/locales/en/groups.json index 19eb5b3f18..3adc92b236 100644 --- a/website/common/locales/en/groups.json +++ b/website/common/locales/en/groups.json @@ -1,258 +1,262 @@ -{ - "tavern": "Tavern Chat", - "innCheckOut": "Check Out of Inn", - "innCheckIn": "Rest in the Inn", - "innText": "You're resting in the Inn! While checked-in, your Dailies won't hurt you at the day's end, but they will still refresh every day. Be warned: If you are participating in a Boss Quest, the Boss will still damage you for your party mates' missed Dailies unless they are also in the Inn! Also, your own damage to the Boss (or items collected) will not be applied until you check out of the Inn.", - "innTextBroken": "You're resting in the Inn, I guess... While checked-in, your Dailies won't hurt you at the day's end, but they will still refresh every day... If you are participating in a Boss Quest, the Boss will still damage you for your party mates' missed Dailies... unless they are also in the Inn... Also, your own damage to the Boss (or items collected) will not be applied until you check out of the Inn... so tired...", - "lfgPosts": "Looking for Group (Party Wanted) Posts", - "tutorial": "Tutorial", - "glossary": "Glossary", - "wiki": "Wiki", - "wikiLink": "Wiki", - "reportAP": "Report a Problem", - "requestAF": "Request a Feature", - "community": "Community Forum", - "dataTool": "Data Display Tool", - "resources": "Resources", - "askQuestionNewbiesGuild": "Ask a Question (Habitica Help guild)", - "tavernAlert1": "To report a bug, visit", - "tavernAlert2": "the Report a Bug Guild", - "moderatorIntro1": "Tavern and guild moderators are: ", - "communityGuidelines": "Community Guidelines", - "communityGuidelinesRead1": "Please read our", - "communityGuidelinesRead2": "before chatting.", - "party": "Party", - "createAParty": "Create A Party", - "updatedParty": "Party settings updated.", - "noPartyText": "You are either not in a party or your party is taking a while to load. You can either create one and invite friends, or if you want to join an existing party, have them enter your Unique User ID below and then come back here to look for the invitation:", - "LFG": "To advertise your new party or find one to join, go to the <%= linkStart %>Party Wanted (Looking for Group)<%= linkEnd %> Guild.", - "wantExistingParty": "Want to join an existing party? Go to the <%= linkStart %>Party Wanted Guild<%= linkEnd %> and post this User ID:", - "joinExistingParty": "Join Someone Else's Party", - "needPartyToStartQuest": "Whoops! You need to create or join a party before you can start a quest!", - "create": "Create", - "userId": "User ID", - "invite": "Invite", - "leave": "Leave", - "invitedTo": "Invited to <%= name %>", - "invitedToNewParty": "You were invited to join a party! Do you want to leave this party and join <%= partyName %>?", - "invitationAcceptedHeader": "Your Invitation has been Accepted", - "invitationAcceptedBody": "<%= username %> accepted your invitation to <%= groupName %>!", - "joinNewParty": "Join New Party", - "declineInvitation": "Decline Invitation", - "partyLoading1": "Your party is being summoned. Please wait...", - "partyLoading2": "Your party is coming in from battle. Please wait...", - "partyLoading3": "Your party is gathering. Please wait...", - "partyLoading4": "Your party is materializing. Please wait...", - "systemMessage": "System Message", - "newMsg": "New message in \"<%= name %>\"", - "chat": "Chat", - "sendChat": "Send Chat", - "toolTipMsg": "Fetch Recent Messages", - "sendChatToolTip": "You can send a chat from the keyboard by tabbing to the 'Send Chat' button and pressing Enter or by pressing Control (Command on a Mac) + Enter.", - "syncPartyAndChat": "Sync Party and Chat", - "guildBankPop1": "Guild Bank", - "guildBankPop2": "Gems which your guild leader can use for challenge prizes.", - "guildGems": "Guild Gems", - "editGroup": "Edit Group", - "newGroupName": "<%= groupType %> Name", - "groupName": "Group Name", - "groupLeader": "Group Leader", - "groupID": "Group ID", - "groupDescr": "Description shown in public Guilds list (Markdown OK)", - "logoUrl": "Logo URL", - "assignLeader": "Assign Group Leader", - "members": "Members", - "partyList": "Order for party members in header", - "banTip": "Boot Member", - "moreMembers": "more members", - "invited": "Invited", - "leaderMsg": "Message from group leader (Markdown OK)", - "name": "Name", - "description": "Description", - "public": "Public", - "inviteOnly": "Invite Only", - "gemCost": "The Gem cost promotes high quality Guilds, and is transferred into your Guild's bank for use as prizes in Guild Challenges!", - "search": "Search", - "publicGuilds": "Public Guilds", - "createGuild": "Create Guild", - "guild": "Guild", - "guilds": "Guilds", - "guildsLink": "Guilds", - "sureKick": "Do you really want to remove this member from the party/guild?", - "optionalMessage": "Optional message", - "yesRemove": "Yes, remove them", - "foreverAlone": "Can't like your own message. Don't be that person.", - "sortLevel": "Sort by level", - "sortRandom": "Sort randomly", - "sortPets": "Sort by number of pets", - "sortName": "Sort by avatar name", - "sortBackgrounds": "Sort by background", - "sortHabitrpgJoined": "Sort by Habitica date joined", - "sortHabitrpgLastLoggedIn": "Sort by last time user logged in", - "ascendingSort": "Sort Ascending", - "descendingSort": "Sort Descending", - "confirmGuild": "Create Guild for 4 Gems?", - "leaveGroupCha": "Leave Guild challenges and...", - "confirm": "Confirm", - "leaveGroup": "Leave Guild?", - "leavePartyCha": "Leave party challenges and...", - "leaveParty": "Leave party?", - "sendPM": "Send private message", - "send": "Send", - "messageSentAlert": "Message sent", - "pmHeading": "Private message to <%= name %>", - "pmsMarkedRead": "Your private messages have been marked as read", - "possessiveParty": "<%= name %>'s Party", - "clearAll": "Delete All Messages", - "confirmDeleteAllMessages": "Are you sure you want to delete all messages in your inbox? Other users will still see messages you have sent to them.", - "optOutPopover": "Don't like private messages? Click to completely opt out", - "block": "Block", - "unblock": "Un-block", - "pm-reply": "Send a reply", - "inbox": "Inbox", - "messageRequired": "A message is required.", - "toUserIDRequired": "A User ID is required", - "gemAmountRequired": "A number of gems is required", - "notAuthorizedToSendMessageToThisUser": "Can't send message to this user.", - "privateMessageGiftGemsMessage": "Hello <%= receiverName %>, <%= senderName %> has sent you <%= gemAmount %> gems!", - "privateMessageGiftSubscriptionMessage": "<%= numberOfMonths %> months of subscription! ", - "cannotSendGemsToYourself": "Cannot send gems to yourself. Try a subscription instead.", - "badAmountOfGemsToSend": "Amount must be within 1 and your current number of gems.", - "abuseFlag": "Report violation of Community Guidelines", - "abuseFlagModalHeading": "Report <%= name %> for violation?", - "abuseFlagModalBody": "Are you sure you want to report this post? You should ONLY report a post that violates the <%= firstLinkStart %>Community Guidelines<%= linkEnd %> and/or <%= secondLinkStart %>Terms of Service<%= linkEnd %>. Inappropriately reporting a post is a violation of the Community Guidelines and may give you an infraction. Appropriate reasons to flag a post include but are not limited to:

", - "abuseFlagModalButton": "Report Violation", - "abuseReported": "Thank you for reporting this violation. The moderators have been notified.", - "abuseAlreadyReported": "You have already reported this message.", - "needsText": "Please type a message.", - "needsTextPlaceholder": "Type your message here.", - "copyMessageAsToDo": "Copy message as To-Do", - "messageAddedAsToDo": "Message copied as To-Do.", - "messageWroteIn": "<%= user %> wrote in <%= group %>", - "taskFromInbox": "<%= from %> wrote '<%= message %>'", - "taskTextFromInbox": "Message from <%= from %>", - "msgPreviewHeading": "Message Preview", - "leaderOnlyChallenges": "Only group leader can create challenges", - "sendGift": "Send Gift", - "inviteFriends": "Invite Friends", - "inviteByEmail": "Invite by Email", - "inviteByEmailExplanation": "If a friend joins Habitica via your email, they'll automatically be invited to your party!", - "inviteFriendsNow": "Invite Friends Now", - "inviteFriendsLater": "Invite Friends Later", - "inviteAlertInfo": "If you have friends already using Habitica, invite them by User ID here.", - "inviteExistUser": "Invite Existing Users", - "byColon": "By:", - "inviteNewUsers": "Invite New Users", - "sendInvitations": "Send Invitations", - "invitationsSent": "Invitations sent!", - "invitationSent": "Invitation sent!", - "inviteAlertInfo2": "Or share this link (copy/paste):", - "sendGiftHeading": "Send Gift to <%= name %>", - "sendGiftGemsBalance": "From <%= number %> Gems", - "sendGiftCost": "Total: $<%= cost %> USD", - "sendGiftFromBalance": "From Balance", - "sendGiftPurchase": "Purchase", - "sendGiftMessagePlaceholder": "Personal message (optional)", - "sendGiftSubscription": "<%= months %> Month(s): $<%= price %> USD", - "battleWithFriends": "Battle Monsters With Friends", - "startPartyWithFriends": "Start a Party with your friends!", - "startAParty": "Start a Party", - "addToParty": "Add someone to your party", - "likePost": "Click if you like this post!", - "partyExplanation1": "Play Habitica with friends to stay accountable!", - "partyExplanation2": "Battle monsters and create Challenges!", - "partyExplanation3": "Invite friends now to earn a Quest Scroll!", - "wantToStartParty": "Do you want to start a party?", - "exclusiveQuestScroll": "Inviting a friend to your party will grant you an exclusive Quest Scroll to battle the Basi-List together!", - "nameYourParty": "Name your new party!", - "partyEmpty": "You're the only one in your party. Invite your friends!", - "partyChatEmpty": "Your party chat is empty! Type a message in the box above to start chatting.", - "guildChatEmpty": "This guild's chat is empty! Type a message in the box above to start chatting.", - "possessiveParty": "<%= name %>'s Party", - "requestAcceptGuidelines": "If you would like to post messages in the Tavern or any party or guild chat, please first read our <%= linkStart %>Community Guidelines<%= linkEnd %> and then click the button below to indicate that you accept them.", - "partyUpName": "Party Up", - "partyOnName": "Party On", - "partyUpText": "Joined a Party with another person! Have fun battling monsters and supporting each other.", - "partyOnText": "Joined a Party with at least four people! Enjoy your increased accountability as you unite with your friends to vanquish your foes!", - "largeGroupNote": "Note: This Guild is now too large to support notifications! Be sure to check back every day to see new messages.", - "groupIdRequired": "\"groupId\" must be a valid UUID", - "groupNotFound": "Group not found or you don't have access.", - "groupTypesRequired": "You must supply a valid \"type\" query string.", - "questLeaderCannotLeaveGroup": "You cannot leave your party when you have started a quest. Abort the quest first.", - "cannotLeaveWhileActiveQuest": "You cannot leave party during an active quest. Please leave the quest first.", - "onlyLeaderCanRemoveMember": "Only group leader can remove a member!", - "memberCannotRemoveYourself": "You cannot remove yourself!", - "groupMemberNotFound": "User not found among group's members", - "mustBeGroupMember": "Must be member of the group.", - "keepOrRemoveAll": "req.query.keep must be either \"keep-all\" or \"remove-all\"", - "keepOrRemove": "req.query.keep must be either \"keep\" or \"remove\"", - "canOnlyInviteEmailUuid": "Can only invite using uuids or emails.", - "inviteMissingEmail": "Missing email address in invite.", - "inviteMissingUuid": "Missing user id in invite", - "inviteMustNotBeEmpty": "Invite must not be empty.", - "partyMustbePrivate": "Parties must be private", - "userAlreadyInGroup": "User already in that group.", - "cannotInviteSelfToGroup": "You cannot invite yourself to a group.", - "userAlreadyInvitedToGroup": "User already invited to that group.", - "userAlreadyPendingInvitation": "User already pending invitation.", - "userAlreadyInAParty": "User already in a party.", - "userWithIDNotFound": "User with id \"<%= userId %>\" not found.", - "userHasNoLocalRegistration": "User does not have a local registration (username, email, password).", - "uuidsMustBeAnArray": "User ID invites must be an array.", - "emailsMustBeAnArray": "Email address invites must be an array.", - "canOnlyInviteMaxInvites": "You can only invite \"<%= maxInvites %>\" at a time", - "onlyCreatorOrAdminCanDeleteChat": "Not authorized to delete this message!", - "onlyGroupLeaderCanEditTasks": "Not authorized to manage tasks!", - "onlyGroupTasksCanBeAssigned": "Only group tasks can be assigned", - "newChatMessagePlainNotification": "New message in <%= groupName %> by <%= authorName %>. Click here to open the chat page!", - "newChatMessageTitle": "New message in <%= groupName %>", - "exportInbox": "Export Messages", - "exportInboxPopoverTitle": "Export your messages as HTML", - "exportInboxPopoverBody": "HTML allows easy reading of messages in a browser. For a machine-readable format, use Data > Export Data", - "to": "To:", - "from": "From:", - "desktopNotificationsText": "We need your permission to enable desktop notifications for new messages in party chat! Follow your browser's instructions to turn them on.

You'll receive these notifications only while you have Habitica open. If you decide you don't like them, they can be disabled in your browser's settings.

This box will close automatically when a decision is made.", - "confirmAddTag": "Do you want to assign this task to \"<%= tag %>\"?", - "confirmRemoveTag": "Do you really want to remove \"<%= tag %>\"?", - "groupHomeTitle": "Home", - "assignTask": "Assign Task", - "desktopNotificationsText": "We need your permission to enable desktop notifications for new messages in party chat! Follow your browser's instructions to turn them on.

You'll receive these notifications only while you have Habitica open. If you decide you don't like them, they can be disabled in your browser's settings.

This box will close automatically when a decision is made.", - "claim": "Claim", - "onlyGroupLeaderCanManageSubscription": "Only the group leader can manage the group's subscription", - "yourTaskHasBeenApproved": "Your task \"<%= taskText %>\" has been approved", - "userHasRequestedTaskApproval": "<%= user %> has requested task approval for <%= taskName %>", - "approve": "Approve", - "approvalTitle": "<%= text %> for user: <%= userName %>", - "confirmTaskApproval": "Do you want to reward <%= username %> for completing this task?", - "groupSubscriptionPrice": "$9 every month + $3 a month for every additional group member", - "groupAdditionalUserCost": " +$3.00/month/user", - - "groupBenefitsTitle": "How a group plan can help you", - "groupBenefitsDescription": "We've just launched the beta version of our group plans! Upgrading to a group plan unlocks some unique features to optimize the social side of Habitica.", - "groupBenefitOneTitle": "Create a shared task list", - "groupBenefitOneDescription": "Set up a shared task list for the group that everyone can easily view and edit.", - "groupBenefitTwoTitle": "Assign tasks to group members", - "groupBenefitTwoDescription": "Want a coworker to answer a critical email? Need your roommate to pick up the groceries? Just assign them the tasks you create, and they'll automatically appear in that person's task dashboard.", - "groupBenefitThreeTitle": "Claim a task that you are working on", - "groupBenefitThreeDescription": "Stake your claim on any group task with a simple click. Make it clear what everybody is working on!", - "groupBenefitFourTitle": "Mark tasks that require special approval", - "groupBenefitFourDescription": "Need to verify that a task really did get done before that user gets their rewards? Just adjust the approval settings for added control.", - "groupBenefitFiveTitle": "Chat privately with your group", - "groupBenefitFiveDescription": "Stay in the loop about important decisions in our easy-to-use chatroom!", - "createAGroup": "Create a Group", - "assignFieldPlaceholder": "Type a group member's profile name", - "cannotDeleteActiveGroup": "You cannot remove a group with an active subscription", - "groupTasksTitle": "Group Tasks List", - "approvalsTitle": "Tasks Awaiting Approval", - "upgradeTitle": "Upgrade", - "blankApprovalsDescription": "When your group completes tasks that need your approval, they'll appear here! Adjust approval requirement settings under task editing.", - "userIsClamingTask": "`<%= username %> has claimed \"<%= task %>\"`", - "approvalRequested": "Approval Requested", - "refreshApprovals": "Refresh Approvals", - "refreshGroupTasks": "Refresh Group Tasks", - "claimedBy": "\n\nClaimed by: <%= claimingUsers %>", - "cantDeleteAssignedGroupTasks": "Can't delete group tasks that are assigned to you.", - "confirmGuildPlanCreation": "Create this group?", - "onlyGroupLeaderCanInviteToGroupPlan": "Only the group leader can invite users to a group with a subscription.", - "remainOrLeaveChallenges": "req.query.keep must be either 'remain-in-challenges' or 'leave-challenges'" -} +{ + "tavern": "Tavern Chat", + "innCheckOut": "Check Out of Inn", + "innCheckIn": "Rest in the Inn", + "innText": "You're resting in the Inn! While checked-in, your Dailies won't hurt you at the day's end, but they will still refresh every day. Be warned: If you are participating in a Boss Quest, the Boss will still damage you for your party mates' missed Dailies unless they are also in the Inn! Also, your own damage to the Boss (or items collected) will not be applied until you check out of the Inn.", + "innTextBroken": "You're resting in the Inn, I guess... While checked-in, your Dailies won't hurt you at the day's end, but they will still refresh every day... If you are participating in a Boss Quest, the Boss will still damage you for your party mates' missed Dailies... unless they are also in the Inn... Also, your own damage to the Boss (or items collected) will not be applied until you check out of the Inn... so tired...", + "lfgPosts": "Looking for Group (Party Wanted) Posts", + "tutorial": "Tutorial", + "glossary": "Glossary", + "wiki": "Wiki", + "wikiLink": "Wiki", + "reportAP": "Report a Problem", + "requestAF": "Request a Feature", + "community": "Community Forum", + "dataTool": "Data Display Tool", + "resources": "Resources", + "askQuestionNewbiesGuild": "Ask a Question (Habitica Help guild)", + "tavernAlert1": "To report a bug, visit", + "tavernAlert2": "the Report a Bug Guild", + "moderatorIntro1": "Tavern and guild moderators are: ", + "communityGuidelines": "Community Guidelines", + "communityGuidelinesRead1": "Please read our", + "communityGuidelinesRead2": "before chatting.", + "party": "Party", + "createAParty": "Create A Party", + "updatedParty": "Party settings updated.", + "noPartyText": "You are either not in a party or your party is taking a while to load. You can either create one and invite friends, or if you want to join an existing party, have them enter your Unique User ID below and then come back here to look for the invitation:", + "LFG": "To advertise your new party or find one to join, go to the <%= linkStart %>Party Wanted (Looking for Group)<%= linkEnd %> Guild.", + "wantExistingParty": "Want to join an existing party? Go to the <%= linkStart %>Party Wanted Guild<%= linkEnd %> and post this User ID:", + "joinExistingParty": "Join Someone Else's Party", + "needPartyToStartQuest": "Whoops! You need to create or join a party before you can start a quest!", + "create": "Create", + "userId": "User ID", + "invite": "Invite", + "leave": "Leave", + "invitedTo": "Invited to <%= name %>", + "invitedToNewParty": "You were invited to join a party! Do you want to leave this party and join <%= partyName %>?", + "invitationAcceptedHeader": "Your Invitation has been Accepted", + "invitationAcceptedBody": "<%= username %> accepted your invitation to <%= groupName %>!", + "joinNewParty": "Join New Party", + "declineInvitation": "Decline Invitation", + "partyLoading1": "Your party is being summoned. Please wait...", + "partyLoading2": "Your party is coming in from battle. Please wait...", + "partyLoading3": "Your party is gathering. Please wait...", + "partyLoading4": "Your party is materializing. Please wait...", + "systemMessage": "System Message", + "newMsg": "New message in \"<%= name %>\"", + "chat": "Chat", + "sendChat": "Send Chat", + "toolTipMsg": "Fetch Recent Messages", + "sendChatToolTip": "You can send a chat from the keyboard by tabbing to the 'Send Chat' button and pressing Enter or by pressing Control (Command on a Mac) + Enter.", + "syncPartyAndChat": "Sync Party and Chat", + "guildBankPop1": "Guild Bank", + "guildBankPop2": "Gems which your guild leader can use for challenge prizes.", + "guildGems": "Guild Gems", + "editGroup": "Edit Group", + "newGroupName": "<%= groupType %> Name", + "groupName": "Group Name", + "groupLeader": "Group Leader", + "groupID": "Group ID", + "groupDescr": "Description shown in public Guilds list (Markdown OK)", + "logoUrl": "Logo URL", + "assignLeader": "Assign Group Leader", + "members": "Members", + "partyList": "Order for party members in header", + "banTip": "Boot Member", + "moreMembers": "more members", + "invited": "Invited", + "leaderMsg": "Message from group leader (Markdown OK)", + "name": "Name", + "description": "Description", + "public": "Public", + "inviteOnly": "Invite Only", + "gemCost": "The Gem cost promotes high quality Guilds, and is transferred into your Guild's bank for use as prizes in Guild Challenges!", + "search": "Search", + "publicGuilds": "Public Guilds", + "createGuild": "Create Guild", + "guild": "Guild", + "guilds": "Guilds", + "guildsLink": "Guilds", + "sureKick": "Do you really want to remove this member from the party/guild?", + "optionalMessage": "Optional message", + "yesRemove": "Yes, remove them", + "foreverAlone": "Can't like your own message. Don't be that person.", + "sortLevel": "Sort by level", + "sortRandom": "Sort randomly", + "sortPets": "Sort by number of pets", + "sortName": "Sort by avatar name", + "sortBackgrounds": "Sort by background", + "sortHabitrpgJoined": "Sort by Habitica date joined", + "sortHabitrpgLastLoggedIn": "Sort by last time user logged in", + "ascendingSort": "Sort Ascending", + "descendingSort": "Sort Descending", + "confirmGuild": "Create Guild for 4 Gems?", + "leaveGroupCha": "Leave Guild challenges and...", + "confirm": "Confirm", + "leaveGroup": "Leave Guild?", + "leavePartyCha": "Leave party challenges and...", + "leaveParty": "Leave party?", + "sendPM": "Send private message", + "send": "Send", + "messageSentAlert": "Message sent", + "pmHeading": "Private message to <%= name %>", + "pmsMarkedRead": "Your private messages have been marked as read", + "possessiveParty": "<%= name %>'s Party", + "clearAll": "Delete All Messages", + "confirmDeleteAllMessages": "Are you sure you want to delete all messages in your inbox? Other users will still see messages you have sent to them.", + "optOutPopover": "Don't like private messages? Click to completely opt out", + "block": "Block", + "unblock": "Un-block", + "pm-reply": "Send a reply", + "inbox": "Inbox", + "messageRequired": "A message is required.", + "toUserIDRequired": "A User ID is required", + "gemAmountRequired": "A number of gems is required", + "notAuthorizedToSendMessageToThisUser": "Can't send message to this user.", + "privateMessageGiftGemsMessage": "Hello <%= receiverName %>, <%= senderName %> has sent you <%= gemAmount %> gems!", + "privateMessageGiftSubscriptionMessage": "<%= numberOfMonths %> months of subscription! ", + "cannotSendGemsToYourself": "Cannot send gems to yourself. Try a subscription instead.", + "badAmountOfGemsToSend": "Amount must be within 1 and your current number of gems.", + "abuseFlag": "Report violation of Community Guidelines", + "abuseFlagModalHeading": "Report <%= name %> for violation?", + "abuseFlagModalBody": "Are you sure you want to report this post? You should ONLY report a post that violates the <%= firstLinkStart %>Community Guidelines<%= linkEnd %> and/or <%= secondLinkStart %>Terms of Service<%= linkEnd %>. Inappropriately reporting a post is a violation of the Community Guidelines and may give you an infraction. Appropriate reasons to flag a post include but are not limited to:

", + "abuseFlagModalButton": "Report Violation", + "abuseReported": "Thank you for reporting this violation. The moderators have been notified.", + "abuseAlreadyReported": "You have already reported this message.", + "needsText": "Please type a message.", + "needsTextPlaceholder": "Type your message here.", + "copyMessageAsToDo": "Copy message as To-Do", + "messageAddedAsToDo": "Message copied as To-Do.", + "messageWroteIn": "<%= user %> wrote in <%= group %>", + "taskFromInbox": "<%= from %> wrote '<%= message %>'", + "taskTextFromInbox": "Message from <%= from %>", + "msgPreviewHeading": "Message Preview", + "leaderOnlyChallenges": "Only group leader can create challenges", + "sendGift": "Send Gift", + "inviteFriends": "Invite Friends", + "inviteByEmail": "Invite by Email", + "inviteByEmailExplanation": "If a friend joins Habitica via your email, they'll automatically be invited to your party!", + "inviteFriendsNow": "Invite Friends Now", + "inviteFriendsLater": "Invite Friends Later", + "inviteAlertInfo": "If you have friends already using Habitica, invite them by User ID here.", + "inviteExistUser": "Invite Existing Users", + "byColon": "By:", + "inviteNewUsers": "Invite New Users", + "sendInvitations": "Send Invitations", + "invitationsSent": "Invitations sent!", + "invitationSent": "Invitation sent!", + "inviteAlertInfo2": "Or share this link (copy/paste):", + "sendGiftHeading": "Send Gift to <%= name %>", + "sendGiftGemsBalance": "From <%= number %> Gems", + "sendGiftCost": "Total: $<%= cost %> USD", + "sendGiftFromBalance": "From Balance", + "sendGiftPurchase": "Purchase", + "sendGiftMessagePlaceholder": "Personal message (optional)", + "sendGiftSubscription": "<%= months %> Month(s): $<%= price %> USD", + "battleWithFriends": "Battle Monsters With Friends", + "startPartyWithFriends": "Start a Party with your friends!", + "startAParty": "Start a Party", + "addToParty": "Add someone to your party", + "likePost": "Click if you like this post!", + "partyExplanation1": "Play Habitica with friends to stay accountable!", + "partyExplanation2": "Battle monsters and create Challenges!", + "partyExplanation3": "Invite friends now to earn a Quest Scroll!", + "wantToStartParty": "Do you want to start a party?", + "exclusiveQuestScroll": "Inviting a friend to your party will grant you an exclusive Quest Scroll to battle the Basi-List together!", + "nameYourParty": "Name your new party!", + "partyEmpty": "You're the only one in your party. Invite your friends!", + "partyChatEmpty": "Your party chat is empty! Type a message in the box above to start chatting.", + "guildChatEmpty": "This guild's chat is empty! Type a message in the box above to start chatting.", + "possessiveParty": "<%= name %>'s Party", + "requestAcceptGuidelines": "If you would like to post messages in the Tavern or any party or guild chat, please first read our <%= linkStart %>Community Guidelines<%= linkEnd %> and then click the button below to indicate that you accept them.", + "partyUpName": "Party Up", + "partyOnName": "Party On", + "partyUpText": "Joined a Party with another person! Have fun battling monsters and supporting each other.", + "partyOnText": "Joined a Party with at least four people! Enjoy your increased accountability as you unite with your friends to vanquish your foes!", + "largeGroupNote": "Note: This Guild is now too large to support notifications! Be sure to check back every day to see new messages.", + "groupIdRequired": "\"groupId\" must be a valid UUID", + "groupNotFound": "Group not found or you don't have access.", + "groupTypesRequired": "You must supply a valid \"type\" query string.", + "questLeaderCannotLeaveGroup": "You cannot leave your party when you have started a quest. Abort the quest first.", + "cannotLeaveWhileActiveQuest": "You cannot leave party during an active quest. Please leave the quest first.", + "onlyLeaderCanRemoveMember": "Only group leader can remove a member!", + "memberCannotRemoveYourself": "You cannot remove yourself!", + "groupMemberNotFound": "User not found among group's members", + "mustBeGroupMember": "Must be member of the group.", + "keepOrRemoveAll": "req.query.keep must be either \"keep-all\" or \"remove-all\"", + "keepOrRemove": "req.query.keep must be either \"keep\" or \"remove\"", + "canOnlyInviteEmailUuid": "Can only invite using uuids or emails.", + "inviteMissingEmail": "Missing email address in invite.", + "inviteMissingUuid": "Missing user id in invite", + "inviteMustNotBeEmpty": "Invite must not be empty.", + "partyMustbePrivate": "Parties must be private", + "userAlreadyInGroup": "User already in that group.", + "cannotInviteSelfToGroup": "You cannot invite yourself to a group.", + "userAlreadyInvitedToGroup": "User already invited to that group.", + "userAlreadyPendingInvitation": "User already pending invitation.", + "userAlreadyInAParty": "User already in a party.", + "userWithIDNotFound": "User with id \"<%= userId %>\" not found.", + "userHasNoLocalRegistration": "User does not have a local registration (username, email, password).", + "uuidsMustBeAnArray": "User ID invites must be an array.", + "emailsMustBeAnArray": "Email address invites must be an array.", + "canOnlyInviteMaxInvites": "You can only invite \"<%= maxInvites %>\" at a time", + "onlyCreatorOrAdminCanDeleteChat": "Not authorized to delete this message!", + "onlyGroupLeaderCanEditTasks": "Not authorized to manage tasks!", + "onlyGroupTasksCanBeAssigned": "Only group tasks can be assigned", + "newChatMessagePlainNotification": "New message in <%= groupName %> by <%= authorName %>. Click here to open the chat page!", + "newChatMessageTitle": "New message in <%= groupName %>", + "exportInbox": "Export Messages", + "exportInboxPopoverTitle": "Export your messages as HTML", + "exportInboxPopoverBody": "HTML allows easy reading of messages in a browser. For a machine-readable format, use Data > Export Data", + "to": "To:", + "from": "From:", + "desktopNotificationsText": "We need your permission to enable desktop notifications for new messages in party chat! Follow your browser's instructions to turn them on.

You'll receive these notifications only while you have Habitica open. If you decide you don't like them, they can be disabled in your browser's settings.

This box will close automatically when a decision is made.", + "confirmAddTag": "Do you want to assign this task to \"<%= tag %>\"?", + "confirmRemoveTag": "Do you really want to remove \"<%= tag %>\"?", + "groupHomeTitle": "Home", + "assignTask": "Assign Task", + "desktopNotificationsText": "We need your permission to enable desktop notifications for new messages in party chat! Follow your browser's instructions to turn them on.

You'll receive these notifications only while you have Habitica open. If you decide you don't like them, they can be disabled in your browser's settings.

This box will close automatically when a decision is made.", + "claim": "Claim", + "onlyGroupLeaderCanManageSubscription": "Only the group leader can manage the group's subscription", + "yourTaskHasBeenApproved": "Your task \"<%= taskText %>\" has been approved", + "userHasRequestedTaskApproval": "<%= user %> has requested task approval for <%= taskName %>", + "approve": "Approve", + "approvalTitle": "<%= text %> for user: <%= userName %>", + "confirmTaskApproval": "Do you want to reward <%= username %> for completing this task?", + "groupSubscriptionPrice": "$9 every month + $3 a month for every additional group member", + "groupAdditionalUserCost": " +$3.00/month/user", + + "groupBenefitsTitle": "How a group plan can help you", + "groupBenefitsDescription": "We've just launched the beta version of our group plans! Upgrading to a group plan unlocks some unique features to optimize the social side of Habitica.", + "groupBenefitOneTitle": "Create a shared task list", + "groupBenefitOneDescription": "Set up a shared task list for the group that everyone can easily view and edit.", + "groupBenefitTwoTitle": "Assign tasks to group members", + "groupBenefitTwoDescription": "Want a coworker to answer a critical email? Need your roommate to pick up the groceries? Just assign them the tasks you create, and they'll automatically appear in that person's task dashboard.", + "groupBenefitThreeTitle": "Claim a task that you are working on", + "groupBenefitThreeDescription": "Stake your claim on any group task with a simple click. Make it clear what everybody is working on!", + "groupBenefitFourTitle": "Mark tasks that require special approval", + "groupBenefitFourDescription": "Need to verify that a task really did get done before that user gets their rewards? Just adjust the approval settings for added control.", + "groupBenefitFiveTitle": "Chat privately with your group", + "groupBenefitFiveDescription": "Stay in the loop about important decisions in our easy-to-use chatroom!", + "createAGroup": "Create a Group", + "assignFieldPlaceholder": "Type a group member's profile name", + "cannotDeleteActiveGroup": "You cannot remove a group with an active subscription", + "groupTasksTitle": "Group Tasks List", + "approvalsTitle": "Tasks Awaiting Approval", + "upgradeTitle": "Upgrade", + "blankApprovalsDescription": "When your group completes tasks that need your approval, they'll appear here! Adjust approval requirement settings under task editing.", + "userIsClamingTask": "`<%= username %> has claimed \"<%= task %>\"`", + "approvalRequested": "Approval Requested", + "refreshApprovals": "Refresh Approvals", + "refreshGroupTasks": "Refresh Group Tasks", + "claimedBy": "\n\nClaimed by: <%= claimingUsers %>", + "cantDeleteAssignedGroupTasks": "Can't delete group tasks that are assigned to you.", + "confirmGuildPlanCreation": "Create this group?", + "onlyGroupLeaderCanInviteToGroupPlan": "Only the group leader can invite users to a group with a subscription.", + "remainOrLeaveChallenges": "req.query.keep must be either 'remain-in-challenges' or 'leave-challenges'", + "paymentDetails": "Payment Details", + "aboutToJoinCancelledGroupPlan": "You are about to join a group with a canceled plan. You will NOT receive a free subscription.", + "cannotChangeLeaderWithActiveGroupPlan": "You can not change the leader while the group has an active plan.", + "leaderCannotLeaveGroupWithActiveGroup": "A leader can not leave a group while the group has an active plan" +} diff --git a/website/common/script/content/subscriptionBlocks.js b/website/common/script/content/subscriptionBlocks.js index 52116c4c63..77d5bae437 100644 --- a/website/common/script/content/subscriptionBlocks.js +++ b/website/common/script/content/subscriptionBlocks.js @@ -30,6 +30,12 @@ let subscriptionBlocks = { price: 9, quantity: 3, // Default quantity for Stripe - The same as having 3 user subscriptions }, + group_plan_auto: { + type: 'group', + months: 0, + price: 0, + quantity: 1, + }, }; each(subscriptionBlocks, function createKeys (b, k) { diff --git a/website/server/controllers/api-v3/groups.js b/website/server/controllers/api-v3/groups.js index 9bb1776b76..efeef635a9 100644 --- a/website/server/controllers/api-v3/groups.js +++ b/website/server/controllers/api-v3/groups.js @@ -387,10 +387,13 @@ api.updateGroup = { if (group.leader !== user._id) throw new NotAuthorized(res.t('messageGroupOnlyLeaderCanUpdate')); + if (req.body.leader !== user._id && group.hasNotCancelled()) throw new NotAuthorized(res.t('cannotChangeLeaderWithActiveGroupPlan')); + _.assign(group, _.merge(group.toObject(), Group.sanitizeUpdate(req.body))); let savedGroup = await group.save(); let response = Group.toJSONCleanChat(savedGroup, user); + // If the leader changed fetch new data, otherwise use authenticated user if (response.leader !== user._id) { let rawLeader = await User.findById(response.leader).select(nameFields).exec(); @@ -493,7 +496,10 @@ api.joinGroup = { group.memberCount += 1; - if (group.purchased.plan.customerId) await payments.updateStripeGroupPlan(group); + if (group.hasNotCancelled()) { + await payments.addSubToGroupUser(user, group); + await group.updateGroupPlan(); + } let promises = [group.save(), user.save()]; @@ -676,7 +682,7 @@ api.leaveGroup = { await group.leave(user, req.query.keep, req.body.keepChallenges); - if (group.purchased.plan && group.purchased.plan.customerId) await payments.updateStripeGroupPlan(group); + if (group.hasNotCancelled()) await group.updateGroupPlan(true); _removeMessagesFromMember(user, group._id); @@ -763,7 +769,10 @@ api.removeGroupMember = { if (isInGroup) { group.memberCount -= 1; - if (group.purchased.plan.customerId) await payments.updateStripeGroupPlan(group); + if (group.hasNotCancelled()) { + await group.updateGroupPlan(true); + await payments.cancelGroupSubscriptionForUser(member, group); + } if (group.quest && group.quest.leader === member._id) { group.quest.key = undefined; @@ -831,7 +840,10 @@ async function _inviteByUUID (uuid, group, inviter, req, res) { if (_.find(userToInvite.invitations.guilds, {id: group._id})) { throw new NotAuthorized(res.t('userAlreadyInvitedToGroup')); } - userToInvite.invitations.guilds.push({id: group._id, name: group.name, inviter: inviter._id}); + + let guildInvite = {id: group._id, name: group.name, inviter: inviter._id}; + if (group.isSubscribed() && !group.hasNotCancelled()) guildInvite.cancelledPlan = true; + userToInvite.invitations.guilds.push(guildInvite); } else if (group.type === 'party') { if (userToInvite.invitations.party.id) { throw new NotAuthorized(res.t('userAlreadyPendingInvitation')); @@ -844,7 +856,9 @@ async function _inviteByUUID (uuid, group, inviter, req, res) { if (userParty && userParty.memberCount !== 1) throw new NotAuthorized(res.t('userAlreadyInAParty')); } - userToInvite.invitations.party = {id: group._id, name: group.name, inviter: inviter._id}; + let partyInvite = {id: group._id, name: group.name, inviter: inviter._id}; + if (group.isSubscribed() && !group.hasNotCancelled()) partyInvite.cancelledPlan = true; + userToInvite.invitations.party = partyInvite; } let groupLabel = group.type === 'guild' ? 'Guild' : 'Party'; @@ -907,10 +921,15 @@ async function _inviteByEmail (invite, group, inviter, req, res) { userReturnInfo = await _inviteByUUID(userToContact._id, group, inviter, req, res); } else { userReturnInfo = invite.email; + + let cancelledPlan = false; + if (group.isSubscribed() && !group.hasNotCancelled()) cancelledPlan = true; + const groupQueryString = JSON.stringify({ id: group._id, inviter: inviter._id, sentAt: Date.now(), // so we can let it expire + cancelledPlan, }); let link = `/static/front?groupInvite=${encrypt(groupQueryString)}`; diff --git a/website/server/controllers/api-v3/user.js b/website/server/controllers/api-v3/user.js index e9e6a24a01..9903d007d3 100644 --- a/website/server/controllers/api-v3/user.js +++ b/website/server/controllers/api-v3/user.js @@ -209,7 +209,7 @@ api.deleteUser = { } let types = ['party', 'guilds']; - let groupFields = basicGroupFields.concat(' leader memberCount'); + let groupFields = basicGroupFields.concat(' leader memberCount purchased'); let groupsUserIsMemberOf = await Group.getGroups({user, types, groupFields}); diff --git a/website/server/libs/amazonPayments.js b/website/server/libs/amazonPayments.js index 07762e62fc..8cfb07d3ae 100644 --- a/website/server/libs/amazonPayments.js +++ b/website/server/libs/amazonPayments.js @@ -3,6 +3,7 @@ import nconf from 'nconf'; import Bluebird from 'bluebird'; import moment from 'moment'; import cc from 'coupon-code'; +import uuid from 'uuid'; import common from '../../common'; import { @@ -38,6 +39,7 @@ api.constants = { SELLER_NOTE: 'Habitica Payment', SELLER_NOTE_SUBSCRIPTION: 'Habitica Subscription', SELLER_NOTE_ATHORIZATION_SUBSCRIPTION: 'Habitica Subscription Payment', + SELLER_NOTE_GROUP_NEW_MEMBER: 'Habitica Group Plan New Member', STORE_NAME: 'Habitica', GIFT_TYPE_GEMS: 'gems', @@ -45,8 +47,8 @@ api.constants = { METHOD_BUY_GEMS: 'buyGems', METHOD_CREATE_SUBSCRIPTION: 'createSubscription', - PAYMENT_METHOD_AMAZON: 'Amazon Payments', - PAYMENT_METHOD_AMAZON_GIFT: 'Amazon Payments (Gift)', + PAYMENT_METHOD: 'Amazon Payments', + PAYMENT_METHOD_GIFT: 'Amazon Payments (Gift)', }; api.getTokenInfo = Bluebird.promisify(amzPayment.api.getTokenInfo, {context: amzPayment.api}); @@ -138,7 +140,7 @@ api.checkout = async function checkout (options = {}) { let data = { user, - paymentMethod: this.constants.PAYMENT_METHOD_AMAZON, + paymentMethod: this.constants.PAYMENT_METHOD, headers, }; @@ -146,7 +148,7 @@ api.checkout = async function checkout (options = {}) { if (gift.type === this.constants.GIFT_TYPE_SUBSCRIPTION) method = this.constants.METHOD_CREATE_SUBSCRIPTION; gift.member = await User.findById(gift ? gift.uuid : undefined).exec(); data.gift = gift; - data.paymentMethod = this.constants.PAYMENT_METHOD_AMAZON_GIFT; + data.paymentMethod = this.constants.PAYMENT_METHOD_GIFT; } await payments[method](data); @@ -209,7 +211,7 @@ api.cancelSubscription = async function cancelSubscription (options = {}) { user, groupId, nextBill: moment(lastBillingDate).add({ days: subscriptionLength }), - paymentMethod: this.constants.PAYMENT_METHOD_AMAZON, + paymentMethod: this.constants.PAYMENT_METHOD, headers, }); }; @@ -281,11 +283,36 @@ api.subscribe = async function subscribe (options) { await payments.createSubscription({ user, customerId: billingAgreementId, - paymentMethod: this.constants.PAYMENT_METHOD_AMAZON, + paymentMethod: this.constants.PAYMENT_METHOD, sub, headers, groupId, }); }; + +api.chargeForAdditionalGroupMember = async function chargeForAdditionalGroupMember (group) { + // @TODO: Can we get this from the content plan? + let priceForNewMember = 3; + + // @TODO: Prorate? + + return this.authorizeOnBillingAgreement({ + AmazonBillingAgreementId: group.purchased.plan.customerId, + AuthorizationReferenceId: uuid.v4().substring(0, 32), + AuthorizationAmount: { + CurrencyCode: this.constants.CURRENCY_CODE, + Amount: priceForNewMember, + }, + SellerAuthorizationNote: this.constants.SELLER_NOTE_GROUP_NEW_MEMBER, + TransactionTimeout: 0, + CaptureNow: true, + SellerNote: this.constants.SELLER_NOTE_GROUP_NEW_MEMBER, + SellerOrderAttributes: { + SellerOrderId: uuid.v4(), + StoreName: this.constants.STORE_NAME, + }, + }); +}; + module.exports = api; diff --git a/website/server/libs/analyticsService.js b/website/server/libs/analyticsService.js index cbf1326611..ca2c9a154a 100644 --- a/website/server/libs/analyticsService.js +++ b/website/server/libs/analyticsService.js @@ -23,7 +23,9 @@ const PLATFORM_MAP = Object.freeze({ 'habitica-android': 'Android', }); -let amplitude = new Amplitude(AMPLIUDE_TOKEN); +let amplitude; +if (AMPLIUDE_TOKEN) amplitude = new Amplitude(AMPLIUDE_TOKEN); + let ga = googleAnalytics(GA_TOKEN); let _lookUpItemName = (itemKey) => { diff --git a/website/server/libs/cron.js b/website/server/libs/cron.js index 1539a21b81..9f6474b630 100644 --- a/website/server/libs/cron.js +++ b/website/server/libs/cron.js @@ -75,24 +75,21 @@ function grantEndOfTheMonthPerks (user, now) { } function removeTerminatedSubscription (user) { - // If subscription's termination date has arrived let plan = user.purchased.plan; - if (plan.dateTerminated && moment(plan.dateTerminated).isBefore(new Date())) { - _.merge(plan, { - planId: null, - customerId: null, - paymentMethod: null, - }); + _.merge(plan, { + planId: null, + customerId: null, + paymentMethod: null, + }); - _.merge(plan.consecutive, { - count: 0, - offset: 0, - gemCapExtra: 0, - }); + _.merge(plan.consecutive, { + count: 0, + offset: 0, + gemCapExtra: 0, + }); - user.markModified('purchased.plan'); - } + user.markModified('purchased.plan'); } function performSleepTasks (user, tasksByType, now) { @@ -195,11 +192,15 @@ export function cron (options = {}) { if (user.purchased && user.purchased.plan && !moment(user.purchased.plan.dateUpdated).startOf('month').isSame(moment().startOf('month'))) { user.purchased.plan.gemsBought = 0; } + if (user.isSubscribed()) { grantEndOfTheMonthPerks(user, now); - if (!CRON_SAFE_MODE) removeTerminatedSubscription(user); } + let plan = user.purchased.plan; + let userHasTerminatedSubscription = plan.dateTerminated && moment(plan.dateTerminated).isBefore(new Date()); + if (!CRON_SAFE_MODE && userHasTerminatedSubscription) removeTerminatedSubscription(user); + // Login Incentives user.loginIncentives++; awardLoginIncentives(user); diff --git a/website/server/libs/payments.js b/website/server/libs/payments.js index 08fa923ec9..26bc40c011 100644 --- a/website/server/libs/payments.js +++ b/website/server/libs/payments.js @@ -11,19 +11,21 @@ import { model as Group, basicFields as basicGroupFields, } from '../models/group'; +import { model as User } from '../models/user'; import { NotAuthorized, NotFound, } from './errors'; import slack from './slack'; -import nconf from 'nconf'; - -import stripeModule from 'stripe'; -const stripe = stripeModule(nconf.get('STRIPE_API_KEY')); - let api = {}; +api.constants = { + UNLIMITED_CUSTOMER_ID: 'habitrpg', // Users with the customerId have an unlimted free subscription + GROUP_PLAN_CUSTOMER_ID: 'group-plan', + GROUP_PLAN_PAYMENT_METHOD: 'Group Plan', +}; + function revealMysteryItems (user) { _.each(shared.content.gear.flat, function findMysteryItems (item) { if ( @@ -44,6 +46,158 @@ function _dateDiff (earlyDate, lateDate) { return moment(lateDate).diff(earlyDate, 'months', true); } +/** + * Add a subscription to members of a group + * + * @param group The Group Model that is subscribed to a group plan + * + * @return undefined + */ +api.addSubscriptionToGroupUsers = async function addSubscriptionToGroupUsers (group) { + let members; + if (group.type === 'guild') { + members = await User.find({guilds: group._id}).select('_id purchased').exec(); + } else { + members = await User.find({'party._id': group._id}).select('_id purchased').exec(); + } + + let promises = members.map((member) => { + return this.addSubToGroupUser(member, group); + }); + + await Promise.all(promises); +}; + +/** + * Add a subscription to a new member of a group + * + * @param member The new member of the group + * + * @return undefined + */ +api.addSubToGroupUser = async function addSubToGroupUser (member, group) { + let customerIdsToIgnore = [this.constants.GROUP_PLAN_CUSTOMER_ID, this.constants.UNLIMITED_CUSTOMER_ID]; + + let data = { + user: {}, + sub: { + key: 'group_plan_auto', + }, + customerId: 'group-plan', + paymentMethod: 'Group Plan', + headers: {}, + }; + + let plan = { + planId: 'group_plan_auto', + customerId: 'group-plan', + dateUpdated: new Date(), + gemsBought: 0, + paymentMethod: 'groupPlan', + extraMonths: 0, + dateTerminated: null, + lastBillingDate: null, + dateCreated: new Date(), + mysteryItems: [], + consecutive: { + trinkets: 0, + offset: 0, + gemCapExtra: 0, + }, + }; + + if (member.isSubscribed()) { + let memberPlan = member.purchased.plan; + let customerHasCancelledGroupPlan = memberPlan.customerId === this.constants.GROUP_PLAN_CUSTOMER_ID && !member.hasNotCancelled(); + if (customerIdsToIgnore.indexOf(memberPlan.customerId) !== -1 && !customerHasCancelledGroupPlan) return; + + if (member.hasNotCancelled()) await member.cancelSubscription(); + + let today = new Date(); + plan = _.clone(member.purchased.plan.toObject()); + let extraMonths = Number(plan.extraMonths); + if (plan.dateTerminated) extraMonths += _dateDiff(today, plan.dateTerminated); + + _(plan).merge({ // override with these values + planId: 'group_plan_auto', + customerId: 'group-plan', + dateUpdated: today, + paymentMethod: 'groupPlan', + extraMonths, + dateTerminated: null, + lastBillingDate: null, + owner: member._id, + }).defaults({ // allow non-override if a plan was previously used + gemsBought: 0, + dateCreated: today, + mysteryItems: [], + }).value(); + } + + member.purchased.plan = plan; + + data.user = member; + await this.createSubscription(data); + + let leader = await User.findById(group.leader).exec(); + txnEmail(data.user, 'group-member-joining', [ + {name: 'LEADER', content: leader.profile.name}, + {name: 'GROUP_NAME', content: group.name}, + ]); +}; + + +/** + * Cancels subscriptions of members of a group + * + * @param group The Group Model that is cancelling a group plan + * + * @return undefined + */ +api.cancelGroupUsersSubscription = async function cancelGroupUsersSubscription (group) { + let members; + if (group.type === 'guild') { + members = await User.find({guilds: group._id}).select('_id guilds purchased').exec(); + } else { + members = await User.find({'party._id': group._id}).select('_id guilds purchased').exec(); + } + + let promises = members.map((member) => { + return this.cancelGroupSubscriptionForUser(member, group); + }); + + await Promise.all(promises); +}; + +api.cancelGroupSubscriptionForUser = async function cancelGroupSubscriptionForUser (user, group) { + if (user.purchased.plan.customerId !== this.constants.GROUP_PLAN_CUSTOMER_ID) return; + + let userGroups = _.clone(user.guilds.toObject()); + userGroups.push('party'); + + let index = userGroups.indexOf(group._id); + userGroups.splice(index, 1); + + let groupPlansQuery = { + type: {$in: ['guild', 'party']}, + // privacy: 'private', + _id: {$in: userGroups}, + 'purchased.plan.dateTerminated': null, + }; + + let groupFields = `${basicGroupFields} purchased`; + let userGroupPlans = await Group.find(groupPlansQuery).select(groupFields).exec(); + + if (userGroupPlans.length === 0) { + let leader = await User.findById(group.leader).exec(); + txnEmail(user, 'group-member-cancel', [ + {name: 'LEADER', content: leader.profile.name}, + {name: 'GROUP_NAME', content: group.name}, + ]); + await this.cancelSubscription({user}); + } +}; + api.createSubscription = async function createSubscription (data) { let recipient = data.gift ? data.gift.member : data.user; let block = shared.content.subscriptionBlocks[data.gift ? data.gift.subscription.key : data.sub.key]; @@ -75,6 +229,8 @@ api.createSubscription = async function createSubscription (data) { emailType = 'group-subscription-begins'; groupId = group._id; recipient.purchased.plan.quantity = data.sub.quantity; + + await this.addSubscriptionToGroupUsers(group); } plan = recipient.purchased.plan; @@ -135,7 +291,8 @@ api.createSubscription = async function createSubscription (data) { revealMysteryItems(recipient); } - if (!data.gift) { + // @TODO: Create a factory pattern for use cases + if (!data.gift && data.customerId !== this.constants.GROUP_PLAN_CUSTOMER_ID) { txnEmail(data.user, emailType); } @@ -225,22 +382,6 @@ api.createSubscription = async function createSubscription (data) { }); }; -api.updateStripeGroupPlan = async function updateStripeGroupPlan (group, stripeInc) { - if (group.purchased.plan.paymentMethod !== 'Stripe') return; - let stripeApi = stripeInc || stripe; - let plan = shared.content.subscriptionBlocks.group_monthly; - - await stripeApi.subscriptions.update( - group.purchased.plan.subscriptionId, - { - plan: plan.key, - quantity: group.memberCount + plan.quantity - 1, - } - ); - - group.purchased.plan.quantity = group.memberCount + plan.quantity - 1; -}; - // Sets their subscription to be cancelled later api.cancelSubscription = async function cancelSubscription (data) { let plan; @@ -248,6 +389,7 @@ api.cancelSubscription = async function cancelSubscription (data) { let cancelType = 'unsubscribe'; let groupId; let emailType = 'cancel-subscription'; + let emailMergeData = []; // If we are buying a group subscription if (data.groupId) { @@ -265,12 +407,23 @@ api.cancelSubscription = async function cancelSubscription (data) { } plan = group.purchased.plan; emailType = 'group-cancel-subscription'; + emailMergeData.push({name: 'GROUP_NAME', content: group.name}); + + await this.cancelGroupUsersSubscription(group); } else { plan = data.user.purchased.plan; } + let customerId = plan.customerId; let now = moment(); - let remaining = data.nextBill ? moment(data.nextBill).diff(new Date(), 'days') : 30; + let defaultRemainingDays = 30; + + if (plan.customerId === this.constants.GROUP_PLAN_CUSTOMER_ID) { + defaultRemainingDays = 2; + } + + let remaining = data.nextBill ? moment(data.nextBill).diff(new Date(), 'days') : defaultRemainingDays; + if (plan.extraMonths < 0) plan.extraMonths = 0; let extraDays = Math.ceil(30.5 * plan.extraMonths); let nowStr = `${now.format('MM')}/${moment(plan.dateUpdated).format('DD')}/${now.format('YYYY')}`; let nowStrFormat = 'MM/DD/YYYY'; @@ -289,7 +442,7 @@ api.cancelSubscription = async function cancelSubscription (data) { await data.user.save(); } - txnEmail(data.user, emailType); + if (customerId !== this.constants.GROUP_PLAN_CUSTOMER_ID) txnEmail(data.user, emailType, emailMergeData); if (group) { cancelType = 'group-unsubscribe'; diff --git a/website/server/libs/paypalPayments.js b/website/server/libs/paypalPayments.js index 4f48b7ee46..6b18e60a19 100644 --- a/website/server/libs/paypalPayments.js +++ b/website/server/libs/paypalPayments.js @@ -42,6 +42,22 @@ paypal.configure({ let api = {}; +api.constants = { + // CURRENCY_CODE: 'USD', + // SELLER_NOTE: 'Habitica Payment', + // SELLER_NOTE_SUBSCRIPTION: 'Habitica Subscription', + // SELLER_NOTE_ATHORIZATION_SUBSCRIPTION: 'Habitica Subscription Payment', + // STORE_NAME: 'Habitica', + // + // GIFT_TYPE_GEMS: 'gems', + // GIFT_TYPE_SUBSCRIPTION: 'subscription', + // + // METHOD_BUY_GEMS: 'buyGems', + // METHOD_CREATE_SUBSCRIPTION: 'createSubscription', + PAYMENT_METHOD: 'Paypal', + // PAYMENT_METHOD_GIFT: 'Amazon Payments (Gift)', +}; + api.paypalPaymentCreate = Bluebird.promisify(paypal.payment.create, {context: paypal.payment}); api.paypalPaymentExecute = Bluebird.promisify(paypal.payment.execute, {context: paypal.payment}); api.paypalBillingAgreementCreate = Bluebird.promisify(paypal.billingAgreement.create, {context: paypal.billingAgreement}); @@ -68,7 +84,7 @@ api.checkout = async function checkout (options = {}) { let createPayment = { intent: 'sale', - payer: { payment_method: 'Paypal' }, + payer: { payment_method: this.constants.PAYMENT_METHOD }, redirect_urls: { return_url: `${BASE_URL}/paypal/checkout/success`, cancel_url: `${BASE_URL}`, @@ -103,7 +119,7 @@ api.checkoutSuccess = async function checkoutSuccess (options = {}) { let data = { user, customerId, - paymentMethod: 'Paypal', + paymentMethod: this.constants.PAYMENT_METHOD, }; if (gift) { @@ -138,7 +154,7 @@ api.subscribe = async function subscribe (options = {}) { id: sub.paypalKey, }, payer: { - payment_method: 'Paypal', + payment_method: this.constants.PAYMENT_METHOD, }, }; let billingAgreement = await this.paypalBillingAgreementCreate(billingAgreementAttributes); @@ -154,7 +170,7 @@ api.subscribeSuccess = async function subscribeSuccess (options = {}) { user, groupId, customerId: result.id, - paymentMethod: 'Paypal', + paymentMethod: this.constants.PAYMENT_METHOD, sub: block, headers, }); @@ -194,7 +210,7 @@ api.subscribeCancel = async function subscribeCancel (options = {}) { await payments.cancelSubscription({ user, groupId, - paymentMethod: 'Paypal', + paymentMethod: this.constants.PAYMENT_METHOD, nextBill: nextBillingDate, }); }; @@ -208,7 +224,7 @@ api.ipn = async function ipnApi (options = {}) { // @TODO: Should this request billing date? let user = await User.findOne({ 'purchased.plan.customerId': recurring_payment_id }).exec(); if (user) { - await payments.cancelSubscription({ user, paymentMethod: 'Paypal' }); + await payments.cancelSubscription({ user, paymentMethod: this.constants.PAYMENT_METHOD }); return; } @@ -219,7 +235,7 @@ api.ipn = async function ipnApi (options = {}) { .exec(); if (group) { - await payments.cancelSubscription({ groupId: group._id, paymentMethod: 'Paypal' }); + await payments.cancelSubscription({ groupId: group._id, paymentMethod: this.constants.PAYMENT_METHOD }); } }; diff --git a/website/server/libs/stripePayments.js b/website/server/libs/stripePayments.js index d8ccd82f95..7aeb74ec16 100644 --- a/website/server/libs/stripePayments.js +++ b/website/server/libs/stripePayments.js @@ -16,11 +16,32 @@ import { } from '../models/group'; import shared from '../../common'; -const stripe = stripeModule(nconf.get('STRIPE_API_KEY')); +let stripe = stripeModule(nconf.get('STRIPE_API_KEY')); const i18n = shared.i18n; let api = {}; +api.constants = { + // CURRENCY_CODE: 'USD', + // SELLER_NOTE: 'Habitica Payment', + // SELLER_NOTE_SUBSCRIPTION: 'Habitica Subscription', + // SELLER_NOTE_ATHORIZATION_SUBSCRIPTION: 'Habitica Subscription Payment', + // STORE_NAME: 'Habitica', + // + // GIFT_TYPE_GEMS: 'gems', + // GIFT_TYPE_SUBSCRIPTION: 'subscription', + // + // METHOD_BUY_GEMS: 'buyGems', + // METHOD_CREATE_SUBSCRIPTION: 'createSubscription', + PAYMENT_METHOD: 'Stripe', + // PAYMENT_METHOD_GIFT: 'Amazon Payments (Gift)', +}; + +api.setStripeApi = function setStripeApi (stripeInc) { + stripe = stripeInc; +}; + + /** * Allows for purchasing a user subscription, group subscription or gems with Stripe * @@ -97,7 +118,7 @@ api.checkout = async function checkout (options, stripeInc) { await payments.createSubscription({ user, customerId: response.id, - paymentMethod: 'Stripe', + paymentMethod: this.constants.PAYMENT_METHOD, sub, headers, groupId, @@ -108,7 +129,7 @@ api.checkout = async function checkout (options, stripeInc) { let data = { user, customerId: response.id, - paymentMethod: 'Stripe', + paymentMethod: this.constants.PAYMENT_METHOD, gift, }; @@ -204,20 +225,38 @@ api.cancelSubscription = async function cancelSubscription (options, stripeInc) if (!customerId) throw new NotAuthorized(i18n.t('missingSubscription')); + // @TODO: Handle error response let customer = await stripeApi.customers.retrieve(customerId); let subscription = customer.subscription; - if (!subscription) { + if (!subscription && customer.subscriptions) { subscription = customer.subscriptions.data[0]; } + if (!subscription) return; + await stripeApi.customers.del(customerId); await payments.cancelSubscription({ user, groupId, nextBill: subscription.current_period_end * 1000, // timestamp in seconds - paymentMethod: 'Stripe', + paymentMethod: this.constants.PAYMENT_METHOD, }); }; +api.chargeForAdditionalGroupMember = async function chargeForAdditionalGroupMember (group) { + let stripeApi = stripe; + let plan = shared.content.subscriptionBlocks.group_monthly; + + await stripeApi.subscriptions.update( + group.purchased.plan.subscriptionId, + { + plan: plan.key, + quantity: group.memberCount + plan.quantity - 1, + } + ); + + group.purchased.plan.quantity = group.memberCount + plan.quantity - 1; +}; + module.exports = api; diff --git a/website/server/models/group.js b/website/server/models/group.js index 712d260ccf..15cd463ba7 100644 --- a/website/server/models/group.js +++ b/website/server/models/group.js @@ -10,6 +10,7 @@ import { model as Challenge} from './challenge'; import * as Tasks from './task'; import validator from 'validator'; import { removeFromArray } from '../libs/collectionManipulators'; +import payments from '../libs/payments'; import { groupChatReceivedWebhook } from '../libs/webhook'; import { InternalServerError, @@ -28,6 +29,8 @@ import { import { schema as SubscriptionPlanSchema, } from './subscriptionPlan'; +import amazonPayments from '../libs/amazonPayments'; +import stripePayments from '../libs/stripePayments'; const questScrolls = shared.content.quests; const Schema = mongoose.Schema; @@ -904,10 +907,14 @@ schema.methods.leave = async function leaveGroup (user, keep = 'keep-all', keepC let group = this; let update = {}; - if (group.memberCount <= 1 && group.privacy === 'private' && group.isSubscribed()) { + if (group.memberCount <= 1 && group.privacy === 'private' && group.hasNotCancelled()) { throw new NotAuthorized(shared.i18n.t('cannotDeleteActiveGroup')); } + if (group.leader === user._id && group.hasNotCancelled()) { + throw new NotAuthorized(shared.i18n.t('leaderCannotLeaveGroupWithActiveGroup')); + } + // only remove user from challenges if it's set to leave-challenges if (keepChallenges === 'leave-challenges') { let challenges = await Challenge.find({ @@ -948,6 +955,10 @@ schema.methods.leave = async function leaveGroup (user, keep = 'keep-all', keepC update.$unset = {[`quest.members.${user._id}`]: 1}; } + if (group.purchased.plan.customerId) { + promises.push(payments.cancelGroupSubscriptionForUser(user, this)); + } + // If user is the last one in group and group is private, delete it if (group.memberCount <= 1 && group.privacy === 'private') { // double check the member count is correct so we don't accidentally delete a group that still has users in it @@ -957,7 +968,9 @@ schema.methods.leave = async function leaveGroup (user, keep = 'keep-all', keepC } else { members = await User.find({'party._id': group._id}).select('_id').exec(); } + _.remove(members, {_id: user._id}); + if (members.length === 0) { promises.push(group.remove()); return await Bluebird.all(promises); @@ -1165,6 +1178,28 @@ schema.methods.isSubscribed = function isSubscribed () { return plan && plan.customerId && (!plan.dateTerminated || moment(plan.dateTerminated).isAfter(now)); }; +schema.methods.hasNotCancelled = function hasNotCancelled () { + let plan = this.purchased.plan; + return this.isSubscribed() && !plan.dateTerminated; +}; + +schema.methods.updateGroupPlan = async function updateGroupPlan (removingMember) { + // Recheck the group plan count + let members; + if (this.type === 'guild') { + members = await User.find({guilds: this._id}).select('_id').exec(); + } else { + members = await User.find({'party._id': this._id}).select('_id').exec(); + } + this.memberCount = members.length; + + if (this.purchased.plan.paymentMethod === stripePayments.constants.PAYMENT_METHOD) { + await stripePayments.chargeForAdditionalGroupMember(this); + } else if (this.purchased.plan.paymentMethod === amazonPayments.constants.PAYMENT_METHOD && !removingMember) { + await amazonPayments.chargeForAdditionalGroupMember(this); + } +}; + export let model = mongoose.model('Group', schema); // initialize tavern if !exists (fresh installs) diff --git a/website/server/models/user/methods.js b/website/server/models/user/methods.js index 517be30c0c..34cd5608f0 100644 --- a/website/server/models/user/methods.js +++ b/website/server/models/user/methods.js @@ -1,3 +1,4 @@ +import moment from 'moment'; import common from '../../../common'; import Bluebird from 'bluebird'; import { @@ -7,9 +8,21 @@ import { import { defaults } from 'lodash'; import { model as UserNotification } from '../userNotification'; import schema from './schema'; +import payments from '../../libs/payments'; +import amazonPayments from '../../libs/amazonPayments'; +import stripePayments from '../../libs/stripePayments'; +import paypalPayments from '../../libs/paypalPayments'; schema.methods.isSubscribed = function isSubscribed () { - return !!this.purchased.plan.customerId; // eslint-disable-line no-implicit-coercion + let now = new Date(); + let plan = this.purchased.plan; + + return plan && plan.customerId && (!plan.dateTerminated || moment(plan.dateTerminated).isAfter(now)); +}; + +schema.methods.hasNotCancelled = function hasNotCancelled () { + let plan = this.purchased.plan; + return this.isSubscribed() && !plan.dateTerminated; }; // Get an array of groups ids the user is member of @@ -91,3 +104,22 @@ schema.methods.addComputedStatsToJSONObj = function addComputedStatsToUserJSONOb return statsObject; }; + +// @TODO: There is currently a three way relation between the user, payment methods and the payment helper +// This creates some odd Dependency Injection issues. To counter that, we use the user as the third layer +// To negotiate between the payment providers and the payment helper (which probably has too many responsiblities) +// In summary, currently is is best practice to use this method to cancel a user subscription, rather than calling the +// payment helper. +schema.methods.cancelSubscription = async function cancelSubscription () { + let plan = this.purchased.plan; + + if (plan.paymentMethod === amazonPayments.constants.PAYMENT_METHOD) { + return await amazonPayments.cancelSubscription({user: this}); + } else if (plan.paymentMethod === stripePayments.constants.PAYMENT_METHOD) { + return await stripePayments.cancelSubscription({user: this}); + } else if (plan.paymentMethod === paypalPayments.constants.PAYMENT_METHOD) { + return await paypalPayments.subscribeCancel({user: this}); + } + + return await payments.cancelSubscription({user: this}); +}; diff --git a/website/views/options/settings/subscription.jade b/website/views/options/settings/subscription.jade index 09d380a72b..b597e50fdf 100644 --- a/website/views/options/settings/subscription.jade +++ b/website/views/options/settings/subscription.jade @@ -47,17 +47,17 @@ script(id='partials/options.settings.subscription.html',type='text/ng-template', div(ng-if='user.purchased.plan.customerId') .btn.btn-primary(ng-if='!user.purchased.plan.dateTerminated && user.purchased.plan.paymentMethod=="Stripe"', ng-click='Payments.showStripeEdit()')=env.t('subUpdateCard') - .btn.btn-sm.btn-danger(ng-if='!user.purchased.plan.dateTerminated', ng-click='Payments.cancelSubscription()')=env.t('cancelSub') + .btn.btn-sm.btn-danger(ng-if='!user.purchased.plan.dateTerminated && user.purchased.plan.customerId !== "group-plan"', ng-click='Payments.cancelSubscription()')=env.t('cancelSub') .container-fluid.slight-vertical-padding(ng-if='!user.purchased.plan.customerId || (user.purchased.plan.customerId && user.purchased.plan.dateTerminated)') small.muted=env.t('subscribeUsing') .row.text-center - .col-xs-4 + .col-md-4 a.purchase.btn.btn-primary(ng-click='Payments.showStripe({subscription:_subscription.key, coupon:_subscription.coupon})', ng-disabled='!_subscription.key')= env.t('card') - .col-xs-4 + .col-md-4 a.purchase(href='/paypal/subscribe?_id={{user._id}}&apiToken={{User.settings.auth.apiToken}}&sub={{_subscription.key}}{{_subscription.coupon ? "&coupon="+_subscription.coupon : ""}}', ng-disabled='!_subscription.key') img(src='https://www.paypalobjects.com/webstatic/en_US/i/buttons/pp-acceptance-small.png',alt=env.t('paypal')) - .col-xs-4 + .col-md-4 a.purchase(ng-click="Payments.amazonPayments.init({type: 'subscription', subscription:_subscription.key, coupon:_subscription.coupon})") img(src='https://payments.amazon.com/gp/cba/button',alt=env.t('amazonPayments')) diff --git a/website/views/options/social/group.jade b/website/views/options/social/group.jade index 0a39f2a8b2..d782b1740a 100644 --- a/website/views/options/social/group.jade +++ b/website/views/options/social/group.jade @@ -17,7 +17,7 @@ a.pull-right.gem-wallet(ng-if='group.type!="party"', popover-trigger='mouseenter li(ng-show='group.purchased.active && group.leader._id === user._id') a(ng-click="groupPanel = 'approvals'")=env.t('approvalsTitle') li - a(ng-click="groupPanel = 'subscription'", ng-show='group.leader._id === user._id && group.purchased.plan.customerId')=env.t('subscription') + a(ng-click="groupPanel = 'subscription'", ng-show='group.leader._id === user._id && group.purchased.plan.customerId')=env.t('paymentDetails') a(ng-click="groupPanel = 'subscription'", ng-show='group.leader._id === user._id && !group.purchased.plan.customerId')=env.t('upgradeTitle') .tab-content diff --git a/website/views/options/social/groups/group-subscription.jade b/website/views/options/social/groups/group-subscription.jade index 58c6b2ff05..2225102423 100644 --- a/website/views/options/social/groups/group-subscription.jade +++ b/website/views/options/social/groups/group-subscription.jade @@ -37,8 +37,8 @@ mixin groupSubscription() .row.text-center h3 Upgrade My Group div - a.purchase.btn.btn-primary(ng-click='Payments.showStripe({subscription:_subscription.key, coupon:_subscription.coupon, groupId: group.id})', ng-disabled='!_subscription.key')= env.t('card') - a.purchase(ng-click="Payments.amazonPayments.init({type: 'subscription', subscription:_subscription.key, coupon:_subscription.coupon, groupId: group.id})") + a.purchase.btn.btn-primary(ng-click='Payments.showStripe({subscription:_subscription.key, coupon:_subscription.coupon, groupId: group.id, group: group})', ng-disabled='!_subscription.key')= env.t('card') + a.purchase(ng-click="Payments.amazonPayments.init({type: 'subscription', subscription:_subscription.key, coupon:_subscription.coupon, groupId: group.id, group: group})") img(src='https://payments.amazon.com/gp/cba/button',alt=env.t('amazonPayments')) //- .col-xs-4 //- a.purchase(href='/paypal/subscribe?_id={{user._id}}&apiToken={{User.settings.auth.apiToken}}&sub={{_subscription.key}}{{_subscription.coupon ? "&coupon="+_subscription.coupon : ""}}&groupId={{group.id}}', ng-disabled='!_subscription.key') diff --git a/website/views/shared/tasks/edit/advanced_options.jade b/website/views/shared/tasks/edit/advanced_options.jade index 58d0a15945..63bf57fbd6 100644 --- a/website/views/shared/tasks/edit/advanced_options.jade +++ b/website/views/shared/tasks/edit/advanced_options.jade @@ -27,36 +27,35 @@ div(ng-if='(task.type !== "reward") || (!obj.auth && obj.purchased && obj.purcha hr - .form-group - legend.option-title=env.t('repeat') - select.form-control(ng-model='task._edit.frequency', ng-disabled='!canEdit(task)') - option(value='daily')=env.t('daily') - option(value='weekly')=env.t('weekly') - option(value='monthly')=env.t('monthly') - option(value='yearly')=env.t('yearly') - - //- select.form-control(ng-model='task._edit.frequency', ng-disabled='!canEdit(task)') - //- option(value='weekly')=env.t('repeatWeek') - //- option(value='daily')=env.t('repeatDays') + fieldset.option-group.advanced-option(ng-show="task._edit._advanced && task.type !== 'reward'") + select.form-control(ng-model='task._edit.frequency', ng-disabled='!canEdit(task)') + option(value='daily')=env.t('daily') + option(value='weekly')=env.t('weekly') + option(value='monthly')=env.t('monthly') + option(value='yearly')=env.t('yearly') - include ./dailies/repeat_options - - .form-group(ng-show='task._edit.frequency === "monthly"') - legend.option-title=env.t('repeatsOn') - label - input(type="radio", ng-model='task._edit.repeatsOn', value='dayOfMonth') - =env.t('dayOfMonth') - label - input(type="radio", ng-model='task._edit.repeatsOn', value='dayOfWeek') - =env.t('dayOfWeek') - - .form-group - legend.option-title=env.t('summary') - div {{summary}} + //- select.form-control(ng-model='task._edit.frequency', ng-disabled='!canEdit(task)') + //- option(value='weekly')=env.t('repeatWeek') + //- option(value='daily')=env.t('repeatDays') - hr + include ./dailies/repeat_options - fieldset.option-group.advanced-option(ng-show="task._edit._advanced") + .form-group(ng-show='task._edit.frequency === "monthly"') + legend.option-title=env.t('repeatsOn') + label + input(type="radio", ng-model='task._edit.repeatsOn', value='dayOfMonth') + =env.t('dayOfMonth') + label + input(type="radio", ng-model='task._edit.repeatsOn', value='dayOfWeek') + =env.t('dayOfWeek') + + .form-group + legend.option-title=env.t('summary') + div {{summary}} + + hr + + fieldset.option-group.advanced-option(ng-show="task._edit._advanced && task.type !== 'reward'") legend.option-title a.hint.priority-multiplier-help(href='http://habitica.wikia.com/wiki/Difficulty', target='_blank', popover-title=env.t('difficultyHelpTitle'), popover-trigger='mouseenter', popover=env.t('difficultyHelpContent'))=env.t('difficulty') ul.priority-multiplier diff --git a/website/views/shared/tasks/edit/dailies/repeat_options.jade b/website/views/shared/tasks/edit/dailies/repeat_options.jade index fe820fb53a..7556537914 100644 --- a/website/views/shared/tasks/edit/dailies/repeat_options.jade +++ b/website/views/shared/tasks/edit/dailies/repeat_options.jade @@ -1,20 +1,14 @@ -//- .form-group -//- legend.option-title=env.t('repeat') -//- select.form-control(ng-model='task._edit.frequency', ng-disabled='!canEdit(task)') -//- option(value='weekly')=env.t('repeatWeek') -//- option(value='daily')=env.t('repeatDays') +.form-group + legend.option-title + span.hint(popover-trigger='mouseenter', popover-title=env.t('repeatHelpTitle'), + popover='{{env.t(task._edit.frequency + "RepeatHelpContent")}}')=env.t('repeatEvery') -legend.option-title - span.hint(popover-trigger='mouseenter', popover-title=env.t('repeatHelpTitle'), - popover='{{env.t(task._edit.frequency + "RepeatHelpContent")}}')=env.t('repeatEvery') + // If frequency is daily + ng-form.form-group(name='everyX') + .input-group + input.form-control(type='number', ng-model='task._edit.everyX', min='0', ng-disabled='!canEdit(task)', required) + span.input-group-addon {{repeatSuffix}} -// If frequency is daily -ng-form.form-group(name='everyX') - .input-group - input.form-control(type='number', ng-model='task._edit.everyX', min='0', ng-disabled='!canEdit(task)', required) - span.input-group-addon {{repeatSuffix}} - -// If frequency is weekly .form-group(ng-if='task._edit.frequency=="weekly"') legend.option-title=env.t('onDays') ul.repeat-days