diff --git a/config.json.example b/config.json.example index aefb56d1b9..19b58c2a1d 100644 --- a/config.json.example +++ b/config.json.example @@ -20,9 +20,6 @@ "NEW_RELIC_APPLICATION_ID":"NEW_RELIC_APPLICATION_ID", "NEW_RELIC_API_KEY":"NEW_RELIC_API_KEY", "GA_ID": "GA_ID", - "PAYPAL_USERNAME": "PAYPAL_USERNAME", - "PAYPAL_PASSWORD": "PAYPAL_PASSWORD", - "PAYPAL_SIGNATURE": "PAYPAL_SIGNATURE", "EMAIL_SERVER_URL": "http://example.com", "EMAIL_SERVER_AUTH_USER": "user", "EMAIL_SERVER_AUTH_PASSWORD": "password", @@ -30,6 +27,11 @@ "bucket":"bucket", "accessKeyId":"accessKeyId", "secretAccessKey":"secretAccessKey" + }, + "PAYPAL":{ + "mode":"sandbox", + "client_id":"client_id", + "client_secret":"client_secret" } } diff --git a/package.json b/package.json index 5fe7519805..fc3edb043b 100644 --- a/package.json +++ b/package.json @@ -40,9 +40,8 @@ "pageres": "^1.0.1", "passport": "~0.2.1", "passport-facebook": "Fonger/passport-facebook#a8f98adcddad99caa9a918bc7b76462c92c5c9fd", - "paypal-express-checkout": "git://github.com/HabitRPG/node-paypal-express-checkout#habitrpg", - "paypal-ipn": "~1.0.1", - "paypal-recurring": "git://github.com/jaybryant/paypal-recurring#656b496f43440893c984700191666a5c5c535dca", + "paypal-ipn": "2.1.0", + "paypal-rest-sdk": "^1.2.1", "pretty-data": "git://github.com/vkiryukhin/pretty-data#master", "qs": "^2.3.2", "request": "~2.44.0", diff --git a/src/controllers/payments.js b/src/controllers/payments.js index fd2d3ff27a..c92450a38e 100644 --- a/src/controllers/payments.js +++ b/src/controllers/payments.js @@ -2,28 +2,18 @@ var _ = require('lodash'); var logger = require('../logging'); -var ipn = require('paypal-ipn'); var shared = require('habitrpg-shared'); var nconf = require('nconf'); var async = require('async'); var User = require('./../models/user').model; var utils = require('./../utils'); var logging = require('./../logging'); -var userAPI = require('./user'); var request = require('request'); var moment = require('moment'); var api = module.exports; var isProduction = nconf.get("NODE_ENV") === "production"; var stripe = require("stripe")(nconf.get('STRIPE_API_KEY')); - -var PaypalRecurring = require('paypal-recurring'); -var paypalRecurring = new PaypalRecurring({ - username: nconf.get('PAYPAL_USERNAME'), - password: nconf.get('PAYPAL_PASSWORD'), - signature: nconf.get('PAYPAL_SIGNATURE') -}, isProduction ? "production" : "sandbox"); -var paypalCheckout = require('paypal-express-checkout') - .init(nconf.get('PAYPAL_USERNAME'), nconf.get('PAYPAL_PASSWORD'), nconf.get('PAYPAL_SIGNATURE'), nconf.get('BASE_URL'), nconf.get('BASE_URL'), !isProduction); +var paypal = require('./payments/paypal'); function revealMysteryItems(user) { _.each(shared.content.gear.flat, function(item) { @@ -39,7 +29,7 @@ function revealMysteryItems(user) { }); } -function createSubscription(user, data) { +var createSubscription = api.createSubscription = function(user, data) { if (!user.purchased.plan) user.purchased.plan = {}; _(user.purchased.plan) .merge({ // override with these values @@ -64,7 +54,7 @@ function createSubscription(user, data) { /** * Sets their subscription to be cancelled later */ -function cancelSubscription(user, data){ +var cancelSubscription = api.cancelSubscription = function(user, data) { var du = user.purchased.plan.dateUpdated, now = moment(); if(isProduction) utils.txnEmail(user, 'cancel-subscription'); user.purchased.plan.dateTerminated = @@ -72,10 +62,9 @@ function cancelSubscription(user, data){ .add({months:1}) .toDate(); utils.ga.event('unsubscribe', 'Stripe').send(); - } -function buyGems(user, data) { +var buyGems = api.buyGems = function(user, data) { user.balance += 5; user.purchased.txnCount++; if(isProduction) utils.txnEmail(user, 'donation'); @@ -83,12 +72,6 @@ function buyGems(user, data) { utils.ga.transaction(data.customerId, 5).item(5, 1, data.paymentMethod.toLowerCase() + "-checkout", "Gems > " + data.paymentMethod).send(); } -// Expose some functions for tests -if (nconf.get('NODE_ENV')==='testing') { - api.cancelSubscription = cancelSubscription; - api.createSubscription = createSubscription; -} - /* Setup Stripe response when posting payment */ @@ -103,7 +86,7 @@ api.stripeCheckout = function(req, res, next) { email: req.body.email, metadata: {uuid: res.locals.user._id}, card: token, - plan: req.query.plan, + plan: req.query.plan }, cb); } else { stripe.charges.create({ @@ -174,111 +157,9 @@ api.stripeSubscribeEdit = function(req, res, next) { }); }; -api.paypalSubscribe = function(req,res,next) { - // Authenticate a future subscription of ~5 USD - paypalRecurring.authenticate({ - RETURNURL: nconf.get('BASE_URL') + '/paypal/subscribe/success?uuid=' + res.locals.user._id, - CANCELURL: nconf.get("BASE_URL"), - PAYMENTREQUEST_0_AMT: 5, - L_BILLINGAGREEMENTDESCRIPTION0: "HabitRPG Subscription" - }, function(err, data, url) { - // Redirect the user if everything went well with - // a HTTP 302 according to PayPal's guidelines - if (err) return next(err); - res.redirect(302, url); - }); -}; - -api.paypalSubscribeSuccess = function(req,res,next) { - // Create a subscription of 10 USD every month - var uuid = req.query.uuid; - if (!uuid) return next("UUID required"); - paypalRecurring.createSubscription(req.query.token, req.query.PayerID,{ - AMT: 5, - DESC: "HabitRPG Subscription", - BILLINGPERIOD: "Month", - BILLINGFREQUENCY: 1, - }, function(err, data) { - if (err) return res.next(err); - User.findById(uuid, function(err,user){ - if (err) return next(err); - createSubscription(user, {customerId: data.PROFILEID, paymentMethod: 'Paypal'}); - user.save(function(err,saved){ - res.redirect('/'); - }) - }) - }); -}; - -api.paypalSubscribeCancel = 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) { - paypalRecurring.modifySubscription(user.purchased.plan.customerId, 'cancel', cb); - }, - function(response, cb) { - cancelSubscription(user); - user.save(cb); - } - ], function(err, saved){ - if (err) return next(err); - res.redirect('/'); - user = null; - }); -}; - -api.paypalCheckout = function(req, res, next) { - var opts = {RETURNURL:nconf.get('BASE_URL') + '/paypal/checkout/success?uuid=' + res.locals.user._id}; - paypalCheckout.pay(+new Date(), 5, 'HabitRPG Gems', 'USD', opts, function(err, url) { - if (err) return next(err); - res.redirect(url); - }); -}; - -api.paypalCheckoutSuccess = function(req,res,next) { - paypalCheckout.detail(req.query.token, req.query.PayerID, function(err, data, invoiceNumber, price) { - // see `data` vars at https://github.com/petersirka/node-paypal-express-checkout#paypal-account - //if (err) return next('PayPal Error: ' + msg); - if (err) return next(err); - if (data.ACK !== 'Success') return next('PayPal transaction failed, please try again'); - - var uuid = req.query.uuid; //, apiToken = query.apiToken; - User.findById(uuid , function(err, user) { - if (_.isEmpty(user)) err = "user not found with uuid " + uuid + " when completing paypal transaction"; - if (err) return next(err); - buyGems(user, {customerId:req.query.PayerID, paymentMethod:'Paypal'}); - user.save(function(){ - if (err) return next(err); - res.redirect('/'); - uuid = null; - }); - }); - }); -}; - -/** - * General IPN handler. We could use this for all paypal transaction handling (instead of the above functions), but I've - * found it extremely unreliable. Instead, here we'll cancel HabitRPG subscriptions for users who manually cancel their - * recurring paypal payments. - */ -api.paypalIPN = function(req, res, next) { - // Must respond to PayPal IPN request with an empty 200 first, if using Express uncomment the following: - res.send(200); - ipn.verify(req.body, function callback(err, msg) { - if (err) return logger.error(msg); - switch (req.body.txn_type) { - // 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': - 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) - cancelSubscription(user); - user.save(); - }); - break; - } - }); -}; +api.paypalSubscribe = paypal.createBillingAgreement; +api.paypalSubscribeSuccess = paypal.executeBillingAgreement; +api.paypalSubscribeCancel = paypal.cancelSubscription; +api.paypalCheckout = paypal.createPayment; +api.paypalCheckoutSuccess = paypal.executePayment; +api.paypalIPN = paypal.ipn; \ No newline at end of file diff --git a/src/controllers/payments/paypal.js b/src/controllers/payments/paypal.js new file mode 100644 index 0000000000..d6efdd17ea --- /dev/null +++ b/src/controllers/payments/paypal.js @@ -0,0 +1,217 @@ +var nconf = require('nconf'); +var moment = require('moment'); +var async = require('async'); +var _ = require('lodash'); +var url = require('url'); +var mongoose = require('mongoose'); +var payments = require('./../payments'); +var logger = require('../../logging'); +var ipn = require('paypal-ipn'); +var paypal = require('paypal-rest-sdk'); + +paypal.configure({ + 'mode': nconf.get("PAYPAL:mode"), //sandbox or live + 'client_id': nconf.get("PAYPAL:client_id"), + 'client_secret': nconf.get("PAYPAL:client_secret") +}); + +var parseErr = function(err){ + return (err.response && err.response.message || err.response.details[0].issue) || err; +} + +// Initialize Billing Plans +var billingPlanID; +var billingPlanTitle ="HabitRPG subscription ($5 month-to-month)"; +(function(){ + var billingPlanAttributes = { + // https://developer.paypal.com/docs/api/#billing-plans-and-agreements + "name": billingPlanTitle, + "description": billingPlanTitle, + "type": "INFINITE", + "merchant_preferences": { + "auto_bill_amount": "yes", + "cancel_url": nconf.get("BASE_URL"), + "return_url": nconf.get('BASE_URL') + '/paypal/subscribe/success' + }, + "payment_definitions": [{ + "name": billingPlanTitle, + "type": "REGULAR", + "frequency_interval": "1", + "frequency": "MONTH", + "cycles": "0", + "amount": { + "currency": "USD", + "value": "5" + } + }] + }; + + async.waterfall([ + function(cb) { + paypal.billingPlan.list({status: 'ACTIVE'}, cb); + }, + function(plans, cb){ + var plan = _.find(plans.plans, {name:billingPlanTitle}); + if (plan) return cb(null, plan); + paypal.billingPlan.create(billingPlanAttributes, cb); + }, + function(plan, cb){ + if (plan.state == "ACTIVE") return cb(null, plan); + // Super obvious this stuff, right? *sigh* + var billingPlanUpdateAttributes = [{ + "op": "replace", + "path": "/", + "value": { + "state": "ACTIVE" + } + }]; + // Activate the plan by changing status to Active + paypal.billingPlan.update(plan.id, billingPlanUpdateAttributes, function(err, response){ + if (err) return cb(err); + cb(null, plan); + }); + }, + ],function(err, plan){ + billingPlanID = plan.id; + }) +})(); + +exports.createBillingAgreement = function(req,res,next){ + var billingAgreementAttributes = { + "name": billingPlanTitle, + "description": billingPlanTitle, + "start_date": moment().add({minutes:1}).format(), + "plan": { + "id": billingPlanID + }, + "payer": { + "payment_method": "paypal" + } + }; + paypal.billingAgreement.create(billingAgreementAttributes, function (err, billingAgreement) { + if (err) return next(parseErr(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); + }); +} + +exports.executeBillingAgreement = function(req,res,next){ + async.waterfall([ + function(cb){ + paypal.billingAgreement.execute(req.query.token, {}, cb); + }, + function(billingAgreement, cb){ + mongoose.model('User').findById(req.session.userId, function(err, user){ + if (err) return cb(err); + cb(null, {billingAgreement:billingAgreement, user:user}); + }); + }, + function(data, cb){ + payments.createSubscription(data.user, {customerId: data.billingAgreement.id, paymentMethod: 'Paypal'}); + data.user.save(cb); + } + ],function(err){ + if (err) return next(parseErr(err)); + res.redirect('/'); + }) +} + +exports.createPayment = function(req, res, next) { + var create_payment = { + "intent": "sale", + "payer": { + "payment_method": "paypal" + }, + "redirect_urls": { + "return_url": nconf.get('BASE_URL') + '/paypal/checkout/success', + "cancel_url": nconf.get('BASE_URL') + }, + "transactions": [{ + "item_list": { + "items": [{ + "name": "HabitRPG Gems", + //"sku": "1", + "price": "5.00", + "currency": "USD", + "quantity": 1 + }] + }, + "amount": { + "currency": "USD", + "total": "5.00" + }, + "description": "HabitRPG Gems" + }] + }; + paypal.payment.create(create_payment, function (err, payment) { + if (err) return next(parseErr(err)); + var link = _.find(payment.links, {rel: 'approval_url'}).href; + res.redirect(link); + }); +} + +exports.executePayment = function(req, res, next) { + var paymentId = req.query.paymentId, + PayerID = req.query.PayerID; + async.waterfall([ + function(cb){ + paypal.payment.execute(paymentId, {payer_id: PayerID}, cb); + }, + function(payment, cb){ + mongoose.model('User').findById(req.session.userId, 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(err, saved){ + if (err) return next(parseErr(err)); + res.redirect('/'); + }) +} + +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); + }, + function(response, cb) { + payments.cancelSubscription(user); + user.save(cb); + } + ], function(err, saved){ + if (err) return next(parseErr(err)); + res.redirect('/'); + user = null; + }); +} + +/** + * General IPN handler. We catch cancelled HabitRPG subscriptions for users who manually cancel their + * recurring paypal payments in their paypal dashboard. Remove this when we can move to webhooks or some other solution + */ +exports.ipn = function(req, res, next) { + console.log('IPN Called'); + res.send(200); // Must respond to PayPal IPN request with an empty 200 first + ipn.verify(req.body, function(err, msg) { + if (err) return logger.error(msg); + switch (req.body.txn_type) { + // 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){ + 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(); + }); + break; + } + }); +}; + diff --git a/test/api.mocha.coffee b/test/api.mocha.coffee index a1e75ec11e..28b2a97c3d 100644 --- a/test/api.mocha.coffee +++ b/test/api.mocha.coffee @@ -606,7 +606,7 @@ describe "API", -> user = _user done() - it.skip "Handles unsubscription", (done) -> + it "Handles unsubscription", (done) -> cron = -> user.lastCron = moment().subtract("d", 1) user.fns.cron()