mirror of
https://github.com/sudoxnym/habitica-self-host.git
synced 2026-08-02 04:00:36 +00:00
fix(paypal): move from classic paypal APIs to paypal REST SDK
This commit is contained in:
parent
64fd6c02f7
commit
25a5bf92a6
5 changed files with 236 additions and 137 deletions
|
|
@ -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"
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
217
src/controllers/payments/paypal.js
Normal file
217
src/controllers/payments/paypal.js
Normal 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;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue