Merge pull request #7030 from HabitRPG/sabrecat/v3-payments

[API v3] Payments refactor
This commit is contained in:
Matteo Pagliazzi
2016-05-10 18:33:00 +02:00
41 changed files with 1630 additions and 1056 deletions

View File

@@ -20,9 +20,9 @@ website/src/routes/payments.js
website/src/routes/pages.js
website/src/middlewares/apiThrottle.js
website/src/middlewares/forceRefresh.js
website/src/controllers/payments/
debug-scripts/*
scripts/*
tasks/*.js
gulpfile.js
Gruntfile.js

View File

@@ -2,5 +2,8 @@
"extends": [
"habitrpg/server",
"habitrpg/babel"
]
],
"globals": {
"Promise": true
}
}

View File

@@ -1,5 +1,6 @@
{
"missingAuthHeaders": "Missing authentication headers.",
"missingAuthParams": "Missing authentication parameters.",
"missingUsernameEmail": "Missing username or email.",
"missingEmail": "Missing email.",
"missingUsername": "Missing username.",
@@ -100,6 +101,8 @@
"noAdminAccess": "You don't have admin access.",
"pageMustBeNumber": "req.query.page must be a number",
"missingUnsubscriptionCode": "Missing unsubscription code.",
"missingSubscription": "User does not have a plan subscription",
"missingSubscriptionCode": "Missing subscription code. Possible values: basic_earned, basic_3mo, basic_6mo, google_6mo, basic_12mo.",
"userNotFound": "User not found.",
"spellNotFound": "Spell \"<%= spellId %>\" not found.",
"partyNotFound": "Party not found",
@@ -171,5 +174,8 @@
"pushDeviceAlreadyAdded": "The user already has the push device",
"resetComplete": "Reset completed",
"lvl10ChangeClass": "To change class you must be at least level 10.",
"equipmentAlreadyOwned": "You already own that piece of equipment"
"equipmentAlreadyOwned": "You already own that piece of equipment",
"paymentNotSuccessful": "The payment was not successful",
"planNotActive": "The plan hasn't activated yet (due to a PayPal bug). It will begin <%= nextBillingDate %>, after which you can cancel to retain your full benefits",
"cancelingSubscription": "Canceling the subscription"
}

View File

@@ -143,6 +143,8 @@ function processChallenges (afterId) {
oldTask.tags = _.map(oldTask.tags || {}, function (tagPresent, tagId) {
return tagPresent && tagId;
}).filter(function (tag) {
return tag !== false;
});
if (!oldTask.text) oldTask.text = 'task text'; // required

View File

@@ -165,6 +165,8 @@ function processUsers (afterId) {
if (!oldTask.text) oldTask.text = 'task text'; // required
oldTask.tags = _.map(oldTask.tags, function (tagPresent, tagId) {
return tagPresent && tagId;
}).filter(function (tag) {
return tag !== false;
});
if (oldTask.type !== 'todo' || (oldTask.type === 'todo' && !oldTask.completed)) {

View File

@@ -2,14 +2,16 @@
// 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
var path = require('path');
var nconf = require('nconf');
_ = require('lodash');
nconf.argv().env().file('user', path.join(path.resolve(__dirname, '../../../config.json')));
var _ = require('lodash');
var paypal = require('paypal-rest-sdk');
var blocks = require('../../../../common').content.subscriptionBlocks;
var live = nconf.get('PAYPAL:mode')=='live';
nconf.argv().env().file('user', path.join(path.resolve(__dirname, '../../../config.json')));
var OP = 'create'; // list create update remove
paypal.configure({

View File

@@ -358,6 +358,10 @@ gulp.task('test:api-v3:unit', (done) => {
pipe(runner);
});
gulp.task('test:api-v3:unit:watch', () => {
gulp.watch(['website/src/libs/api-v3/*', 'test/api/v3/unit/**/*', 'website/src/controllers/**/*'], ['test:api-v3:unit']);
});
gulp.task('test:api-v3:integration', (done) => {
let runner = exec(
testBin('mocha test/api/v3/integration --recursive'),
@@ -369,7 +373,8 @@ gulp.task('test:api-v3:integration', (done) => {
});
gulp.task('test:api-v3:integration:watch', () => {
gulp.watch(['website/src/controllers/api-v3/**/*', 'test/api/v3/integration/**/*', 'common/script/ops/*'], ['test:api-v3:integration']);
gulp.watch(['website/src/controllers/api-v3/**/*', 'common/script/ops/*', 'website/src/libs/api-v3/*.js',
'test/api/v3/integration/**/*'], ['test:api-v3:integration']);
});
gulp.task('test:api-v3:integration:separate-server', (done) => {

View File

@@ -0,0 +1,21 @@
import {
generateUser,
translate as t,
} from '../../../../helpers/api-integration/v3';
describe('payments : amazon #subscribeCancel', () => {
let endpoint = '/amazon/subscribe/cancel';
let user;
beforeEach(async () => {
user = await generateUser();
});
it('verifies subscription', async () => {
await expect(user.get(endpoint)).to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('missingAuthParams'),
});
});
});

View File

@@ -0,0 +1,21 @@
import {
generateUser,
translate as t,
} from '../../../../helpers/api-integration/v3';
describe('payments : paypal #checkout', () => {
let endpoint = '/paypal/checkout';
let user;
beforeEach(async () => {
user = await generateUser();
});
it('verifies subscription', async () => {
await expect(user.get(endpoint)).to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('missingAuthParams'),
});
});
});

View File

@@ -0,0 +1,21 @@
import {
generateUser,
translate as t,
} from '../../../../helpers/api-integration/v3';
describe('payments : paypal #checkoutSuccess', () => {
let endpoint = '/paypal/checkout/success';
let user;
beforeEach(async () => {
user = await generateUser();
});
it('verifies subscription', async () => {
await expect(user.get(endpoint)).to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('invalidCredentials'),
});
});
});

View File

@@ -0,0 +1,21 @@
import {
generateUser,
translate as t,
} from '../../../../helpers/api-integration/v3';
describe('payments : paypal #subscribe', () => {
let endpoint = '/paypal/subscribe';
let user;
beforeEach(async () => {
user = await generateUser();
});
it('verifies credentials', async () => {
await expect(user.get(endpoint)).to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('missingAuthParams'),
});
});
});

View File

@@ -0,0 +1,21 @@
import {
generateUser,
translate as t,
} from '../../../../helpers/api-integration/v3';
describe('payments : paypal #subscribeCancel', () => {
let endpoint = '/paypal/subscribe/cancel';
let user;
beforeEach(async () => {
user = await generateUser();
});
it('verifies credentials', async () => {
await expect(user.get(endpoint)).to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('missingAuthParams'),
});
});
});

View File

@@ -0,0 +1,21 @@
import {
generateUser,
translate as t,
} from '../../../../helpers/api-integration/v3';
describe('payments : paypal #subscribeSuccess', () => {
let endpoint = '/paypal/subscribe/success';
let user;
beforeEach(async () => {
user = await generateUser();
});
it('verifies credentials', async () => {
await expect(user.get(endpoint)).to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('invalidCredentials'),
});
});
});

View File

@@ -0,0 +1,21 @@
import {
generateUser,
translate as t,
} from '../../../../helpers/api-integration/v3';
describe('payments - stripe - #subscribeCancel', () => {
let endpoint = '/stripe/subscribe/cancel';
let user;
beforeEach(async () => {
user = await generateUser();
});
it('verifies credentials', async () => {
await expect(user.get(endpoint)).to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('missingAuthParams'),
});
});
});

View File

@@ -0,0 +1,20 @@
import {
generateUser,
} from '../../../../helpers/api-integration/v3';
describe('payments - amazon - #checkout', () => {
let endpoint = '/amazon/checkout';
let user;
beforeEach(async () => {
user = await generateUser();
});
it('verifies credentials', async () => {
await expect(user.post(endpoint)).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: 'Missing req.body.orderReferenceId',
});
});
});

View File

@@ -0,0 +1,22 @@
import {
generateUser,
} from '../../../../helpers/api-integration/v3';
describe('payments - amazon - #createOrderReferenceId', () => {
let endpoint = '/amazon/createOrderReferenceId';
let user;
beforeEach(async () => {
user = await generateUser();
});
it('verifies billingAgreementId', async (done) => {
try {
await user.post(endpoint);
} catch (e) {
// Parameter AWSAccessKeyId cannot be empty.
expect(e.error).to.eql('BadRequest');
done();
}
});
});

View File

@@ -0,0 +1,21 @@
import {
generateUser,
translate as t,
} from '../../../../helpers/api-integration/v3';
describe('payments - amazon - #subscribe', () => {
let endpoint = '/amazon/subscribe';
let user;
beforeEach(async () => {
user = await generateUser();
});
it('verifies subscription code', async () => {
await expect(user.post(endpoint)).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('missingSubscriptionCode'),
});
});
});

View File

@@ -0,0 +1,20 @@
import {
generateUser,
} from '../../../../helpers/api-integration/v3';
describe('payments : amazon', () => {
let endpoint = '/amazon/verifyAccessToken';
let user;
beforeEach(async () => {
user = await generateUser();
});
it('verifies access token', async () => {
await expect(user.post(endpoint)).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: 'Missing req.body.access_token',
});
});
});

View File

@@ -0,0 +1,17 @@
import {
generateUser,
} from '../../../../helpers/api-integration/v3';
describe('payments - paypal - #ipn', () => {
let endpoint = '/paypal/ipn';
let user;
beforeEach(async () => {
user = await generateUser();
});
it('verifies credentials', async () => {
let result = await user.post(endpoint);
expect(result).to.eql('OK');
});
});

View File

@@ -0,0 +1,20 @@
import {
generateUser,
} from '../../../../helpers/api-integration/v3';
describe('payments - stripe - #checkout', () => {
let endpoint = '/stripe/checkout';
let user;
beforeEach(async () => {
user = await generateUser();
});
it('verifies credentials', async () => {
await expect(user.post(endpoint, {id: 123})).to.eventually.be.rejected.and.eql({
code: 401,
error: 'Error',
message: 'Invalid API Key provided: ****************************1111',
});
});
});

View File

@@ -0,0 +1,21 @@
import {
generateUser,
translate as t,
} from '../../../../helpers/api-integration/v3';
describe('payments - stripe - #subscribeEdit', () => {
let endpoint = '/stripe/subscribe/edit';
let user;
beforeEach(async () => {
user = await generateUser();
});
it('verifies credentials', async () => {
await expect(user.post(endpoint)).to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('missingSubscription'),
});
});
});

View File

@@ -0,0 +1,72 @@
import * as sender from '../../../../../website/src/libs/api-v3/email';
import * as api from '../../../../../website/src/libs/api-v3/payments';
import { model as User } from '../../../../../website/src/models/user';
import moment from 'moment';
describe('payments/index', () => {
let fakeSend;
let data;
let user;
describe('#createSubscription', () => {
beforeEach(async () => {
user = new User();
});
it('succeeds', async () => {
data = { user, sub: { key: 'basic_3mo' } };
expect(user.purchased.plan.planId).to.not.exist;
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.exist;
});
});
describe('#cancelSubscription', () => {
beforeEach(() => {
fakeSend = sinon.spy(sender, 'sendTxn');
data = { user: new User() };
});
afterEach(() => {
fakeSend.restore();
});
it('plan.extraMonths is defined', () => {
api.cancelSubscription(data);
let terminated = data.user.purchased.plan.dateTerminated;
data.user.purchased.plan.extraMonths = 2;
api.cancelSubscription(data);
let difference = Math.abs(moment(terminated).diff(data.user.purchased.plan.dateTerminated, 'days'));
expect(difference - 60).to.be.lessThan(3); // the difference is approximately two months, +/- 2 days
});
it('plan.extraMonth is a fraction', () => {
api.cancelSubscription(data);
let terminated = data.user.purchased.plan.dateTerminated;
data.user.purchased.plan.extraMonths = 0.3;
api.cancelSubscription(data);
let difference = Math.abs(moment(terminated).diff(data.user.purchased.plan.dateTerminated, 'days'));
expect(difference - 10).to.be.lessThan(3); // the difference should be 10 days.
});
it('nextBill is defined', () => {
api.cancelSubscription(data);
let terminated = data.user.purchased.plan.dateTerminated;
data.nextBill = moment().add({ days: 25 });
api.cancelSubscription(data);
let difference = Math.abs(moment(terminated).diff(data.user.purchased.plan.dateTerminated, 'days'));
expect(difference - 5).to.be.lessThan(2); // the difference should be 5 days, +/- 1 day
});
it('saves the canceled subscription for the user', () => {
expect(data.user.purchased.plan.dateTerminated).to.not.exist;
api.cancelSubscription(data);
expect(data.user.purchased.plan.dateTerminated).to.exist;
});
it('sends a text', async () => {
await api.cancelSubscription(data);
sinon.assert.called(fakeSend);
});
});
});

View File

@@ -33,7 +33,7 @@ function _requestMaker (user, method, additionalSets = {}) {
let url = `http://localhost:${API_TEST_SERVER_PORT}`;
// do not prefix with api/apiVersion requests to top level routes like dataexport and payments
if (route.indexOf('/export') === 0 || route.indexOf('/payments') === 0) {
if (route.indexOf('/export') === 0 || route.indexOf('/paypal') === 0 || route.indexOf('/amazon') === 0 || route.indexOf('/stripe') === 0) {
url += `${route}`;
} else {
url += `/api/${apiVersion}${route}`;

View File

@@ -37,7 +37,7 @@ function($rootScope, User, $http, Content) {
$http.post(url, res).success(function() {
window.location.reload(true);
}).error(function(res) {
alert(res.err);
alert(res.message);
});
}
});
@@ -55,7 +55,7 @@ function($rootScope, User, $http, Content) {
$http.post(url, data).success(function() {
window.location.reload(true);
}).error(function(data) {
alert(data.err);
alert(data.message);
});
}
});
@@ -127,12 +127,12 @@ function($rootScope, User, $http, Content) {
var url = '/amazon/createOrderReferenceId'
$http.post(url, {
billingAgreementId: Payments.amazonPayments.billingAgreementId
}).success(function(data){
}).success(function(res){
Payments.amazonPayments.loggedIn = true;
Payments.amazonPayments.orderReferenceId = data.orderReferenceId;
Payments.amazonPayments.orderReferenceId = res.data.orderReferenceId;
Payments.amazonPayments.initWidgets();
}).error(function(res){
alert(res.err);
alert(res.message);
});
}
},
@@ -146,7 +146,7 @@ function($rootScope, User, $http, Content) {
var url = '/amazon/verifyAccessToken'
$http.post(url, response).error(function(res){
alert(res.err);
alert(res.message);
});
});
},
@@ -232,7 +232,7 @@ function($rootScope, User, $http, Content) {
Payments.amazonPayments.reset();
window.location.reload(true);
}).error(function(res){
alert(res.err);
alert(res.message);
Payments.amazonPayments.reset();
});
}else if(Payments.amazonPayments.type === 'subscription'){
@@ -246,7 +246,7 @@ function($rootScope, User, $http, Content) {
Payments.amazonPayments.reset();
window.location.reload(true);
}).error(function(res){
alert(res.err);
alert(res.message);
Payments.amazonPayments.reset();
});
}

View File

@@ -289,8 +289,6 @@ api.update = function(req, res, next){
});
}
import { _closeChal } from '../api-v3/challenges';
/**
* Delete & close
*/
@@ -304,7 +302,7 @@ api.delete = async function(req, res, next){
if (!challenge.canModify(user)) return next(shared.i18n.t('noPermissionCloseChallenge'));
// Close channel in background, some ops are run in the background without `await`ing
await _closeChal(challenge, {broken: 'CHALLENGE_DELETED'});
await challenge.closeChal({broken: 'CHALLENGE_DELETED'});
res.sendStatus(200);
} catch (err) {
next(err);
@@ -326,7 +324,7 @@ api.selectWinner = async function(req, res, next) {
if (!winner || winner.challenges.indexOf(challenge._id) === -1) return next('Winner ' + req.query.uid + ' not found.');
// Close channel in background, some ops are run in the background without `await`ing
await _closeChal(challenge, {broken: 'CHALLENGE_CLOSED', winner});
await challenge.closeChal({broken: 'CHALLENGE_CLOSED', winner});
res.respond(200, {});
} catch (err) {
next(err);

View File

@@ -14,10 +14,7 @@ import {
NotFound,
NotAuthorized,
} from '../../libs/api-v3/errors';
import shared from '../../../../common';
import * as Tasks from '../../models/task';
import { sendTxn as txnEmail } from '../../libs/api-v3/email';
import sendPushNotification from '../../libs/api-v3/pushNotifications';
import Q from 'q';
import csvStringify from '../../libs/api-v3/csvStringify';
@@ -449,64 +446,6 @@ api.updateChallenge = {
},
};
// TODO everything here should be moved to a worker
// actually even for a worker it's probably just too big and will kill mongo
// Exported because it's used in v2 controller
export async function _closeChal (challenge, broken = {}) {
let winner = broken.winner;
let brokenReason = broken.broken;
// Delete the challenge
await Challenge.remove({_id: challenge._id}).exec();
// Refund the leader if the challenge is closed and the group not the tavern
if (challenge.group !== TAVERN_ID && brokenReason === 'CHALLENGE_DELETED') {
await User.update({_id: challenge.leader}, {$inc: {balance: challenge.prize / 4}}).exec();
}
// Update the challengeCount on the group
await Group.update({_id: challenge.group}, {$inc: {challengeCount: -1}}).exec();
// Award prize to winner and notify
if (winner) {
winner.achievements.challenges.push(challenge.name);
winner.balance += challenge.prize / 4;
let savedWinner = await winner.save();
if (savedWinner.preferences.emailNotifications.wonChallenge !== false) {
txnEmail(savedWinner, 'won-challenge', [
{name: 'CHALLENGE_NAME', content: challenge.name},
]);
}
sendPushNotification(savedWinner, shared.i18n.t('wonChallenge'), challenge.name);
}
// Run some operations in the background withouth blocking the thread
let backgroundTasks = [
// And it's tasks
Tasks.Task.remove({'challenge.id': challenge._id, userId: {$exists: false}}).exec(),
// Set the challenge tag to non-challenge status and remove the challenge from the user's challenges
User.update({
challenges: challenge._id,
'tags._id': challenge._id,
}, {
$set: {'tags.$.challenge': false},
$pull: {challenges: challenge._id},
}, {multi: true}).exec(),
// Break users' tasks
Tasks.Task.update({
'challenge.id': challenge._id,
}, {
$set: {
'challenge.broken': brokenReason,
'challenge.winner': winner && winner.profile.name,
},
}, {multi: true}).exec(),
];
Q.all(backgroundTasks);
}
/**
* @api {delete} /api/v3/challenges/:challengeId Delete a challenge
* @apiVersion 3.0.0
@@ -534,7 +473,7 @@ api.deleteChallenge = {
if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyLeaderDeleteChal'));
// Close channel in background, some ops are run in the background without `await`ing
await _closeChal(challenge, {broken: 'CHALLENGE_DELETED'});
await challenge.closeChal({broken: 'CHALLENGE_DELETED'});
res.respond(200, {});
},
};
@@ -571,7 +510,7 @@ api.selectChallengeWinner = {
if (!winner || winner.challenges.indexOf(challenge._id) === -1) throw new NotFound(res.t('winnerNotFound', {userId: req.params.winnerId}));
// Close channel in background, some ops are run in the background without `await`ing
await _closeChal(challenge, {broken: 'CHALLENGE_CLOSED', winner});
await challenge.closeChal({broken: 'CHALLENGE_CLOSED', winner});
res.respond(200, {});
},
};

View File

@@ -1,271 +0,0 @@
var amazonPayments = require('amazon-payments');
var mongoose = require('mongoose');
var moment = require('moment');
var nconf = require('nconf');
var async = require('async');
var User = require('mongoose').model('User');
var shared = require('../../../../common');
var payments = require('./index');
var cc = require('coupon-code');
var isProd = nconf.get('NODE_ENV') === 'production';
var amzPayment = amazonPayments.connect({
environment: amazonPayments.Environment[isProd ? 'Production' : 'Sandbox'],
sellerId: nconf.get('AMAZON_PAYMENTS:SELLER_ID'),
mwsAccessKey: nconf.get('AMAZON_PAYMENTS:MWS_KEY'),
mwsSecretKey: nconf.get('AMAZON_PAYMENTS:MWS_SECRET'),
clientId: nconf.get('AMAZON_PAYMENTS:CLIENT_ID')
});
exports.verifyAccessToken = function(req, res, next){
if(!req.body || !req.body['access_token']){
return res.status(400).json({err: 'Access token not supplied.'});
}
amzPayment.api.getTokenInfo(req.body['access_token'], function(err, tokenInfo){
if(err) return res.status(400).json({err:err});
res.sendStatus(200);
});
};
exports.createOrderReferenceId = function(req, res, next){
if(!req.body || !req.body.billingAgreementId){
return res.status(400).json({err: 'Billing Agreement Id not supplied.'});
}
amzPayment.offAmazonPayments.createOrderReferenceForId({
Id: req.body.billingAgreementId,
IdType: 'BillingAgreement',
ConfirmNow: false
}, function(err, response){
if(err) return next(err);
if(!response.OrderReferenceDetails || !response.OrderReferenceDetails.AmazonOrderReferenceId){
return next(new Error('Missing attributes in Amazon response.'));
}
res.json({
orderReferenceId: response.OrderReferenceDetails.AmazonOrderReferenceId
});
});
};
exports.checkout = function(req, res, next){
if(!req.body || !req.body.orderReferenceId){
return res.status(400).json({err: 'Billing Agreement Id not supplied.'});
}
var gift = req.body.gift;
var user = res.locals.user;
var orderReferenceId = req.body.orderReferenceId;
var amount = 5;
if(gift){
if(gift.type === 'gems'){
amount = gift.gems.amount/4;
}else if(gift.type === 'subscription'){
amount = shared.content.subscriptionBlocks[gift.subscription.key].price;
}
}
async.series({
setOrderReferenceDetails: function(cb){
amzPayment.offAmazonPayments.setOrderReferenceDetails({
AmazonOrderReferenceId: orderReferenceId,
OrderReferenceAttributes: {
OrderTotal: {
CurrencyCode: 'USD',
Amount: amount
},
SellerNote: 'HabitRPG Payment',
SellerOrderAttributes: {
SellerOrderId: shared.uuid(),
StoreName: 'HabitRPG'
}
}
}, cb);
},
confirmOrderReference: function(cb){
amzPayment.offAmazonPayments.confirmOrderReference({
AmazonOrderReferenceId: orderReferenceId
}, cb);
},
authorize: function(cb){
amzPayment.offAmazonPayments.authorize({
AmazonOrderReferenceId: orderReferenceId,
AuthorizationReferenceId: shared.uuid().substring(0, 32),
AuthorizationAmount: {
CurrencyCode: 'USD',
Amount: amount
},
SellerAuthorizationNote: 'HabitRPG Payment',
TransactionTimeout: 0,
CaptureNow: true
}, function(err, res){
if(err) return cb(err);
if(res.AuthorizationDetails.AuthorizationStatus.State === 'Declined'){
return cb(new Error('The payment was not successfull.'));
}
return cb();
});
},
closeOrderReference: function(cb){
amzPayment.offAmazonPayments.closeOrderReference({
AmazonOrderReferenceId: orderReferenceId
}, cb);
},
executePayment: function(cb){
async.waterfall([
function(cb2){ User.findById(gift ? gift.uuid : undefined, cb2); },
function(member, cb2){
var data = {user:user, paymentMethod:'Amazon Payments'};
var method = 'buyGems';
if (gift){
if (gift.type == 'subscription') method = 'createSubscription';
gift.member = member;
data.gift = gift;
data.paymentMethod = 'Gift';
}
payments[method](data, cb2);
}
], cb);
}
}, function(err, results){
if(err) return next(err);
res.sendStatus(200);
});
};
exports.subscribe = function(req, res, next){
if(!req.body || !req.body['billingAgreementId']){
return res.status(400).json({err: 'Billing Agreement Id not supplied.'});
}
var billingAgreementId = req.body.billingAgreementId;
var sub = req.body.subscription ? shared.content.subscriptionBlocks[req.body.subscription] : false;
var coupon = req.body.coupon;
var user = res.locals.user;
if(!sub){
return res.status(400).json({err: 'Subscription plan not found.'});
}
async.series({
applyDiscount: function(cb){
if (!sub.discount) return cb();
if (!coupon) return cb(new Error('Please provide a coupon code for this plan.'));
mongoose.model('Coupon').findOne({_id:cc.validate(coupon), event:sub.key}, function(err, coupon){
if(err) return cb(err);
if(!coupon) return cb(new Error('Coupon code not found.'));
cb();
});
},
setBillingAgreementDetails: function(cb){
amzPayment.offAmazonPayments.setBillingAgreementDetails({
AmazonBillingAgreementId: billingAgreementId,
BillingAgreementAttributes: {
SellerNote: 'HabitRPG Subscription',
SellerBillingAgreementAttributes: {
SellerBillingAgreementId: shared.uuid(),
StoreName: 'HabitRPG',
CustomInformation: 'HabitRPG Subscription'
}
}
}, cb);
},
confirmBillingAgreement: function(cb){
amzPayment.offAmazonPayments.confirmBillingAgreement({
AmazonBillingAgreementId: billingAgreementId
}, cb);
},
authorizeOnBillingAgreeement: function(cb){
amzPayment.offAmazonPayments.authorizeOnBillingAgreement({
AmazonBillingAgreementId: billingAgreementId,
AuthorizationReferenceId: shared.uuid().substring(0, 32),
AuthorizationAmount: {
CurrencyCode: 'USD',
Amount: sub.price
},
SellerAuthorizationNote: 'HabitRPG Subscription Payment',
TransactionTimeout: 0,
CaptureNow: true,
SellerNote: 'HabitRPG Subscription Payment',
SellerOrderAttributes: {
SellerOrderId: shared.uuid(),
StoreName: 'HabitRPG'
}
}, function(err, res){
if(err) return cb(err);
if(res.AuthorizationDetails.AuthorizationStatus.State === 'Declined'){
return cb(new Error('The payment was not successfull.'));
}
return cb();
});
},
createSubscription: function(cb){
payments.createSubscription({
user: user,
customerId: billingAgreementId,
paymentMethod: 'Amazon Payments',
sub: sub
}, cb);
}
}, function(err, results){
if(err) return next(err);
res.sendStatus(200);
});
};
exports.subscribeCancel = function(req, res, next){
var user = res.locals.user;
if (!user.purchased.plan.customerId)
return res.status(401).json({err: 'User does not have a plan subscription'});
var billingAgreementId = user.purchased.plan.customerId;
async.series({
closeBillingAgreement: function(cb){
amzPayment.offAmazonPayments.closeBillingAgreement({
AmazonBillingAgreementId: billingAgreementId
}, cb);
},
cancelSubscription: function(cb){
var data = {
user: user,
// Date of next bill
nextBill: moment(user.purchased.plan.lastBillingDate).add({days: 30}),
paymentMethod: 'Amazon Payments'
};
payments.cancelSubscription(data, cb);
}
}, function(err, results){
if (err) return next(err); // don't json this, let toString() handle errors
if(req.query.noRedirect){
res.sendStatus(200);
}else{
res.redirect('/');
}
user = null;
});
};

View File

@@ -1,155 +0,0 @@
var iap = require('in-app-purchase');
var async = require('async');
var payments = require('./index');
var nconf = require('nconf');
var inAppPurchase = require('in-app-purchase');
inAppPurchase.config({
// this is the path to the directory containing iap-sanbox/iap-live files
googlePublicKeyPath: nconf.get('IAP_GOOGLE_KEYDIR')
});
// Validation ERROR Codes
var INVALID_PAYLOAD = 6778001;
var CONNECTION_FAILED = 6778002;
var PURCHASE_EXPIRED = 6778003;
exports.androidVerify = function(req, res, next) {
var iapBody = req.body;
var user = res.locals.user;
iap.setup(function (error) {
if (error) {
var resObj = {
ok: false,
data: 'IAP Error'
};
return res.json(resObj);
}
/*
google receipt must be provided as an object
{
"data": "{stringified data object}",
"signature": "signature from google"
}
*/
var testObj = {
data: iapBody.transaction.receipt,
signature: iapBody.transaction.signature
};
// iap is ready
iap.validate(iap.GOOGLE, testObj, function (err, googleRes) {
if (err) {
var resObj = {
ok: false,
data: {
code: INVALID_PAYLOAD,
message: err.toString()
}
};
return res.json(resObj);
}
if (iap.isValidated(googleRes)) {
var resObj = {
ok: true,
data: googleRes
};
payments.buyGems({user:user, paymentMethod:'IAP GooglePlay', amount: 5.25});
return res.json(resObj);
}
});
});
};
exports.iosVerify = function(req, res, next) {
var iapBody = req.body;
var user = res.locals.user;
iap.setup(function (error) {
if (error) {
var resObj = {
ok: false,
data: 'IAP Error'
};
return res.json(resObj);
}
//iap is ready
iap.validate(iap.APPLE, iapBody.transaction.receipt, function (err, appleRes) {
if (err) {
var resObj = {
ok: false,
data: {
code: INVALID_PAYLOAD,
message: err.toString()
}
};
return res.json(resObj);
}
if (iap.isValidated(appleRes)) {
var purchaseDataList = iap.getPurchaseData(appleRes);
if (purchaseDataList.length > 0) {
var correctReceipt = true;
for (var index in purchaseDataList) {
switch (purchaseDataList[index].productId) {
case 'com.habitrpg.ios.Habitica.4gems':
payments.buyGems({user:user, paymentMethod:'IAP AppleStore', amount: 1});
break;
case 'com.habitrpg.ios.Habitica.8gems':
payments.buyGems({user:user, paymentMethod:'IAP AppleStore', amount: 2});
break;
case 'com.habitrpg.ios.Habitica.20gems':
case 'com.habitrpg.ios.Habitica.21gems':
payments.buyGems({user:user, paymentMethod:'IAP AppleStore', amount: 5.25});
break;
case 'com.habitrpg.ios.Habitica.42gems':
payments.buyGems({user:user, paymentMethod:'IAP AppleStore', amount: 10.5});
break;
default:
correctReceipt = false;
}
}
if (correctReceipt) {
var resObj = {
ok: true,
data: appleRes
};
// yay good!
return res.json(resObj);
}
}
//wrong receipt content
var resObj = {
ok: false,
data: {
code: INVALID_PAYLOAD,
message: 'Incorrect receipt content'
}
};
return res.json(resObj);
}
//invalid receipt
var resObj = {
ok: false,
data: {
code: INVALID_PAYLOAD,
message: 'Invalid receipt'
}
};
return res.json(resObj);
});
});
};

View File

@@ -1,207 +0,0 @@
var _ = require('lodash');
var shared = require('../../../../common');
var nconf = require('nconf');
var utils = require('./../../libs/api-v2/utils');
var moment = require('moment');
var isProduction = nconf.get("NODE_ENV") === "production";
var stripe = require('./stripe');
var paypal = require('./paypal');
var amazon = require('./amazon');
var members = require('../api-v2/members')
var async = require('async');
var iap = require('./iap');
var mongoose= require('mongoose');
var cc = require('coupon-code');
var pushNotify = require('./../api-v2/pushNotifications');
function revealMysteryItems(user) {
_.each(shared.content.gear.flat, function(item) {
if (
item.klass === 'mystery' &&
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)
) {
user.purchased.plan.mysteryItems.push(item.key);
}
});
}
exports.createSubscription = function(data, cb) {
var recipient = data.gift ? data.gift.member : data.user;
//if (!recipient.purchased.plan) recipient.purchased.plan = {}; // TODO double-check, this should never be the case
var p = recipient.purchased.plan;
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
p.extraMonths += months;
} else {
p.dateTerminated = moment(p.dateTerminated).add({months: months}).toDate();
if (!p.dateUpdated) p.dateUpdated = new Date();
}
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,
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,
// Specify a lastBillingDate just for Amazon Payments
// Resetted every time the subscription restarts
lastBillingDate: data.paymentMethod === 'Amazon Payments' ? new Date() : undefined
}).defaults({ // allow non-override if a plan was previously used
dateCreated: new Date(),
mysteryItems: []
}).value();
}
// 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) {
if (!data.gift) utils.txnEmail(data.user, 'subscription-begins');
var analyticsData = {
uuid: data.user._id,
itemPurchased: 'Subscription',
sku: data.paymentMethod.toLowerCase() + '-subscription',
purchaseType: 'subscribe',
paymentMethod: data.paymentMethod,
quantity: 1,
gift: !!data.gift, // coerced into a boolean
purchaseValue: block.price
}
utils.analytics.trackPurchase(analyticsData);
}
data.user.purchased.txnCount++;
if (data.gift){
members.sendMessage(data.user, data.gift.member, data.gift);
var byUserName = utils.getUserInfo(data.user, ['name']).name;
if(data.gift.member.preferences.emailNotifications.giftedSubscription !== false){
utils.txnEmail(data.gift.member, 'gifted-subscription', [
{name: 'GIFTER', content: byUserName},
{name: 'X_MONTHS_SUBSCRIPTION', content: months}
]);
}
if (data.gift.member._id != data.user._id) { // Only send push notifications if sending to a user other than yourself
pushNotify.sendNotify(data.gift.member, shared.i18n.t('giftedSubscription'), months + " months - by "+ byUserName);
}
}
async.parallel([
function(cb2){data.user.save(cb2)},
function(cb2){data.gift ? data.gift.member.save(cb2) : cb2(null);}
], cb);
}
/**
* Sets their subscription to be cancelled later
*/
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({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. TODO: 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
data.user.save(cb);
utils.txnEmail(data.user, 'cancel-subscription');
var analyticsData = {
uuid: data.user._id,
gaCategory: 'commerce',
gaLabel: data.paymentMethod,
paymentMethod: data.paymentMethod
}
utils.analytics.track('unsubscribe', analyticsData);
}
exports.buyGems = function(data, cb) {
var amt = data.amount || 5;
amt = data.gift ? data.gift.gems.amount/4 : amt;
(data.gift ? data.gift.member : data.user).balance += amt;
data.user.purchased.txnCount++;
if(isProduction) {
if (!data.gift) utils.txnEmail(data.user, 'donation');
var analyticsData = {
uuid: data.user._id,
itemPurchased: 'Gems',
sku: data.paymentMethod.toLowerCase() + '-checkout',
purchaseType: 'checkout',
paymentMethod: data.paymentMethod,
quantity: 1,
gift: !!data.gift, // coerced into a boolean
purchaseValue: amt
}
utils.analytics.trackPurchase(analyticsData);
}
if (data.gift){
var byUsername = utils.getUserInfo(data.user, ['name']).name;
var gemAmount = data.gift.gems.amount || 20;
members.sendMessage(data.user, data.gift.member, data.gift);
if(data.gift.member.preferences.emailNotifications.giftedGems !== false){
utils.txnEmail(data.gift.member, 'gifted-gems', [
{name: 'GIFTER', content: byUsername},
{name: 'X_GEMS_GIFTED', content: gemAmount}
]);
}
if (data.gift.member._id != data.user._id) { // Only send push notifications if sending to a user other than yourself
pushNotify.sendNotify(data.gift.member, shared.i18n.t('giftedGems'), gemAmount + ' Gems - by '+byUsername);
}
}
async.parallel([
function(cb2){data.user.save(cb2)},
function(cb2){data.gift ? data.gift.member.save(cb2) : cb2(null);}
], 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.status(401).json({err:"Invalid coupon code"});
return res.sendStatus(200);
});
}
exports.stripeCheckout = stripe.checkout;
exports.stripeSubscribeCancel = stripe.subscribeCancel;
exports.stripeSubscribeEdit = stripe.subscribeEdit;
exports.paypalSubscribe = paypal.createBillingAgreement;
exports.paypalSubscribeSuccess = paypal.executeBillingAgreement;
exports.paypalSubscribeCancel = paypal.cancelSubscription;
exports.paypalCheckout = paypal.createPayment;
exports.paypalCheckoutSuccess = paypal.executePayment;
exports.paypalIPN = paypal.ipn;
exports.amazonVerifyAccessToken = amazon.verifyAccessToken;
exports.amazonCreateOrderReferenceId = amazon.createOrderReferenceId;
exports.amazonCheckout = amazon.checkout;
exports.amazonSubscribe = amazon.subscribe;
exports.amazonSubscribeCancel = amazon.subscribeCancel;
exports.iapAndroidVerify = iap.androidVerify;
exports.iapIosVerify = iap.iosVerify;

View File

@@ -1,216 +0,0 @@
var nconf = require('nconf');
var moment = require('moment');
var async = require('async');
var _ = require('lodash');
var url = require('url');
var User = require('mongoose').model('User');
var payments = require('./index');
var logger = require('../../libs/api-v2/logging');
var ipn = require('paypal-ipn');
var paypal = require('paypal-rest-sdk');
var shared = require('../../../../common');
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.key);
});
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(res, err){
//var error = err.response ? err.response.message || err.response.details[0].issue : err;
var error = JSON.stringify(err);
return res.status(400).json({err:error});
}
exports.createBillingAgreement = function(req,res,next){
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);
},
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);
}
], 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);
});
}
exports.executeBillingAgreement = function(req,res,next){
var block = shared.content.subscriptionBlocks[req.session.paypalBlock];
delete req.session.paypalBlock;
async.auto({
exec: function (cb) {
paypal.billingAgreement.execute(req.query.token, {}, cb);
},
get_user: function (cb) {
User.findById(req.session.userId, cb);
},
create_sub: ['exec', 'get_user', function (cb, results) {
payments.createSubscription({
user: results.get_user,
customerId: results.exec.id,
paymentMethod: 'Paypal',
sub: block
}, cb);
}]
},function(err){
if (err) return parseErr(res, err);
res.redirect('/');
})
}
exports.createPayment = function(req, res) {
// if we're gifting to a user, put it in session for the `execute()`
req.session.gift = req.query.gift || undefined;
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.key].price).toFixed(2);
var description = !gift ? "HabitRPG Gems"
: gift.type=='gems' ? "HabitRPG Gems (Gift)"
: shared.content.subscriptionBlocks[gift.subscription.key].months + "mo. HabitRPG Subscription (Gift)";
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": description,
//"sku": "1",
"price": price,
"currency": "USD",
"quantity": 1
}]
},
"amount": {
"currency": "USD",
"total": price
},
"description": description
}]
};
paypal.payment.create(create_payment, function (err, payment) {
if (err) return parseErr(res, err);
var link = _.find(payment.links, {rel: 'approval_url'}).href;
res.redirect(link);
});
}
exports.executePayment = function(req, res) {
var paymentId = req.query.paymentId,
PayerID = req.query.PayerID,
gift = req.session.gift ? JSON.parse(req.session.gift) : undefined;
delete req.session.gift;
async.waterfall([
function(cb){
paypal.payment.execute(paymentId, {payer_id: PayerID}, cb);
},
function(payment, cb){
async.parallel([
function(cb2){ User.findById(req.session.userId, cb2); },
function(cb2){ User.findById(gift ? gift.uuid : undefined, cb2); }
], cb);
},
function(results, 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){
if (err) return parseErr(res, err);
res.redirect('/');
})
}
exports.cancelSubscription = function(req, res, next){
var user = res.locals.user;
if (!user.purchased.plan.customerId)
return res.status(401).json({err: "User does not have a plan subscription"});
async.auto({
get_cus: function(cb){
paypal.billingAgreement.get(user.purchased.plan.customerId, cb);
},
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;
});
}
/**
* 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.sendStatus(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':
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, paymentMethod: 'Paypal'});
});
break;
}
});
};

View File

@@ -1,123 +0,0 @@
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');
var shared = require('../../../../common');
var mongoose = require('mongoose');
var cc = require('coupon-code');
/*
Setup Stripe response when posting payment
*/
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 (sub) {
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.key].price*100
: ''+gift.gems.amount/4*100,
currency: 'usd',
card: token
}, cb);
}
},
function(response, 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){
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';
}
payments[method](data, cb2);
}
], cb);
}
], function(err){
if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors
res.sendStatus(200);
user = token = null;
});
};
exports.subscribeCancel = function(req, res, next) {
var user = res.locals.user;
if (!user.purchased.plan.customerId)
return res.status(401).json({err: 'User does not have a plan subscription'});
async.auto({
get_cus: function(cb){
stripe.customers.retrieve(user.purchased.plan.customerId, cb);
},
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;
});
};
exports.subscribeEdit = function(req, res, next) {
var token = req.body.id;
var user = res.locals.user;
var user_id = user.purchased.plan.customerId;
var sub_id;
async.waterfall([
function(cb){
stripe.customers.listSubscriptions(user_id, cb);
},
function(response, cb) {
sub_id = response.data[0].id;
console.warn(sub_id);
console.warn([user_id, sub_id, { card: token }]);
stripe.customers.updateSubscription(user_id, sub_id, { card: token }, cb);
},
function(response, cb) {
user.save(cb);
}
], function(err, saved){
if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors
res.sendStatus(200);
token = user = user_id = sub_id;
});
};

View File

@@ -0,0 +1,256 @@
import {
BadRequest,
NotAuthorized,
} from '../../../libs/api-v3/errors';
import amzLib from '../../../libs/api-v3/amazonPayments';
import {
authWithHeaders,
authWithUrl,
} from '../../../middlewares/api-v3/auth';
import shared from '../../../../../common';
import payments from '../../../libs/api-v3/payments';
import moment from 'moment';
import { model as Coupon } from '../../../models/coupon';
import { model as User } from '../../../models/user';
import cc from 'coupon-code';
let api = {};
/**
* @apiIgnore Payments are considered part of the private API
* @api {post} /amazon/verifyAccessToken Amazon Payments: verify access token
* @apiVersion 3.0.0
* @apiName AmazonVerifyAccessToken
* @apiGroup Payments
*
* @apiSuccess {Object} data Empty object
**/
api.verifyAccessToken = {
method: 'POST',
url: '/amazon/verifyAccessToken',
middlewares: [authWithHeaders()],
async handler (req, res) {
let accessToken = req.body.access_token;
if (!accessToken) throw new BadRequest('Missing req.body.access_token');
await amzLib.getTokenInfo(accessToken);
res.respond(200, {});
},
};
/**
* @apiIgnore Payments are considered part of the private API
* @api {post} /amazon/createOrderReferenceId Amazon Payments: create order reference id
* @apiVersion 3.0.0
* @apiName AmazonCreateOrderReferenceId
* @apiGroup Payments
*
* @apiSuccess {string} data.orderReferenceId The order reference id.
**/
api.createOrderReferenceId = {
method: 'POST',
url: '/amazon/createOrderReferenceId',
middlewares: [authWithHeaders()],
async handler (req, res) {
let billingAgreementId = req.body.billingAgreementId;
if (!billingAgreementId) throw new BadRequest('Missing req.body.billingAgreementId');
let response = await amzLib.createOrderReferenceId({
Id: billingAgreementId,
IdType: 'BillingAgreement',
ConfirmNow: false,
});
res.respond(200, {
orderReferenceId: response.OrderReferenceDetails.AmazonOrderReferenceId,
});
},
};
/**
* @apiIgnore Payments are considered part of the private API
* @api {post} /amazon/checkout Amazon Payments: checkout
* @apiVersion 3.0.0
* @apiName AmazonCheckout
* @apiGroup Payments
*
* @apiSuccess {object} data Empty object
**/
api.checkout = {
method: 'POST',
url: '/amazon/checkout',
middlewares: [authWithHeaders()],
async handler (req, res) {
let gift = req.body.gift;
let user = res.locals.user;
let orderReferenceId = req.body.orderReferenceId;
let amount = 5;
if (!orderReferenceId) throw new BadRequest('Missing req.body.orderReferenceId');
if (gift) {
if (gift.type === 'gems') {
amount = gift.gems.amount / 4;
} else if (gift.type === 'subscription') {
amount = shared.content.subscriptionBlocks[gift.subscription.key].price;
}
}
await amzLib.setOrderReferenceDetails({
AmazonOrderReferenceId: orderReferenceId,
OrderReferenceAttributes: {
OrderTotal: {
CurrencyCode: 'USD',
Amount: amount,
},
SellerNote: 'HabitRPG Payment',
SellerOrderAttributes: {
SellerOrderId: shared.uuid(),
StoreName: 'HabitRPG',
},
},
});
await amzLib.confirmOrderReference({ AmazonOrderReferenceId: orderReferenceId });
await amzLib.authorize({
AmazonOrderReferenceId: orderReferenceId,
AuthorizationReferenceId: shared.uuid().substring(0, 32),
AuthorizationAmount: {
CurrencyCode: 'USD',
Amount: amount,
},
SellerAuthorizationNote: 'HabitRPG Payment',
TransactionTimeout: 0,
CaptureNow: true,
});
await amzLib.closeOrderReference({ AmazonOrderReferenceId: orderReferenceId });
// execute payment
let method = 'buyGems';
let data = { user, paymentMethod: 'Amazon Payments' };
if (gift) {
if (gift.type === 'subscription') method = 'createSubscription';
gift.member = await User.findById(gift ? gift.uuid : undefined);
data.gift = gift;
data.paymentMethod = 'Gift';
}
await payments[method](data);
res.respond(200);
},
};
/**
* @apiIgnore Payments are considered part of the private API
* @api {post} /amazon/subscribe Amazon Payments: subscribe
* @apiVersion 3.0.0
* @apiName AmazonSubscribe
* @apiGroup Payments
*
* @apiSuccess {object} data Empty object
**/
api.subscribe = {
method: 'POST',
url: '/amazon/subscribe',
middlewares: [authWithHeaders()],
async handler (req, res) {
let billingAgreementId = req.body.billingAgreementId;
let sub = req.body.subscription ? shared.content.subscriptionBlocks[req.body.subscription] : false;
let coupon = req.body.coupon;
let user = res.locals.user;
if (!sub) throw new BadRequest(res.t('missingSubscriptionCode'));
if (!billingAgreementId) throw new BadRequest('Missing req.body.billingAgreementId');
if (sub.discount) { // apply discount
if (!coupon) throw new BadRequest(res.t('couponCodeRequired'));
let result = await Coupon.findOne({_id: cc.validate(coupon), event: sub.key});
if (!result) throw new NotAuthorized(res.t('invalidCoupon'));
}
await amzLib.setBillingAgreementDetails({
AmazonBillingAgreementId: billingAgreementId,
BillingAgreementAttributes: {
SellerNote: 'HabitRPG Subscription',
SellerBillingAgreementAttributes: {
SellerBillingAgreementId: shared.uuid(),
StoreName: 'HabitRPG',
CustomInformation: 'HabitRPG Subscription',
},
},
});
await amzLib.confirmBillingAgreement({
AmazonBillingAgreementId: billingAgreementId,
});
await amzLib.authorizeOnBillingAgreement({
AmazonBillingAgreementId: billingAgreementId,
AuthorizationReferenceId: shared.uuid().substring(0, 32),
AuthorizationAmount: {
CurrencyCode: 'USD',
Amount: sub.price,
},
SellerAuthorizationNote: 'HabitRPG Subscription Payment',
TransactionTimeout: 0,
CaptureNow: true,
SellerNote: 'HabitRPG Subscription Payment',
SellerOrderAttributes: {
SellerOrderId: shared.uuid(),
StoreName: 'HabitRPG',
},
});
await payments.createSubscription({
user,
customerId: billingAgreementId,
paymentMethod: 'Amazon Payments',
sub,
});
res.respond(200);
},
};
/**
* @apiIgnore Payments are considered part of the private API
* @api {get} /amazon/subscribe/cancel Amazon Payments: subscribe cancel
* @apiVersion 3.0.0
* @apiName AmazonSubscribe
* @apiGroup Payments
**/
api.subscribeCancel = {
method: 'GET',
url: '/amazon/subscribe/cancel',
middlewares: [authWithUrl],
async handler (req, res) {
let user = res.locals.user;
let billingAgreementId = user.purchased.plan.customerId;
if (!billingAgreementId) throw new NotAuthorized(res.t('missingSubscription'));
await amzLib.closeBillingAgreement({
AmazonBillingAgreementId: billingAgreementId,
});
await payments.cancelSubscription({
user,
nextBill: moment(user.purchased.plan.lastBillingDate).add({ days: 30 }),
paymentMethod: 'Amazon Payments',
});
if (req.query.noRedirect) {
res.respond(200);
} else {
res.redirect('/');
}
},
};
module.exports = api;

View File

@@ -0,0 +1,191 @@
import iap from 'in-app-purchase';
import nconf from 'nconf';
import {
authWithHeaders,
authWithUrl,
} from '../../../middlewares/api-v3/auth';
import payments from '../../../libs/api-v3/payments';
// NOT PORTED TO v3
iap.config({
// this is the path to the directory containing iap-sanbox/iap-live files
googlePublicKeyPath: nconf.get('IAP_GOOGLE_KEYDIR'),
});
// Validation ERROR Codes
const INVALID_PAYLOAD = 6778001;
// const CONNECTION_FAILED = 6778002;
// const PURCHASE_EXPIRED = 6778003;
let api = {};
/**
* @apiIgnore Payments are considered part of the private API
* @api {post} /iap/android/verify Android Verify IAP
* @apiVersion 3.0.0
* @apiName IapAndroidVerify
* @apiGroup Payments
**/
api.iapAndroidVerify = {
method: 'POST',
url: '/iap/android/verify',
middlewares: [authWithUrl],
async handler (req, res) {
let user = res.locals.user;
let iapBody = req.body;
iap.setup((error) => {
if (error) {
let resObj = {
ok: false,
data: 'IAP Error',
};
return res.json(resObj);
}
// google receipt must be provided as an object
// {
// "data": "{stringified data object}",
// "signature": "signature from google"
// }
let testObj = {
data: iapBody.transaction.receipt,
signature: iapBody.transaction.signature,
};
// iap is ready
iap.validate(iap.GOOGLE, testObj, (err, googleRes) => {
if (err) {
let resObj = {
ok: false,
data: {
code: INVALID_PAYLOAD,
message: err.toString(),
},
};
return res.json(resObj);
}
if (iap.isValidated(googleRes)) {
let resObj = {
ok: true,
data: googleRes,
};
payments.buyGems({
user,
paymentMethod: 'IAP GooglePlay',
amount: 5.25,
}).then(() => res.json(resObj));
}
});
});
},
};
/**
* @apiIgnore Payments are considered part of the private API
* @api {post} /iap/ios/verify iOS Verify IAP
* @apiVersion 3.0.0
* @apiName IapiOSVerify
* @apiGroup Payments
**/
api.iapiOSVerify = {
method: 'POST',
url: '/iap/android/verify',
middlewares: [authWithHeaders()],
async handler (req, res) {
let iapBody = req.body;
let user = res.locals.user;
iap.setup(function iosSetupResult (error) {
if (error) {
let resObj = {
ok: false,
data: 'IAP Error',
};
return res.json(resObj);
}
// iap is ready
iap.validate(iap.APPLE, iapBody.transaction.receipt, (err, appleRes) => {
if (err) {
let resObj = {
ok: false,
data: {
code: INVALID_PAYLOAD,
message: err.toString(),
},
};
return res.json(resObj);
}
if (iap.isValidated(appleRes)) {
let purchaseDataList = iap.getPurchaseData(appleRes);
if (purchaseDataList.length > 0) {
let correctReceipt = true;
for (let index of purchaseDataList) {
switch (purchaseDataList[index].productId) {
case 'com.habitrpg.ios.Habitica.4gems':
payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 1});
break;
case 'com.habitrpg.ios.Habitica.8gems':
payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 2});
break;
case 'com.habitrpg.ios.Habitica.20gems':
case 'com.habitrpg.ios.Habitica.21gems':
payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 5.25});
break;
case 'com.habitrpg.ios.Habitica.42gems':
payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 10.5});
break;
default:
correctReceipt = false;
}
}
if (correctReceipt) {
let resObj = {
ok: true,
data: appleRes,
};
// yay good!
return res.json(resObj);
}
}
// wrong receipt content
let resObj = {
ok: false,
data: {
code: INVALID_PAYLOAD,
message: 'Incorrect receipt content',
},
};
return res.json(resObj);
}
// invalid receipt
let resObj = {
ok: false,
data: {
code: INVALID_PAYLOAD,
message: 'Invalid receipt',
},
};
return res.json(resObj);
});
});
},
};
module.exports = api;

View File

@@ -0,0 +1,278 @@
/* eslint-disable camelcase */
import nconf from 'nconf';
import moment from 'moment';
import _ from 'lodash';
import payments from '../../../libs/api-v3/payments';
import ipn from 'paypal-ipn';
import paypal from 'paypal-rest-sdk';
import shared from '../../../../../common';
import cc from 'coupon-code';
import Q from 'q';
import { model as Coupon } from '../../../models/coupon';
import { model as User } from '../../../models/user';
import {
authWithUrl,
authWithSession,
} from '../../../middlewares/api-v3/auth';
import {
BadRequest,
NotAuthorized,
} from '../../../libs/api-v3/errors';
const BASE_URL = nconf.get('BASE_URL');
// 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, (block) => {
block.paypalKey = nconf.get(`PAYPAL:billing_plans:${block.key}`);
});
paypal.configure({
mode: nconf.get('PAYPAL:mode'), // sandbox or live
client_id: nconf.get('PAYPAL:client_id'),
client_secret: nconf.get('PAYPAL:client_secret'),
});
// TODO better handling of errors
const paypalPaymentCreate = Q.nbind(paypal.payment.create, paypal.payment);
const paypalPaymentExecute = Q.nbind(paypal.payment.execute, paypal.payment);
const paypalBillingAgreementCreate = Q.nbind(paypal.billingAgreement.create, paypal.billingAgreement);
const paypalBillingAgreementExecute = Q.nbind(paypal.billingAgreement.execute, paypal.billingAgreement);
const paypalBillingAgreementGet = Q.nbind(paypal.billingAgreement.get, paypal.billingAgreement);
const paypalBillingAgreementCancel = Q.nbind(paypal.billingAgreement.cancel, paypal.billingAgreement);
const ipnVerifyAsync = Q.nbind(ipn.verify, ipn);
let api = {};
/**
* @apiIgnore Payments are considered part of the private API
* @api {get} /paypal/checkout Paypal: checkout
* @apiVersion 3.0.0
* @apiName PaypalCheckout
* @apiGroup Payments
**/
api.checkout = {
method: 'GET',
url: '/paypal/checkout',
middlewares: [authWithUrl],
async handler (req, res) {
let gift = req.query.gift ? JSON.parse(req.query.gift) : undefined;
req.session.gift = req.query.gift;
let amount = 5.00;
let description = 'HabitRPG gems';
if (gift) {
if (gift.type === 'gems') {
amount = Number(gift.gems.amount / 4).toFixed(2);
description = `${description} (Gift)`;
} else {
amount = Number(shared.content.subscriptionBlocks[gift.subscription.key].price).toFixed(2);
description = 'mo. HabitRPG Subscription (Gift)';
}
}
let createPayment = {
intent: 'sale',
payer: { payment_method: 'Paypal' },
redirect_urls: {
return_url: `${BASE_URL}/paypal/checkout/success`,
cancel_url: `${BASE_URL}`,
},
transactions: [{
item_list: {
items: [{
name: description,
// sku: 1,
price: amount,
currency: 'USD',
quality: 1,
}],
},
amount: {
currency: 'USD',
total: amount,
},
description,
}],
};
let result = await paypalPaymentCreate(createPayment);
let link = _.find(result.links, { rel: 'approval_url' }).href;
res.redirect(link);
},
};
/**
* @apiIgnore Payments are considered part of the private API
* @api {get} /paypal/checkout/success Paypal: checkout success
* @apiVersion 3.0.0
* @apiName PaypalCheckoutSuccess
* @apiGroup Payments
**/
api.checkoutSuccess = {
method: 'GET',
url: '/paypal/checkout/success',
middlewares: [authWithSession],
async handler (req, res) {
let paymentId = req.query.paymentId;
let customerId = req.query.payerID;
let method = 'buyGems';
let data = {
user: res.locals.user,
customerId,
paymentMethod: 'Paypal',
};
let gift = req.session.gift ? JSON.parse(req.session.gift) : undefined;
delete req.session.gift;
if (gift) {
gift.member = await User.findById(gift.uuid);
if (gift.type === 'subscription') {
method = 'createSubscription';
}
data.paymentMethod = 'Gift';
data.gift = gift;
}
await paypalPaymentExecute(paymentId, { payer_id: customerId });
await payments[method](data);
res.redirect('/');
},
};
/**
* @apiIgnore Payments are considered part of the private API
* @api {get} /paypal/subscribe Paypal: subscribe
* @apiVersion 3.0.0
* @apiName PaypalSubscribe
* @apiGroup Payments
**/
api.subscribe = {
method: 'GET',
url: '/paypal/subscribe',
middlewares: [authWithUrl],
async handler (req, res) {
let sub = shared.content.subscriptionBlocks[req.query.sub];
if (sub.discount) {
if (!req.query.coupon) throw new BadRequest(res.t('couponCodeRequired'));
let coupon = await Coupon.findOne({_id: cc.validate(req.query.coupon), event: sub.key});
if (!coupon) throw new NotAuthorized(res.t('invalidCoupon'));
}
let billingPlanTitle = `HabitRPG Subscription ($${sub.price} every ${sub.months} months, recurring)`;
let billingAgreementAttributes = {
name: billingPlanTitle,
description: billingPlanTitle,
start_date: moment().add({ minutes: 5 }).format(),
plan: {
id: sub.paypalKey,
},
payer: {
payment_method: 'Paypal',
},
};
let billingAgreement = await paypalBillingAgreementCreate(billingAgreementAttributes);
req.session.paypalBlock = req.query.sub;
let link = _.find(billingAgreement.links, { rel: 'approval_url' }).href;
res.redirect(link);
},
};
/**
* @apiIgnore Payments are considered part of the private API
* @api {get} /paypal/subscribe/success Paypal: subscribe success
* @apiVersion 3.0.0
* @apiName PaypalSubscribeSuccess
* @apiGroup Payments
**/
api.subscribeSuccess = {
method: 'GET',
url: '/paypal/subscribe/success',
middlewares: [authWithSession],
async handler (req, res) {
let user = res.locals.user;
let block = shared.content.subscriptionBlocks[req.session.paypalBlock];
delete req.session.paypalBlock;
let result = await paypalBillingAgreementExecute(req.query.token, {});
await payments.createSubscription({
user,
customerId: result.id,
paymentMethod: 'Paypal',
sub: block,
});
res.redirect('/');
},
};
/**
* @apiIgnore Payments are considered part of the private API
* @api {get} /paypal/subscribe/cancel Paypal: subscribe cancel
* @apiVersion 3.0.0
* @apiName PaypalSubscribeCancel
* @apiGroup Payments
**/
api.subscribeCancel = {
method: 'GET',
url: '/paypal/subscribe/cancel',
middlewares: [authWithUrl],
async handler (req, res) {
let user = res.locals.user;
let customerId = user.purchased.plan.customerId;
if (!user.purchased.plan.customerId) throw new NotAuthorized(res.t('missingSubscription'));
let customer = await paypalBillingAgreementGet(customerId);
let nextBillingDate = customer.agreement_details.next_billing_date;
if (customer.agreement_details.cycles_completed === '0') { // hasn't billed yet
throw new BadRequest(res.t('planNotActive', { nextBillingDate }));
}
await paypalBillingAgreementCancel(customerId, { note: res.t('cancelingSubscription') });
await payments.cancelSubscription({
user,
paymentMethod: 'Paypal',
nextBill: nextBillingDate,
});
res.redirect('/');
},
};
// General IPN handler. We catch cancelled HabitRPG subscriptions for users who manually cancel their
// recurring paypal payments in their paypal dashboard. TODO ? Remove this when we can move to webhooks or some other solution
/**
* @apiIgnore Payments are considered part of the private API
* @api {post} /paypal/ipn Paypal IPN
* @apiVersion 3.0.0
* @apiName PaypalIpn
* @apiGroup Payments
**/
api.ipn = {
method: 'POST',
url: '/paypal/ipn',
async handler (req, res) {
res.sendStatus(200);
await ipnVerifyAsync(req.body);
if (req.body.txn_type === 'recurring_payment_profile_cancel' || req.body.txn_type === 'subscr_cancel') {
let user = await User.findOne({ 'purchased.plan.customerId': req.body.recurring_payment_id });
if (user) {
await payments.cancelSubscription({ user, paymentMethod: 'Paypal' });
}
}
},
};
module.exports = api;

View File

@@ -0,0 +1,169 @@
import stripeModule from 'stripe';
import shared from '../../../../../common';
import {
BadRequest,
NotAuthorized,
} from '../../../libs/api-v3/errors';
import { model as Coupon } from '../../../models/coupon';
import payments from '../../../libs/api-v3/payments';
import nconf from 'nconf';
import { model as User } from '../../../models/user';
import cc from 'coupon-code';
import {
authWithHeaders,
authWithUrl,
} from '../../../middlewares/api-v3/auth';
const stripe = stripeModule(nconf.get('STRIPE_API_KEY'));
let api = {};
/**
* @apiIgnore Payments are considered part of the private API
* @api {post} /stripe/checkout Stripe checkout
* @apiVersion 3.0.0
* @apiName StripeCheckout
* @apiGroup Payments
*
* @apiParam {string} id Body parameter - The token
* @apiParam {string} email Body parameter - the customer email
* @apiParam {string} gift Query parameter - stringified json object, gift
* @apiParam {string} sub Query parameter - subscription, possible values are: basic_earned, basic_3mo, basic_6mo, google_6mo, basic_12mo
* @apiParam {string} coupon Query parameter - coupon for the matching subscription, required only for certain subscriptions
*
* @apiSuccess {Object} data Empty object
**/
api.checkout = {
method: 'POST',
url: '/stripe/checkout',
middlewares: [authWithHeaders()],
async handler (req, res) {
let token = req.body.id;
let user = res.locals.user;
let gift = req.query.gift ? JSON.parse(req.query.gift) : undefined;
let sub = req.query.sub ? shared.content.subscriptionBlocks[req.query.sub] : false;
let coupon;
let response;
if (!token) throw new BadRequest('Missing req.body.id');
if (sub) {
if (sub.discount) {
if (!req.query.coupon) throw new BadRequest(res.t('couponCodeRequired'));
coupon = await Coupon.findOne({_id: cc.validate(req.query.coupon), event: sub.key});
if (!coupon) throw new BadRequest(res.t('invalidCoupon'));
}
response = await stripe.customers.create({
email: req.body.email,
metadata: { uuid: user._id },
card: token,
plan: sub.key,
});
} else {
let amount = 500; // $5
if (gift) {
if (gift.type === 'subscription') {
amount = `${shared.content.subscriptionBlocks[gift.subscription.key].price * 100}`;
} else {
amount = `${gift.gems.amount / 4 * 100}`;
}
}
response = await stripe.charges.create({
amount,
currency: 'usd',
card: token,
});
}
if (sub) {
await payments.createSubscription({
user,
customerId: response.id,
paymentMethod: 'Stripe',
sub,
});
} else {
let method = 'buyGems';
let data = {
user,
customerId: response.id,
paymentMethod: 'Stripe',
gift,
};
if (gift) {
let member = await User.findById(gift.uuid);
gift.member = member;
if (gift.type === 'subscription') method = 'createSubscription';
data.paymentMethod = 'Gift';
}
await payments[method](data);
}
res.respond(200, {});
},
};
/**
* @apiIgnore Payments are considered part of the private API
* @api {post} /stripe/subscribe/edit Edit Stripe subscription
* @apiVersion 3.0.0
* @apiName StripeSubscribeEdit
* @apiGroup Payments
*
* @apiParam {string} id Body parameter - The token
*
* @apiSuccess {Object} data Empty object
**/
api.subscribeEdit = {
method: 'POST',
url: '/stripe/subscribe/edit',
middlewares: [authWithHeaders()],
async handler (req, res) {
let token = req.body.id;
let user = res.locals.user;
let customerId = user.purchased.plan.customerId;
if (!customerId) throw new NotAuthorized(res.t('missingSubscription'));
if (!token) throw new BadRequest('Missing req.body.id');
let subscriptions = await stripe.customers.listSubscriptions(customerId);
let subscriptionId = subscriptions.data[0].id;
await stripe.customers.updateSubscription(customerId, subscriptionId, { card: token });
res.respond(200, {});
},
};
/**
* @apiIgnore Payments are considered part of the private API
* @api {get} /stripe/subscribe/cancel Cancel Stripe subscription
* @apiVersion 3.0.0
* @apiName StripeSubscribeCancel
* @apiGroup Payments
**/
api.subscribeCancel = {
method: 'GET',
url: '/stripe/subscribe/cancel',
middlewares: [authWithUrl],
async handler (req, res) {
let user = res.locals.user;
if (!user.purchased.plan.customerId) throw new NotAuthorized(res.t('missingSubscription'));
let customer = await stripe.customers.retrieve(user.purchased.plan.customeerId);
await stripe.customers.del(user.purchased.plan.customerId);
await payments.cancelSubscriptoin({
user,
nextBill: customer.subscription.current_period_end * 1000, // timestamp in seconds
paymentMethod: 'Stripe',
});
res.redirect('/');
},
};
module.exports = api;

View File

@@ -0,0 +1,62 @@
import amazonPayments from 'amazon-payments';
import nconf from 'nconf';
import common from '../../../../common';
import Q from 'q';
import {
BadRequest,
} from './errors';
// TODO better handling of errors
const i18n = common.i18n;
const IS_PROD = nconf.get('NODE_ENV') === 'production';
let amzPayment = amazonPayments.connect({
environment: amazonPayments.Environment[IS_PROD ? 'Production' : 'Sandbox'],
sellerId: nconf.get('AMAZON_PAYMENTS:SELLER_ID'),
mwsAccessKey: nconf.get('AMAZON_PAYMENTS:MWS_KEY'),
mwsSecretKey: nconf.get('AMAZON_PAYMENTS:MWS_SECRET'),
clientId: nconf.get('AMAZON_PAYMENTS:CLIENT_ID'),
});
let getTokenInfo = Q.nbind(amzPayment.api.getTokenInfo, amzPayment.api);
let createOrderReferenceId = Q.nbind(amzPayment.offAmazonPayments.createOrderReferenceForId, amzPayment.offAmazonPayments);
let setOrderReferenceDetails = Q.nbind(amzPayment.offAmazonPayments.setOrderReferenceDetails, amzPayment.offAmazonPayments);
let confirmOrderReference = Q.nbind(amzPayment.offAmazonPayments.confirmOrderReference, amzPayment.offAmazonPayments);
let closeOrderReference = Q.nbind(amzPayment.offAmazonPayments.closeOrderReference, amzPayment.offAmazonPayments);
let setBillingAgreementDetails = Q.nbind(amzPayment.offAmazonPayments.setBillingAgreementDetails, amzPayment.offAmazonPayments);
let confirmBillingAgreement = Q.nbind(amzPayment.offAmazonPayments.confirmBillingAgreement, amzPayment.offAmazonPayments);
let closeBillingAgreement = Q.nbind(amzPayment.offAmazonPayments.closeBillingAgreement, amzPayment.offAmazonPayments);
let authorizeOnBillingAgreement = (inputSet) => {
return new Promise((resolve, reject) => {
amzPayment.offAmazonPayments.authorizeOnBillingAgreement(inputSet, (err, response) => {
if (err) return reject(err);
if (response.AuthorizationDetails.AuthorizationStatus.State === 'Declined') return reject(new BadRequest(i18n.t('paymentNotSuccessful')));
return resolve(response);
});
});
};
let authorize = (inputSet) => {
return new Promise((resolve, reject) => {
amzPayment.offAmazonPayments.authorize(inputSet, (err, response) => {
if (err) return reject(err);
if (response.AuthorizationDetails.AuthorizationStatus.State === 'Declined') return reject(new BadRequest(i18n.t('paymentNotSuccessful')));
return resolve(response);
});
});
};
module.exports = {
getTokenInfo,
createOrderReferenceId,
setOrderReferenceDetails,
confirmOrderReference,
closeOrderReference,
confirmBillingAgreement,
setBillingAgreementDetails,
closeBillingAgreement,
authorizeOnBillingAgreement,
authorize,
};

View File

@@ -0,0 +1,185 @@
import _ from 'lodash' ;
import analytics from './analyticsService';
import {
getUserInfo,
sendTxn as txnEmail,
} from './email';
import members from '../../controllers/api-v3/members';
import moment from 'moment';
import nconf from 'nconf';
import pushNotify from './pushNotifications';
import shared from '../../../../common' ;
const IS_PROD = nconf.get('IS_PROD');
let api = {};
function revealMysteryItems (user) {
_.each(shared.content.gear.flat, function findMysteryItems (item) {
if (
item.klass === 'mystery' &&
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) !== -1
) {
user.purchased.plan.mysteryItems.push(item.key);
}
});
}
api.createSubscription = async function createSubscription (data) {
let recipient = data.gift ? data.gift.member : data.user;
let plan = recipient.purchased.plan;
let block = shared.content.subscriptionBlocks[data.gift ? data.gift.subscription.key : data.sub.key];
let months = Number(block.months);
if (data.gift) {
if (plan.customerId && !plan.dateTerminated) { // User has active plan
plan.extraMonths += months;
} else {
plan.dateTerminated = moment(plan.dateTerminated).add({months}).toDate();
if (!plan.dateUpdated) plan.dateUpdated = new Date();
}
if (!plan.customerId) plan.customerId = 'Gift'; // don't override existing customer, but all sub need a customerId
} else {
_(plan).merge({ // override with these values
planId: block.key,
customerId: data.customerId,
dateUpdated: new Date(),
gemsBought: 0,
paymentMethod: data.paymentMethod,
extraMonths: Number(plan.extraMonths) +
Number(plan.dateTerminated ? moment(plan.dateTerminated).diff(new Date(), 'months', true) : 0),
dateTerminated: null,
// Specify a lastBillingDate just for Amazon Payments
// Resetted every time the subscription restarts
lastBillingDate: data.paymentMethod === 'Amazon Payments' ? new Date() : undefined,
}).defaults({ // allow non-override if a plan was previously used
dateCreated: new Date(),
mysteryItems: [],
}).value();
}
// Block sub perks
let perks = Math.floor(months / 3);
if (perks) {
plan.consecutive.offset += months;
plan.consecutive.gemCapExtra += perks * 5;
if (plan.consecutive.gemCapExtra > 25) plan.consecutive.gemCapExtra = 25;
plan.consecutive.trinkets += perks;
}
revealMysteryItems(recipient);
if (IS_PROD) {
if (!data.gift) txnEmail(data.user, 'subscription-begins');
analytics.trackPurchase({
uuid: data.user._id,
itemPurchased: 'Subscription',
sku: `${data.paymentMethod.toLowerCase()}-subscription`,
purchaseType: 'subscribe',
paymentMethod: data.paymentMethod,
quantity: 1,
gift: Boolean(data.gift),
purchaseValue: block.price,
});
}
data.user.purchased.txnCount++;
if (data.gift) {
members.sendMessage(data.user, data.gift.member, data.gift);
let byUserName = getUserInfo(data.user, ['name']).name;
if (data.gift.member.preferences.emailNotifications.giftedSubscription !== false) {
txnEmail(data.gift.member, 'gifted-subscription', [
{name: 'GIFTER', content: byUserName},
{name: 'X_MONTHS_SUBSCRIPTION', content: months},
]);
}
if (data.gift.member._id !== data.user._id) { // Only send push notifications if sending to a user other than yourself
pushNotify.sendNotify(data.gift.member, shared.i18n.t('giftedSubscription'), `${months} months - by ${byUserName}`);
}
}
await data.user.save();
if (data.gift) await data.gift.member.save();
};
// Sets their subscription to be cancelled later
api.cancelSubscription = async function cancelSubscription (data) {
let plan = data.user.purchased.plan;
let now = moment();
let remaining = data.nextBill ? moment(data.nextBill).diff(new Date(), 'days') : 30;
let nowStr = `${now.format('MM')}/${moment(plan.dateUpdated).format('DD')}/${now.format('YYYY')}`;
let nowStrFormat = 'MM/DD/YYYY';
plan.dateTerminated =
moment(nowStr, nowStrFormat)
.add({days: remaining}) // end their subscription 1mo from their last payment
.add({days: Math.ceil(30 * plan.extraMonths)}) // plus any extra time (carry-over, gifted subscription, etc) they have.
.toDate();
plan.extraMonths = 0; // clear extra time. If they subscribe again, it'll be recalculated from p.dateTerminated
await data.user.save();
txnEmail(data.user, 'cancel-subscription');
analytics.track('unsubscribe', {
uuid: data.user._id,
gaCategory: 'commerce',
gaLabel: data.paymentMethod,
paymentMethod: data.paymentMethod,
});
};
api.buyGems = async function buyGems (data) {
let amt = data.amount || 5;
amt = data.gift ? data.gift.gems.amount / 4 : amt;
(data.gift ? data.gift.member : data.user).balance += amt;
data.user.purchased.txnCount++;
if (IS_PROD) {
if (!data.gift) txnEmail(data.user, 'donation');
analytics.trackPurchase({
uuid: data.user._id,
itemPurchased: 'Gems',
sku: `${data.paymentMethod.toLowerCase()}-checkout`,
purchaseType: 'checkout',
paymentMethod: data.paymentMethod,
quantity: 1,
gift: Boolean(data.gift),
purchaseValue: amt,
});
}
if (data.gift) {
let byUsername = getUserInfo(data.user, ['name']).name;
let gemAmount = data.gift.gems.amount || 20;
members.sendMessage(data.user, data.gift.member, data.gift);
if (data.gift.member.preferences.emailNotifications.giftedGems !== false) {
txnEmail(data.gift.member, 'gifted-gems', [
{name: 'GIFTER', content: byUsername},
{name: 'X_GEMS_GIFTED', content: gemAmount},
]);
}
if (data.gift.member._id !== data.user._id) { // Only send push notifications if sending to a user other than yourself
pushNotify.sendNotify(data.gift.member, shared.i18n.t('giftedGems'), `${gemAmount} Gems - by ${byUsername}`);
}
await data.gift.member.save();
}
await data.user.save();
};
module.exports = api;

View File

@@ -55,3 +55,21 @@ export function authWithSession (req, res, next) {
})
.catch(next);
}
export function authWithUrl (req, res, next) {
let userId = req.query._id;
let apiToken = req.query.apiToken;
if (!userId || !apiToken) {
throw new NotAuthorized(res.t('missingAuthParams'));
}
User.findOne({ _id: userId, apiToken }).exec()
.then((user) => {
if (!user) throw new NotAuthorized(res.t('invalidCredentials'));
res.locals.user = user;
next();
})
.catch(next);
}

View File

@@ -52,6 +52,12 @@ module.exports = function errorHandler (err, req, res, next) { // eslint-disable
});
}
// Handle Stripe Card errors errors (can be safely shown to the users)
// https://stripe.com/docs/api/node#errors
if (err.type === 'StripeCardError') {
responseErr = new BadRequest(err.message);
}
if (!responseErr || responseErr.httpCode >= 500) {
// Try to identify the error...
// ...

View File

@@ -19,8 +19,6 @@ v2app.use(responseHandler);
// Custom Directives
v2app.use('/', require('../../routes/api-v2/auth'));
// v2app.use('/', require('../../routes/api-v2/coupon')); // TODO REMOVE - ONLY v3
// v2app.use('/', require('../../routes/api-v2/unsubscription')); // TODO REMOVE - ONLY v3
require('../../routes/api-v2/swagger')(swagger, v2app);

View File

@@ -5,7 +5,14 @@ import baseModel from '../libs/api-v3/baseModel';
import _ from 'lodash';
import * as Tasks from './task';
import { model as User } from './user';
import {
model as Group,
TAVERN_ID,
} from './group';
import { removeFromArray } from '../libs/api-v3/collectionManipulators';
import shared from '../../../common';
import { sendTxn as txnEmail } from '../libs/api-v3/email';
import sendPushNotification from '../libs/api-v3/pushNotifications';
let Schema = mongoose.Schema;
@@ -251,6 +258,65 @@ schema.methods.unlinkTasks = async function challengeUnlinkTasks (user, keep) {
}
};
// TODO everything here should be moved to a worker
// actually even for a worker it's probably just too big and will kill mongo
schema.methods.closeChal = async function closeChal (broken = {}) {
let challenge = this;
let winner = broken.winner;
let brokenReason = broken.broken;
// Delete the challenge
await this.model('Challenge').remove({_id: challenge._id}).exec();
// Refund the leader if the challenge is closed and the group not the tavern
if (challenge.group !== TAVERN_ID && brokenReason === 'CHALLENGE_DELETED') {
await User.update({_id: challenge.leader}, {$inc: {balance: challenge.prize / 4}}).exec();
}
// Update the challengeCount on the group
await Group.update({_id: challenge.group}, {$inc: {challengeCount: -1}}).exec();
// Award prize to winner and notify
if (winner) {
winner.achievements.challenges.push(challenge.name);
winner.balance += challenge.prize / 4;
let savedWinner = await winner.save();
if (savedWinner.preferences.emailNotifications.wonChallenge !== false) {
txnEmail(savedWinner, 'won-challenge', [
{name: 'CHALLENGE_NAME', content: challenge.name},
]);
}
sendPushNotification(savedWinner, shared.i18n.t('wonChallenge'), challenge.name);
}
// Run some operations in the background withouth blocking the thread
let backgroundTasks = [
// And it's tasks
Tasks.Task.remove({'challenge.id': challenge._id, userId: {$exists: false}}).exec(),
// Set the challenge tag to non-challenge status and remove the challenge from the user's challenges
User.update({
challenges: challenge._id,
'tags._id': challenge._id,
}, {
$set: {'tags.$.challenge': false},
$pull: {challenges: challenge._id},
}, {multi: true}).exec(),
// Break users' tasks
Tasks.Task.update({
'challenge.id': challenge._id,
}, {
$set: {
'challenge.broken': brokenReason,
'challenge.winner': winner && winner.profile.name,
},
}, {multi: true}).exec(),
];
Q.all(backgroundTasks);
};
// Methods to adapt the new schema to API v2 responses (mostly tasks inside the challenge model)
// These will be removed once API v2 is discontinued