From d8c37f6e2d330898c88f08be496a2b81ff7febe5 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Tue, 1 Nov 2016 21:51:30 +0100 Subject: [PATCH] Group plan subscription (#8153) * Added payment to groups and pay with group plan with Stripe * Added edit card for Stripe * Added stripe cancel * Added subscribe with Amazon payments * Added Amazon cancel for group subscription * Added group subscription with paypal * Added paypal cancel * Added ipn cancel for Group plan * Added a subscription tab and hid only the task tab when group is not subscribed * Fixed linting issues * Fixed tests * Added payment unit tests * Added back refresh after stripe payment * Fixed style issues * Limited grouop query fields and checked access * Abstracted subscription schema * Added year group plan and more access checks * Maded purchase fields private * Removed id and timestampes * Added else checks to ensure user subscription is not altered. Removed active field from group model * Added toJSONTransform function * Moved plan active check to other toJson function * Added check to see if purchaed has been populated * Added purchase details to private * Added correct data usage when paying for group sub --- test/api/v3/unit/libs/payments.test.js | 210 +++++++++++++++--- .../client-old/js/services/paymentServices.js | 83 ++++--- website/common/locales/en/groups.json | 3 +- website/common/script/content/index.js | 10 +- website/server/controllers/api-v3/groups.js | 15 +- .../controllers/top-level/payments/amazon.js | 40 +++- .../controllers/top-level/payments/paypal.js | 40 +++- .../controllers/top-level/payments/stripe.js | 61 ++++- website/server/libs/amazonPayments.js | 2 +- website/server/libs/payments.js | 90 +++++++- website/server/models/group.js | 11 +- website/server/models/subscriptionPlan.js | 32 +++ website/server/models/user/schema.js | 24 +- website/views/options/social/group.jade | 55 ++++- 14 files changed, 558 insertions(+), 118 deletions(-) create mode 100644 website/server/models/subscriptionPlan.js diff --git a/test/api/v3/unit/libs/payments.test.js b/test/api/v3/unit/libs/payments.test.js index 312c22a2d6..e71e15dea5 100644 --- a/test/api/v3/unit/libs/payments.test.js +++ b/test/api/v3/unit/libs/payments.test.js @@ -3,15 +3,27 @@ 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 moment from 'moment'; +import { + generateGroup, +} from '../../../../helpers/api-unit.helper.js'; describe('payments/index', () => { - let user, data, plan; + let user, group, data, plan; - beforeEach(() => { + beforeEach(async () => { user = new User(); user.profile.name = 'sender'; + group = generateGroup({ + name: 'test group', + type: 'guild', + privacy: 'public', + leader: user._id, + }); + await group.save(); + sandbox.stub(sender, 'sendTxn'); sandbox.stub(user, 'sendMessage'); sandbox.stub(analytics, 'trackPurchase'); @@ -186,6 +198,7 @@ describe('payments/index', () => { expect(analytics.trackPurchase).to.be.calledOnce; expect(analytics.trackPurchase).to.be.calledWith({ uuid: user._id, + groupId: undefined, itemPurchased: 'Subscription', sku: 'payment method-subscription', purchaseType: 'subscribe', @@ -276,6 +289,7 @@ describe('payments/index', () => { expect(analytics.trackPurchase).to.be.calledOnce; expect(analytics.trackPurchase).to.be.calledWith({ uuid: user._id, + groupId: undefined, itemPurchased: 'Subscription', sku: 'payment method-subscription', purchaseType: 'subscribe', @@ -291,6 +305,53 @@ 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); @@ -426,61 +487,136 @@ describe('payments/index', () => { data = { user }; }); - it('adds a month termination date by default', async () => { - await api.cancelSubscription(data); + context('Canceling a subscription for self', () => { + it('adds a month termination date by default', async () => { + await api.cancelSubscription(data); - let now = new Date(); - let daysTillTermination = moment(user.purchased.plan.dateTerminated).diff(now, 'days'); + let now = new Date(); + let daysTillTermination = moment(user.purchased.plan.dateTerminated).diff(now, 'days'); - expect(daysTillTermination).to.be.within(29, 30); // 1 month +/- 1 days + expect(daysTillTermination).to.be.within(29, 30); // 1 month +/- 1 days + }); + + it('adds extraMonths to dateTerminated value', async () => { + user.purchased.plan.extraMonths = 2; + + await api.cancelSubscription(data); + + let now = new Date(); + let daysTillTermination = moment(user.purchased.plan.dateTerminated).diff(now, 'days'); + + expect(daysTillTermination).to.be.within(89, 90); // 3 months +/- 1 days + }); + + it('handles extra month fractions', async () => { + user.purchased.plan.extraMonths = 0.3; + + await api.cancelSubscription(data); + + let now = new Date(); + let daysTillTermination = moment(user.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 }); + + await api.cancelSubscription(data); + + let now = new Date(); + let daysTillTermination = moment(user.purchased.plan.dateTerminated).diff(now, 'days'); + + expect(daysTillTermination).to.be.within(13, 15); + }); + + it('resets plan.extraMonths', async () => { + user.purchased.plan.extraMonths = 5; + + await api.cancelSubscription(data); + + expect(user.purchased.plan.extraMonths).to.eql(0); + }); + + it('sends an email', async () => { + await api.cancelSubscription(data); + + expect(sender.sendTxn).to.be.calledOnce; + expect(sender.sendTxn).to.be.calledWith(user, 'cancel-subscription'); + }); }); - it('adds extraMonths to dateTerminated value', async () => { - user.purchased.plan.extraMonths = 2; + context('Canceling a subscription for group', () => { + it('adds a month termination date by default', async () => { + data.groupId = group._id; + await api.cancelSubscription(data); - 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'); - let now = new Date(); - let daysTillTermination = moment(user.purchased.plan.dateTerminated).diff(now, 'days'); + expect(daysTillTermination).to.be.within(29, 30); // 1 month +/- 1 days + }); - expect(daysTillTermination).to.be.within(89, 90); // 3 months +/- 1 days - }); + it('adds extraMonths to dateTerminated value', async () => { + group.purchased.plan.extraMonths = 2; + await group.save(); + data.groupId = group._id; - it('handles extra month fractions', async () => { - user.purchased.plan.extraMonths = 0.3; + await api.cancelSubscription(data); - 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'); - let now = new Date(); - let daysTillTermination = moment(user.purchased.plan.dateTerminated).diff(now, 'days'); + expect(daysTillTermination).to.be.within(89, 90); // 3 months +/- 1 days + }); - expect(daysTillTermination).to.be.within(38, 39); // should be about 1 month + 1/3 month - }); + it('handles extra month fractions', async () => { + group.purchased.plan.extraMonths = 0.3; + await group.save(); + data.groupId = group._id; - it('terminates at next billing date if it exists', async () => { - data.nextBill = moment().add({ days: 15 }); + await api.cancelSubscription(data); - 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'); - let now = new Date(); - let daysTillTermination = moment(user.purchased.plan.dateTerminated).diff(now, 'days'); + expect(daysTillTermination).to.be.within(38, 39); // should be about 1 month + 1/3 month + }); - expect(daysTillTermination).to.be.within(13, 15); - }); + it('terminates at next billing date if it exists', async () => { + data.nextBill = moment().add({ days: 15 }); + data.groupId = group._id; - it('resets plan.extraMonths', async () => { - user.purchased.plan.extraMonths = 5; + await api.cancelSubscription(data); - 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(user.purchased.plan.extraMonths).to.eql(0); - }); + expect(daysTillTermination).to.be.within(13, 15); + }); - it('sends an email', async () => { - await api.cancelSubscription(data); + it('resets plan.extraMonths', async () => { + group.purchased.plan.extraMonths = 5; + await group.save(); + data.groupId = group._id; - expect(sender.sendTxn).to.be.calledOnce; - expect(sender.sendTxn).to.be.calledWith(user, 'cancel-subscription'); + 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, 'cancel-subscription'); + }); }); }); diff --git a/website/client-old/js/services/paymentServices.js b/website/client-old/js/services/paymentServices.js index 6797da7bf9..e67477cd74 100644 --- a/website/client-old/js/services/paymentServices.js +++ b/website/client-old/js/services/paymentServices.js @@ -12,15 +12,21 @@ function($rootScope, User, $http, Content) { }; Payments.showStripe = function(data) { - var sub = - data.subscription ? data.subscription - : data.gift && data.gift.type=='subscription' ? data.gift.subscription.key - : false; + var sub = false; + + if (data.subscription) { + sub = data.subscription; + } else if (data.gift && data.gift.type=='subscription') { + sub = data.gift.subscription.key; + } + 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; + StripeCheckout.open({ key: window.env.STRIPE_PUB_KEY, address: false, @@ -34,6 +40,7 @@ function($rootScope, User, $http, Content) { if (data.gift) url += '&gift=' + Payments.encodeGift(data.uuid, data.gift); if (data.subscription) url += '&sub='+sub.key; if (data.coupon) url += '&coupon='+data.coupon; + if (data.groupId) url += '&groupId=' + data.groupId; $http.post(url, res).success(function() { window.location.reload(true); }).error(function(res) { @@ -43,7 +50,12 @@ function($rootScope, User, $http, Content) { }); } - Payments.showStripeEdit = function(){ + Payments.showStripeEdit = function(config) { + var groupId; + if (config.groupId) { + groupId = config.groupId; + } + StripeCheckout.open({ key: window.env.STRIPE_PUB_KEY, address: false, @@ -51,6 +63,7 @@ function($rootScope, User, $http, Content) { description: window.env.t('subUpdateDescription'), panelLabel: window.env.t('subUpdateCard'), token: function(data) { + data.groupId = groupId; var url = '/stripe/subscribe/edit'; $http.post(url, data).success(function() { window.location.reload(true); @@ -85,20 +98,24 @@ function($rootScope, User, $http, Content) { }; // Needs to be called everytime the modal/router is accessed - Payments.amazonPayments.init = function(data){ + Payments.amazonPayments.init = function(data) { if(!isAmazonReady) return; if(data.type !== 'single' && data.type !== 'subscription') return; - if(data.gift){ + if (data.gift) { if(data.gift.gems && data.gift.gems.amount && data.gift.gems.amount <= 0) return; data.gift.uuid = data.giftedTo; } - if(data.subscription){ + if (data.subscription) { Payments.amazonPayments.subscription = data.subscription; Payments.amazonPayments.coupon = data.coupon; } + if (data.groupId) { + Payments.amazonPayments.groupId = data.groupId; + } + Payments.amazonPayments.gift = data.gift; Payments.amazonPayments.type = data.type; @@ -120,10 +137,10 @@ function($rootScope, User, $http, Content) { onSignIn: function(contract){ Payments.amazonPayments.billingAgreementId = contract.getAmazonBillingAgreementId(); - if(Payments.amazonPayments.type === 'subscription'){ + if (Payments.amazonPayments.type === 'subscription') { Payments.amazonPayments.loggedIn = true; Payments.amazonPayments.initWidgets(); - }else{ + } else { var url = '/amazon/createOrderReferenceId' $http.post(url, { billingAgreementId: Payments.amazonPayments.billingAgreementId @@ -137,11 +154,11 @@ function($rootScope, User, $http, Content) { } }, - authorization: function(){ + authorization: function() { amazon.Login.authorize({ scope: 'payments:widget', popup: true - }, function(response){ + }, function(response) { if(response.error) return alert(response.error); var url = '/amazon/verifyAccessToken' @@ -169,7 +186,7 @@ function($rootScope, User, $http, Content) { } } - Payments.amazonPayments.initWidgets = function(){ + Payments.amazonPayments.initWidgets = function() { var walletParams = { sellerId: window.env.AMAZON_PAYMENTS.SELLER_ID, design: { @@ -177,7 +194,7 @@ function($rootScope, User, $http, Content) { }, onPaymentSelect: function() { - $rootScope.$apply(function(){ + $rootScope.$apply(function() { Payments.amazonPayments.paymentSelected = true; }); }, @@ -185,11 +202,11 @@ function($rootScope, User, $http, Content) { onError: amazonOnError } - if(Payments.amazonPayments.type === 'subscription'){ + if (Payments.amazonPayments.type === 'subscription') { walletParams.agreementType = 'BillingAgreement'; console.log(Payments.amazonPayments.billingAgreementId); walletParams.billingAgreementId = Payments.amazonPayments.billingAgreementId; - walletParams.onReady = function(billingAgreement){ + walletParams.onReady = function(billingAgreement) { Payments.amazonPayments.billingAgreementId = billingAgreement.getAmazonBillingAgreementId(); new OffAmazonPayments.Widgets.Consent({ @@ -215,14 +232,14 @@ function($rootScope, User, $http, Content) { onError: amazonOnError }).bind('AmazonPayRecurring'); } - }else{ + } else { walletParams.amazonOrderReferenceId = Payments.amazonPayments.orderReferenceId; } new OffAmazonPayments.Widgets.Wallet(walletParams).bind('AmazonPayWallet'); } - Payments.amazonPayments.checkout = function(){ + Payments.amazonPayments.checkout = function() { if(Payments.amazonPayments.type === 'single'){ var url = '/amazon/checkout'; $http.post(url, { @@ -235,13 +252,14 @@ function($rootScope, User, $http, Content) { alert(res.message); Payments.amazonPayments.reset(); }); - }else if(Payments.amazonPayments.type === 'subscription'){ + } else if(Payments.amazonPayments.type === 'subscription') { var url = '/amazon/subscribe'; $http.post(url, { billingAgreementId: Payments.amazonPayments.billingAgreementId, subscription: Payments.amazonPayments.subscription, - coupon: Payments.amazonPayments.coupon + coupon: Payments.amazonPayments.coupon, + groupId: Payments.amazonPayments.groupId, }).success(function(){ Payments.amazonPayments.reset(); window.location.reload(true); @@ -252,20 +270,33 @@ function($rootScope, User, $http, Content) { } } - Payments.cancelSubscription = function(){ + Payments.cancelSubscription = function(config) { if (!confirm(window.env.t('sureCancelSub'))) return; - var paymentMethod = User.user.purchased.plan.paymentMethod; - if(paymentMethod === 'Amazon Payments'){ + var group; + if (config.group) { + group = config.group; + } + + var paymentMethod = User.user.purchased.plan.paymentMethod; + if (group) { + paymentMethod = group.purchased.plan.paymentMethod; + } + + if (paymentMethod === 'Amazon Payments') { paymentMethod = 'amazon'; - }else{ + } else { paymentMethod = paymentMethod.toLowerCase(); } - window.location.href = '/' + paymentMethod + '/subscribe/cancel?_id=' + User.user._id + '&apiToken=' + User.settings.auth.apiToken; + var cancelUrl = '/' + paymentMethod + '/subscribe/cancel?_id=' + User.user._id + '&apiToken=' + User.settings.auth.apiToken; + if (group) { + cancelUrl += '&groupId=' + group._id; + } + window.location.href = cancelUrl; } - Payments.encodeGift = function(uuid, gift){ + Payments.encodeGift = function(uuid, gift) { gift.uuid = uuid; var encodedString = JSON.stringify(gift); return encodeURIComponent(encodedString); diff --git a/website/common/locales/en/groups.json b/website/common/locales/en/groups.json index d750b4aec8..310722ae95 100644 --- a/website/common/locales/en/groups.json +++ b/website/common/locales/en/groups.json @@ -215,5 +215,6 @@ "confirmRemoveTag": "Do you really want to remove \"<%= tag %>\"?", "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" + "claim": "Claim", + "onlyGroupLeaderCanManageSubscription": "Only the group leader can manage the group's subscription" } diff --git a/website/common/script/content/index.js b/website/common/script/content/index.js index 3531d41df2..68ede6a9a8 100644 --- a/website/common/script/content/index.js +++ b/website/common/script/content/index.js @@ -2628,7 +2628,15 @@ api.subscriptionBlocks = { basic_12mo: { months: 12, price: 48 - } + }, + group_monthly: { + months: 1, + price: 5 + }, + group_yearly: { + months: 1, + price: 48, + }, }; _.each(api.subscriptionBlocks, function(b, k) { diff --git a/website/server/controllers/api-v3/groups.js b/website/server/controllers/api-v3/groups.js index 65446652ac..47668ede7c 100644 --- a/website/server/controllers/api-v3/groups.js +++ b/website/server/controllers/api-v3/groups.js @@ -165,12 +165,17 @@ api.getGroup = { throw new NotFound(res.t('groupNotFound')); } - group = Group.toJSONCleanChat(group, user); - // Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833 - let leader = await User.findById(group.leader).select(nameFields).exec(); - if (leader) group.leader = leader.toJSON({minimize: true}); + let groupJson = Group.toJSONCleanChat(group, user); - res.respond(200, group); + if (groupJson.leader === user._id) { + groupJson.purchased.plan = group.purchased.plan.toObject(); + } + + // Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833 + let leader = await User.findById(groupJson.leader).select(nameFields).exec(); + if (leader) groupJson.leader = leader.toJSON({minimize: true}); + + res.respond(200, groupJson); }, }; diff --git a/website/server/controllers/top-level/payments/amazon.js b/website/server/controllers/top-level/payments/amazon.js index 34ee03bb6c..f8c5c1c856 100644 --- a/website/server/controllers/top-level/payments/amazon.js +++ b/website/server/controllers/top-level/payments/amazon.js @@ -1,6 +1,7 @@ import { BadRequest, NotAuthorized, + NotFound, } from '../../../libs/errors'; import amzLib from '../../../libs/amazonPayments'; import { @@ -12,6 +13,10 @@ import payments from '../../../libs/payments'; import moment from 'moment'; import { model as Coupon } from '../../../models/coupon'; import { model as User } from '../../../models/user'; +import { + model as Group, + basicFields as basicGroupFields, +} from '../../../models/group'; import cc from 'coupon-code'; let api = {}; @@ -34,6 +39,7 @@ api.verifyAccessToken = { if (!accessToken) throw new BadRequest('Missing req.body.access_token'); await amzLib.getTokenInfo(accessToken); + res.respond(200, {}); }, }; @@ -164,6 +170,7 @@ api.subscribe = { let sub = req.body.subscription ? shared.content.subscriptionBlocks[req.body.subscription] : false; let coupon = req.body.coupon; let user = res.locals.user; + let groupId = req.body.groupId; if (!sub) throw new BadRequest(res.t('missingSubscriptionCode')); if (!billingAgreementId) throw new BadRequest('Missing req.body.billingAgreementId'); @@ -213,6 +220,7 @@ api.subscribe = { paymentMethod: 'Amazon Payments', sub, headers: req.headers, + groupId, }); res.respond(200); @@ -231,7 +239,32 @@ api.subscribeCancel = { middlewares: [authWithUrl], async handler (req, res) { let user = res.locals.user; - let billingAgreementId = user.purchased.plan.customerId; + let groupId = req.query.groupId; + + let billingAgreementId; + let planId; + let lastBillingDate; + + if (groupId) { + let groupFields = basicGroupFields.concat(' purchased'); + let group = await Group.getGroup({user, groupId, populateLeader: false, groupFields}); + + if (!group) { + throw new NotFound(res.t('groupNotFound')); + } + + if (!group.leader === user._id) { + throw new NotAuthorized(res.t('onlyGroupLeaderCanManageSubscription')); + } + + billingAgreementId = group.purchased.plan.customerId; + planId = group.purchased.plan.planId; + lastBillingDate = group.purchased.plan.lastBillingDate; + } else { + billingAgreementId = user.purchased.plan.customerId; + planId = user.purchased.plan.planId; + lastBillingDate = user.purchased.plan.lastBillingDate; + } if (!billingAgreementId) throw new NotAuthorized(res.t('missingSubscription')); @@ -245,12 +278,13 @@ api.subscribeCancel = { }); } - let subscriptionBlock = shared.content.subscriptionBlocks[user.purchased.plan.planId]; + let subscriptionBlock = shared.content.subscriptionBlocks[planId]; let subscriptionLength = subscriptionBlock.months * 30; await payments.cancelSubscription({ user, - nextBill: moment(user.purchased.plan.lastBillingDate).add({ days: subscriptionLength }), + groupId, + nextBill: moment(lastBillingDate).add({ days: subscriptionLength }), paymentMethod: 'Amazon Payments', headers: req.headers, }); diff --git a/website/server/controllers/top-level/payments/paypal.js b/website/server/controllers/top-level/payments/paypal.js index 0c9d20a8d3..bb4ffe665e 100644 --- a/website/server/controllers/top-level/payments/paypal.js +++ b/website/server/controllers/top-level/payments/paypal.js @@ -11,6 +11,10 @@ import cc from 'coupon-code'; import Bluebird from 'bluebird'; import { model as Coupon } from '../../../models/coupon'; import { model as User } from '../../../models/user'; +import { + model as Group, + basicFields as basicGroupFields, +} from '../../../models/group'; import { authWithUrl, authWithSession, @@ -18,6 +22,7 @@ import { import { BadRequest, NotAuthorized, + NotFound, } from '../../../libs/errors'; const BASE_URL = nconf.get('BASE_URL'); @@ -178,6 +183,7 @@ api.subscribe = { let billingAgreement = await paypalBillingAgreementCreate(billingAgreementAttributes); req.session.paypalBlock = req.query.sub; + req.session.groupId = req.query.groupId; let link = _.find(billingAgreement.links, { rel: 'approval_url' }).href; res.redirect(link); }, @@ -196,11 +202,15 @@ api.subscribeSuccess = { async handler (req, res) { let user = res.locals.user; let block = shared.content.subscriptionBlocks[req.session.paypalBlock]; + let groupId = req.session.groupId; + delete req.session.paypalBlock; + delete req.session.groupId; let result = await paypalBillingAgreementExecute(req.query.token, {}); await payments.createSubscription({ user, + groupId, customerId: result.id, paymentMethod: 'Paypal', sub: block, @@ -223,8 +233,26 @@ api.subscribeCancel = { middlewares: [authWithUrl], async handler (req, res) { let user = res.locals.user; - let customerId = user.purchased.plan.customerId; - if (!user.purchased.plan.customerId) throw new NotAuthorized(res.t('missingSubscription')); + let groupId = req.query.groupId; + + let customerId; + if (groupId) { + let groupFields = basicGroupFields.concat(' purchased'); + let group = await Group.getGroup({user, groupId, populateLeader: false, groupFields}); + + if (!group) { + throw new NotFound(res.t('groupNotFound')); + } + + if (!group.leader === user._id) { + throw new NotAuthorized(res.t('onlyGroupLeaderCanManageSubscription')); + } + customerId = group.purchased.plan.customerId; + } else { + customerId = user.purchased.plan.customerId; + } + + if (!customerId) throw new NotAuthorized(res.t('missingSubscription')); let customer = await paypalBillingAgreementGet(customerId); @@ -236,6 +264,7 @@ api.subscribeCancel = { await paypalBillingAgreementCancel(customerId, { note: res.t('cancelingSubscription') }); await payments.cancelSubscription({ user, + groupId, paymentMethod: 'Paypal', nextBill: nextBillingDate, }); @@ -265,6 +294,13 @@ api.ipn = { let user = await User.findOne({ 'purchased.plan.customerId': req.body.recurring_payment_id }); if (user) { await payments.cancelSubscription({ user, paymentMethod: 'Paypal' }); + return; + } + + let groupFields = basicGroupFields.concat(' purchased'); + let group = await Group.findOne({ 'purchased.plan.customerId': req.body.recurring_payment_id }).select(groupFields).exec(); + if (group) { + await payments.cancelSubscription({ groupId: group._id, paymentMethod: 'Paypal' }); } } }, diff --git a/website/server/controllers/top-level/payments/stripe.js b/website/server/controllers/top-level/payments/stripe.js index 12b73cb3d7..9fd86c703a 100644 --- a/website/server/controllers/top-level/payments/stripe.js +++ b/website/server/controllers/top-level/payments/stripe.js @@ -3,11 +3,16 @@ import shared from '../../../../common'; import { BadRequest, NotAuthorized, + NotFound, } from '../../../libs/errors'; import { model as Coupon } from '../../../models/coupon'; import payments from '../../../libs/payments'; import nconf from 'nconf'; import { model as User } from '../../../models/user'; +import { + model as Group, + basicFields as basicGroupFields, +} from '../../../models/group'; import cc from 'coupon-code'; import { authWithHeaders, @@ -41,6 +46,7 @@ api.checkout = { let user = res.locals.user; let gift = req.query.gift ? JSON.parse(req.query.gift) : undefined; let sub = req.query.sub ? shared.content.subscriptionBlocks[req.query.sub] : false; + let groupId = req.query.groupId; let coupon; let response; @@ -84,6 +90,7 @@ api.checkout = { paymentMethod: 'Stripe', sub, headers: req.headers, + groupId, }); } else { let method = 'buyGems'; @@ -124,8 +131,26 @@ api.subscribeEdit = { middlewares: [authWithHeaders()], async handler (req, res) { let token = req.body.id; + let groupId = req.body.groupId; let user = res.locals.user; - let customerId = user.purchased.plan.customerId; + let customerId; + + // If we are buying a group subscription + if (groupId) { + let groupFields = basicGroupFields.concat(' purchased'); + let group = await Group.getGroup({user, groupId, populateLeader: false, groupFields}); + + if (!group) { + throw new NotFound(res.t('groupNotFound')); + } + + if (!group.leader === user._id) { + throw new NotAuthorized(res.t('onlyGroupLeaderCanManageSubscription')); + } + customerId = group.purchased.plan.customerId; + } else { + customerId = user.purchased.plan.customerId; + } if (!customerId) throw new NotAuthorized(res.t('missingSubscription')); if (!token) throw new BadRequest('Missing req.body.id'); @@ -150,13 +175,39 @@ api.subscribeCancel = { middlewares: [authWithUrl], async handler (req, res) { let user = res.locals.user; - if (!user.purchased.plan.customerId) throw new NotAuthorized(res.t('missingSubscription')); + let groupId = req.query.groupId; + let customerId; - let customer = await stripe.customers.retrieve(user.purchased.plan.customerId); - await stripe.customers.del(user.purchased.plan.customerId); + if (groupId) { + let groupFields = basicGroupFields.concat(' purchased'); + let group = await Group.getGroup({user, groupId, populateLeader: false, groupFields}); + + if (!group) { + throw new NotFound(res.t('groupNotFound')); + } + + if (!group.leader === user._id) { + throw new NotAuthorized(res.t('onlyGroupLeaderCanManageSubscription')); + } + customerId = group.purchased.plan.customerId; + } else { + customerId = user.purchased.plan.customerId; + } + + if (!customerId) throw new NotAuthorized(res.t('missingSubscription')); + + let customer = await stripe.customers.retrieve(customerId); + + let subscription = customer.subscription; + if (!subscription) { + subscription = customer.subscriptions.data[0]; + } + + await stripe.customers.del(customerId); await payments.cancelSubscription({ user, - nextBill: customer.subscription.current_period_end * 1000, // timestamp in seconds + groupId, + nextBill: subscription.current_period_end * 1000, // timestamp in seconds paymentMethod: 'Stripe', }); diff --git a/website/server/libs/amazonPayments.js b/website/server/libs/amazonPayments.js index 2a4b57652b..40c4592e95 100644 --- a/website/server/libs/amazonPayments.js +++ b/website/server/libs/amazonPayments.js @@ -56,8 +56,8 @@ module.exports = { confirmOrderReference, closeOrderReference, confirmBillingAgreement, - setBillingAgreementDetails, getBillingAgreementDetails, + setBillingAgreementDetails, closeBillingAgreement, authorizeOnBillingAgreement, authorize, diff --git a/website/server/libs/payments.js b/website/server/libs/payments.js index 1684a18f24..564fbe8fa4 100644 --- a/website/server/libs/payments.js +++ b/website/server/libs/payments.js @@ -7,6 +7,14 @@ import { import moment from 'moment'; import { sendNotification as sendPushNotification } from './pushNotifications'; import shared from '../../common' ; +import { + model as Group, + basicFields as basicGroupFields, +} from '../models/group'; +import { + NotAuthorized, + NotFound, +} from './errors'; let api = {}; @@ -32,10 +40,35 @@ function _dateDiff (earlyDate, lateDate) { api.createSubscription = async function createSubscription (data) { let recipient = data.gift ? data.gift.member : data.user; - let plan = recipient.purchased.plan; let block = shared.content.subscriptionBlocks[data.gift ? data.gift.subscription.key : data.sub.key]; let months = Number(block.months); let today = new Date(); + let plan; + let group; + let groupId; + let itemPurchased = 'Subscription'; + let purchaseType = 'subscribe'; + + // If we are buying a group subscription + if (data.groupId) { + let groupFields = basicGroupFields.concat(' purchased'); + group = await Group.getGroup({user: data.user, groupId: data.groupId, populateLeader: false, groupFields}); + + if (!group) { + throw new NotFound(shared.i18n.t('groupNotFound')); + } + + if (!group.leader === data.user._id) { + throw new NotAuthorized(shared.i18n.t('onlyGroupLeaderCanManageSubscription')); + } + + recipient = group; + itemPurchased = 'Group-Subscription'; + purchaseType = 'group-subscribe'; + groupId = group._id; + } + + plan = recipient.purchased.plan; if (data.gift) { if (plan.customerId && !plan.dateTerminated) { // User has active plan @@ -79,7 +112,9 @@ api.createSubscription = async function createSubscription (data) { plan.consecutive.trinkets += perks; } - revealMysteryItems(recipient); + if (recipient !== group) { + revealMysteryItems(recipient); + } if (!data.gift) { txnEmail(data.user, 'subscription-begins'); @@ -87,9 +122,10 @@ api.createSubscription = async function createSubscription (data) { analytics.trackPurchase({ uuid: data.user._id, - itemPurchased: 'Subscription', + groupId, + itemPurchased, sku: `${data.paymentMethod.toLowerCase()}-subscription`, - purchaseType: 'subscribe', + purchaseType, paymentMethod: data.paymentMethod, quantity: 1, gift: Boolean(data.gift), @@ -97,7 +133,7 @@ api.createSubscription = async function createSubscription (data) { headers: data.headers, }); - data.user.purchased.txnCount++; + if (!group) data.user.purchased.txnCount++; if (data.gift) { let message = `\`Hello ${data.gift.member.profile.name}, ${data.user.profile.name} has sent you ${shared.content.subscriptionBlocks[data.gift.subscription.key].months} months of subscription!\``; @@ -128,13 +164,39 @@ api.createSubscription = async function createSubscription (data) { } } - await data.user.save(); + if (group) { + await group.save(); + } else { + await data.user.save(); + } + if (data.gift) await data.gift.member.save(); }; // Sets their subscription to be cancelled later api.cancelSubscription = async function cancelSubscription (data) { - let plan = data.user.purchased.plan; + let plan; + let group; + let cancelType = 'unsubscribe'; + let groupId; + + // If we are buying a group subscription + if (data.groupId) { + let groupFields = basicGroupFields.concat(' purchased'); + group = await Group.getGroup({user: data.user, groupId: data.groupId, populateLeader: false, groupFields}); + + if (!group) { + throw new NotFound(shared.i18n.t('groupNotFound')); + } + + if (!group.leader === data.user._id) { + throw new NotAuthorized(shared.i18n.t('onlyGroupLeaderCanManageSubscription')); + } + plan = group.purchased.plan; + } else { + plan = data.user.purchased.plan; + } + let now = moment(); let remaining = data.nextBill ? moment(data.nextBill).diff(new Date(), 'days') : 30; let extraDays = Math.ceil(30.5 * plan.extraMonths); @@ -149,12 +211,22 @@ api.cancelSubscription = async function cancelSubscription (data) { plan.extraMonths = 0; // clear extra time. If they subscribe again, it'll be recalculated from p.dateTerminated - await data.user.save(); + if (group) { + await group.save(); + } else { + await data.user.save(); + } txnEmail(data.user, 'cancel-subscription'); - analytics.track('unsubscribe', { + if (group) { + cancelType = 'group-unsubscribe'; + groupId = group._id; + } + + analytics.track(cancelType, { uuid: data.user._id, + groupId, gaCategory: 'commerce', gaLabel: data.paymentMethod, paymentMethod: data.paymentMethod, diff --git a/website/server/models/group.js b/website/server/models/group.js index da25eef480..51501caac3 100644 --- a/website/server/models/group.js +++ b/website/server/models/group.js @@ -23,6 +23,9 @@ import pusher from '../libs/pusher'; import { syncableAttrs, } from '../libs/taskManager'; +import { + schema as SubscriptionPlanSchema, +} from './subscriptionPlan'; const questScrolls = shared.content.quests; const Schema = mongoose.Schema; @@ -91,7 +94,9 @@ export let schema = new Schema({ rewards: [{type: String, ref: 'Task'}], }, purchased: { - active: {type: Boolean, default: false}, + plan: {type: SubscriptionPlanSchema, default: () => { + return {}; + }}, }, }, { strict: true, @@ -100,6 +105,10 @@ export let schema = new Schema({ schema.plugin(baseModel, { noSet: ['_id', 'balance', 'quest', 'memberCount', 'chat', 'challengeCount', 'tasksOrder', 'purchased'], + private: ['purchased.plan'], + toJSONTransform (plainObj, originalDoc) { + if (plainObj.purchased) plainObj.purchased.active = originalDoc.purchased.plan && originalDoc.purchased.plan.customerId; + }, }); // A list of additional fields that cannot be updated (but can be set on creation) diff --git a/website/server/models/subscriptionPlan.js b/website/server/models/subscriptionPlan.js new file mode 100644 index 0000000000..ccf0c06cec --- /dev/null +++ b/website/server/models/subscriptionPlan.js @@ -0,0 +1,32 @@ +import mongoose from 'mongoose'; +import baseModel from '../libs/baseModel'; + +export let schema = new mongoose.Schema({ + planId: String, + paymentMethod: String, // enum: ['Paypal','Stripe', 'Gift', 'Amazon Payments', '']} + customerId: String, // Billing Agreement Id in case of Amazon Payments + dateCreated: Date, + dateTerminated: Date, + dateUpdated: Date, + extraMonths: {type: Number, default: 0}, + gemsBought: {type: Number, default: 0}, + mysteryItems: {type: Array, default: () => []}, + lastBillingDate: Date, // Used only for Amazon Payments to keep track of billing date + consecutive: { + count: {type: Number, default: 0}, + offset: {type: Number, default: 0}, // when gifted subs, offset++ for each month. offset-- each new-month (cron). count doesn't ++ until offset==0 + gemCapExtra: {type: Number, default: 0}, + trinkets: {type: Number, default: 0}, + }, +}, { + strict: true, + minimize: false, // So empty objects are returned + _id: false, +}); + +schema.plugin(baseModel, { + noSet: ['_id'], + timestamps: false, +}); + +export let model = mongoose.model('SubscriptionPlan', schema); diff --git a/website/server/models/user/schema.js b/website/server/models/user/schema.js index d9c613b1e5..648e183f4c 100644 --- a/website/server/models/user/schema.js +++ b/website/server/models/user/schema.js @@ -8,6 +8,9 @@ import { schema as WebhookSchema } from '../webhook'; import { schema as UserNotificationSchema, } from '../userNotification'; +import { + schema as SubscriptionPlanSchema, +} from '../subscriptionPlan'; const Schema = mongoose.Schema; @@ -144,24 +147,9 @@ let schema = new Schema({ }}, txnCount: {type: Number, default: 0}, mobileChat: Boolean, - plan: { - planId: String, - paymentMethod: String, // enum: ['Paypal','Stripe', 'Gift', 'Amazon Payments', '']} - customerId: String, // Billing Agreement Id in case of Amazon Payments - dateCreated: Date, - dateTerminated: Date, - dateUpdated: Date, - extraMonths: {type: Number, default: 0}, - gemsBought: {type: Number, default: 0}, - mysteryItems: {type: Array, default: () => []}, - lastBillingDate: Date, // Used only for Amazon Payments to keep track of billing date - consecutive: { - count: {type: Number, default: 0}, - offset: {type: Number, default: 0}, // when gifted subs, offset++ for each month. offset-- each new-month (cron). count doesn't ++ until offset==0 - gemCapExtra: {type: Number, default: 0}, - trinkets: {type: Number, default: 0}, - }, - }, + plan: {type: SubscriptionPlanSchema, default: () => { + return {}; + }}, }, flags: { diff --git a/website/views/options/social/group.jade b/website/views/options/social/group.jade index 0131f8e797..aef459ba5a 100644 --- a/website/views/options/social/group.jade +++ b/website/views/options/social/group.jade @@ -147,16 +147,15 @@ a.pull-right.gem-wallet(ng-if='group.type!="party"', popover-trigger='mouseenter h3.popover-title {{group.leader.profile.name}} .popover-content markdown(text='group._editing ? groupCopy.leaderMessage : group.leaderMessage') - - ul.options-menu(ng-init="groupPane = 'chat'", ng-show="group.purchased.active") + + ul.options-menu(ng-init="groupPane = 'chat'") li - a(ng-click="groupPane = 'chat'") - | Chat + a(ng-click="groupPane = 'chat'")=env.t('chat') li - a(ng-click="groupPane = 'tasks'") - | Tasks - - + a(ng-click="groupPane = 'tasks'", ng-if='group.purchased.active')=env.t('tasks') + li + a(ng-click="groupPane = 'subscription'", ng-show='group.leader._id === user._id')=env.t('subscription') + .tab-content .tab-pane.active @@ -168,5 +167,43 @@ a.pull-right.gem-wallet(ng-if='group.type!="party"', popover-trigger='mouseenter +chatMessages() h4(ng-if='group.chat.length < 1 && group.type === "party"')=env.t('partyChatEmpty') h4(ng-if='group.chat.length < 1 && group.type === "guild"')=env.t('guildChatEmpty') + + group-tasks(ng-show="groupPane == 'tasks'") + + //TODO: This can be a directive and the group/user can be an object passed via attribute + div(ng-show="groupPane == 'subscription'") + .col-md-12 + table.table.alert.alert-info(ng-if='group.purchased.plan.customerId') + tr(ng-if='group.purchased.plan.dateTerminated'): td.alert.alert-warning + span.noninteractive-button.btn-danger=env.t('canceledSubscription') + i.glyphicon.glyphicon-time + | #{env.t('subCanceled')} {{moment(group.purchased.plan.dateTerminated).format('MM/DD/YYYY')}} + tr(ng-if='!group.purchased.plan.dateTerminated'): td + h4=env.t('subscribed') + p(ng-if='group.purchased.plan.planId')=env.t('purchasedPlanId', {price: '{{Content.subscriptionBlocks[group.purchased.plan.planId].price}}', months: '{{Content.subscriptionBlocks[group.purchased.plan.planId].months}}', plan: '{{group.purchased.plan.paymentMethod}}'}) + tr(ng-if='group.purchased.plan.extraMonths'): td + span.glyphicon.glyphicon-credit-card + |  #{env.t('purchasedPlanExtraMonths', {months: '{{group.purchased.plan.extraMonths | number:2}}'})} + tr(ng-if='group.purchased.plan.consecutive.count || group.purchased.plan.consecutive.offset'): td + span.glyphicon.glyphicon-forward + |  #{env.t('consecutiveSubscription')} + ul.list-unstyled + li #{env.t('consecutiveMonths')} {{group.purchased.plan.consecutive.count + group.purchased.plan.consecutive.offset}} + li #{env.t('gemCapExtra')} {{group.purchased.plan.consecutive.gemCapExtra}} + li #{env.t('mysticHourglasses')} {{group.purchased.plan.consecutive.trinkets}} - group-tasks(ng-show="groupPane == 'tasks'") + div(ng-if='group.purchased.plan.customerId') + .btn.btn-primary(ng-if='!group.purchased.plan.dateTerminated && group.purchased.plan.paymentMethod=="Stripe"', ng-click='Payments.showStripeEdit({groupId: group.id})')=env.t('subUpdateCard') + .btn.btn-sm.btn-danger(ng-if='!group.purchased.plan.dateTerminated', ng-click='Payments.cancelSubscription({group: group})')=env.t('cancelSub') + + .container-fluid.slight-vertical-padding(ng-if='!group.purchased.plan.customerId || (group.purchased.plan.customerId && group.purchased.plan.dateTerminated)', ng-init="_subscription.key='group_monthly'") + small.muted=env.t('subscribeUsing') + .row.text-center + .col-xs-4 + 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') + .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') + img(src='https://www.paypalobjects.com/webstatic/en_US/i/buttons/pp-acceptance-small.png',alt=env.t('paypal')) + .col-xs-4 + a.purchase(ng-click="Payments.amazonPayments.init({type: 'subscription', subscription:_subscription.key, coupon:_subscription.coupon, groupId: group.id})") + img(src='https://payments.amazon.com/gp/cba/button',alt=env.t('amazonPayments'))