fix(paypal): move from classic paypal APIs to paypal REST SDK

This commit is contained in:
Tyler Renelle 2014-11-14 17:47:51 -07:00
parent 64fd6c02f7
commit 25a5bf92a6
5 changed files with 236 additions and 137 deletions

View file

@ -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"
}
}

View file

@ -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",

View file

@ -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;

View file

@ -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;
}
});
};

View file

@ -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()