diff --git a/config.json.example b/config.json.example index e34d187838..6933f92426 100644 --- a/config.json.example +++ b/config.json.example @@ -34,10 +34,11 @@ }, "PAYPAL":{ "billing_plans": { - "1":"1", - "3":"3", - "6":"6", - "12":"12" + "basic_earned":"basic_earned", + "basic_3mo":"basic_3mo", + "basic_6mo":"basic_6mo", + "google_6mo":"google_6mo", + "basic_12mo":"basic_12mo" }, "mode":"sandbox", "client_id":"client_id", diff --git a/public/css/index.styl b/public/css/index.styl index 381ab03082..998fcad48e 100644 --- a/public/css/index.styl +++ b/public/css/index.styl @@ -179,3 +179,5 @@ a.label .white, .white a color: #fff !important +.line-through + text-decoration line-through \ No newline at end of file diff --git a/public/js/controllers/groupsCtrl.js b/public/js/controllers/groupsCtrl.js index 338338f76b..fe30af9239 100644 --- a/public/js/controllers/groupsCtrl.js +++ b/public/js/controllers/groupsCtrl.js @@ -129,7 +129,7 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', ' $scope.gift = { type: 'gems', gems: {amount:0, fromBalance:true}, - subscription: {months:0}, + subscription: {key:''}, message:'' }; $scope.sendGift = function(uuid, gift){ diff --git a/public/js/controllers/settingsCtrl.js b/public/js/controllers/settingsCtrl.js index 4481ed2480..a7927d5d0c 100644 --- a/public/js/controllers/settingsCtrl.js +++ b/public/js/controllers/settingsCtrl.js @@ -181,5 +181,15 @@ habitrpg.controller('SettingsCtrl', $scope.deleteWebhook = function(id) { User.user.ops.deleteWebhook({params:{id:id}}); } + + $scope.applyCoupon = function(coupon){ + $http.get(ApiUrlService.get() + '/api/v2/coupons/valid-discount/'+coupon) + .success(function(){ + Notification.text("Coupon applied!"); + var subs = $scope.Content.subscriptionBlocks; + subs["basic_6mo"].discount = true; + subs["google_6mo"].discount = false; + }); + } } ]); diff --git a/public/js/services/paymentServices.js b/public/js/services/paymentServices.js index 0fd7682512..3a0f9b21da 100644 --- a/public/js/services/paymentServices.js +++ b/public/js/services/paymentServices.js @@ -3,23 +3,12 @@ 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': - case 'Paypal': // FIXME store paypalKey somewhere? - return b.key == 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 + : data.gift && data.gift.type=='subscription' ? data.gift.subscription.key : false; sub = sub && Content.subscriptionBlocks[sub]; var amount = // 500 = $5 @@ -36,7 +25,8 @@ function($rootScope, User, $http, Content) { token: function(res) { var url = '/stripe/checkout?a=a'; // just so I can concat &x=x below if (data.gift) url += '&gift=' + Payments.encodeGift(data.uuid, data.gift); - if (data.subscription) url += '&sub='+sub.months; + if (data.subscription) url += '&sub='+sub.key; + if (data.coupon) url += '&coupon='+data.coupon; $http.post(url, res).success(function() { window.location.reload(true); }).error(function(res) { @@ -66,7 +56,7 @@ function($rootScope, User, $http, Content) { Payments.cancelSubscription = function(){ if (!confirm(window.env.t('sureCancelSub'))) return; - window.location.href = '/' + plan.paymentMethod.toLowerCase() + '/subscribe/cancel?_id=' + user._id + '&apiToken=' + user.apiToken; + window.location.href = '/' + User.user.purchased.plan.paymentMethod.toLowerCase() + '/subscribe/cancel?_id=' + User.user._id + '&apiToken=' + User.user.apiToken; } Payments.encodeGift = function(uuid, gift){ diff --git a/src/controllers/members.js b/src/controllers/members.js index 26092e872c..25ffdce25d 100644 --- a/src/controllers/members.js +++ b/src/controllers/members.js @@ -35,7 +35,7 @@ api.sendMessage = function(user, member, data){ 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.type=='gems') ? data.gems.amount + " gems!`" : shared.content.subscriptionBlocks[data.subscription.key].months + " months of subscription!`"; msg += data.message; } shared.refPush(member.inbox.messages, groups.chatDefaults(msg, user)); diff --git a/src/controllers/payments/index.js b/src/controllers/payments/index.js index b0c59f643c..fea3c73025 100644 --- a/src/controllers/payments/index.js +++ b/src/controllers/payments/index.js @@ -10,6 +10,8 @@ var paypal = require('./paypal'); var members = require('../members') var async = require('async'); var iap = require('./iap'); +var mongoose= require('mongoose'); +var cc = require('coupon-code'); function revealMysteryItems(user) { _.each(shared.content.gear.flat, function(item) { @@ -29,8 +31,8 @@ exports.createSubscription = function(data, cb) { var recipient = data.gift ? data.gift.member : data.user; //if (!recipient.purchased.plan) recipient.purchased.plan = {}; // FIXME double-check, this should never be the case var p = recipient.purchased.plan; - var months = +(data.gift ? data.gift.subscription.months : data.sub.months); - var block = shared.content.subscriptionBlocks[months]; + var block = shared.content.subscriptionBlocks[data.gift ? data.gift.subscription.key : data.sub.key]; + var months = +block.months; if (data.gift) { if (p.customerId && !p.dateTerminated) { // User has active plan @@ -114,6 +116,14 @@ exports.buyGems = function(data, cb) { ], cb); } +exports.validCoupon = function(req, res, next){ + mongoose.model('Coupon').findOne({_id:cc.validate(req.params.code), event:'google_6mo'}, function(err, coupon){ + if (err) return next(err); + if (!coupon) return res.json(401, {err:"Invalid coupon code"}); + return res.send(200); + }); +} + exports.stripeCheckout = stripe.checkout; exports.stripeSubscribeCancel = stripe.subscribeCancel; exports.stripeSubscribeEdit = stripe.subscribeEdit; @@ -126,4 +136,4 @@ exports.paypalCheckoutSuccess = paypal.executePayment; exports.paypalIPN = paypal.ipn; exports.iapAndroidVerify = iap.androidVerify; -exports.iapIosVerify = iap.iosVerify; +exports.iapIosVerify = iap.iosVerify; \ No newline at end of file diff --git a/src/controllers/payments/paypal.js b/src/controllers/payments/paypal.js index 9c7a1d4f3a..f69ab306da 100644 --- a/src/controllers/payments/paypal.js +++ b/src/controllers/payments/paypal.js @@ -9,12 +9,14 @@ var logger = require('../../logging'); var ipn = require('paypal-ipn'); var paypal = require('paypal-rest-sdk'); var shared = require('habitrpg-shared'); +var mongoose = require('mongoose'); +var cc = require('coupon-code'); // 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 _.each(shared.content.subscriptionBlocks, function(block){ - block.paypalKey = nconf.get("PAYPAL:billing_plans:"+block.months); + block.paypalKey = nconf.get("PAYPAL:billing_plans:"+block.key); }); paypal.configure({ @@ -30,23 +32,33 @@ var parseErr = function(res, err){ } exports.createBillingAgreement = function(req,res,next){ - req.session.paypalBlock = req.query.sub; - var block = 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({minutes:5}).format(), - "plan": { - "id": block.paypalKey + var sub = shared.content.subscriptionBlocks[req.query.sub]; + async.waterfall([ + function(cb){ + if (!sub.discount) return cb(null, null); + if (!req.query.coupon) return cb('Please provide a coupon code for this plan.'); + mongoose.model('Coupon').findOne({_id:cc.validate(req.query.coupon), event:sub.key}, cb); }, - "payer": { - "payment_method": "paypal" + function(coupon, cb){ + if (sub.discount && !coupon) return cb('Invalid coupon code.'); + var billingPlanTitle = "HabitRPG Subscription" + ' ($'+sub.price+' every '+sub.months+' months, recurring)'; + var billingAgreementAttributes = { + "name": billingPlanTitle, + "description": billingPlanTitle, + "start_date": moment().add({minutes:5}).format(), + "plan": { + "id": sub.paypalKey + }, + "payer": { + "payment_method": "paypal" + } + }; + paypal.billingAgreement.create(billingAgreementAttributes, cb); } - }; - paypal.billingAgreement.create(billingAgreementAttributes, function (err, billingAgreement) { + ], function(err, billingAgreement){ if (err) return parseErr(res, err); // For approving subscription via Paypal, first redirect user to: approval_url + req.session.paypalBlock = req.query.sub; var approval_url = _.find(billingAgreement.links, {rel:'approval_url'}).href; res.redirect(approval_url); }); @@ -82,10 +94,10 @@ exports.createPayment = function(req, res) { var 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); + : Number(shared.content.subscriptionBlocks[gift.subscription.key].price).toFixed(2); var description = !gift ? "HabitRPG Gems" : gift.type=='gems' ? "HabitRPG Gems (Gift)" - : gift.subscription.months + "mo. HabitRPG Subscription (Gift)"; + : shared.content.subscriptionBlocks[gift.subscription.key].months + "mo. HabitRPG Subscription (Gift)"; var create_payment = { "intent": "sale", "payer": { diff --git a/src/controllers/payments/paypalBillingSetup.js b/src/controllers/payments/paypalBillingSetup.js index 38b70c0101..14721e22d1 100644 --- a/src/controllers/payments/paypalBillingSetup.js +++ b/src/controllers/payments/paypalBillingSetup.js @@ -11,7 +11,7 @@ var paypal = require('paypal-rest-sdk'); var blocks = require('habitrpg-shared').content.subscriptionBlocks; var live = nconf.get('PAYPAL:mode')=='live'; -var OP = 'get'; // list create update remove +var OP = 'create'; // list create update remove paypal.configure({ 'mode': nconf.get("PAYPAL:mode"), //sandbox or live @@ -72,7 +72,7 @@ switch(OP) { }); break; case "create": - paypal.billingPlan.create(blocks["12"].definition, function(err,plan){ + paypal.billingPlan.create(blocks["google_6mo"].definition, function(err,plan){ if (err) return console.log(err); if (plan.state == "ACTIVE") return console.log({err:err, plan:plan}); diff --git a/src/controllers/payments/stripe.js b/src/controllers/payments/stripe.js index 117fc29175..74172cc8ab 100644 --- a/src/controllers/payments/stripe.js +++ b/src/controllers/payments/stripe.js @@ -4,6 +4,8 @@ var async = require('async'); var payments = require('./index'); var User = require('mongoose').model('User'); var shared = require('habitrpg-shared'); +var mongoose = require('mongoose'); +var cc = require('coupon-code'); /* Setup Stripe response when posting payment @@ -17,16 +19,27 @@ exports.checkout = function(req, res, next) { async.waterfall([ function(cb){ if (sub) { - stripe.customers.create({ - email: req.body.email, - metadata: {uuid: user._id}, - card: token, - plan: sub.key - }, cb); + async.waterfall([ + function(cb2){ + if (!sub.discount) return cb2(null, null); + if (!req.query.coupon) return cb2('Please provide a coupon code for this plan.'); + mongoose.model('Coupon').findOne({_id:cc.validate(req.query.coupon), event:sub.key}, cb2); + }, + function(coupon, cb2){ + if (sub.discount && !coupon) return cb2('Invalid coupon code.'); + var customer = { + email: req.body.email, + metadata: {uuid: user._id}, + card: token, + plan: sub.key + }; + stripe.customers.create(customer, cb2); + } + ], cb); } else { stripe.charges.create({ amount: !gift ? "500" //"500" = $5 - : gift.type=='subscription' ? ""+shared.content.subscriptionBlocks[gift.subscription.months].price*100 + : gift.type=='subscription' ? ""+shared.content.subscriptionBlocks[gift.subscription.key].price*100 : ""+gift.gems.amount/4*100, currency: "usd", card: token diff --git a/src/models/coupon.js b/src/models/coupon.js index 497b45697f..4f2680546c 100644 --- a/src/models/coupon.js +++ b/src/models/coupon.js @@ -7,7 +7,7 @@ var autoinc = require('mongoose-id-autoinc'); var CouponSchema = new mongoose.Schema({ _id: {type: String, 'default': cc.generate}, - event: {type:String, enum:['wondercon']}, + event: {type:String, enum:['wondercon','google_6mo']}, user: {type: 'String', ref: 'User'} }); diff --git a/src/routes/payments.js b/src/routes/payments.js index adc9a65629..118f923689 100644 --- a/src/routes/payments.js +++ b/src/routes/payments.js @@ -20,4 +20,6 @@ router.get("/stripe/subscribe/cancel", auth.authWithUrl, i18n.getUserLanguage, p router.post("/iap/android/verify", auth.authWithUrl, /*i18n.getUserLanguage, */payments.iapAndroidVerify); router.post("/iap/ios/verify", /*auth.authWithUrl, i18n.getUserLanguage, */ payments.iapIosVerify); +router.get("/api/v2/coupons/valid-discount/:code", /*auth.authWithUrl, i18n.getUserLanguage, */ payments.validCoupon); + module.exports = router; \ No newline at end of file diff --git a/src/server.js b/src/server.js index 8e551f7891..7c53317e23 100644 --- a/src/server.js +++ b/src/server.js @@ -8,7 +8,7 @@ var logging = require('./logging'); var isProd = nconf.get('NODE_ENV') === 'production'; var isDev = nconf.get('NODE_ENV') === 'development'; -if (cluster.isMaster && (isDev || isProd)) { +if (false && cluster.isMaster && (isDev || isProd)) { // Fork workers. If config.json has CORES=x, use that - otherwise, use all cpus-1 (production) var cpus = require('os').cpus(), cores = +nconf.get("CORES"); diff --git a/views/options/settings.jade b/views/options/settings.jade index e8e50b2405..3ba5192ec8 100644 --- a/views/options/settings.jade +++ b/views/options/settings.jade @@ -204,7 +204,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 + user.purchased.plan.consecutive.gemCapExtra + Math.floor(_subscription.months/3*5), 50] | min }} + span.badge.badge-success(ng-show='_subscription.key!="basic_earned"') Cap raised to {{ [25 + user.purchased.plan.consecutive.gemCapExtra + Math.floor(Content.subscriptionBlocks[_subscription.key].months/3*5), 50] | min }} tr td span.hint(popover=env.t('retainHistoryText'),popover-trigger='mouseenter',popover-placement='right')=env.t('retainHistory') @@ -214,8 +214,8 @@ mixin subPerks() tr td 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 + div(ng-show='_subscription.key!="basic_earned"') + .badge.badge-success +{{Math.floor(Content.subscriptionBlocks[_subscription.key].months/3)}} Mystic Hourglass .small.muted Mystic Hourglasses allow purchasing a previous month's Mystery Item set. tr td @@ -228,7 +228,7 @@ script(id='partials/feature-matrix-check.html',type='text/ng-template') script(id='partials/options.settings.subscription.html',type='text/ng-template') //-h2=env.t('individualSub') - .container-fluid(ng-init='_subscription={months:1}') + .container-fluid(ng-init='_subscription={key:"basic_earned"}') .row .col-md-6 h3 Benefits @@ -241,7 +241,7 @@ script(id='partials/options.settings.subscription.html',type='text/ng-template') | #{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}}) + p(ng-if='user.purchased.plan.planId') Recurring ${{Content.subscriptionBlocks[user.purchased.plan.planId].price}} each {{Content.subscriptionBlocks[user.purchased.plan.planId].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. @@ -254,16 +254,23 @@ script(id='partials/options.settings.subscription.html',type='text/ng-template') 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) + .radio(ng-repeat='block in Content.subscriptionBlocks | toArray | omit:"discount==true" | orderBy:"months"') + label + input(type="radio", name="subRadio", ng-value="block.key", ng-model='_subscription.key') + span(ng-show='block.original') + | Recurring ${{block.original}} ${{block.price}} each {{block.months}} Month(s) + span(ng-hide='block.original') + | Recurring ${{block.price}} each {{block.months}} Month(s) + + .form-inline + .form-group + input.form-control(type='text', ng-model='_subscription.coupon', placeholder='Promo Code') + .form-group + button.btn.btn-small(type='button',ng-click='applyCoupon(_subscription.coupon)') Apply 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 + a.btn.btn-primary(ng-click='Payments.showStripe({subscription:_subscription.key, coupon:_subscription.coupon})', ng-disabled='!_subscription.key') Card + a.btn.btn-warning(href='/paypal/subscribe?_id={{user._id}}&apiToken={{user.apiToken}}&sub={{_subscription.key}}{{_subscription.coupon ? "&coupon="+_subscription.coupon : ""}}', ng-disabled='!_subscription.key') 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()')=env.t('subUpdateCard') .btn.btn-sm.btn-danger(ng-if='!user.purchased.plan.dateTerminated', ng-click='Payments.cancelSubscription()')=env.t('cancelSub') \ No newline at end of file diff --git a/views/shared/modals/members.jade b/views/shared/modals/members.jade index 7f44481a17..fbe64c6279 100644 --- a/views/shared/modals/members.jade +++ b/views/shared/modals/members.jade @@ -75,11 +75,10 @@ script(type='text/ng-template', id='modals/send-gift.html') .panel-heading Subscription .panel-body .form-group - 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} + .radio(ng-repeat='block in Content.subscriptionBlocks | toArray | omit:"discount==true" | orderBy:"months"') + label + input(type="radio", name="subRadio", ng-value="block.key", ng-model='gift.subscription.key') + | {{block.months}} Month(s): ${{block.price}} textarea.form-control(rows='3', ng-model='gift.message', placeholder='Personal message (optional)')