From 117a424a5d4978eff7f69d40b9e7f59ff5deaf25 Mon Sep 17 00:00:00 2001 From: Tyler Renelle Date: Tue, 25 Nov 2014 13:48:36 -0700 Subject: [PATCH 01/11] refactor(members): DRY some members code --- src/controllers/members.js | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/src/controllers/members.js b/src/controllers/members.js index 9d3017960f..d87f561827 100644 --- a/src/controllers/members.js +++ b/src/controllers/members.js @@ -6,21 +6,33 @@ var async = require('async'); var _ = require('lodash'); var shared = require('habitrpg-shared'); +var fetchMember = function(uuid, restrict){ + return function(cb){ + var q = User.findById(uuid); + if (restrict) q.select(partyFields); + q.exec(function(err, member){ + if (err) return cb(err); + if (!member) return cb({code:404, err: 'User not found'}); + return cb(null, member); + }) + } +} + +var sendErr = function(err, res, next){ + err.code ? res.json(err.code, {err: err.err}) : next(err); +} + api.getMember = function(req, res, next) { - User.findById(req.params.uuid).select(partyFields).exec(function(err, user){ - if (err) return next(err); - if (!user) return res.json(400,{err:'User not found'}); - res.json(user); + fetchMember(req.params.uuid, true)(function(err, member){ + if (err) return sendErr(err, res, next); + res.json(member); }) } api.sendPrivateMessage = function(req,res,next){ async.waterfall([ - function(cb){ - User.findById(req.params.uuid, cb); - }, + fetchMember(req.params.uuid), function(member, cb){ - if (!member) return cb({code:404, err: 'User not found'}); if (~member.inbox.blocks.indexOf(res.locals.user._id) // can't send message if that user blocked me || ~res.locals.user.inbox.blocks.indexOf(member._id) // or if I blocked them || member.inbox.optOut) { // or if they've opted out of messaging @@ -43,8 +55,7 @@ api.sendPrivateMessage = function(req,res,next){ res.locals.user.save(cb); } ], function(err){ - if (err) return err.code ? res.json(err.code,{err:err.err}) : err; + if (err) return sendErr(err, res, next); res.send(200); }) - } \ No newline at end of file From 57bfee02cf34b9b44f662f90328d2615cc8f4ebe Mon Sep 17 00:00:00 2001 From: Tyler Renelle Date: Wed, 26 Nov 2014 12:39:30 -0700 Subject: [PATCH 02/11] feat(plans): consecutive months & extra months (like a time-card) --- migrations/20141117_consecutive_months.js | 11 +++++++ src/controllers/payments/index.js | 38 +++++++++++++---------- src/models/user.js | 4 +++ views/options/settings.jade | 3 ++ 4 files changed, 39 insertions(+), 17 deletions(-) create mode 100644 migrations/20141117_consecutive_months.js diff --git a/migrations/20141117_consecutive_months.js b/migrations/20141117_consecutive_months.js new file mode 100644 index 0000000000..6893cdcb4c --- /dev/null +++ b/migrations/20141117_consecutive_months.js @@ -0,0 +1,11 @@ +// require moment, lodash +db.users.find( + {'purchased.plan.customerId':{$ne:null}}, + {'purchased.plan':1} +).forEach(function(user){ + var p = user.purchased.plan; + var latestMonth = p.dateTerminated || p.dateCreated; + // TODO is rounding up what we want? + var consecutiveMonths = Math.ceil(moment(p.dateCreated).diff(latestMonth, 'months', true)); + db.users.update({_id: user._id}, {$set: {'purchased.plan.consecutiveMonths': consecutiveMonths}}); +}); diff --git a/src/controllers/payments/index.js b/src/controllers/payments/index.js index ba6359d4f9..eed12edd4d 100644 --- a/src/controllers/payments/index.js +++ b/src/controllers/payments/index.js @@ -24,19 +24,19 @@ function revealMysteryItems(user) { exports.createSubscription = function(user, data) { if (!user.purchased.plan) user.purchased.plan = {}; - _(user.purchased.plan) - .merge({ // override with these values - planId:'basic_earned', - customerId: data.customerId, - dateUpdated: new Date(), - gemsBought: 0, - paymentMethod: data.paymentMethod, - dateTerminated: null - }) - .defaults({ // allow non-override if a plan was previously used - dateCreated: new Date(), - mysteryItems: [] - }); + var p = user.purchased.plan; + _(p).merge({ // override with these values + planId:'basic_earned', + customerId: data.customerId, + dateUpdated: new Date(), + gemsBought: 0, + paymentMethod: data.paymentMethod, + extraMonths: p.extraMonths + (p.dateTerminated ? moment(p.dateTerminated).diff(new Date(),'months',true) : 0), + dateTerminated: null + }).defaults({ // allow non-override if a plan was previously used + dateCreated: new Date(), + mysteryItems: [] + }); revealMysteryItems(user); if(isProduction) utils.txnEmail(user, 'subscription-begins'); user.purchased.txnCount++; @@ -48,12 +48,16 @@ exports.createSubscription = function(user, data) { * Sets their subscription to be cancelled later */ exports.cancelSubscription = function(user, data) { - var du = user.purchased.plan.dateUpdated, now = moment(); + var p = user.purchased.plan, + now = moment(); if(isProduction) utils.txnEmail(user, 'cancel-subscription'); - user.purchased.plan.dateTerminated = - moment( now.format('MM') + '/' + moment(du).format('DD') + '/' + now.format('YYYY') ) - .add({months:1}) + p.dateTerminated = + moment( now.format('MM') + '/' + moment(p.dateUpdated).format('DD') + '/' + now.format('YYYY') ) + .add({months:1})// end their subscription 1mo from their last payment + .add({months:p.extraMonths})// plus any extra time (carry-over, gifted subscription, etc) they have .toDate(); + p.extraMonths = 0; // clear extra time. If they subscribe again, it'll be recalculated from p.dateTerminated + utils.ga.event('unsubscribe', 'Stripe').send(); } diff --git a/src/models/user.js b/src/models/user.js index 4aef97d59c..e973fa9a12 100644 --- a/src/models/user.js +++ b/src/models/user.js @@ -94,9 +94,13 @@ var UserSchema = new Schema({ planId: String, paymentMethod: String, //enum: ['Paypal','Stripe', '']} customerId: String, + dateCreated: Date, dateTerminated: Date, dateUpdated: Date, + consecutiveMonths: {type:Number, 'default':0}, + extraMonths: {type:Number, 'default':0}, + gemsBought: {type: Number, 'default': 0}, mysteryItems: {type: Array, 'default': []} } diff --git a/views/options/settings.jade b/views/options/settings.jade index 99c94f4dc2..3cd1d09223 100644 --- a/views/options/settings.jade +++ b/views/options/settings.jade @@ -228,6 +228,9 @@ script(id='partials/options.settings.subscription.html',type='text/ng-template') i.glyphicon.glyphicon-time | #{env.t('subCanceled')} {{moment(p.dateTerminated).format('MM/DD/YYYY')}} p.alert.alert-success(ng-if='!p.dateTerminated')=env.t('subscribed') + p.alert.alert-success(ng-if='p.extraMonths') + span.glyphicon.glyphicon-credit-card + | You have {{p.extraMonths | number:2}} months of subscription credit. h2=env.t('individualSub') div(ng-include="'partials/options.settings.subscription.perks.html'") From 7e9b92fcad3d2ad43da5395b34f580b8d961d648 Mon Sep 17 00:00:00 2001 From: Tyler Renelle Date: Tue, 25 Nov 2014 14:53:52 -0700 Subject: [PATCH 03/11] feat(gifts): allow gifting from balance --- public/css/helpers.styl | 5 ++- public/js/controllers/groupsCtrl.js | 17 +++++++ src/controllers/members.js | 70 +++++++++++++++++++++-------- src/routes/apiv2.coffee | 10 +++++ views/shared/modals/buy-gems.jade | 1 - views/shared/modals/members.jade | 51 +++++++++++++++++++++ 6 files changed, 134 insertions(+), 20 deletions(-) diff --git a/public/css/helpers.styl b/public/css/helpers.styl index e827803593..52d245a3fb 100644 --- a/public/css/helpers.styl +++ b/public/css/helpers.styl @@ -29,4 +29,7 @@ // Hide visually and from screenreaders, but maintain layout .invisible - visibility: hidden \ No newline at end of file + visibility: hidden + +.transparent + opacity: 0.5 \ No newline at end of file diff --git a/public/js/controllers/groupsCtrl.js b/public/js/controllers/groupsCtrl.js index 69c09aa938..e3ee2be624 100644 --- a/public/js/controllers/groupsCtrl.js +++ b/public/js/controllers/groupsCtrl.js @@ -2,6 +2,11 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', '$http', '$q', 'User', 'Members', '$state', 'Notification', function($scope, $rootScope, Shared, Groups, $http, $q, User, Members, $state, Notification) { + Members.selectMember('9', function(){ + $rootScope.openModal('send-gift',{controller:'MemberModalCtrl'}) + }); + + $scope.isMemberOfPendingQuest = function(userid, group) { if (!group.quest || !group.quest.members) return false; if (group.quest.active) return false; // quest is started, not pending @@ -125,6 +130,18 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', ' $scope.$close(); }); } + $scope.gift = { + type: 'gems', + gems: {amount:0, fromBalance:true}, + subscription: {}, + message:'' + }; + $scope.sendGift = function(uuid, gift){ + $http.post('/api/v2/members/'+uuid+'/gift', gift).success(function(){ + Notification.text('Gift sent!') + $rootScope.User.sync(); + }) + } } ]) diff --git a/src/controllers/members.js b/src/controllers/members.js index d87f561827..f839d0fbce 100644 --- a/src/controllers/members.js +++ b/src/controllers/members.js @@ -29,33 +29,67 @@ api.getMember = function(req, res, next) { }) } -api.sendPrivateMessage = function(req,res,next){ +var sendMessage = function(user, member, msg){ + var message = groups.chatDefaults(msg, user); + shared.refPush(member.inbox.messages, message); + member.inbox.newMessages++; + member._v++; + member.markModified('inbox.messages'); + + var message = groups.chatDefaults(msg, member); + shared.refPush(user.inbox.messages, _.defaults({sent:true},message)); + user.markModified('inbox.messages'); +} + +api.sendPrivateMessage = function(req, res, next){ async.waterfall([ fetchMember(req.params.uuid), - function(member, cb){ + function(member, cb) { if (~member.inbox.blocks.indexOf(res.locals.user._id) // can't send message if that user blocked me || ~res.locals.user.inbox.blocks.indexOf(member._id) // or if I blocked them || member.inbox.optOut) { // or if they've opted out of messaging - return cb({code:401, err: "Can't send message to this user."}); + return cb({code: 401, err: "Can't send message to this user."}); } - - var message = groups.chatDefaults(req.body.message, res.locals.user); - shared.refPush(member.inbox.messages, message); - member.inbox.newMessages++; - member._v++; - member.markModified('inbox.messages'); - - var message = groups.chatDefaults(req.body.message, member); - shared.refPush(res.locals.user.inbox.messages, _.defaults({sent:true},message)); - res.locals.user.markModified('inbox.messages'); - - member.save(cb); - }, - function(a,b,cb){ - res.locals.user.save(cb); + sendMessage(res.locals.user, member, req.body.message); + async.parallel([ + function (cb2) { member.save(cb2) }, + function (cb2) { res.locals.user.save(cb2) } + ], cb); } ], function(err){ if (err) return sendErr(err, res, next); res.send(200); }) +} + +api.sendGift = function(req, res, next){ + async.waterfall([ + fetchMember(req.params.uuid), + function(member, cb) { + // Gems + switch (req.body.type) { + case "gems": + var amt = req.body.gems.amount / 4, + user = res.locals.user; + if (amt < 1 || user.balance < amt) + return cb({code: 401, err: "Amount must be within 0 and your current number of gems."}); + member.balance += amt; + user.balance -= amt; + var msg = "`Hello "+member.profile.name+", "+user.profile.name+" has sent you "+amt+" gems!` "; + if (req.body.message) msg += req.body.message; + sendMessage(user, member, msg); + return async.parallel([ + function (cb2) { member.save(cb2) }, + function (cb2) { res.locals.user.save(cb2) } + ], cb); + case "subscription": + return cb(); + default: + return cb({code:400, err:"Body must contain a gems:{amount,fromBalance} or subscription:{months} object"}); + } + } + ], function(err) { + if (err) return sendErr(err, res, next); + res.send(200); + }); } \ No newline at end of file diff --git a/src/routes/apiv2.coffee b/src/routes/apiv2.coffee index 00191a52b1..415577d565 100644 --- a/src/routes/apiv2.coffee +++ b/src/routes/apiv2.coffee @@ -602,6 +602,16 @@ module.exports = (swagger, v2) -> ] middleware: [auth.auth] action: user.blockUser + "/members/{uuid}/gift": + spec: + method: 'POST' + description: 'Send a gift to a member' + parameters: [ + path 'uuid', 'The UUID of the member', 'string' + body '', '{gems:{amount:Number, fromBalance:Boolean}, subscription:{months:Number}}', 'object' + ] + middleware: [auth.auth] + action: members.sendGift # --------------------------------- # Hall of Heroes / Patrons diff --git a/views/shared/modals/buy-gems.jade b/views/shared/modals/buy-gems.jade index c907973f7b..c6ade26a00 100644 --- a/views/shared/modals/buy-gems.jade +++ b/views/shared/modals/buy-gems.jade @@ -20,7 +20,6 @@ script(id='modals/buyGems.html', type='text/ng-template') td p small.muted=env.t('paymentMethods') - //.btn.btn-primary(ng-click='showStripe()')=env.t('payWithCard') .btn.btn-primary(ng-click='showStripe()')=env.t('card') a.btn.btn-warning(href='/paypal/checkout?_id={{user._id}}&apiToken={{user.apiToken}}') PayPal diff --git a/views/shared/modals/members.jade b/views/shared/modals/members.jade index 2925d4617d..e94596ffa1 100644 --- a/views/shared/modals/members.jade +++ b/views/shared/modals/members.jade @@ -38,6 +38,8 @@ script(type='text/ng-template', id='modals/member.html') span.glyphicon.glyphicon-ban-circle button.btn.btn-md.btn-default(tooltip=env.t('sendPM'), ng-click="openModal('private-message',{controller:'MemberModalCtrl'})", tooltip-placement='right') span.glyphicon.glyphicon-envelope + button.btn.btn-md.btn-default(tooltip="Send Gift", ng-click="openModal('send-gift',{controller:'MemberModalCtrl'})", tooltip-placement='right') + span.glyphicon.glyphicon-gift button.btn.btn-default(ng-click='$close()')=env.t('close') script(type='text/ng-template', id='modals/private-message.html') @@ -48,3 +50,52 @@ script(type='text/ng-template', id='modals/private-message.html') .modal-footer button.btn.btn-primary(ng-click='sendPrivateMessage(profile._id, _message)')=env.t("send") button.btn.btn-default(ng-click='$close()')=env.t('cancel') + +script(type='text/ng-template', id='modals/send-gift.html') + .modal-header + h4 Send Gift to {{::profile.profile.name}} + .modal-body + .panel.panel-default(class="{{gift.type=='gems' ? 'panel-primary' : 'transparent'}}", ng-click='gift.type="gems"') + .panel-heading + .pull-right + span(ng-show='gift.gems.fromBalance') From {{user.balance*4}} Gems + span(ng-hide='gift.gems.fromBalance') Total: ${{gift.gems.amount/4}} USD + | Gems + .panel-body + .row + .col-md-6 + .form-group + input.form-control(type='number', placeholder='Number of Gems', min='0', max='{{gift.gems.fromBalance ? user.balance*4 : ""}}', ng-model='gift.gems.amount') + .col-md-6 + .btn-group + a.btn.btn-default(ng-class="{active:gift.gems.fromBalance}", ng-click="gift.gems.fromBalance=true") From Balance + a.btn.btn-default(ng-class="{active:!gift.gems.fromBalance}", ng-click="gift.gems.fromBalance=false") Purchase + + .panel.panel-default(class="{{gift.type=='subscription' ? 'panel-primary' : 'transparent'}}", ng-click='gift.type="subscription"') + .panel-heading Subscription + .panel-body + .form-group + .radio + label + input(type="radio", name="subRadio", value="1", ng-model='gift.subscription.months') + | 1 Month ($5) + .radio + label + input(type="radio", name="subRadio", value="3", ng-model='gift.subscription.months') + | 3 Months ($15) + .radio + label + input(type="radio", name="subRadio", value="6", ng-model='gift.subscription.months') + | 6 Months ($30) + .radio + label + input(type="radio", name="subRadio", value="12", ng-model='gift.subscription.months') + | 1 Year ($48) + textarea.form-control(rows='3', ng-model='gift.message', placeholder='Personal message (optional)') + + .modal-footer + - var fromBal = "gift.type=='gems' && gift.gems.fromBalance" + button.btn.btn-primary(ng-show=fromBal, ng-click='sendGift(profile._id, gift)')=env.t("send") + a.btn.btn-primary(ng-hide=fromBal, ng-click='showStripe({uuid:profile._id})')=env.t('card') + a.btn.btn-warning(ng-hide=fromBal, href='/paypal/checkout?uuid={{profile._id}}') PayPal + button.btn.btn-default(ng-click='$close()')=env.t('cancel') \ No newline at end of file From e8dbd5ca1a89c45d1d63f2dfc714822bdc444a9d Mon Sep 17 00:00:00 2001 From: Tyler Renelle Date: Wed, 26 Nov 2014 15:14:23 -0700 Subject: [PATCH 04/11] feat(gifts): allow gifting gems from paypal/stripe --- public/js/controllers/groupsCtrl.js | 2 +- public/js/controllers/rootCtrl.js | 30 ++++++++++++++++----------- src/controllers/members.js | 19 ++++++++--------- src/controllers/payments/index.js | 24 ++++++++++++++++------ src/controllers/payments/paypal.js | 32 ++++++++++++++++++----------- src/controllers/payments/stripe.js | 16 +++++++++++---- src/models/group.js | 6 +++--- views/options/settings.jade | 2 +- views/shared/modals/buy-gems.jade | 2 +- views/shared/modals/members.jade | 4 ++-- 10 files changed, 85 insertions(+), 52 deletions(-) diff --git a/public/js/controllers/groupsCtrl.js b/public/js/controllers/groupsCtrl.js index e3ee2be624..e4a0a8638f 100644 --- a/public/js/controllers/groupsCtrl.js +++ b/public/js/controllers/groupsCtrl.js @@ -2,7 +2,7 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', '$http', '$q', 'User', 'Members', '$state', 'Notification', function($scope, $rootScope, Shared, Groups, $http, $q, User, Members, $state, Notification) { - Members.selectMember('9', function(){ + Members.selectMember('d7f809df-81bd-4d58-992b-851d94bfaccb', function(){ $rootScope.openModal('send-gift',{controller:'MemberModalCtrl'}) }); diff --git a/public/js/controllers/rootCtrl.js b/public/js/controllers/rootCtrl.js index 4e27c81b52..7c5b6c3ed4 100644 --- a/public/js/controllers/rootCtrl.js +++ b/public/js/controllers/rootCtrl.js @@ -3,8 +3,8 @@ /* Make user and settings available for everyone through root scope. */ -habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$http', '$state', '$stateParams', 'Notification', 'Groups', 'Shared', 'Content', '$modal', '$timeout', 'ApiUrlService', - function($scope, $rootScope, $location, User, $http, $state, $stateParams, Notification, Groups, Shared, Content, $modal, $timeout, ApiUrlService) { +habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$http', '$state', '$stateParams', 'Notification', 'Groups', 'Shared', 'Content', '$modal', '$timeout', 'ApiUrlService', '$filter', + function($scope, $rootScope, $location, User, $http, $state, $stateParams, Notification, Groups, Shared, Content, $modal, $timeout, ApiUrlService, $filter) { var user = User.user; var initSticky = _.once(function(){ @@ -139,24 +139,30 @@ habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$ $rootScope.flash[type].splice($index, 1); } - $rootScope.showStripe = function(subscription) { + $rootScope.encodeGift = function(uuid, gift){ + return JSON.stringify({uuid:uuid, amount:gift.gems.amount, message:gift.message}); + } + + $rootScope.showStripe = function(data) { StripeCheckout.open({ key: window.env.STRIPE_PUB_KEY, address: false, - amount: 500, - name: subscription ? window.env.t('subscribe') : window.env.t('checkout'), - description: subscription ? + amount: data.gift ? data.gift.gems.amount/4*100 : 500, + name: data.subscription ? window.env.t('subscribe') : window.env.t('checkout'), + description: data.subscription ? window.env.t('buySubsText') : window.env.t('donationDesc'), - panelLabel: subscription ? window.env.t('subscribe') : window.env.t('checkout'), - token: function(data) { + panelLabel: data.subscription ? window.env.t('subscribe') : window.env.t('checkout'), + token: function(res) { var url = '/stripe/checkout'; - if (subscription) url += '?plan=basic_earned'; + if (data.gift) url += '?gift=' + $rootScope.encodeGift(data.uuid, data.gift); + //TODO else if? ^ + if (data.subscription) url += '?plan=basic_earned'; $scope.$apply(function(){ - $http.post(url, data).success(function() { + $http.post(url, res).success(function() { window.location.reload(true); - }).error(function(data) { - alert(data.err); + }).error(function(res) { + alert(res.err); }); }) } diff --git a/src/controllers/members.js b/src/controllers/members.js index f839d0fbce..61ec8c8cd0 100644 --- a/src/controllers/members.js +++ b/src/controllers/members.js @@ -29,15 +29,16 @@ api.getMember = function(req, res, next) { }) } -var sendMessage = function(user, member, msg){ - var message = groups.chatDefaults(msg, user); - shared.refPush(member.inbox.messages, message); +api.sendMessage = function(user, member, data){ + var msg = data.amount + ? "`Hello " + member.profile.name + ", " + user.profile.name + " has sent you " + data.amount + " gems!` " + data.message + : data.message; + shared.refPush(member.inbox.messages, groups.chatDefaults(msg, user)); member.inbox.newMessages++; member._v++; member.markModified('inbox.messages'); - var message = groups.chatDefaults(msg, member); - shared.refPush(user.inbox.messages, _.defaults({sent:true},message)); + shared.refPush(user.inbox.messages, _.defaults({sent:true}, groups.chatDefaults(msg, member))); user.markModified('inbox.messages'); } @@ -50,7 +51,7 @@ api.sendPrivateMessage = function(req, res, next){ || member.inbox.optOut) { // or if they've opted out of messaging return cb({code: 401, err: "Can't send message to this user."}); } - sendMessage(res.locals.user, member, req.body.message); + api.sendMessage(res.locals.user, member, {message:req.body.message}); async.parallel([ function (cb2) { member.save(cb2) }, function (cb2) { res.locals.user.save(cb2) } @@ -75,12 +76,10 @@ api.sendGift = function(req, res, next){ return cb({code: 401, err: "Amount must be within 0 and your current number of gems."}); member.balance += amt; user.balance -= amt; - var msg = "`Hello "+member.profile.name+", "+user.profile.name+" has sent you "+amt+" gems!` "; - if (req.body.message) msg += req.body.message; - sendMessage(user, member, msg); + api.sendMessage(user, member, {amount:req.body.gems.amount, message:req.body.message}); return async.parallel([ function (cb2) { member.save(cb2) }, - function (cb2) { res.locals.user.save(cb2) } + function (cb2) { user.save(cb2) } ], cb); case "subscription": return cb(); diff --git a/src/controllers/payments/index.js b/src/controllers/payments/index.js index eed12edd4d..23e5925114 100644 --- a/src/controllers/payments/index.js +++ b/src/controllers/payments/index.js @@ -7,6 +7,9 @@ var moment = require('moment'); var isProduction = nconf.get("NODE_ENV") === "production"; var stripe = require('./stripe'); var paypal = require('./paypal'); +var User = require('mongoose').model('User'); +var members = require('../members') +var async = require('async'); function revealMysteryItems(user) { _.each(shared.content.gear.flat, function(item) { @@ -61,12 +64,21 @@ exports.cancelSubscription = function(user, data) { utils.ga.event('unsubscribe', 'Stripe').send(); } -exports.buyGems = function(user, data) { - user.balance += 5; - user.purchased.txnCount++; - if(isProduction) utils.txnEmail(user, 'donation'); - utils.ga.event('checkout', data.paymentMethod).send(); - utils.ga.transaction(data.customerId, 5).item(5, 1, data.paymentMethod.toLowerCase() + "-checkout", "Gems > " + data.paymentMethod).send(); +exports.buyGems = function(data, cb) { + var amt = data.gift ? data.gift.amount/4 : 5; + (data.gift ? data.gift.member : data.user).balance += amt; + data.user.purchased.txnCount++; + if(isProduction) { + utils.txnEmail(data.user, 'donation'); + utils.ga.event('checkout', data.paymentMethod).send(); + //TODO ga.transaction to reflect whether this is gift or self-purchase + utils.ga.transaction(data.customerId, amt).item(amt, 1, data.paymentMethod.toLowerCase() + "-checkout", "Gems > " + data.paymentMethod).send(); + } + if (data.gift) members.sendMessage(data.user, data.gift.member, data.gift); + async.parallel([ + function(cb2){data.user.save(cb2)}, + function(cb2){data.gift ? data.gift.member.save(cb2) : cb2(null);} + ], cb); } exports.stripeCheckout = stripe.checkout; diff --git a/src/controllers/payments/paypal.js b/src/controllers/payments/paypal.js index cfc0b8d4d2..b264f32325 100644 --- a/src/controllers/payments/paypal.js +++ b/src/controllers/payments/paypal.js @@ -3,7 +3,7 @@ var moment = require('moment'); var async = require('async'); var _ = require('lodash'); var url = require('url'); -var mongoose = require('mongoose'); +var User = require('mongoose').model('User'); var payments = require('./index'); var logger = require('../../logging'); var ipn = require('paypal-ipn'); @@ -51,7 +51,7 @@ exports.executeBillingAgreement = function(req,res,next){ paypal.billingAgreement.execute(req.query.token, {}, cb); }, function(billingAgreement, cb){ - mongoose.model('User').findById(req.session.userId, function(err, user){ + User.findById(req.session.userId, function(err, user){ if (err) return cb(err); cb(null, {billingAgreement:billingAgreement, user:user}); }); @@ -67,6 +67,9 @@ exports.executeBillingAgreement = function(req,res,next){ } exports.createPayment = function(req, res, next) { + // if we're gifting to a user, put it in session for the `execute()` + req.session.gift = req.query.gift ? JSON.parse(req.query.gift) : undefined; + var price = req.session.gift ? Number(req.session.gift.amount/4).toFixed(2) : 5.00; var create_payment = { "intent": "sale", "payer": { @@ -81,14 +84,14 @@ exports.createPayment = function(req, res, next) { "items": [{ "name": "HabitRPG Gems", //"sku": "1", - "price": "5.00", + "price": price, "currency": "USD", "quantity": 1 }] }, "amount": { "currency": "USD", - "total": "5.00" + "total": price }, "description": "HabitRPG Gems" }] @@ -102,20 +105,25 @@ exports.createPayment = function(req, res, next) { exports.executePayment = function(req, res, next) { var paymentId = req.query.paymentId, - PayerID = req.query.PayerID; + PayerID = req.query.PayerID, + gift = req.session.gift; async.waterfall([ function(cb){ paypal.payment.execute(paymentId, {payer_id: PayerID}, cb); }, function(payment, cb){ - mongoose.model('User').findById(req.session.userId, cb); + async.parallel([ + function(cb2){ User.findById(req.session.userId, cb2); }, + function(cb2){ User.findById(gift ? gift.uuid : undefined, cb2); } + ], cb); }, - function(user, cb){ - if (_.isEmpty(user)) return cb("user not found when completing paypal transaction"); - payments.buyGems(user, {customerId:PayerID, paymentMethod:'Paypal'}); - user.save(cb); + function(results, cb){ + if (_.isEmpty(results[0])) + return cb("user not found when completing paypal transaction"); + if (gift) gift.member = results[1]; + payments.buyGems({user:results[0], customerId:PayerID, paymentMethod:'Paypal', gift:gift}, cb); } - ],function(err, saved){ + ],function(err, results){ if (err) return next(parseErr(err)); res.redirect('/'); }) @@ -153,7 +161,7 @@ exports.ipn = function(req, res, next) { // TODO what's the diff b/w the two data.txn_types below? The docs recommend subscr_cancel, but I'm getting the other one instead... case 'recurring_payment_profile_cancel': case 'subscr_cancel': - mongoose.model('User').findOne({'purchased.plan.customerId':req.body.recurring_payment_id},function(err, user){ + User.findOne({'purchased.plan.customerId':req.body.recurring_payment_id},function(err, user){ if (err) return logger.error(err); if (_.isEmpty(user)) return; // looks like the cancellation was already handled properly above (see api.paypalSubscribeCancel) payments.cancelSubscription(user); diff --git a/src/controllers/payments/stripe.js b/src/controllers/payments/stripe.js index 4f9a52afeb..404ed80ad9 100644 --- a/src/controllers/payments/stripe.js +++ b/src/controllers/payments/stripe.js @@ -2,6 +2,7 @@ var nconf = require('nconf'); var stripe = require("stripe")(nconf.get('STRIPE_API_KEY')); var async = require('async'); var payments = require('./index'); +var User = require('mongoose').model('User'); /* Setup Stripe response when posting payment @@ -9,19 +10,20 @@ var payments = require('./index'); exports.checkout = function(req, res, next) { var token = req.body.id; var user = res.locals.user; + var gift = req.query.gift ? JSON.parse(req.query.gift) : undefined; async.waterfall([ function(cb){ if (req.query.plan) { stripe.customers.create({ email: req.body.email, - metadata: {uuid: res.locals.user._id}, + metadata: {uuid: user._id}, card: token, plan: req.query.plan }, cb); } else { stripe.charges.create({ - amount: "500", // $5 + amount: gift ? ""+gift.amount/4*100 : "500", //"500" = $5 currency: "usd", card: token }, cb); @@ -30,10 +32,16 @@ exports.checkout = function(req, res, next) { function(response, cb) { if (req.query.plan) { payments.createSubscription(user, {customerId: response.id, paymentMethod: 'Stripe'}); + user.save(cb); } else { - payments.buyGems(user, {customerId: response.id, paymentMethod: 'Stripe'}); + async.waterfall([ + function(cb2){ User.findById(gift ? gift.uuid : undefined, cb2) }, + function(member, cb2){ + if (gift) gift.member = member; + payments.buyGems({user:user, customerId:response.id, paymentMethod:'Stripe', gift:gift},cb2); + } + ], cb); } - user.save(cb); } ], function(err, saved){ if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors diff --git a/src/models/group.js b/src/models/group.js index fd2518b8ed..1bc275afe4 100644 --- a/src/models/group.js +++ b/src/models/group.js @@ -93,10 +93,10 @@ GroupSchema.methods.toJSON = function(){ return doc; } -var chatDefaults = module.exports.chatDefaults = function(message,user){ +var chatDefaults = module.exports.chatDefaults = function(msg,user){ var message = { id: shared.uuid(), - text: message, + text: msg, timestamp: +new Date, likes: {} }; @@ -105,7 +105,7 @@ var chatDefaults = module.exports.chatDefaults = function(message,user){ uuid: user._id, contributor: user.contributor && user.contributor.toObject(), backer: user.backer && user.backer.toObject(), - user: user.profile.name, + user: user.profile.name }); } else { message.uuid = 'system'; diff --git a/views/options/settings.jade b/views/options/settings.jade index 3cd1d09223..b094bb9e07 100644 --- a/views/options/settings.jade +++ b/views/options/settings.jade @@ -237,7 +237,7 @@ script(id='partials/options.settings.subscription.html',type='text/ng-template') div(ng-if='!p.customerId || (p.customerId && p.dateTerminated)') h3(ng-if='(p.customerId && p.dateTerminated)') Resubscribe - a.btn.btn-primary(ng-click='showStripe(true)') Card + a.btn.btn-primary(ng-click='showStripe({subscription:true})') Card //a.btn.btn-warning(ng-click='paypalSubscribe()') PayPal a.btn.btn-warning(href='/paypal/subscribe?_id={{user._id}}&apiToken={{user.apiToken}}') PayPal div(ng-if='p.customerId') diff --git a/views/shared/modals/buy-gems.jade b/views/shared/modals/buy-gems.jade index c6ade26a00..b1caf50816 100644 --- a/views/shared/modals/buy-gems.jade +++ b/views/shared/modals/buy-gems.jade @@ -20,7 +20,7 @@ script(id='modals/buyGems.html', type='text/ng-template') td p small.muted=env.t('paymentMethods') - .btn.btn-primary(ng-click='showStripe()')=env.t('card') + .btn.btn-primary(ng-click='showStripe({})')=env.t('card') a.btn.btn-warning(href='/paypal/checkout?_id={{user._id}}&apiToken={{user.apiToken}}') PayPal div(ng-include="'partials/options.settings.subscription.html'") diff --git a/views/shared/modals/members.jade b/views/shared/modals/members.jade index e94596ffa1..94c31ec0c8 100644 --- a/views/shared/modals/members.jade +++ b/views/shared/modals/members.jade @@ -96,6 +96,6 @@ script(type='text/ng-template', id='modals/send-gift.html') .modal-footer - var fromBal = "gift.type=='gems' && gift.gems.fromBalance" button.btn.btn-primary(ng-show=fromBal, ng-click='sendGift(profile._id, gift)')=env.t("send") - a.btn.btn-primary(ng-hide=fromBal, ng-click='showStripe({uuid:profile._id})')=env.t('card') - a.btn.btn-warning(ng-hide=fromBal, href='/paypal/checkout?uuid={{profile._id}}') PayPal + a.btn.btn-primary(ng-hide=fromBal, ng-click='showStripe({gift:gift, uuid:profile._id})')=env.t('card') + a.btn.btn-warning(ng-hide=fromBal, href='/paypal/checkout?_id={{::user._id}}&apiToken={{::user.apiToken}}&gift={{encodeGift(profile._id, gift)}}') PayPal button.btn.btn-default(ng-click='$close()')=env.t('cancel') \ No newline at end of file From 5b01fff70038886f470f2769235759ff29d7fcf5 Mon Sep 17 00:00:00 2001 From: Tyler Renelle Date: Wed, 26 Nov 2014 18:13:25 -0700 Subject: [PATCH 05/11] feat(gifts): allow gifting subscriptions --- public/js/controllers/groupsCtrl.js | 2 +- public/js/controllers/rootCtrl.js | 16 ++++++++------- src/controllers/members.js | 13 ++++++++---- src/controllers/payments/index.js | 31 +++++++++++++++++++--------- src/controllers/payments/paypal.js | 29 ++++++++++++++++++-------- src/controllers/payments/stripe.js | 32 +++++++++++++++++------------ src/models/user.js | 2 +- views/shared/modals/members.jade | 22 ++++++-------------- 8 files changed, 86 insertions(+), 61 deletions(-) diff --git a/public/js/controllers/groupsCtrl.js b/public/js/controllers/groupsCtrl.js index e4a0a8638f..9efd8826eb 100644 --- a/public/js/controllers/groupsCtrl.js +++ b/public/js/controllers/groupsCtrl.js @@ -133,7 +133,7 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', ' $scope.gift = { type: 'gems', gems: {amount:0, fromBalance:true}, - subscription: {}, + subscription: {months:1}, message:'' }; $scope.sendGift = function(uuid, gift){ diff --git a/public/js/controllers/rootCtrl.js b/public/js/controllers/rootCtrl.js index 7c5b6c3ed4..7fc249754e 100644 --- a/public/js/controllers/rootCtrl.js +++ b/public/js/controllers/rootCtrl.js @@ -140,19 +140,21 @@ habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$ } $rootScope.encodeGift = function(uuid, gift){ - return JSON.stringify({uuid:uuid, amount:gift.gems.amount, message:gift.message}); + gift.uuid = uuid; + return JSON.stringify(gift); } $rootScope.showStripe = function(data) { + var isSub = data.subscription || (data.gift && data.gift.type=='subscription'); StripeCheckout.open({ key: window.env.STRIPE_PUB_KEY, address: false, - amount: data.gift ? data.gift.gems.amount/4*100 : 500, - name: data.subscription ? window.env.t('subscribe') : window.env.t('checkout'), - description: data.subscription ? - window.env.t('buySubsText') : - window.env.t('donationDesc'), - panelLabel: data.subscription ? window.env.t('subscribe') : window.env.t('checkout'), + amount: !data.gift ? 500 : // 500 = $5 + data.gift.type=='subscription' ? Content.subscriptionBlocks[data.gift.subscription.months].price*100: + data.gift.gems.amount/4*100, + name: isSub ? window.env.t('subscribe') : window.env.t('checkout'), + description: isSub ? window.env.t('buySubsText') : window.env.t('donationDesc'), + panelLabel: isSub ? window.env.t('subscribe') : window.env.t('checkout'), token: function(res) { var url = '/stripe/checkout'; if (data.gift) url += '?gift=' + $rootScope.encodeGift(data.uuid, data.gift); diff --git a/src/controllers/members.js b/src/controllers/members.js index 61ec8c8cd0..e0a8b653b9 100644 --- a/src/controllers/members.js +++ b/src/controllers/members.js @@ -30,9 +30,14 @@ api.getMember = function(req, res, next) { } api.sendMessage = function(user, member, data){ - var msg = data.amount - ? "`Hello " + member.profile.name + ", " + user.profile.name + " has sent you " + data.amount + " gems!` " + data.message - : data.message; + var msg; + if (!data.type) { + msg = data.message + } else { + msg = "`Hello " + member.profile.name + ", " + user.profile.name + " has sent you "; + msg += (data.type=='gems') ? data.gems.amount + " gems!`" : data.subscription.months + " months of subscription!`"; + msg += data.message; + } shared.refPush(member.inbox.messages, groups.chatDefaults(msg, user)); member.inbox.newMessages++; member._v++; @@ -76,7 +81,7 @@ api.sendGift = function(req, res, next){ return cb({code: 401, err: "Amount must be within 0 and your current number of gems."}); member.balance += amt; user.balance -= amt; - api.sendMessage(user, member, {amount:req.body.gems.amount, message:req.body.message}); + api.sendMessage(user, member, req.body); return async.parallel([ function (cb2) { member.save(cb2) }, function (cb2) { user.save(cb2) } diff --git a/src/controllers/payments/index.js b/src/controllers/payments/index.js index 23e5925114..332f2cdf45 100644 --- a/src/controllers/payments/index.js +++ b/src/controllers/payments/index.js @@ -25,26 +25,37 @@ function revealMysteryItems(user) { }); } -exports.createSubscription = function(user, data) { - if (!user.purchased.plan) user.purchased.plan = {}; - var p = user.purchased.plan; +exports.createSubscription = function(data, cb) { + var recipient = data.gift ? data.gift.member : data.user; + if (!recipient.purchased.plan) recipient.purchased.plan = {}; + var p = recipient.purchased.plan; _(p).merge({ // override with these values planId:'basic_earned', customerId: data.customerId, dateUpdated: new Date(), gemsBought: 0, paymentMethod: data.paymentMethod, - extraMonths: p.extraMonths + (p.dateTerminated ? moment(p.dateTerminated).diff(new Date(),'months',true) : 0), + extraMonths: +p.extraMonths + + +(p.dateTerminated ? moment(p.dateTerminated).diff(new Date(),'months',true) : 0) + + +(data.gift ? data.gift.subscription.months : 0), dateTerminated: null }).defaults({ // allow non-override if a plan was previously used dateCreated: new Date(), mysteryItems: [] }); - revealMysteryItems(user); - if(isProduction) utils.txnEmail(user, 'subscription-begins'); - user.purchased.txnCount++; - utils.ga.event('subscribe', data.paymentMethod).send(); - utils.ga.transaction(data.customerId, 5).item(5, 1, data.paymentMethod.toLowerCase() + '-subscription', data.paymentMethod + " > Stripe").send(); + revealMysteryItems(recipient); + if(isProduction) { + utils.txnEmail(data.user, 'subscription-begins'); + //TODO proper ga.event / transaction + utils.ga.event('subscribe', data.paymentMethod).send(); + utils.ga.transaction(data.customerId, 5).item(5, 1, data.paymentMethod.toLowerCase() + '-subscription', data.paymentMethod + " > Stripe").send(); + } + data.user.purchased.txnCount++; + if (data.gift) members.sendMessage(data.user, data.gift.member, data.gift); + async.parallel([ + function(cb2){data.user.save(cb2)}, + function(cb2){data.gift ? data.gift.member.save(cb2) : cb2(null);} + ], cb); } /** @@ -65,7 +76,7 @@ exports.cancelSubscription = function(user, data) { } exports.buyGems = function(data, cb) { - var amt = data.gift ? data.gift.amount/4 : 5; + var amt = data.gift ? data.gift.gems.amount/4 : 5; (data.gift ? data.gift.member : data.user).balance += amt; data.user.purchased.txnCount++; if(isProduction) { diff --git a/src/controllers/payments/paypal.js b/src/controllers/payments/paypal.js index b264f32325..1277b7c967 100644 --- a/src/controllers/payments/paypal.js +++ b/src/controllers/payments/paypal.js @@ -8,6 +8,7 @@ var payments = require('./index'); var logger = require('../../logging'); var ipn = require('paypal-ipn'); var paypal = require('paypal-rest-sdk'); +var shared = require('habitrpg-shared'); // This is the plan.id for paypal subscriptions. You have to set up billing plans via their REST sdk (they don't have // a web interface for billing-plan creation), see ./paypalBillingSetup.js for how. After the billing plan is created @@ -68,8 +69,13 @@ exports.executeBillingAgreement = function(req,res,next){ exports.createPayment = function(req, res, next) { // if we're gifting to a user, put it in session for the `execute()` - req.session.gift = req.query.gift ? JSON.parse(req.query.gift) : undefined; - var price = req.session.gift ? Number(req.session.gift.amount/4).toFixed(2) : 5.00; + var gift = req.session.gift = req.query.gift ? JSON.parse(req.query.gift) : undefined; + var price = !gift ? 5.00 + : gift.type=='gems' ? Number(gift.gems.amount/4).toFixed(2) + : Number(shared.content.subscriptionBlocks[gift.subscription.months].price).toFixed(2); + var description = !gift ? "HabitRPG Gems" + : gift.type=='gems' ? "HabitRPG Gems (Gift)" + : gift.subscription.months + "mo. HabitRPG Subscription (Gift)"; var create_payment = { "intent": "sale", "payer": { @@ -82,7 +88,7 @@ exports.createPayment = function(req, res, next) { "transactions": [{ "item_list": { "items": [{ - "name": "HabitRPG Gems", + "name": description, //"sku": "1", "price": price, "currency": "USD", @@ -93,7 +99,7 @@ exports.createPayment = function(req, res, next) { "currency": "USD", "total": price }, - "description": "HabitRPG Gems" + "description": description }] }; paypal.payment.create(create_payment, function (err, payment) { @@ -118,12 +124,17 @@ exports.executePayment = function(req, res, next) { ], cb); }, function(results, cb){ - if (_.isEmpty(results[0])) - return cb("user not found when completing paypal transaction"); - if (gift) gift.member = results[1]; - payments.buyGems({user:results[0], customerId:PayerID, paymentMethod:'Paypal', gift:gift}, cb); + if (_.isEmpty(results[0])) return cb("User not found when completing paypal transaction"); + var data = {user:results[0], customerId:PayerID, paymentMethod:'Paypal', gift:gift} + var method = 'buyGems'; + if (gift) { + gift.member = results[1]; + if (gift.type=='subscription') method = 'createSubscription'; + data.paymentMethod = 'Gift'; + } + payments[method](data, cb); } - ],function(err, results){ + ],function(err){ if (err) return next(parseErr(err)); res.redirect('/'); }) diff --git a/src/controllers/payments/stripe.js b/src/controllers/payments/stripe.js index 404ed80ad9..549c58bfb0 100644 --- a/src/controllers/payments/stripe.js +++ b/src/controllers/payments/stripe.js @@ -3,6 +3,7 @@ var stripe = require("stripe")(nconf.get('STRIPE_API_KEY')); var async = require('async'); var payments = require('./index'); var User = require('mongoose').model('User'); +var shared = require('habitrpg-shared'); /* Setup Stripe response when posting payment @@ -23,27 +24,32 @@ exports.checkout = function(req, res, next) { }, cb); } else { stripe.charges.create({ - amount: gift ? ""+gift.amount/4*100 : "500", //"500" = $5 + amount: !gift ? "500" //"500" = $5 + : gift.type=='subscription' ? ""+shared.content.subscriptionBlocks[gift.subscription.months].price*100 + : ""+gift.gems.amount/4*100, currency: "usd", card: token }, cb); } }, function(response, cb) { - if (req.query.plan) { - payments.createSubscription(user, {customerId: response.id, paymentMethod: 'Stripe'}); - user.save(cb); - } else { - async.waterfall([ - function(cb2){ User.findById(gift ? gift.uuid : undefined, cb2) }, - function(member, cb2){ - if (gift) gift.member = member; - payments.buyGems({user:user, customerId:response.id, paymentMethod:'Stripe', gift:gift},cb2); + if (req.query.plan) + return payments.createSubscription({user:user, customerId:response.id, paymentMethod:'Stripe'}, cb); + async.waterfall([ + function(cb2){ User.findById(gift ? gift.uuid : undefined, cb2) }, + function(member, cb2){ + var data = {user:user, customerId:response.id, paymentMethod:'Stripe', gift:gift}; + var method = 'buyGems'; + if (gift) { + gift.member = member; + if (gift.type=='subscription') method = 'createSubscription'; + data.paymentMethod = 'Gift'; } - ], cb); - } + payments[method](data, cb2); + } + ], cb); } - ], function(err, saved){ + ], function(err){ if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors res.send(200); user = token = null; diff --git a/src/models/user.js b/src/models/user.js index e973fa9a12..92be197914 100644 --- a/src/models/user.js +++ b/src/models/user.js @@ -92,7 +92,7 @@ var UserSchema = new Schema({ mobileChat: Boolean, plan: { planId: String, - paymentMethod: String, //enum: ['Paypal','Stripe', '']} + paymentMethod: String, //enum: ['Paypal','Stripe', 'Gift', '']} customerId: String, dateCreated: Date, diff --git a/views/shared/modals/members.jade b/views/shared/modals/members.jade index 94c31ec0c8..60eaf2f79f 100644 --- a/views/shared/modals/members.jade +++ b/views/shared/modals/members.jade @@ -75,22 +75,12 @@ script(type='text/ng-template', id='modals/send-gift.html') .panel-heading Subscription .panel-body .form-group - .radio - label - input(type="radio", name="subRadio", value="1", ng-model='gift.subscription.months') - | 1 Month ($5) - .radio - label - input(type="radio", name="subRadio", value="3", ng-model='gift.subscription.months') - | 3 Months ($15) - .radio - label - input(type="radio", name="subRadio", value="6", ng-model='gift.subscription.months') - | 6 Months ($30) - .radio - label - input(type="radio", name="subRadio", value="12", ng-model='gift.subscription.months') - | 1 Year ($48) + each block in env.Content.subscriptionBlocks + .radio + label + input(type="radio", name="subRadio", value="#{block.months}", ng-model='gift.subscription.months') + | #{block.months} Month(s): $#{block.price} + textarea.form-control(rows='3', ng-model='gift.message', placeholder='Personal message (optional)') .modal-footer From 5fd805db881901df68b342fc66ec741bb85da581 Mon Sep 17 00:00:00 2001 From: Tyler Renelle Date: Fri, 28 Nov 2014 14:44:40 -0700 Subject: [PATCH 06/11] feat(plans): consectuive-month subscription perks (gifting) --- src/controllers/payments/index.js | 12 +++++++++++- src/models/user.js | 11 +++++++---- views/options/settings.jade | 7 +++++++ 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/controllers/payments/index.js b/src/controllers/payments/index.js index 332f2cdf45..c20a90a6f3 100644 --- a/src/controllers/payments/index.js +++ b/src/controllers/payments/index.js @@ -29,6 +29,7 @@ exports.createSubscription = function(data, cb) { var recipient = data.gift ? data.gift.member : data.user; if (!recipient.purchased.plan) recipient.purchased.plan = {}; var p = recipient.purchased.plan; + var giftMonths = data.gift ? data.gift.subscription.months : 0 _(p).merge({ // override with these values planId:'basic_earned', customerId: data.customerId, @@ -37,12 +38,21 @@ exports.createSubscription = function(data, cb) { paymentMethod: data.paymentMethod, extraMonths: +p.extraMonths + +(p.dateTerminated ? moment(p.dateTerminated).diff(new Date(),'months',true) : 0) - + +(data.gift ? data.gift.subscription.months : 0), + + +(giftMonths), dateTerminated: null }).defaults({ // allow non-override if a plan was previously used dateCreated: new Date(), mysteryItems: [] }); + + // Block sub perks + if (giftMonths) { + p.consecutive.offset += giftMonths; + p.consecutive.gemCapExtra += giftMonths*5; + if (p.consecutive.gemCapExtra > 25) p.consecutive.gemCapExtra = 25; + p.consecutive.trinkets += giftMonths; + } + revealMysteryItems(recipient); if(isProduction) { utils.txnEmail(data.user, 'subscription-begins'); diff --git a/src/models/user.js b/src/models/user.js index 92be197914..3dbb7f0f29 100644 --- a/src/models/user.js +++ b/src/models/user.js @@ -94,15 +94,18 @@ var UserSchema = new Schema({ planId: String, paymentMethod: String, //enum: ['Paypal','Stripe', 'Gift', '']} customerId: String, - dateCreated: Date, dateTerminated: Date, dateUpdated: Date, - consecutiveMonths: {type:Number, 'default':0}, extraMonths: {type:Number, 'default':0}, - gemsBought: {type: Number, 'default': 0}, - mysteryItems: {type: Array, 'default': []} + mysteryItems: {type: Array, 'default': []}, + 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} + } } }, diff --git a/views/options/settings.jade b/views/options/settings.jade index b094bb9e07..b1bee5e7ad 100644 --- a/views/options/settings.jade +++ b/views/options/settings.jade @@ -231,6 +231,13 @@ script(id='partials/options.settings.subscription.html',type='text/ng-template') p.alert.alert-success(ng-if='p.extraMonths') span.glyphicon.glyphicon-credit-card | You have {{p.extraMonths | number:2}} months of subscription credit. + ul.alert.alert-success.list-unstyled(ng-if='p.consecutive.count || p.consecutive.offset') + li: h4 + span.glyphicon.glyphicon-forward + |  Consecutive Subscription + li You've been subscribed for {{p.consecutive.count}} consecutive months, plus {{p.consecutive.offset}} extra (gift / block-subscription). + li Gem Cap Extra: {{p.consecutive.gemCapExtra}} + li Time Trinkets: {{p.consecutive.trinkets}} h2=env.t('individualSub') div(ng-include="'partials/options.settings.subscription.perks.html'") From 7f2b6b5cfbff94dc7339126308428ee93e23e1a5 Mon Sep 17 00:00:00 2001 From: Tyler Renelle Date: Fri, 28 Nov 2014 16:11:13 -0700 Subject: [PATCH 07/11] feat(plans): allow block subscriptions from paypal/stripe --- public/js/controllers/rootCtrl.js | 27 +++++--- src/controllers/members.js | 2 +- src/controllers/payments/index.js | 68 +++++++++++-------- src/controllers/payments/paypal.js | 11 +-- .../payments/paypalBillingSetup.js | 32 +++++---- src/controllers/payments/stripe.js | 8 +-- views/options/settings.jade | 13 +++- 7 files changed, 96 insertions(+), 65 deletions(-) diff --git a/public/js/controllers/rootCtrl.js b/public/js/controllers/rootCtrl.js index 7fc249754e..4b30f7f6ec 100644 --- a/public/js/controllers/rootCtrl.js +++ b/public/js/controllers/rootCtrl.js @@ -145,21 +145,26 @@ habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$ } $rootScope.showStripe = function(data) { - var isSub = data.subscription || (data.gift && data.gift.type=='subscription'); + var sub = + data.subscription ? data.subscription + : data.gift && data.gift.type=='subscription' ? data.gift.subscription.months + : false; + 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, - amount: !data.gift ? 500 : // 500 = $5 - data.gift.type=='subscription' ? Content.subscriptionBlocks[data.gift.subscription.months].price*100: - data.gift.gems.amount/4*100, - name: isSub ? window.env.t('subscribe') : window.env.t('checkout'), - description: isSub ? window.env.t('buySubsText') : window.env.t('donationDesc'), - panelLabel: isSub ? window.env.t('subscribe') : window.env.t('checkout'), + amount: amount, + name: sub ? window.env.t('subscribe') : window.env.t('checkout'), + description: sub ? window.env.t('buySubsText') : window.env.t('donationDesc'), + panelLabel: sub ? window.env.t('subscribe') : window.env.t('checkout'), token: function(res) { - var url = '/stripe/checkout'; - if (data.gift) url += '?gift=' + $rootScope.encodeGift(data.uuid, data.gift); - //TODO else if? ^ - if (data.subscription) url += '?plan=basic_earned'; + var url = '/stripe/checkout?a=a'; // just so I can concat &x=x below + if (data.gift) url += '&gift=' + $rootScope.encodeGift(data.uuid, data.gift); + if (data.subscription) url += '&sub='+sub.months; $scope.$apply(function(){ $http.post(url, res).success(function() { window.location.reload(true); diff --git a/src/controllers/members.js b/src/controllers/members.js index e0a8b653b9..b26ddbabf5 100644 --- a/src/controllers/members.js +++ b/src/controllers/members.js @@ -77,7 +77,7 @@ api.sendGift = function(req, res, next){ case "gems": var amt = req.body.gems.amount / 4, user = res.locals.user; - if (amt < 1 || user.balance < amt) + if (!amt || amt < 1 || user.balance < amt) return cb({code: 401, err: "Amount must be within 0 and your current number of gems."}); member.balance += amt; user.balance -= amt; diff --git a/src/controllers/payments/index.js b/src/controllers/payments/index.js index c20a90a6f3..df8f762382 100644 --- a/src/controllers/payments/index.js +++ b/src/controllers/payments/index.js @@ -27,38 +27,47 @@ function revealMysteryItems(user) { exports.createSubscription = function(data, cb) { var recipient = data.gift ? data.gift.member : data.user; - if (!recipient.purchased.plan) recipient.purchased.plan = {}; + //if (!recipient.purchased.plan) recipient.purchased.plan = {}; // FIXME double-check, this should never be the case var p = recipient.purchased.plan; - var giftMonths = data.gift ? data.gift.subscription.months : 0 - _(p).merge({ // override with these values - planId:'basic_earned', - customerId: data.customerId, - dateUpdated: new Date(), - gemsBought: 0, - paymentMethod: data.paymentMethod, - extraMonths: +p.extraMonths - + +(p.dateTerminated ? moment(p.dateTerminated).diff(new Date(),'months',true) : 0) - + +(giftMonths), - dateTerminated: null - }).defaults({ // allow non-override if a plan was previously used - dateCreated: new Date(), - mysteryItems: [] - }); + var months = data.gift ? data.gift.subscription.months : data.sub.months; + var block = shared.content.subscriptionBlocks[months]; - // Block sub perks - if (giftMonths) { - p.consecutive.offset += giftMonths; - p.consecutive.gemCapExtra += giftMonths*5; - if (p.consecutive.gemCapExtra > 25) p.consecutive.gemCapExtra = 25; - p.consecutive.trinkets += giftMonths; + if (data.gift) { + if (!p.customerId) p.customerId = 'Gift'; // don't override existing customer, but all sub need a customerId + if (p.dateTerminated) { // User already has a plan + p.dateTerminated = moment(p.dateTerminated).add({months: months}).toDate(); + } else { + p.extraMonths += +months; + } + } else { + _(p).merge({ // override with these values + planId: block.key, + customerId: data.customerId, + dateUpdated: new Date(), + gemsBought: 0, + paymentMethod: data.paymentMethod, + extraMonths: +p.extraMonths + + +(p.dateTerminated ? moment(p.dateTerminated).diff(new Date(),'months',true) : 0), + dateTerminated: null + }).defaults({ // allow non-override if a plan was previously used + dateCreated: new Date(), + mysteryItems: [] + }); } + // Block sub perks + var perks = Math.floor(months/3); + if (perks) { + p.consecutive.offset += months; + p.consecutive.gemCapExtra += perks*5; + if (p.consecutive.gemCapExtra > 25) p.consecutive.gemCapExtra = 25; + p.consecutive.trinkets += perks; + } revealMysteryItems(recipient); if(isProduction) { - utils.txnEmail(data.user, 'subscription-begins'); - //TODO proper ga.event / transaction + data.gift && utils.txnEmail(data.user, 'subscription-begins'); utils.ga.event('subscribe', data.paymentMethod).send(); - utils.ga.transaction(data.customerId, 5).item(5, 1, data.paymentMethod.toLowerCase() + '-subscription', data.paymentMethod + " > Stripe").send(); + utils.ga.transaction(data.user._id, block.price).item(block.price, 1, data.paymentMethod.toLowerCase() + '-subscription', data.paymentMethod).send(); } data.user.purchased.txnCount++; if (data.gift) members.sendMessage(data.user, data.gift.member, data.gift); @@ -72,16 +81,15 @@ exports.createSubscription = function(data, cb) { * Sets their subscription to be cancelled later */ exports.cancelSubscription = function(user, data) { - var p = user.purchased.plan, - now = moment(); - if(isProduction) utils.txnEmail(user, 'cancel-subscription'); + var p = user.purchased.plan, now = moment(); p.dateTerminated = moment( now.format('MM') + '/' + moment(p.dateUpdated).format('DD') + '/' + now.format('YYYY') ) - .add({months:1})// end their subscription 1mo from their last payment - .add({months:p.extraMonths})// plus any extra time (carry-over, gifted subscription, etc) they have + .add({months:1}) // end their subscription 1mo from their last payment + .add({months: Math.ceil(p.extraMonths)})// plus any extra time (carry-over, gifted subscription, etc) they have. FIXME: moment can't add months in fractions... .toDate(); p.extraMonths = 0; // clear extra time. If they subscribe again, it'll be recalculated from p.dateTerminated + if(isProduction) utils.txnEmail(user, 'cancel-subscription'); utils.ga.event('unsubscribe', 'Stripe').send(); } diff --git a/src/controllers/payments/paypal.js b/src/controllers/payments/paypal.js index 1277b7c967..35ca4cfe41 100644 --- a/src/controllers/payments/paypal.js +++ b/src/controllers/payments/paypal.js @@ -13,7 +13,9 @@ var shared = require('habitrpg-shared'); // This is the plan.id for paypal subscriptions. You have to set up billing plans via their REST sdk (they don't have // a web interface for billing-plan creation), see ./paypalBillingSetup.js for how. After the billing plan is created // there, get it's plan.id and store it in config.json -var billingPlanID = nconf.get('PAYPAL:billing_plan_id'); +_.each(shared.content.subscriptionBlocks, function(block){ + block.paypalKey = nconf.get("PAYPAL:billing_plans:"+block.months); +}); paypal.configure({ 'mode': nconf.get("PAYPAL:mode"), //sandbox or live @@ -26,13 +28,14 @@ var parseErr = function(err){ } exports.createBillingAgreement = function(req,res,next){ - var billingPlanTitle ="HabitRPG subscription ($5 month-to-month)"; + var block = req.session.paypalBlock = shared.content.subscriptionBlocks[req.query.sub]; + var billingPlanTitle = "HabitRPG Subscription" + ' ($'+block.price+' every '+block.months+' months, recurring)'; var billingAgreementAttributes = { "name": billingPlanTitle, "description": billingPlanTitle, "start_date": moment().add({seconds:5}).format(), "plan": { - "id": billingPlanID + "id": block.paypalKey }, "payer": { "payment_method": "paypal" @@ -58,7 +61,7 @@ exports.executeBillingAgreement = function(req,res,next){ }); }, function(data, cb){ - payments.createSubscription(data.user, {customerId: data.billingAgreement.id, paymentMethod: 'Paypal'}); + payments.createSubscription({user:data.user, customerId: data.billingAgreement.id, paymentMethod: 'Paypal', sub:req.session.paypalBlock}); data.user.save(cb); } ],function(err){ diff --git a/src/controllers/payments/paypalBillingSetup.js b/src/controllers/payments/paypalBillingSetup.js index d4d7f8e167..8c7a1523c0 100644 --- a/src/controllers/payments/paypalBillingSetup.js +++ b/src/controllers/payments/paypalBillingSetup.js @@ -2,12 +2,14 @@ // payment plan definitions, instead you have to create it via their REST SDK and keep it updated the same way. So this // file will be used once for initing your billing plan (then you get the resultant plan.id to store in config.json), // and once for any time you need to edit the plan thereafter - +require('coffee-script'); var path = require('path'); var nconf = require('nconf'); +_ = require('lodash'); nconf.argv().env().file('user', path.join(path.resolve(__dirname, '../../../config.json'))); var paypal = require('paypal-rest-sdk'); -var OP = "list"; // list create update remove +var OP = "create"; // list create update remove +var blocks = require('habitrpg-shared').content.subscriptionBlocks; paypal.configure({ 'mode': nconf.get("PAYPAL:mode"), //sandbox or live @@ -15,8 +17,8 @@ paypal.configure({ 'client_secret': nconf.get("PAYPAL:client_secret") }); -var billingPlanTitle ="HabitRPG subscription ($5 month-to-month)"; // https://developer.paypal.com/docs/api/#billing-plans-and-agreements +var billingPlanTitle ="HabitRPG Subscription"; var billingPlanAttributes = { "name": billingPlanTitle, "description": billingPlanTitle, @@ -26,18 +28,23 @@ var billingPlanAttributes = { "cancel_url": nconf.get("BASE_URL"), "return_url": nconf.get('BASE_URL') + '/paypal/subscribe/success' }, - "payment_definitions": [{ - "name": billingPlanTitle, + payment_definitions: [{ "type": "REGULAR", - "frequency_interval": "1", "frequency": "MONTH", - "cycles": "0", - "amount": { - "currency": "USD", - "value": "5" - } + "cycles": "0" }] }; +_.each(blocks, function(block){ + block.definition = _.cloneDeep(billingPlanAttributes); + _.merge(block.definition.payment_definitions[0], { + "name": billingPlanTitle + ' ($'+block.price+' every '+block.months+' months, recurring)', + "frequency_interval": ""+block.months, + "amount": { + "currency": "USD", + "value": ""+block.price + } + }); +}) switch(OP) { case "list": @@ -48,7 +55,8 @@ switch(OP) { case "update": break; case "create": - paypal.billingPlan.create(billingPlanAttributes, function(err,plan){ + paypal.billingPlan.create(blocks["3"].definition, function(err,plan){ + if (err) return console.log(err); if (plan.state == "ACTIVE") return console.log({err:err, plan:plan}); var billingPlanUpdateAttributes = [{ diff --git a/src/controllers/payments/stripe.js b/src/controllers/payments/stripe.js index 549c58bfb0..edee9501d7 100644 --- a/src/controllers/payments/stripe.js +++ b/src/controllers/payments/stripe.js @@ -12,15 +12,16 @@ exports.checkout = function(req, res, next) { var token = req.body.id; var user = res.locals.user; var gift = req.query.gift ? JSON.parse(req.query.gift) : undefined; + var sub = req.query.sub ? shared.content.subscriptionBlocks[req.query.sub] : false; async.waterfall([ function(cb){ - if (req.query.plan) { + if (sub) { stripe.customers.create({ email: req.body.email, metadata: {uuid: user._id}, card: token, - plan: req.query.plan + plan: sub.key }, cb); } else { stripe.charges.create({ @@ -33,8 +34,7 @@ exports.checkout = function(req, res, next) { } }, function(response, cb) { - if (req.query.plan) - return payments.createSubscription({user:user, customerId:response.id, paymentMethod:'Stripe'}, cb); + if (sub) return payments.createSubscription({user:user, customerId:response.id, paymentMethod:'Stripe', sub:sub}, cb); async.waterfall([ function(cb2){ User.findById(gift ? gift.uuid : undefined, cb2) }, function(member, cb2){ diff --git a/views/options/settings.jade b/views/options/settings.jade index b1bee5e7ad..a32e8d6b0a 100644 --- a/views/options/settings.jade +++ b/views/options/settings.jade @@ -243,10 +243,17 @@ script(id='partials/options.settings.subscription.html',type='text/ng-template') div(ng-include="'partials/options.settings.subscription.perks.html'") div(ng-if='!p.customerId || (p.customerId && p.dateTerminated)') + + .form-group + each block in env.Content.subscriptionBlocks + .radio + label + input(type="radio", name="subRadio", value="#{block.months}", ng-model='subscription') + | #{block.months} Month(s) Recurring: $#{block.price} + h3(ng-if='(p.customerId && p.dateTerminated)') Resubscribe - a.btn.btn-primary(ng-click='showStripe({subscription:true})') Card - //a.btn.btn-warning(ng-click='paypalSubscribe()') PayPal - a.btn.btn-warning(href='/paypal/subscribe?_id={{user._id}}&apiToken={{user.apiToken}}') PayPal + a.btn.btn-primary(ng-click='showStripe({subscription:subscription})') Card + a.btn.btn-warning(href='/paypal/subscribe?_id={{user._id}}&apiToken={{user.apiToken}}&sub={{subscription}}') PayPal div(ng-if='p.customerId') .btn.btn-primary(ng-if='!p.dateTerminated && p.paymentMethod=="Stripe"', ng-click='showStripeEdit()') Update Card .btn.btn-sm.btn-danger(ng-if='!p.dateTerminated', ng-click='cancelSubscription()')=env.t('cancelSub') From 0fffd66588e603513803967f1983797f530011a2 Mon Sep 17 00:00:00 2001 From: Tyler Renelle Date: Fri, 28 Nov 2014 19:24:56 -0700 Subject: [PATCH 08/11] feat(plans): time travelers tab, 1 trinket to buy 1 set --- public/js/app.js | 4 ++++ public/js/controllers/groupsCtrl.js | 4 ---- src/controllers/payments/index.js | 4 ++-- views/options/inventory/index.jade | 3 +++ views/options/inventory/inventory.jade | 27 ++++++++++++++++++++++++++ views/shared/header/menu.jade | 4 ++++ 6 files changed, 40 insertions(+), 6 deletions(-) diff --git a/public/js/app.js b/public/js/app.js index adbcd0f6f8..575e8357ba 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -181,6 +181,10 @@ window.habitrpg = angular.module('habitrpg', url: '/equipment', templateUrl: "partials/options.inventory.equipment.html" }) + .state('options.inventory.timetravelers', { + url: '/timetravelers', + templateUrl: "partials/options.inventory.timetravelers.html" + }) // Options > Settings .state('options.settings', { diff --git a/public/js/controllers/groupsCtrl.js b/public/js/controllers/groupsCtrl.js index 9efd8826eb..9f433fdf37 100644 --- a/public/js/controllers/groupsCtrl.js +++ b/public/js/controllers/groupsCtrl.js @@ -2,10 +2,6 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', '$http', '$q', 'User', 'Members', '$state', 'Notification', function($scope, $rootScope, Shared, Groups, $http, $q, User, Members, $state, Notification) { - Members.selectMember('d7f809df-81bd-4d58-992b-851d94bfaccb', function(){ - $rootScope.openModal('send-gift',{controller:'MemberModalCtrl'}) - }); - $scope.isMemberOfPendingQuest = function(userid, group) { if (!group.quest || !group.quest.members) return false; diff --git a/src/controllers/payments/index.js b/src/controllers/payments/index.js index df8f762382..9463b678c2 100644 --- a/src/controllers/payments/index.js +++ b/src/controllers/payments/index.js @@ -15,8 +15,8 @@ function revealMysteryItems(user) { _.each(shared.content.gear.flat, function(item) { if ( item.klass === 'mystery' && - moment().isAfter(item.mystery.start) && - moment().isBefore(item.mystery.end) && + moment().isAfter(shared.content.mystery[item.mystery].start) && + moment().isBefore(shared.content.mystery[item.mystery].end) && !user.items.gear.owned[item.key] && !~user.purchased.plan.mysteryItems.indexOf(item.key) ) { diff --git a/views/options/inventory/index.jade b/views/options/inventory/index.jade index d75bc6f317..af191f3de1 100644 --- a/views/options/inventory/index.jade +++ b/views/options/inventory/index.jade @@ -15,6 +15,9 @@ script(type='text/ng-template', id='partials/options.inventory.html') li.equipment-tab(ng-class="{ active: $state.includes('options.inventory.equipment') }") a(ui-sref='options.inventory.equipment') =env.t('equipment') + li.equipment-tab(ng-class="{ active: $state.includes('options.inventory.timetravelers') }") + a(ui-sref='options.inventory.timetravelers') + | Time Travelers .tab-content .tab-pane.active diff --git a/views/options/inventory/inventory.jade b/views/options/inventory/inventory.jade index 05b8bde9ab..51885058cc 100644 --- a/views/options/inventory/inventory.jade +++ b/views/options/inventory/inventory.jade @@ -19,6 +19,33 @@ script(type='text/ng-template', id='partials/options.inventory.equipment.html') div(ng-repeat='item in gear[klass]') button.customize-option(popover='{{::item.notes()}}', popover-title='{{::item.text()}}', popover-trigger='mouseenter', popover-placement='right', ng-click='user.ops.equip({params:{type:"costume", key:item.key}})', class='shop_{{::item.key}}', ng-class='{selectableInventory: user.items.gear.costume[item.type] == item.key}') +script(type='text/ng-template', id='partials/options.inventory.timetravelers.html') + .container-fluid + .stable.row(ng-if='user.purchased.plan.consecutive.trinkets <= 0') + .col-md-2 + .npc_timetravelers + .col-md-10 + .popover.static-popover.fade.right.in + .arrow + h3.popover-title Tyler and Vicky + .popover-content + p You'll need a Time Trinket to summon the mysterious Time Travelers! Subscribers earn one Time Trinket for every three months of consecutive subscribing. Come back when you have a Time Trinket, and the Time Travelers will fetch you a Subscriber Item Set from the past.... or maybe even the future. + + .row.stable(ng-if='user.purchased.plan.consecutive.trinkets > 0') + .col-md-2 + .npc_timetravelers + .col-md-10 + .popover.static-popover.fade.right.in + .arrow + h3.popover-title Mysterious Time Travelers + .popover-content + p We see you have a Time Trinket, so we will happily travel back in time for you! Please choose the Mystery Item Set you would like. You can see a list of the past item sets here! If those don't satisfy you, perhaps you'd be interested in one of our fashionably futuristic Steampunk Item Sets? + .col-md-12 + li.customize-menu.inventory-gear + menu.pets-menu(label='{{::set.text}}', ng-repeat='set in Content.timeTravelerStore(user.items.gear.owned)') + div(ng-repeat='item in set.items') + button.customize-option(popover='{{::item.notes()}}', popover-title='{{::item.text()}}', popover-trigger='mouseenter', popover-placement='right', ng-click='user.ops.buyMysterySet({params:{key:set.key}})', class='shop_{{::item.key}}') + script(type='text/ng-template', id='partials/options.inventory.drops.html') .container-fluid .row diff --git a/views/shared/header/menu.jade b/views/shared/header/menu.jade index 3ab0b882fc..24f4f27e16 100644 --- a/views/shared/header/menu.jade +++ b/views/shared/header/menu.jade @@ -47,6 +47,8 @@ nav.toolbar(ng-controller='AuthCtrl', ng-class='{active: isToolbarHidden}') a(ui-sref='options.inventory.mounts')=env.t('mounts') li a(ui-sref='options.inventory.equipment')=env.t('equipment') + li + a(ui-sref='options.inventory.timetravelers') Time Travelers ul.toolbar-submenu li a(target="_blank" ng-href='http://data.habitrpg.com?uuid={{user._id}}')=env.t('dataTool') @@ -126,6 +128,8 @@ nav.toolbar(ng-controller='AuthCtrl', ng-class='{active: isToolbarHidden}') a(ui-sref='options.inventory.mounts')=env.t('mounts') li a(ui-sref='options.inventory.equipment')=env.t('equipment') + li + a(ui-sref='options.inventory.timetravelers') Time Travelers li.toolbar-button-dropdown a(target="_blank" ng-href='http://data.habitrpg.com?uuid={{user._id}}') span=env.t('data') From 8c5842eb527632d2ffa30d2f01eb87ec09cf6609 Mon Sep 17 00:00:00 2001 From: Tyler Renelle Date: Sat, 29 Nov 2014 17:55:41 -0700 Subject: [PATCH 09/11] feat(plans): cleanup plans' perks page, rename Time Trinket => Mystic Hourglass --- views/options/inventory/inventory.jade | 14 ++++- views/options/settings.jade | 82 ++++++++++++++------------ 2 files changed, 54 insertions(+), 42 deletions(-) diff --git a/views/options/inventory/inventory.jade b/views/options/inventory/inventory.jade index 51885058cc..5fed87ba1d 100644 --- a/views/options/inventory/inventory.jade +++ b/views/options/inventory/inventory.jade @@ -29,17 +29,21 @@ script(type='text/ng-template', id='partials/options.inventory.timetravelers.htm .arrow h3.popover-title Tyler and Vicky .popover-content - p You'll need a Time Trinket to summon the mysterious Time Travelers! Subscribers earn one Time Trinket for every three months of consecutive subscribing. Come back when you have a Time Trinket, and the Time Travelers will fetch you a Subscriber Item Set from the past.... or maybe even the future. + p You'll need a Mystic Hourglass to summon the mysterious Time Travelers! Subscribers earn one Mystic Hourglass for every three months of consecutive subscribing. Come back when you have a Mystic Hourglass, and the Time Travelers will fetch you a Subscriber Item Set from the past.... or maybe even the future. .row.stable(ng-if='user.purchased.plan.consecutive.trinkets > 0') .col-md-2 - .npc_timetravelers + .npc_timetravelers_active .col-md-10 .popover.static-popover.fade.right.in .arrow h3.popover-title Mysterious Time Travelers .popover-content - p We see you have a Time Trinket, so we will happily travel back in time for you! Please choose the Mystery Item Set you would like. You can see a list of the past item sets here! If those don't satisfy you, perhaps you'd be interested in one of our fashionably futuristic Steampunk Item Sets? + .pull-right + span.inventory_special_trinket.inline-gems + b x{{user.purchased.plan.consecutive.trinkets}} + p We see you have a Mystic Hourglass, so we will happily travel back in time for you! Please choose the Mystery Item Set you would like. You can see a list of the past item sets here! If those don't satisfy you, perhaps you'd be interested in one of our fashionably futuristic Steampunk Item Sets? + .col-md-12 li.customize-menu.inventory-gear menu.pets-menu(label='{{::set.text}}', ng-repeat='set in Content.timeTravelerStore(user.items.gear.owned)') @@ -105,6 +109,10 @@ script(type='text/ng-template', id='partials/options.inventory.drops.html') button.customize-option(popover=env.t('subscriberItemText'), popover-trigger='mouseenter', popover-placement='right', class='inventory_present', ng-click="user.ops.openMysteryItem({})") .badge.badge-info.stack-count {{user.purchased.plan.mysteryItems.length}} + div(ng-if='user.purchased.plan.consecutive.trinkets') + button.customize-option(popover="Mystic Hourglass allow you to purchase previous months' subscriber sets.", popover-trigger='mouseenter', popover-placement='right', class='inventory_special_trinket', ng-click="$state.go('options.inventory.timetravelers')") + .badge.badge-info.stack-count {{user.purchased.plan.consecutive.trinkets}} + .col-md-6 h2=env.t('market') .row-fluid diff --git a/views/options/settings.jade b/views/options/settings.jade index a32e8d6b0a..899cabb999 100644 --- a/views/options/settings.jade +++ b/views/options/settings.jade @@ -192,14 +192,15 @@ script(id='partials/options.settings.export.html', type="text/ng-template") a(href="/export/userdata.xml")= ' ' + env.t('xml') + ' ' a(href="/export/userdata.json")= env.t('json') -script(id='partials/options.settings.subscription.perks.html',type='text/ng-template') +mixin subPerks() table.table.table-striped tr td span.hint(popover=env.t('disableAdsText'),popover-trigger='mouseenter',popover-placement='right')=env.t('disableAds') tr td - span.hint(popover=env.t('buyGemsGoldText', {gemCost: "{{Shared.planGemLimits.convRate}}", gemLimit: "{{Shared.planGemLimits.convCap}}"}),popover-trigger='mouseenter',popover-placement='right')=env.t('buyGemsGold') + span.hint(popover=env.t('buyGemsGoldText', {gemCost: "{{Shared.planGemLimits.convRate}}", gemLimit: "{{Shared.planGemLimits.convCap}}"}),popover-trigger='mouseenter',popover-placement='right') #{env.t('buyGemsGold')}  + span.badge.badge-success(ng-show='_subscription.months>1') Cap raised to {{ [25 + p.consecutive.gemCapExtra + Math.floor(_subscription.months/3*5), 50] | min }} tr td span.hint(popover=env.t('retainHistoryText'),popover-trigger='mouseenter',popover-placement='right')=env.t('retainHistory') @@ -208,13 +209,13 @@ script(id='partials/options.settings.subscription.perks.html',type='text/ng-temp span.hint(popover=env.t('doubleDropsText'),popover-trigger='mouseenter',popover-placement='right')=env.t('doubleDrops') tr td - span.hint(popover=env.t('mysteryItemText'),popover-trigger='mouseenter',popover-placement='right')=env.t('mysteryItem') + span.hint(popover=env.t('mysteryItemText'),popover-trigger='mouseenter',popover-placement='right') #{env.t('mysteryItem')}  + div(ng-show='_subscription.months>1') + .badge.badge-success +{{Math.floor(_subscription.months/3)}} Mystic Hourglass + .small.muted Mystic Hourglasses allow purchasing a previous month's Mystery Item set. tr td span.hint(popover=env.t('supportDevsText'),popover-trigger='mouseenter',popover-placement='right')=env.t('supportDevs') - tr - td.alert.alert-info $5  - =env.t('monthUSD') script(id='partials/feature-matrix-check.html',type='text/ng-template') span.task-checker.action-yesno @@ -222,38 +223,41 @@ script(id='partials/feature-matrix-check.html',type='text/ng-template') label script(id='partials/options.settings.subscription.html',type='text/ng-template') - .well(ng-init="p = user.purchased.plan") - div(ng-if='p.customerId') - p.alert.alert-warning(ng-if='p.dateTerminated') - i.glyphicon.glyphicon-time - | #{env.t('subCanceled')} {{moment(p.dateTerminated).format('MM/DD/YYYY')}} - p.alert.alert-success(ng-if='!p.dateTerminated')=env.t('subscribed') - p.alert.alert-success(ng-if='p.extraMonths') - span.glyphicon.glyphicon-credit-card - | You have {{p.extraMonths | number:2}} months of subscription credit. - ul.alert.alert-success.list-unstyled(ng-if='p.consecutive.count || p.consecutive.offset') - li: h4 - span.glyphicon.glyphicon-forward - |  Consecutive Subscription - li You've been subscribed for {{p.consecutive.count}} consecutive months, plus {{p.consecutive.offset}} extra (gift / block-subscription). - li Gem Cap Extra: {{p.consecutive.gemCapExtra}} - li Time Trinkets: {{p.consecutive.trinkets}} + //-h2=env.t('individualSub') + .container-fluid(ng-init='_subscription={months:1}') + .row + .col-md-6 + h3 Benefits + +subPerks() - h2=env.t('individualSub') - div(ng-include="'partials/options.settings.subscription.perks.html'") + .col-md-6 + div(ng-init="p = user.purchased.plan") + table.table.alert.alert-info(ng-if='p.customerId') + tr(ng-if='p.dateTerminated'): td.alert.alert-warning + i.glyphicon.glyphicon-time + | #{env.t('subCanceled')} {{moment(p.dateTerminated).format('MM/DD/YYYY')}} + tr(ng-if='!p.dateTerminated'): td: h4=env.t('subscribed') + tr(ng-if='p.extraMonths'): td + span.glyphicon.glyphicon-credit-card + | You have {{p.extraMonths | number:2}} months of subscription credit. + tr(ng-if='p.consecutive.count || p.consecutive.offset'): td + span.glyphicon.glyphicon-forward + |  Consecutive Subscription + ul.list-unstyled + li You've been subscribed for {{p.consecutive.count}} consecutive months, plus {{p.consecutive.offset}} extra (gift / block-subscription). + li Gem Cap Extra: {{p.consecutive.gemCapExtra}} + li Mystic Hourglasses: {{p.consecutive.trinkets}} + div(ng-if='!p.customerId || (p.customerId && p.dateTerminated)') + .form-group + each block in env.Content.subscriptionBlocks + .radio + label + input(type="radio", name="subRadio", value="#{block.months}", ng-model='_subscription.months') + | #{block.months} Month(s) Recurring: $#{block.price} #{env.t('monthUSD')} - div(ng-if='!p.customerId || (p.customerId && p.dateTerminated)') - - .form-group - each block in env.Content.subscriptionBlocks - .radio - label - input(type="radio", name="subRadio", value="#{block.months}", ng-model='subscription') - | #{block.months} Month(s) Recurring: $#{block.price} - - h3(ng-if='(p.customerId && p.dateTerminated)') Resubscribe - a.btn.btn-primary(ng-click='showStripe({subscription:subscription})') Card - a.btn.btn-warning(href='/paypal/subscribe?_id={{user._id}}&apiToken={{user.apiToken}}&sub={{subscription}}') PayPal - div(ng-if='p.customerId') - .btn.btn-primary(ng-if='!p.dateTerminated && p.paymentMethod=="Stripe"', ng-click='showStripeEdit()') Update Card - .btn.btn-sm.btn-danger(ng-if='!p.dateTerminated', ng-click='cancelSubscription()')=env.t('cancelSub') + h3(ng-if='(p.customerId && p.dateTerminated)') Resubscribe + button.btn.btn-primary(ng-click='showStripe({subscription:_subscription.months})', ng-disabled='!_subscription.months') Card + button.btn.btn-warning(href='/paypal/subscribe?_id={{user._id}}&apiToken={{user.apiToken}}&sub={{_subscription.months}}', ng-disabled='!_subscription.months') PayPal + div(ng-if='p.customerId') + .btn.btn-primary(ng-if='!p.dateTerminated && p.paymentMethod=="Stripe"', ng-click='showStripeEdit()') Update Card + .btn.btn-sm.btn-danger(ng-if='!p.dateTerminated', ng-click='cancelSubscription()')=env.t('cancelSub') \ No newline at end of file From 0a53a5717e534ce6422a8e18e714a9e0ef028166 Mon Sep 17 00:00:00 2001 From: Tyler Renelle Date: Sun, 30 Nov 2014 14:01:58 -0700 Subject: [PATCH 10/11] refactor(payments): move payments into it's own service, out of rootCtrl --- public/js/app.js | 2 +- public/js/controllers/rootCtrl.js | 62 ++-------------------- public/js/services/paymentServices.js | 74 +++++++++++++++++++++++++++ public/manifest.json | 1 + views/options/settings.jade | 10 ++-- views/shared/modals/buy-gems.jade | 2 +- views/shared/modals/members.jade | 2 +- 7 files changed, 87 insertions(+), 66 deletions(-) create mode 100644 public/js/services/paymentServices.js diff --git a/public/js/app.js b/public/js/app.js index 575e8357ba..c12ba3aa08 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -2,7 +2,7 @@ window.habitrpg = angular.module('habitrpg', ['ngResource', 'ngSanitize', 'userServices', 'groupServices', 'memberServices', 'challengeServices', - 'authServices', 'notificationServices', 'guideServices', 'authCtrl', + 'authServices', 'notificationServices', 'guideServices', 'authCtrl', 'paymentServices', 'ui.bootstrap', 'ui.keypress', 'ui.router', 'chieffancypants.loadingBar', 'At', 'infinite-scroll', 'ui.select2', 'angular.filter']) // @see https://github.com/angular-ui/ui-router/issues/110 and https://github.com/HabitRPG/habitrpg/issues/1705 diff --git a/public/js/controllers/rootCtrl.js b/public/js/controllers/rootCtrl.js index 4b30f7f6ec..004660dd6a 100644 --- a/public/js/controllers/rootCtrl.js +++ b/public/js/controllers/rootCtrl.js @@ -3,8 +3,8 @@ /* Make user and settings available for everyone through root scope. */ -habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$http', '$state', '$stateParams', 'Notification', 'Groups', 'Shared', 'Content', '$modal', '$timeout', 'ApiUrlService', '$filter', - function($scope, $rootScope, $location, User, $http, $state, $stateParams, Notification, Groups, Shared, Content, $modal, $timeout, ApiUrlService, $filter) { +habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$http', '$state', '$stateParams', 'Notification', 'Groups', 'Shared', 'Content', '$modal', '$timeout', 'ApiUrlService', 'Payments', + function($scope, $rootScope, $location, User, $http, $state, $stateParams, Notification, Groups, Shared, Content, $modal, $timeout, ApiUrlService, Payments) { var user = User.user; var initSticky = _.once(function(){ @@ -33,6 +33,7 @@ habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$ $rootScope.Math = Math; $rootScope.Groups = Groups; $rootScope.toJson = angular.toJson; + $rootScope.Payments = Payments; // Angular UI Router $rootScope.$state = $state; @@ -144,63 +145,6 @@ habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$ return JSON.stringify(gift); } - $rootScope.showStripe = function(data) { - var sub = - data.subscription ? data.subscription - : data.gift && data.gift.type=='subscription' ? data.gift.subscription.months - : false; - 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, - amount: amount, - name: sub ? window.env.t('subscribe') : window.env.t('checkout'), - description: sub ? window.env.t('buySubsText') : window.env.t('donationDesc'), - panelLabel: sub ? window.env.t('subscribe') : window.env.t('checkout'), - token: function(res) { - var url = '/stripe/checkout?a=a'; // just so I can concat &x=x below - if (data.gift) url += '&gift=' + $rootScope.encodeGift(data.uuid, data.gift); - if (data.subscription) url += '&sub='+sub.months; - $scope.$apply(function(){ - $http.post(url, res).success(function() { - window.location.reload(true); - }).error(function(res) { - alert(res.err); - }); - }) - } - }); - } - - $rootScope.showStripeEdit = function(){ - StripeCheckout.open({ - key: window.env.STRIPE_PUB_KEY, - address: false, - name: 'Update', - description: 'Update the card to be charged for your subscription', - panelLabel: 'Update Card', - token: function(data) { - var url = '/stripe/subscribe/edit?plan=basic_earned'; - $scope.$apply(function(){ - $http.post(url, data).success(function() { - window.location.reload(true); - }).error(function(data) { - alert(data.err); - }); - }) - } - }); - } - - $rootScope.cancelSubscription = function(){ - if (!confirm(window.env.t('sureCancelSub'))) return; - window.location.href = '/' + user.purchased.plan.paymentMethod.toLowerCase() + '/subscribe/cancel?_id=' + user._id + '&apiToken=' + user.apiToken; - } - $scope.contribText = function(contrib, backer){ if (!contrib && !backer) return; if (backer && backer.npc) return backer.npc; diff --git a/public/js/services/paymentServices.js b/public/js/services/paymentServices.js new file mode 100644 index 0000000000..e3dcddf6e8 --- /dev/null +++ b/public/js/services/paymentServices.js @@ -0,0 +1,74 @@ +'use strict'; + +angular.module('paymentServices',[]).factory('Payments', +['$rootScope', 'User', '$http', 'Content', +function($rootScope, User, $http, Content) { + var user = User.user; + var plan = User.user.purchased.plan; + var Payments = {}; + + Payments.currentSub = _.find(Content.subscriptionBlocks, function(b){ + switch (plan.paymentMethod) { + case 'Stripe': + return b.key == plan.planId; + case 'Paypal': + return b.paypalKey == plan.planId; + default: return undefined; + } + }) + + Payments.showStripe = function(data) { + var sub = + data.subscription ? data.subscription + : data.gift && data.gift.type=='subscription' ? data.gift.subscription.months + : false; + 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, + amount: amount, + name: sub ? window.env.t('subscribe') : window.env.t('checkout'), + description: sub ? window.env.t('buySubsText') : window.env.t('donationDesc'), + panelLabel: sub ? window.env.t('subscribe') : window.env.t('checkout'), + token: function(res) { + var url = '/stripe/checkout?a=a'; // just so I can concat &x=x below + if (data.gift) url += '&gift=' + $rootScope.encodeGift(data.uuid, data.gift); + if (data.subscription) url += '&sub='+sub.months; + $http.post(url, res).success(function() { + window.location.reload(true); + }).error(function(res) { + alert(res.err); + }); + } + }); + } + + Payments.showStripeEdit = function(){ + StripeCheckout.open({ + key: window.env.STRIPE_PUB_KEY, + address: false, + name: 'Update', + description: 'Update the card to be charged for your subscription', + panelLabel: 'Update Card', + token: function(data) { + var url = '/stripe/subscribe/edit?plan=basic_earned'; + $http.post(url, data).success(function() { + window.location.reload(true); + }).error(function(data) { + alert(data.err); + }); + } + }); + } + + Payments.cancelSubscription = function(){ + if (!confirm(window.env.t('sureCancelSub'))) return; + window.location.href = '/' + plan.paymentMethod.toLowerCase() + '/subscribe/cancel?_id=' + user._id + '&apiToken=' + user.apiToken; + } + + return Payments; +}]); diff --git a/public/manifest.json b/public/manifest.json index 2e8a5b4ee1..dd4e9abe2a 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -46,6 +46,7 @@ "js/services/memberServices.js", "js/services/guideServices.js", "js/services/challengeServices.js", + "js/services/paymentServices.js", "js/filters/filters.js", diff --git a/views/options/settings.jade b/views/options/settings.jade index 899cabb999..da842b27c9 100644 --- a/views/options/settings.jade +++ b/views/options/settings.jade @@ -236,7 +236,9 @@ script(id='partials/options.settings.subscription.html',type='text/ng-template') tr(ng-if='p.dateTerminated'): td.alert.alert-warning i.glyphicon.glyphicon-time | #{env.t('subCanceled')} {{moment(p.dateTerminated).format('MM/DD/YYYY')}} - tr(ng-if='!p.dateTerminated'): td: h4=env.t('subscribed') + tr(ng-if='!p.dateTerminated'): td + h4=env.t('subscribed') + p(ng-if='Payments.currentSub') Recurring ${{Payments.currentSub.price}} each {{Payments.currentSub.months}} Month(s) ({{p.paymentMethod}}) tr(ng-if='p.extraMonths'): td span.glyphicon.glyphicon-credit-card | You have {{p.extraMonths | number:2}} months of subscription credit. @@ -256,8 +258,8 @@ script(id='partials/options.settings.subscription.html',type='text/ng-template') | #{block.months} Month(s) Recurring: $#{block.price} #{env.t('monthUSD')} h3(ng-if='(p.customerId && p.dateTerminated)') Resubscribe - button.btn.btn-primary(ng-click='showStripe({subscription:_subscription.months})', ng-disabled='!_subscription.months') Card + button.btn.btn-primary(ng-click='Payments.showStripe({subscription:_subscription.months})', ng-disabled='!_subscription.months') Card button.btn.btn-warning(href='/paypal/subscribe?_id={{user._id}}&apiToken={{user.apiToken}}&sub={{_subscription.months}}', ng-disabled='!_subscription.months') PayPal div(ng-if='p.customerId') - .btn.btn-primary(ng-if='!p.dateTerminated && p.paymentMethod=="Stripe"', ng-click='showStripeEdit()') Update Card - .btn.btn-sm.btn-danger(ng-if='!p.dateTerminated', ng-click='cancelSubscription()')=env.t('cancelSub') \ No newline at end of file + .btn.btn-primary(ng-if='!p.dateTerminated && p.paymentMethod=="Stripe"', ng-click='Payments.showStripeEdit()') Update Card + .btn.btn-sm.btn-danger(ng-if='!p.dateTerminated', ng-click='Payments.cancelSubscription()')=env.t('cancelSub') \ No newline at end of file diff --git a/views/shared/modals/buy-gems.jade b/views/shared/modals/buy-gems.jade index b1caf50816..8cb78cc0b2 100644 --- a/views/shared/modals/buy-gems.jade +++ b/views/shared/modals/buy-gems.jade @@ -20,7 +20,7 @@ script(id='modals/buyGems.html', type='text/ng-template') td p small.muted=env.t('paymentMethods') - .btn.btn-primary(ng-click='showStripe({})')=env.t('card') + .btn.btn-primary(ng-click='Payments.showStripe({})')=env.t('card') a.btn.btn-warning(href='/paypal/checkout?_id={{user._id}}&apiToken={{user.apiToken}}') PayPal div(ng-include="'partials/options.settings.subscription.html'") diff --git a/views/shared/modals/members.jade b/views/shared/modals/members.jade index 60eaf2f79f..b495489078 100644 --- a/views/shared/modals/members.jade +++ b/views/shared/modals/members.jade @@ -86,6 +86,6 @@ script(type='text/ng-template', id='modals/send-gift.html') .modal-footer - var fromBal = "gift.type=='gems' && gift.gems.fromBalance" button.btn.btn-primary(ng-show=fromBal, ng-click='sendGift(profile._id, gift)')=env.t("send") - a.btn.btn-primary(ng-hide=fromBal, ng-click='showStripe({gift:gift, uuid:profile._id})')=env.t('card') + a.btn.btn-primary(ng-hide=fromBal, ng-click='Payments.showStripe({gift:gift, uuid:profile._id})')=env.t('card') a.btn.btn-warning(ng-hide=fromBal, href='/paypal/checkout?_id={{::user._id}}&apiToken={{::user.apiToken}}&gift={{encodeGift(profile._id, gift)}}') PayPal button.btn.btn-default(ng-click='$close()')=env.t('cancel') \ No newline at end of file From 30c1ac4b1e3899b6450588a736023ffc217c7d50 Mon Sep 17 00:00:00 2001 From: Tyler Renelle Date: Sun, 30 Nov 2014 17:18:54 -0700 Subject: [PATCH 11/11] fix(plans): lots of bug fixes & cleanup --- config.json.example | 7 +- migrations/20141117_consecutive_months.js | 15 +++-- public/js/services/paymentServices.js | 7 +- src/controllers/payments/index.js | 24 ++++--- src/controllers/payments/paypal.js | 45 +++++++------ .../payments/paypalBillingSetup.js | 4 +- src/controllers/payments/stripe.js | 23 ++++--- views/options/settings.jade | 64 +++++++++---------- 8 files changed, 108 insertions(+), 81 deletions(-) diff --git a/config.json.example b/config.json.example index a9e7d5f4ac..c60f7c20e9 100644 --- a/config.json.example +++ b/config.json.example @@ -31,7 +31,12 @@ "secretAccessKey":"secretAccessKey" }, "PAYPAL":{ - "billing_plan_id":"billing_plan_id", + "billing_plans": { + "1":"1", + "3":"3", + "6":"6", + "12":"12" + }, "mode":"sandbox", "client_id":"client_id", "client_secret":"client_secret" diff --git a/migrations/20141117_consecutive_months.js b/migrations/20141117_consecutive_months.js index 6893cdcb4c..312e6734de 100644 --- a/migrations/20141117_consecutive_months.js +++ b/migrations/20141117_consecutive_months.js @@ -1,11 +1,14 @@ // require moment, lodash db.users.find( {'purchased.plan.customerId':{$ne:null}}, - {'purchased.plan':1} + {_id:1, 'purchased.plan':1} ).forEach(function(user){ - var p = user.purchased.plan; - var latestMonth = p.dateTerminated || p.dateCreated; - // TODO is rounding up what we want? - var consecutiveMonths = Math.ceil(moment(p.dateCreated).diff(latestMonth, 'months', true)); - db.users.update({_id: user._id}, {$set: {'purchased.plan.consecutiveMonths': consecutiveMonths}}); + var p = user.purchased.plan + , latestMonth = p.dateTerminated || new Date() // their last sub date, or on-going (now) + , count = moment(latestMonth).diff(p.dateCreated, 'months'); + db.users.update({_id: user._id}, {$set: { + 'purchased.plan.consecutive.count': count, + 'purchased.plan.consecutive.gemCapExtra': _.min([ Math.floor(count/3)*5, 25 ]), + 'purchased.plan.consecutive.trinkets': Math.floor(count/3) + }}); }); diff --git a/public/js/services/paymentServices.js b/public/js/services/paymentServices.js index e3dcddf6e8..7b7f5baf9e 100644 --- a/public/js/services/paymentServices.js +++ b/public/js/services/paymentServices.js @@ -10,9 +10,8 @@ function($rootScope, User, $http, Content) { Payments.currentSub = _.find(Content.subscriptionBlocks, function(b){ switch (plan.paymentMethod) { case 'Stripe': + case 'Paypal': // FIXME store paypalKey somewhere? return b.key == plan.planId; - case 'Paypal': - return b.paypalKey == plan.planId; default: return undefined; } }) @@ -32,7 +31,7 @@ function($rootScope, User, $http, Content) { address: false, amount: amount, name: sub ? window.env.t('subscribe') : window.env.t('checkout'), - description: sub ? window.env.t('buySubsText') : window.env.t('donationDesc'), + //description: sub ? window.env.t('buySubsText') : window.env.t('donationDesc'), panelLabel: sub ? window.env.t('subscribe') : window.env.t('checkout'), token: function(res) { var url = '/stripe/checkout?a=a'; // just so I can concat &x=x below @@ -55,7 +54,7 @@ function($rootScope, User, $http, Content) { description: 'Update the card to be charged for your subscription', panelLabel: 'Update Card', token: function(data) { - var url = '/stripe/subscribe/edit?plan=basic_earned'; + var url = '/stripe/subscribe/edit'; $http.post(url, data).success(function() { window.location.reload(true); }).error(function(data) { diff --git a/src/controllers/payments/index.js b/src/controllers/payments/index.js index 9463b678c2..be5fdff248 100644 --- a/src/controllers/payments/index.js +++ b/src/controllers/payments/index.js @@ -33,12 +33,12 @@ exports.createSubscription = function(data, cb) { var block = shared.content.subscriptionBlocks[months]; if (data.gift) { - if (!p.customerId) p.customerId = 'Gift'; // don't override existing customer, but all sub need a customerId - if (p.dateTerminated) { // User already has a plan - p.dateTerminated = moment(p.dateTerminated).add({months: months}).toDate(); - } else { + if (p.customerId && !p.dateTerminated) { // User has active plan p.extraMonths += +months; + } else { + p.dateTerminated = moment(p.dateTerminated).add({months: months}).toDate(); } + if (!p.customerId) p.customerId = 'Gift'; // don't override existing customer, but all sub need a customerId } else { _(p).merge({ // override with these values planId: block.key, @@ -80,17 +80,21 @@ exports.createSubscription = function(data, cb) { /** * Sets their subscription to be cancelled later */ -exports.cancelSubscription = function(user, data) { - var p = user.purchased.plan, now = moment(); +exports.cancelSubscription = function(data, cb) { + var p = data.user.purchased.plan, + now = moment(), + remaining = data.nextBill ? moment(data.nextBill).diff(new Date, 'days') : 30; + p.dateTerminated = moment( now.format('MM') + '/' + moment(p.dateUpdated).format('DD') + '/' + now.format('YYYY') ) - .add({months:1}) // end their subscription 1mo from their last payment + .add({days: remaining}) // end their subscription 1mo from their last payment .add({months: Math.ceil(p.extraMonths)})// plus any extra time (carry-over, gifted subscription, etc) they have. FIXME: moment can't add months in fractions... .toDate(); p.extraMonths = 0; // clear extra time. If they subscribe again, it'll be recalculated from p.dateTerminated - if(isProduction) utils.txnEmail(user, 'cancel-subscription'); - utils.ga.event('unsubscribe', 'Stripe').send(); + data.user.save(cb); + if(isProduction) utils.txnEmail(data.user, 'cancel-subscription'); + utils.ga.event('unsubscribe', data.paymentMethod).send(); } exports.buyGems = function(data, cb) { @@ -101,7 +105,7 @@ exports.buyGems = function(data, cb) { utils.txnEmail(data.user, 'donation'); utils.ga.event('checkout', data.paymentMethod).send(); //TODO ga.transaction to reflect whether this is gift or self-purchase - utils.ga.transaction(data.customerId, amt).item(amt, 1, data.paymentMethod.toLowerCase() + "-checkout", "Gems > " + data.paymentMethod).send(); + utils.ga.transaction(data.user._id, amt).item(amt, 1, data.paymentMethod.toLowerCase() + "-checkout", "Gems > " + data.paymentMethod).send(); } if (data.gift) members.sendMessage(data.user, data.gift.member, data.gift); async.parallel([ diff --git a/src/controllers/payments/paypal.js b/src/controllers/payments/paypal.js index 35ca4cfe41..25195b5695 100644 --- a/src/controllers/payments/paypal.js +++ b/src/controllers/payments/paypal.js @@ -23,8 +23,9 @@ paypal.configure({ 'client_secret': nconf.get("PAYPAL:client_secret") }); -var parseErr = function(err){ - return (err.response && err.response.message || err.response.details[0].issue) || err; +var parseErr = function(res, err){ + var error = err.response ? err.response.message || err.response.details[0].issue : err; + return res.json(400,{err:error}); } exports.createBillingAgreement = function(req,res,next){ @@ -33,7 +34,7 @@ exports.createBillingAgreement = function(req,res,next){ var billingAgreementAttributes = { "name": billingPlanTitle, "description": billingPlanTitle, - "start_date": moment().add({seconds:5}).format(), + "start_date": moment().add({minutes:5}).format(), "plan": { "id": block.paypalKey }, @@ -42,7 +43,7 @@ exports.createBillingAgreement = function(req,res,next){ } }; paypal.billingAgreement.create(billingAgreementAttributes, function (err, billingAgreement) { - if (err) return next(parseErr(err)); + if (err) return parseErr(res, err); // For approving subscription via Paypal, first redirect user to: approval_url var approval_url = _.find(billingAgreement.links, {rel:'approval_url'}).href; res.redirect(approval_url); @@ -65,7 +66,7 @@ exports.executeBillingAgreement = function(req,res,next){ data.user.save(cb); } ],function(err){ - if (err) return next(parseErr(err)); + if (err) return parseErr(res, err); res.redirect('/'); }) } @@ -106,7 +107,7 @@ exports.createPayment = function(req, res, next) { }] }; paypal.payment.create(create_payment, function (err, payment) { - if (err) return next(parseErr(err)); + if (err) return parseErr(res, err); var link = _.find(payment.links, {rel: 'approval_url'}).href; res.redirect(link); }); @@ -138,7 +139,7 @@ exports.executePayment = function(req, res, next) { payments[method](data, cb); } ],function(err){ - if (err) return next(parseErr(err)); + if (err) return parseErr(res, err); res.redirect('/'); }) } @@ -147,16 +148,25 @@ exports.cancelSubscription = function(req, res, next){ var user = res.locals.user; if (!user.purchased.plan.customerId) return res.json(401, {err: "User does not have a plan subscription"}); - async.waterfall([ - function(cb) { - paypal.billingAgreement.cancel(user.purchased.plan.customerId, {note: "Canceling the subscription"}, cb); + async.auto({ + get_cus: function(cb){ + paypal.billingAgreement.get(user.purchased.plan.customerId, cb); }, - function(response, cb) { - payments.cancelSubscription(user); - user.save(cb); - } - ], function(err, saved){ - if (err) return next(parseErr(err)); + verify_cus: ['get_cus', function(cb, results){ + var hasntBilledYet = results.get_cus.agreement_details.cycles_completed == "0"; + if (hasntBilledYet) + return cb("The plan hasn't activated yet (due to a PayPal bug). It will begin "+results.get_cus.agreement_details.next_billing_date+", after which you can cancel to retain your full benefits"); + cb(); + }], + del_cus: ['verify_cus', function(cb, results){ + paypal.billingAgreement.cancel(user.purchased.plan.customerId, {note: "Canceling the subscription"}, cb); + }], + cancel_sub: ['get_cus', 'verify_cus', function(cb, results){ + var data = {user: user, paymentMethod: 'Paypal', nextBill: results.get_cus.agreement_details.next_billing_date}; + payments.cancelSubscription(data, cb) + }] + }, function(err){ + if (err) return parseErr(res, err); res.redirect('/'); user = null; }); @@ -178,8 +188,7 @@ exports.ipn = function(req, res, next) { User.findOne({'purchased.plan.customerId':req.body.recurring_payment_id},function(err, user){ if (err) return logger.error(err); if (_.isEmpty(user)) return; // looks like the cancellation was already handled properly above (see api.paypalSubscribeCancel) - payments.cancelSubscription(user); - user.save(); + payments.cancelSubscription({user:user, paymentMethod: 'Paypal'}); }); break; } diff --git a/src/controllers/payments/paypalBillingSetup.js b/src/controllers/payments/paypalBillingSetup.js index 8c7a1523c0..c286313ae5 100644 --- a/src/controllers/payments/paypalBillingSetup.js +++ b/src/controllers/payments/paypalBillingSetup.js @@ -55,7 +55,7 @@ switch(OP) { case "update": break; case "create": - paypal.billingPlan.create(blocks["3"].definition, function(err,plan){ + paypal.billingPlan.create(blocks["12"].definition, function(err,plan){ if (err) return console.log(err); if (plan.state == "ACTIVE") return console.log({err:err, plan:plan}); @@ -68,7 +68,7 @@ switch(OP) { }]; // Activate the plan by changing status to Active paypal.billingPlan.update(plan.id, billingPlanUpdateAttributes, function(err, response){ - console.log({err:err, response:response}); + console.log({err:err, response:response, id:plan.id}); }); }); case "remove": diff --git a/src/controllers/payments/stripe.js b/src/controllers/payments/stripe.js index edee9501d7..117fc29175 100644 --- a/src/controllers/payments/stripe.js +++ b/src/controllers/payments/stripe.js @@ -61,15 +61,22 @@ exports.subscribeCancel = function(req, res, next) { if (!user.purchased.plan.customerId) return res.json(401, {err: "User does not have a plan subscription"}); - async.waterfall([ - function(cb) { - stripe.customers.del(user.purchased.plan.customerId, cb); + async.auto({ + get_cus: function(cb){ + stripe.customers.retrieve(user.purchased.plan.customerId, cb); }, - function(response, cb) { - payments.cancelSubscription(user); - user.save(cb); - } - ], function(err, saved){ + del_cus: ['get_cus', function(cb, results){ + stripe.customers.del(user.purchased.plan.customerId, cb); + }], + cancel_sub: ['get_cus', function(cb, results) { + var data = { + user: user, + nextBill: results.get_cus.subscription.current_period_end*1000, // timestamp is in seconds + paymentMethod: 'Stripe' + }; + payments.cancelSubscription(data, cb); + }] + }, function(err, results){ if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors res.redirect('/'); user = null; diff --git a/views/options/settings.jade b/views/options/settings.jade index da842b27c9..ef2727b4f6 100644 --- a/views/options/settings.jade +++ b/views/options/settings.jade @@ -200,7 +200,7 @@ mixin subPerks() tr td span.hint(popover=env.t('buyGemsGoldText', {gemCost: "{{Shared.planGemLimits.convRate}}", gemLimit: "{{Shared.planGemLimits.convCap}}"}),popover-trigger='mouseenter',popover-placement='right') #{env.t('buyGemsGold')}  - span.badge.badge-success(ng-show='_subscription.months>1') Cap raised to {{ [25 + p.consecutive.gemCapExtra + Math.floor(_subscription.months/3*5), 50] | min }} + span.badge.badge-success(ng-show='_subscription.months>1') Cap raised to {{ [25 + user.purchased.plan.consecutive.gemCapExtra + Math.floor(_subscription.months/3*5), 50] | min }} tr td span.hint(popover=env.t('retainHistoryText'),popover-trigger='mouseenter',popover-placement='right')=env.t('retainHistory') @@ -231,35 +231,35 @@ script(id='partials/options.settings.subscription.html',type='text/ng-template') +subPerks() .col-md-6 - div(ng-init="p = user.purchased.plan") - table.table.alert.alert-info(ng-if='p.customerId') - tr(ng-if='p.dateTerminated'): td.alert.alert-warning - i.glyphicon.glyphicon-time - | #{env.t('subCanceled')} {{moment(p.dateTerminated).format('MM/DD/YYYY')}} - tr(ng-if='!p.dateTerminated'): td - h4=env.t('subscribed') - p(ng-if='Payments.currentSub') Recurring ${{Payments.currentSub.price}} each {{Payments.currentSub.months}} Month(s) ({{p.paymentMethod}}) - tr(ng-if='p.extraMonths'): td - span.glyphicon.glyphicon-credit-card - | You have {{p.extraMonths | number:2}} months of subscription credit. - tr(ng-if='p.consecutive.count || p.consecutive.offset'): td - span.glyphicon.glyphicon-forward - |  Consecutive Subscription - ul.list-unstyled - li You've been subscribed for {{p.consecutive.count}} consecutive months, plus {{p.consecutive.offset}} extra (gift / block-subscription). - li Gem Cap Extra: {{p.consecutive.gemCapExtra}} - li Mystic Hourglasses: {{p.consecutive.trinkets}} - div(ng-if='!p.customerId || (p.customerId && p.dateTerminated)') - .form-group - each block in env.Content.subscriptionBlocks - .radio - label - input(type="radio", name="subRadio", value="#{block.months}", ng-model='_subscription.months') - | #{block.months} Month(s) Recurring: $#{block.price} #{env.t('monthUSD')} + table.table.alert.alert-info(ng-if='user.purchased.plan.customerId') + tr(ng-if='user.purchased.plan.dateTerminated'): td.alert.alert-warning + i.glyphicon.glyphicon-time + | #{env.t('subCanceled')} {{moment(user.purchased.plan.dateTerminated).format('MM/DD/YYYY')}} + tr(ng-if='!user.purchased.plan.dateTerminated'): td + h4=env.t('subscribed') + p(ng-if='Payments.currentSub') Recurring ${{Payments.currentSub.price}} each {{Payments.currentSub.months}} Month(s) ({{user.purchased.plan.paymentMethod}}) + tr(ng-if='user.purchased.plan.extraMonths'): td + span.glyphicon.glyphicon-credit-card + | You have {{user.purchased.plan.extraMonths | number:2}} months of subscription credit. + tr(ng-if='user.purchased.plan.consecutive.count || user.purchased.plan.consecutive.offset'): td + span.glyphicon.glyphicon-forward + |  Consecutive Subscription + ul.list-unstyled + li Consecutive Months: {{user.purchased.plan.consecutive.count + user.purchased.plan.consecutive.offset}} + li Gem Cap Extra: {{user.purchased.plan.consecutive.gemCapExtra}} + li Mystic Hourglasses: {{user.purchased.plan.consecutive.trinkets}} + div(ng-if='!user.purchased.plan.customerId || (user.purchased.plan.customerId && user.purchased.plan.dateTerminated)') + .form-group + each block in env.Content.subscriptionBlocks + .radio + label + input(type="radio", name="subRadio", value="#{block.months}", ng-model='_subscription.months') + //-| #{block.months} Month(s) Recurring: $#{block.price} #{env.t('monthUSD')} + | Recurring $#{block.price} each #{block.months} Month(s) - h3(ng-if='(p.customerId && p.dateTerminated)') Resubscribe - button.btn.btn-primary(ng-click='Payments.showStripe({subscription:_subscription.months})', ng-disabled='!_subscription.months') Card - button.btn.btn-warning(href='/paypal/subscribe?_id={{user._id}}&apiToken={{user.apiToken}}&sub={{_subscription.months}}', ng-disabled='!_subscription.months') PayPal - div(ng-if='p.customerId') - .btn.btn-primary(ng-if='!p.dateTerminated && p.paymentMethod=="Stripe"', ng-click='Payments.showStripeEdit()') Update Card - .btn.btn-sm.btn-danger(ng-if='!p.dateTerminated', ng-click='Payments.cancelSubscription()')=env.t('cancelSub') \ No newline at end of file + h3(ng-if='(user.purchased.plan.customerId && user.purchased.plan.dateTerminated)') Resubscribe + a.btn.btn-primary(ng-click='Payments.showStripe({subscription:_subscription.months})', ng-disabled='!_subscription.months') Card + a.btn.btn-warning(href='/paypal/subscribe?_id={{user._id}}&apiToken={{user.apiToken}}&sub={{_subscription.months}}', ng-disabled='!_subscription.months') PayPal + 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()') Update Card + .btn.btn-sm.btn-danger(ng-if='!user.purchased.plan.dateTerminated', ng-click='Payments.cancelSubscription()')=env.t('cancelSub') \ No newline at end of file