v3 payments: working IAP and Stripe, move paypalBillingSetup to its own file, closeChal is now a challenge instance method

This commit is contained in:
Matteo Pagliazzi
2016-05-09 22:58:15 +02:00
parent e980b3ce0a
commit 0114e310eb
18 changed files with 414 additions and 381 deletions

View File

@@ -22,6 +22,7 @@ website/src/middlewares/apiThrottle.js
website/src/middlewares/forceRefresh.js website/src/middlewares/forceRefresh.js
debug-scripts/* debug-scripts/*
scripts/*
tasks/*.js tasks/*.js
gulpfile.js gulpfile.js
Gruntfile.js Gruntfile.js

View File

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

View File

@@ -0,0 +1,94 @@
// This file is used for creating paypal billing plans. PayPal doesn't have a web interface for setting up recurring
// 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');
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({
'mode': nconf.get("PAYPAL:mode"), //sandbox or live
'client_id': nconf.get("PAYPAL:client_id"),
'client_secret': nconf.get("PAYPAL:client_secret")
});
// https://developer.paypal.com/docs/api/#billing-plans-and-agreements
var billingPlanTitle ="Habitica Subscription";
var billingPlanAttributes = {
"name": billingPlanTitle,
"description": billingPlanTitle,
"type": "INFINITE",
"merchant_preferences": {
"auto_bill_amount": "yes",
"cancel_url": live ? 'https://habitica.com' : 'http://localhost:3000',
"return_url": (live ? 'https://habitica.com' : 'http://localhost:3000') + '/paypal/subscribe/success'
},
payment_definitions: [{
"type": "REGULAR",
"frequency": "MONTH",
"cycles": "0"
}]
};
_.each(blocks, function(block){
block.definition = _.cloneDeep(billingPlanAttributes);
_.merge(block.definition.payment_definitions[0], {
"name": billingPlanTitle + ' ($'+block.price+' every '+block.months+' months, recurring)',
"frequency_interval": ""+block.months,
"amount": {
"currency": "USD",
"value": ""+block.price
}
});
})
switch(OP) {
case "list":
paypal.billingPlan.list({status: 'ACTIVE'}, function(err, plans){
console.log({err:err, plans:plans});
});
break;
case "get":
paypal.billingPlan.get(nconf.get("PAYPAL:billing_plans:12"), function (err, plan) {
console.log({err:err, plan:plan});
})
break;
case "update":
var update = {
"op": "replace",
"path": "/merchant_preferences",
"value": {
"cancel_url": "https://habitica.com"
}
};
paypal.billingPlan.update(nconf.get("PAYPAL:billing_plans:12"), update, function (err, res) {
console.log({err:err, plan:res});
});
break;
case "create":
paypal.billingPlan.create(blocks["google_6mo"].definition, function(err,plan){
if (err) return console.log(err);
if (plan.state == "ACTIVE")
return console.log({err:err, plan:plan});
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){
console.log({err:err, response:response, id:plan.id});
});
});
break;
case "remove": break;
}

View File

@@ -289,8 +289,6 @@ api.update = function(req, res, next){
}); });
} }
import { _closeChal } from '../api-v3/challenges';
/** /**
* Delete & close * Delete & close
*/ */
@@ -304,7 +302,7 @@ api.delete = async function(req, res, next){
if (!challenge.canModify(user)) return next(shared.i18n.t('noPermissionCloseChallenge')); if (!challenge.canModify(user)) return next(shared.i18n.t('noPermissionCloseChallenge'));
// Close channel in background, some ops are run in the background without `await`ing // 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); res.sendStatus(200);
} catch (err) { } catch (err) {
next(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.'); 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 // 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, {}); res.respond(200, {});
} catch (err) { } catch (err) {
next(err); next(err);

View File

@@ -2,9 +2,6 @@ import validator from 'validator';
import moment from 'moment'; import moment from 'moment';
import passport from 'passport'; import passport from 'passport';
import nconf from 'nconf'; import nconf from 'nconf';
import setupNconf from '../../libs/api-v3/setupNconf';
setupNconf();
import { import {
authWithHeaders, authWithHeaders,
} from '../../middlewares/api-v3/auth'; } from '../../middlewares/api-v3/auth';

View File

@@ -14,10 +14,7 @@ import {
NotFound, NotFound,
NotAuthorized, NotAuthorized,
} from '../../libs/api-v3/errors'; } from '../../libs/api-v3/errors';
import shared from '../../../../common';
import * as Tasks from '../../models/task'; 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 Q from 'q';
import csvStringify from '../../libs/api-v3/csvStringify'; 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 * @api {delete} /api/v3/challenges/:challengeId Delete a challenge
* @apiVersion 3.0.0 * @apiVersion 3.0.0
@@ -534,7 +473,7 @@ api.deleteChallenge = {
if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyLeaderDeleteChal')); if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyLeaderDeleteChal'));
// Close channel in background, some ops are run in the background without `await`ing // 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, {}); 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})); 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 // 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, {}); res.respond(200, {});
}, },
}; };

View File

@@ -14,9 +14,6 @@ import { sendTxn } from '../../libs/api-v3/email';
import nconf from 'nconf'; import nconf from 'nconf';
import Q from 'q'; import Q from 'q';
import setupNconf from '../../libs/api-v3/setupNconf';
setupNconf();
const FLAG_REPORT_EMAILS = nconf.get('FLAG_REPORT_EMAIL').split(',').map((email) => { const FLAG_REPORT_EMAILS = nconf.get('FLAG_REPORT_EMAIL').split(',').map((email) => {
return { email, canSend: true }; return { email, canSend: true };
}); });

View File

@@ -1,5 +1,6 @@
import { import {
BadRequest, BadRequest,
NotAuthorized,
} from '../../../libs/api-v3/errors'; } from '../../../libs/api-v3/errors';
import amzLib from '../../../libs/api-v3/amazonPayments'; import amzLib from '../../../libs/api-v3/amazonPayments';
import { import {
@@ -16,6 +17,7 @@ import cc from 'coupon-code';
let api = {}; let api = {};
/** /**
* @apiIgnore Payments are considered part of the private API
* @api {post} /amazon/verifyAccessToken verify access token * @api {post} /amazon/verifyAccessToken verify access token
* @apiVersion 3.0.0 * @apiVersion 3.0.0
* @apiName AmazonVerifyAccessToken * @apiName AmazonVerifyAccessToken
@@ -23,11 +25,11 @@ let api = {};
* *
* @apiParam {string} access_token the access token * @apiParam {string} access_token the access token
* *
* @apiSuccess {} empty * @apiSuccess {Object} data Empty object
**/ **/
api.verifyAccessToken = { api.verifyAccessToken = {
method: 'POST', method: 'POST',
url: '/payments/amazon/verifyAccessToken', url: '/amazon/verifyAccessToken',
middlewares: [authWithHeaders()], middlewares: [authWithHeaders()],
async handler (req, res) { async handler (req, res) {
try { try {
@@ -40,6 +42,7 @@ api.verifyAccessToken = {
}; };
/** /**
* @apiIgnore Payments are considered part of the private API
* @api {post} /amazon/createOrderReferenceId create order reference id * @api {post} /amazon/createOrderReferenceId create order reference id
* @apiVersion 3.0.0 * @apiVersion 3.0.0
* @apiName AmazonCreateOrderReferenceId * @apiName AmazonCreateOrderReferenceId
@@ -51,7 +54,7 @@ api.verifyAccessToken = {
**/ **/
api.createOrderReferenceId = { api.createOrderReferenceId = {
method: 'POST', method: 'POST',
url: '/payments/amazon/createOrderReferenceId', url: '/amazon/createOrderReferenceId',
middlewares: [authWithHeaders()], middlewares: [authWithHeaders()],
async handler (req, res) { async handler (req, res) {
try { try {
@@ -70,6 +73,7 @@ api.createOrderReferenceId = {
}; };
/** /**
* @apiIgnore Payments are considered part of the private API
* @api {post} /amazon/checkout do checkout * @api {post} /amazon/checkout do checkout
* @apiVersion 3.0.0 * @apiVersion 3.0.0
* @apiName AmazonCheckout * @apiName AmazonCheckout
@@ -81,7 +85,7 @@ api.createOrderReferenceId = {
**/ **/
api.checkout = { api.checkout = {
method: 'POST', method: 'POST',
url: '/payments/amazon/checkout', url: '/amazon/checkout',
middlewares: [authWithHeaders()], middlewares: [authWithHeaders()],
async handler (req, res) { async handler (req, res) {
let gift = req.body.gift; let gift = req.body.gift;
@@ -148,6 +152,7 @@ api.checkout = {
}; };
/** /**
* @apiIgnore Payments are considered part of the private API
* @api {post} /amazon/subscribe Subscribe * @api {post} /amazon/subscribe Subscribe
* @apiVersion 3.0.0 * @apiVersion 3.0.0
* @apiName AmazonSubscribe * @apiName AmazonSubscribe
@@ -161,7 +166,7 @@ api.checkout = {
**/ **/
api.subscribe = { api.subscribe = {
method: 'POST', method: 'POST',
url: '/payments/amazon/subscribe', url: '/amazon/subscribe',
middlewares: [authWithHeaders()], middlewares: [authWithHeaders()],
async handler (req, res) { async handler (req, res) {
let billingAgreementId = req.body.billingAgreementId; let billingAgreementId = req.body.billingAgreementId;
@@ -228,22 +233,21 @@ api.subscribe = {
}; };
/** /**
* @apiIgnore Payments are considered part of the private API
* @api {get} /amazon/subscribe/cancel SubscribeCancel * @api {get} /amazon/subscribe/cancel SubscribeCancel
* @apiVersion 3.0.0 * @apiVersion 3.0.0
* @apiName AmazonSubscribe * @apiName AmazonSubscribe
* @apiGroup Payments * @apiGroup Payments
*
* @apiSuccess {object} empty object
**/ **/
api.subscribeCancel = { api.subscribeCancel = {
method: 'GET', method: 'GET',
url: '/payments/amazon/subscribe/cancel', url: '/amazon/subscribe/cancel',
middlewares: [authWithUrl], middlewares: [authWithUrl],
async handler (req, res) { async handler (req, res) {
let user = res.locals.user; let user = res.locals.user;
let billingAgreementId = user.purchased.plan.customerId; let billingAgreementId = user.purchased.plan.customerId;
if (!billingAgreementId) throw new BadRequest(res.t('missingSubscription')); if (!billingAgreementId) throw new NotAuthorized(res.t('missingSubscription'));
try { try {
await amzLib.closeBillingAgreement({ await amzLib.closeBillingAgreement({
@@ -257,7 +261,7 @@ api.subscribeCancel = {
}; };
await payments.cancelSubscription(data); await payments.cancelSubscription(data);
res.respond(200, {}); res.redirect('/');
} catch (error) { } catch (error) {
throw new BadRequest(error.message); throw new BadRequest(error.message);
} }

View File

@@ -1,5 +1,12 @@
import iap from 'in-app-purchase'; import iap from 'in-app-purchase';
import nconf from 'nconf'; 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({ iap.config({
// this is the path to the directory containing iap-sanbox/iap-live files // this is the path to the directory containing iap-sanbox/iap-live files
@@ -7,148 +14,178 @@ iap.config({
}); });
// Validation ERROR Codes // Validation ERROR Codes
// const INVALID_PAYLOAD = 6778001; const INVALID_PAYLOAD = 6778001;
// const CONNECTION_FAILED = 6778002; // const CONNECTION_FAILED = 6778002;
// const PURCHASE_EXPIRED = 6778003; // const PURCHASE_EXPIRED = 6778003;
let api = {}; let api = {};
/* /**
api.androidVerify = function androidVerify (req, res) { * @apiIgnore Payments are considered part of the private API
let iapBody = req.body; * @api {post} /iap/android/verify Android Verify IAP
let user = res.locals.user; * @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(function googleSetupResult (error) { iap.setup((error) => {
if (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, function googleValidateResult (err, googleRes) {
if (err) {
let resObj = { let resObj = {
ok: false, ok: false,
data: { data: 'IAP Error',
code: INVALID_PAYLOAD,
message: err.toString(),
},
}; };
return res.json(resObj); return res.json(resObj);
} }
if (iap.isValidated(googleRes)) { // google receipt must be provided as an object
let resObj = { // {
ok: true, // "data": "{stringified data object}",
data: googleRes, // "signature": "signature from google"
}; // }
let testObj = {
data: iapBody.transaction.receipt,
signature: iapBody.transaction.signature,
};
payments.buyGems({user, paymentMethod: 'IAP GooglePlay', amount: 5.25}); // 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); 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));
}
});
}); });
}); },
}; };
exports.iosVerify = function iosVerify (req, res) { /**
let iapBody = req.body; * @apiIgnore Payments are considered part of the private API
let user = res.locals.user; * @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) { iap.setup(function iosSetupResult (error) {
if (error) { if (error) {
let resObj = {
ok: false,
data: 'IAP Error',
};
return res.json(resObj);
}
// iap is ready
iap.validate(iap.APPLE, iapBody.transaction.receipt, function iosValidateResult (err, appleRes) {
if (err) {
let resObj = { let resObj = {
ok: false, ok: false,
data: { data: 'IAP Error',
code: INVALID_PAYLOAD,
message: err.toString(),
},
}; };
return res.json(resObj); return res.json(resObj);
} }
if (iap.isValidated(appleRes)) { // iap is ready
let purchaseDataList = iap.getPurchaseData(appleRes); iap.validate(iap.APPLE, iapBody.transaction.receipt, (err, appleRes) => {
if (purchaseDataList.length > 0) { if (err) {
let correctReceipt = true; let resObj = {
for (let index of purchaseDataList) { ok: false,
switch (purchaseDataList[index].productId) { data: {
case 'com.habitrpg.ios.Habitica.4gems': code: INVALID_PAYLOAD,
payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 1}); message: err.toString(),
break; },
case 'com.habitrpg.ios.Habitica.8gems': };
payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 2});
break; return res.json(resObj);
case 'com.habitrpg.ios.Habitica.20gems': }
case 'com.habitrpg.ios.Habitica.21gems':
payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 5.25}); if (iap.isValidated(appleRes)) {
break; let purchaseDataList = iap.getPurchaseData(appleRes);
case 'com.habitrpg.ios.Habitica.42gems': if (purchaseDataList.length > 0) {
payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 10.5}); let correctReceipt = true;
break;
default: for (let index of purchaseDataList) {
correctReceipt = false; 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);
} }
} }
if (correctReceipt) {
let resObj = { // wrong receipt content
ok: true, let resObj = {
data: appleRes, ok: false,
}; data: {
// yay good! code: INVALID_PAYLOAD,
return res.json(resObj); message: 'Incorrect receipt content',
} },
};
return res.json(resObj);
} }
// wrong receipt content
// invalid receipt
let resObj = { let resObj = {
ok: false, ok: false,
data: { data: {
code: INVALID_PAYLOAD, code: INVALID_PAYLOAD,
message: 'Incorrect receipt content', message: 'Invalid receipt',
}, },
}; };
return res.json(resObj);
}
// invalid receipt
let resObj = {
ok: false,
data: {
code: INVALID_PAYLOAD,
message: 'Invalid receipt',
},
};
return res.json(resObj); return res.json(resObj);
});
}); });
}); },
}; };
*/
module.exports = api; module.exports = api;

View File

@@ -1,3 +1,5 @@
/* eslint-disable camelcase */
import nconf from 'nconf'; import nconf from 'nconf';
import moment from 'moment'; import moment from 'moment';
import _ from 'lodash'; import _ from 'lodash';
@@ -17,6 +19,8 @@ import {
} from '../../../libs/api-v3/errors'; } from '../../../libs/api-v3/errors';
import * as logger from '../../../libs/api-v3/logger'; import * as logger from '../../../libs/api-v3/logger';
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 // 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 // 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 // there, get it's plan.id and store it in config.json
@@ -24,8 +28,6 @@ _.each(shared.content.subscriptionBlocks, (block) => {
block.paypalKey = nconf.get(`PAYPAL:billing_plans:${block.key}`); block.paypalKey = nconf.get(`PAYPAL:billing_plans:${block.key}`);
}); });
/* eslint-disable camelcase */
paypal.configure({ paypal.configure({
mode: nconf.get('PAYPAL:mode'), // sandbox or live mode: nconf.get('PAYPAL:mode'), // sandbox or live
client_id: nconf.get('PAYPAL:client_id'), client_id: nconf.get('PAYPAL:client_id'),
@@ -35,18 +37,18 @@ paypal.configure({
let api = {}; let api = {};
/** /**
* @api {get} /paypal/checkout checkout * @apiIgnore Payments are considered part of the private API
* @api {get} /paypal/checkout Paypal checkout
* @apiDescription Redirects to Paypal
* @apiVersion 3.0.0 * @apiVersion 3.0.0
* @apiName PaypalCheckout * @apiName PaypalCheckout
* @apiGroup Payments * @apiGroup Payments
* *
* @apiParam {string} gift The stringified object representing the user, the gift recepient. * @apiParam {string} gift Query parameter - The stringified object representing the user, the gift recepient.
*
* @apiSuccess {} redirect
**/ **/
api.checkout = { api.checkout = {
method: 'GET', method: 'GET',
url: '/payments/paypal/checkout', url: '/paypal/checkout',
middlewares: [authWithUrl], middlewares: [authWithUrl],
async handler (req, res) { async handler (req, res) {
let gift = req.query.gift ? JSON.parse(req.query.gift) : undefined; let gift = req.query.gift ? JSON.parse(req.query.gift) : undefined;
@@ -68,8 +70,8 @@ api.checkout = {
intent: 'sale', intent: 'sale',
payer: { payment_method: 'Paypal' }, payer: { payment_method: 'Paypal' },
redirect_urls: { redirect_urls: {
return_url: `${nconf.get('BASE_URL')}/paypal/checkout/success`, return_url: `${BASE_URL}/paypal/checkout/success`,
cancel_url: `${nconf.get('BASE_URL')}`, cancel_url: `${BASE_URL}`,
}, },
transactions: [{ transactions: [{
item_list: { item_list: {
@@ -87,6 +89,7 @@ api.checkout = {
description, description,
}], }],
}; };
try { try {
let result = await paypal.payment.create(createPayment); let result = await paypal.payment.create(createPayment);
let link = _.find(result.links, { rel: 'approval_url' }).href; let link = _.find(result.links, { rel: 'approval_url' }).href;
@@ -98,6 +101,7 @@ api.checkout = {
}; };
/** /**
* @apiIgnore Payments are considered part of the private API
* @api {get} /paypal/checkout/success Paypal checkout success * @api {get} /paypal/checkout/success Paypal checkout success
* @apiVersion 3.0.0 * @apiVersion 3.0.0
* @apiName PaypalCheckoutSuccess * @apiName PaypalCheckoutSuccess
@@ -110,7 +114,7 @@ api.checkout = {
**/ **/
api.checkoutSuccess = { api.checkoutSuccess = {
method: 'GET', method: 'GET',
url: '/payments/paypal/checkout/success', url: '/paypal/checkout/success',
middlewares: [authWithSession], middlewares: [authWithSession],
async handler (req, res) { async handler (req, res) {
let paymentId = req.query.paymentId; let paymentId = req.query.paymentId;
@@ -144,6 +148,7 @@ api.checkoutSuccess = {
}; };
/** /**
* @apiIgnore Payments are considered part of the private API
* @api {get} /paypal/subscribe Paypal subscribe * @api {get} /paypal/subscribe Paypal subscribe
* @apiVersion 3.0.0 * @apiVersion 3.0.0
* @apiName PaypalSubscribe * @apiName PaypalSubscribe
@@ -156,7 +161,7 @@ api.checkoutSuccess = {
**/ **/
api.subscribe = { api.subscribe = {
method: 'GET', method: 'GET',
url: '/payments/paypal/subscribe', url: '/paypal/subscribe',
middlewares: [authWithUrl], middlewares: [authWithUrl],
async handler (req, res) { async handler (req, res) {
let sub = shared.content.subscriptionBlocks[req.query.sub]; let sub = shared.content.subscriptionBlocks[req.query.sub];
@@ -190,6 +195,7 @@ api.subscribe = {
}; };
/** /**
* @apiIgnore Payments are considered part of the private API
* @api {get} /paypal/subscribe/success Paypal subscribe success * @api {get} /paypal/subscribe/success Paypal subscribe success
* @apiVersion 3.0.0 * @apiVersion 3.0.0
* @apiName PaypalSubscribeSuccess * @apiName PaypalSubscribeSuccess
@@ -201,7 +207,7 @@ api.subscribe = {
**/ **/
api.subscribeSuccess = { api.subscribeSuccess = {
method: 'GET', method: 'GET',
url: '/payments/paypal/subscribe/success', url: '/paypal/subscribe/success',
middlewares: [authWithSession], middlewares: [authWithSession],
async handler (req, res) { async handler (req, res) {
let user = res.locals.user; let user = res.locals.user;
@@ -223,6 +229,7 @@ api.subscribeSuccess = {
}; };
/** /**
* @apiIgnore Payments are considered part of the private API
* @api {get} /paypal/subscribe/cancel Paypal subscribe cancel * @api {get} /paypal/subscribe/cancel Paypal subscribe cancel
* @apiVersion 3.0.0 * @apiVersion 3.0.0
* @apiName PaypalSubscribeCancel * @apiName PaypalSubscribeCancel
@@ -234,7 +241,7 @@ api.subscribeSuccess = {
**/ **/
api.subscribeCancel = { api.subscribeCancel = {
method: 'GET', method: 'GET',
url: '/payments/paypal/subscribe/cancel', url: '/paypal/subscribe/cancel',
middlewares: [authWithUrl], middlewares: [authWithUrl],
async handler (req, res) { async handler (req, res) {
let user = res.locals.user; let user = res.locals.user;
@@ -261,6 +268,7 @@ api.subscribeCancel = {
}; };
/** /**
* @apiIgnore Payments are considered part of the private API
* @api {post} /paypal/ipn Paypal IPN * @api {post} /paypal/ipn Paypal IPN
* @apiVersion 3.0.0 * @apiVersion 3.0.0
* @apiName PaypalIpn * @apiName PaypalIpn
@@ -273,7 +281,7 @@ api.subscribeCancel = {
**/ **/
api.ipn = { api.ipn = {
method: 'POST', method: 'POST',
url: '/payments/paypal/ipn', url: '/paypal/ipn',
middlewares: [], middlewares: [],
async handler (req, res) { async handler (req, res) {
res.respond(200); res.respond(200);

View File

@@ -1,98 +0,0 @@
// This file is used for creating paypal billing plans. PayPal doesn't have a web interface for setting up recurring
// 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
import path from 'path';
import nconf from 'nconf';
import _ from 'lodash';
import paypal from 'paypal-rest-sdk';
import shared from '../../../../../common';
let blocks = shared.content.subscriptionBlocks;
const BILLING_PLAN_TITLE = 'Habitica Subscription';
const LIVE = nconf.get('PAYPAL:mode') === 'live';
const OP = 'create'; // list create update remove
nconf.argv().env().file('user', path.join(path.resolve(__dirname, '../../../config.json')));
/* eslint-disable camelcase */
paypal.configure({
mode: nconf.get('PAYPAL:mode'), // sandbox or live
client_id: nconf.get('PAYPAL:client_id'),
client_secret: nconf.get('PAYPAL:client_secret'),
});
// https://developer.paypal.com/docs/api/#billing-plans-and-agreements
let billingPlanAttributes = {
name: BILLING_PLAN_TITLE,
description: BILLING_PLAN_TITLE,
type: 'INFINITE',
merchant_preferences: {
auto_bill_amount: 'yes',
cancel_url: LIVE ? 'https://habitica.com' : 'http://localhost:3000',
return_url: LIVE ? 'https://habitica.com/paypal/subscribe/success' : 'http://localhost:3000/paypal/subscribe/success',
},
payment_definitions: [{
type: 'REGULAR',
frequency: 'MONTH',
cycles: '0',
}],
};
_.each(blocks, function defineBlock (block) {
block.definition = _.cloneDeep(billingPlanAttributes);
_.merge(block.definition.payment_definitions[0], {
name: `${BILLING_PLAN_TITLE} (\$${block.price} every ${block.months} months, recurring)`,
frequency_interval: `${block.months}`,
amount: {
currency: 'USD',
value: `${block.price}`,
},
});
});
let update = {
op: 'replace',
path: '/merchant_preferences',
value: {
cancel_url: 'https://habitica.com',
},
};
switch (OP) {
case 'list':
paypal.billingPlan.list({status: 'ACTIVE'}, function listPlans () {
// TODO Was a console.log statement. Need proper response output
});
break;
case 'get':
paypal.billingPlan.get(nconf.get('PAYPAL:billing_plans:12'), function getPlan () {
// TODO Was a console.log statement. Need proper response output
});
break;
case 'update':
paypal.billingPlan.update(nconf.get('PAYPAL:billing_plans:12'), update, function updatePlan () {
// TODO Was a console.log statement. Need proper response output
});
break;
case 'create':
paypal.billingPlan.create(blocks.google_6mo.definition, function createPlan (err, plan) {
if (err) return; // TODO Was a console.log statement. Need proper response output
if (plan.state === 'ACTIVE')
return; // TODO Was a console.log statement. Need proper response output
let billingPlanUpdateAttributes = [{
op: 'replace',
path: '/',
value: {
state: 'ACTIVE',
},
}];
// Activate the plan by changing status to Active
paypal.billingPlan.update(plan.id, billingPlanUpdateAttributes, function activatePlan () {
// TODO Was a console.log statement. Need proper response output
});
});
break;
case 'remove': break;
}
/* eslint-enable camelcase */

View File

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

View File

@@ -1,9 +1,13 @@
import amazonPayments from 'amazon-payments'; import amazonPayments from 'amazon-payments';
import nconf from 'nconf'; import nconf from 'nconf';
import common from '../../../../common'; import common from '../../../../common';
let t = common.i18n.t;
const IS_PROD = nconf.get('NODE_ENV') === 'production';
import Q from 'q'; import Q from 'q';
import {
BadRequest,
} from './errors';
const t = common.i18n.t;
const IS_PROD = nconf.get('NODE_ENV') === 'production';
let amzPayment = amazonPayments.connect({ let amzPayment = amazonPayments.connect({
environment: amazonPayments.Environment[IS_PROD ? 'Production' : 'Sandbox'], environment: amazonPayments.Environment[IS_PROD ? 'Production' : 'Sandbox'],
@@ -13,10 +17,6 @@ let amzPayment = amazonPayments.connect({
clientId: nconf.get('AMAZON_PAYMENTS:CLIENT_ID'), clientId: nconf.get('AMAZON_PAYMENTS:CLIENT_ID'),
}); });
/**
* From: https://payments.amazon.com/documentation/apireference/201751670#201751670
*/
let getTokenInfo = Q.nbind(amzPayment.api.getTokenInfo, amzPayment.api); let getTokenInfo = Q.nbind(amzPayment.api.getTokenInfo, amzPayment.api);
let createOrderReferenceId = Q.nbind(amzPayment.offAmazonPayments.createOrderReferenceForId, amzPayment.offAmazonPayments); let createOrderReferenceId = Q.nbind(amzPayment.offAmazonPayments.createOrderReferenceForId, amzPayment.offAmazonPayments);
let setOrderReferenceDetails = Q.nbind(amzPayment.offAmazonPayments.setOrderReferenceDetails, amzPayment.offAmazonPayments); let setOrderReferenceDetails = Q.nbind(amzPayment.offAmazonPayments.setOrderReferenceDetails, amzPayment.offAmazonPayments);
@@ -30,7 +30,7 @@ let authorizeOnBillingAgreement = (inputSet) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
amzPayment.offAmazonPayments.authorizeOnBillingAgreement(inputSet, (err, response) => { amzPayment.offAmazonPayments.authorizeOnBillingAgreement(inputSet, (err, response) => {
if (err) return reject(err); if (err) return reject(err);
if (response.AuthorizationDetails.AuthorizationStatus.State === 'Declined') return reject(t('paymentNotSuccessful')); if (response.AuthorizationDetails.AuthorizationStatus.State === 'Declined') return reject(new BadRequest(t('paymentNotSuccessful')));
return resolve(response); return resolve(response);
}); });
}); });
@@ -40,7 +40,7 @@ let authorize = (inputSet) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
amzPayment.offAmazonPayments.authorize(inputSet, (err, response) => { amzPayment.offAmazonPayments.authorize(inputSet, (err, response) => {
if (err) return reject(err); if (err) return reject(err);
if (response.AuthorizationDetails.AuthorizationStatus.State === 'Declined') return reject(t('paymentNotSuccessful')); if (response.AuthorizationDetails.AuthorizationStatus.State === 'Declined') return reject(new BadRequest(t('paymentNotSuccessful')));
return resolve(response); return resolve(response);
}); });
}); });

View File

@@ -10,10 +10,6 @@ import nconf from 'nconf';
import pushNotify from './pushNotifications'; import pushNotify from './pushNotifications';
import shared from '../../../../common' ; import shared from '../../../../common' ;
import iap from '../../controllers/top-level/payments/iap';
import paypal from '../../controllers/top-level/payments/paypal';
import stripe from '../../controllers/top-level/payments/stripe';
const IS_PROD = nconf.get('IS_PROD'); const IS_PROD = nconf.get('IS_PROD');
let api = {}; let api = {};
@@ -45,6 +41,7 @@ api.createSubscription = async function createSubscription (data) {
plan.dateTerminated = moment(plan.dateTerminated).add({months}).toDate(); plan.dateTerminated = moment(plan.dateTerminated).add({months}).toDate();
if (!plan.dateUpdated) plan.dateUpdated = new Date(); if (!plan.dateUpdated) plan.dateUpdated = new Date();
} }
if (!plan.customerId) plan.customerId = 'Gift'; // don't override existing customer, but all sub need a customerId if (!plan.customerId) plan.customerId = 'Gift'; // don't override existing customer, but all sub need a customerId
} else { } else {
_(plan).merge({ // override with these values _(plan).merge({ // override with these values
@@ -73,12 +70,13 @@ api.createSubscription = async function createSubscription (data) {
if (plan.consecutive.gemCapExtra > 25) plan.consecutive.gemCapExtra = 25; if (plan.consecutive.gemCapExtra > 25) plan.consecutive.gemCapExtra = 25;
plan.consecutive.trinkets += perks; plan.consecutive.trinkets += perks;
} }
revealMysteryItems(recipient); revealMysteryItems(recipient);
if (IS_PROD) { if (IS_PROD) {
if (!data.gift) txnEmail(data.user, 'subscription-begins'); if (!data.gift) txnEmail(data.user, 'subscription-begins');
let analyticsData = { analytics.trackPurchase({
uuid: data.user._id, uuid: data.user._id,
itemPurchased: 'Subscription', itemPurchased: 'Subscription',
sku: `${data.paymentMethod.toLowerCase()}-subscription`, sku: `${data.paymentMethod.toLowerCase()}-subscription`,
@@ -87,8 +85,7 @@ api.createSubscription = async function createSubscription (data) {
quantity: 1, quantity: 1,
gift: Boolean(data.gift), gift: Boolean(data.gift),
purchaseValue: block.price, purchaseValue: block.price,
}; });
analytics.trackPurchase(analyticsData);
} }
data.user.purchased.txnCount++; data.user.purchased.txnCount++;
@@ -114,9 +111,7 @@ api.createSubscription = async function createSubscription (data) {
if (data.gift) await data.gift.member.save(); if (data.gift) await data.gift.member.save();
}; };
/** // Sets their subscription to be cancelled later
* Sets their subscription to be cancelled later
*/
api.cancelSubscription = async function cancelSubscription (data) { api.cancelSubscription = async function cancelSubscription (data) {
let plan = data.user.purchased.plan; let plan = data.user.purchased.plan;
let now = moment(); let now = moment();
@@ -146,12 +141,14 @@ api.cancelSubscription = async function cancelSubscription (data) {
api.buyGems = async function buyGems (data) { api.buyGems = async function buyGems (data) {
let amt = data.amount || 5; let amt = data.amount || 5;
amt = data.gift ? data.gift.gems.amount / 4 : amt; amt = data.gift ? data.gift.gems.amount / 4 : amt;
(data.gift ? data.gift.member : data.user).balance += amt; (data.gift ? data.gift.member : data.user).balance += amt;
data.user.purchased.txnCount++; data.user.purchased.txnCount++;
if (IS_PROD) { if (IS_PROD) {
if (!data.gift) txnEmail(data.user, 'donation'); if (!data.gift) txnEmail(data.user, 'donation');
let analyticsData = { analytics.trackPurchase({
uuid: data.user._id, uuid: data.user._id,
itemPurchased: 'Gems', itemPurchased: 'Gems',
sku: `${data.paymentMethod.toLowerCase()}-checkout`, sku: `${data.paymentMethod.toLowerCase()}-checkout`,
@@ -160,8 +157,7 @@ api.buyGems = async function buyGems (data) {
quantity: 1, quantity: 1,
gift: Boolean(data.gift), gift: Boolean(data.gift),
purchaseValue: amt, purchaseValue: amt,
}; });
analytics.trackPurchase(analyticsData);
} }
if (data.gift) { if (data.gift) {
@@ -179,23 +175,11 @@ api.buyGems = async function buyGems (data) {
if (data.gift.member._id !== data.user._id) { // Only send push notifications if sending to a user other than yourself 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}`); pushNotify.sendNotify(data.gift.member, shared.i18n.t('giftedGems'), `${gemAmount} Gems - by ${byUsername}`);
} }
await data.gift.member.save(); await data.gift.member.save();
} }
await data.user.save(); await data.user.save();
}; };
api.stripeCheckout = stripe.checkout;
api.stripeSubscribeCancel = stripe.subscribeCancel;
api.stripeSubscribeEdit = stripe.subscribeEdit;
api.paypalSubscribe = paypal.createBillingAgreement;
api.paypalSubscribeSuccess = paypal.executeBillingAgreement;
api.paypalSubscribeCancel = paypal.cancelSubscription;
api.paypalCheckout = paypal.createPayment;
api.paypalCheckoutSuccess = paypal.executePayment;
api.paypalIPN = paypal.ipn;
api.iapAndroidVerify = iap.androidVerify;
api.iapIosVerify = iap.iosVerify;
module.exports = api; module.exports = api;

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) { if (!responseErr || responseErr.httpCode >= 500) {
// Try to identify the error... // Try to identify the error...
// ... // ...

View File

@@ -19,8 +19,6 @@ v2app.use(responseHandler);
// Custom Directives // Custom Directives
v2app.use('/', require('../../routes/api-v2/auth')); 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); require('../../routes/api-v2/swagger')(swagger, v2app);

View File

@@ -5,7 +5,14 @@ import baseModel from '../libs/api-v3/baseModel';
import _ from 'lodash'; import _ from 'lodash';
import * as Tasks from './task'; import * as Tasks from './task';
import { model as User } from './user'; import { model as User } from './user';
import {
model as Group,
TAVERN_ID,
} from './group';
import { removeFromArray } from '../libs/api-v3/collectionManipulators'; 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; 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) // 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 // These will be removed once API v2 is discontinued