From 0114e310eb72122bdd6e4f38332f208e4d238248 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 9 May 2016 22:58:15 +0200 Subject: [PATCH] v3 payments: working IAP and Stripe, move paypalBillingSetup to its own file, closeChal is now a challenge instance method --- .eslintignore | 1 + migrations/api_v3/challenges.js | 2 + migrations/api_v3/users.js | 2 + scripts/paypalBillingSetup.js | 94 +++++++ website/src/controllers/api-v2/challenges.js | 6 +- website/src/controllers/api-v3/auth.js | 3 - website/src/controllers/api-v3/challenges.js | 65 +---- website/src/controllers/api-v3/chat.js | 3 - .../controllers/top-level/payments/amazon.js | 24 +- .../src/controllers/top-level/payments/iap.js | 249 ++++++++++-------- .../controllers/top-level/payments/paypal.js | 36 ++- .../top-level/payments/paypalBillingSetup.js | 98 ------- .../controllers/top-level/payments/stripe.js | 84 +++--- website/src/libs/api-v3/amazonPayments.js | 16 +- website/src/libs/api-v3/payments.js | 38 +-- .../src/middlewares/api-v3/errorHandler.js | 6 + website/src/middlewares/api-v3/v2.js | 2 - website/src/models/challenge.js | 66 +++++ 18 files changed, 414 insertions(+), 381 deletions(-) create mode 100644 scripts/paypalBillingSetup.js delete mode 100644 website/src/controllers/top-level/payments/paypalBillingSetup.js diff --git a/.eslintignore b/.eslintignore index eaef8f567d..b4830a328f 100644 --- a/.eslintignore +++ b/.eslintignore @@ -22,6 +22,7 @@ website/src/middlewares/apiThrottle.js website/src/middlewares/forceRefresh.js debug-scripts/* +scripts/* tasks/*.js gulpfile.js Gruntfile.js diff --git a/migrations/api_v3/challenges.js b/migrations/api_v3/challenges.js index 77972172dd..727492c04a 100644 --- a/migrations/api_v3/challenges.js +++ b/migrations/api_v3/challenges.js @@ -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 diff --git a/migrations/api_v3/users.js b/migrations/api_v3/users.js index be22aee5e0..13db28e768 100644 --- a/migrations/api_v3/users.js +++ b/migrations/api_v3/users.js @@ -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)) { diff --git a/scripts/paypalBillingSetup.js b/scripts/paypalBillingSetup.js new file mode 100644 index 0000000000..d21cd80c1c --- /dev/null +++ b/scripts/paypalBillingSetup.js @@ -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; +} diff --git a/website/src/controllers/api-v2/challenges.js b/website/src/controllers/api-v2/challenges.js index 404fa379d1..aff7b53a80 100644 --- a/website/src/controllers/api-v2/challenges.js +++ b/website/src/controllers/api-v2/challenges.js @@ -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); diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index 867457c07a..8616f889f0 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -2,9 +2,6 @@ import validator from 'validator'; import moment from 'moment'; import passport from 'passport'; import nconf from 'nconf'; -import setupNconf from '../../libs/api-v3/setupNconf'; -setupNconf(); - import { authWithHeaders, } from '../../middlewares/api-v3/auth'; diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index b334dedc41..bb21ced189 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -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, {}); }, }; diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index e07c831f20..d074cb4c7b 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -14,9 +14,6 @@ import { sendTxn } from '../../libs/api-v3/email'; import nconf from 'nconf'; import Q from 'q'; -import setupNconf from '../../libs/api-v3/setupNconf'; -setupNconf(); - const FLAG_REPORT_EMAILS = nconf.get('FLAG_REPORT_EMAIL').split(',').map((email) => { return { email, canSend: true }; }); diff --git a/website/src/controllers/top-level/payments/amazon.js b/website/src/controllers/top-level/payments/amazon.js index 3be2673b79..17b8486389 100644 --- a/website/src/controllers/top-level/payments/amazon.js +++ b/website/src/controllers/top-level/payments/amazon.js @@ -1,5 +1,6 @@ import { BadRequest, + NotAuthorized, } from '../../../libs/api-v3/errors'; import amzLib from '../../../libs/api-v3/amazonPayments'; import { @@ -16,6 +17,7 @@ import cc from 'coupon-code'; let api = {}; /** + * @apiIgnore Payments are considered part of the private API * @api {post} /amazon/verifyAccessToken verify access token * @apiVersion 3.0.0 * @apiName AmazonVerifyAccessToken @@ -23,11 +25,11 @@ let api = {}; * * @apiParam {string} access_token the access token * - * @apiSuccess {} empty + * @apiSuccess {Object} data Empty object **/ api.verifyAccessToken = { method: 'POST', - url: '/payments/amazon/verifyAccessToken', + url: '/amazon/verifyAccessToken', middlewares: [authWithHeaders()], async handler (req, res) { try { @@ -40,6 +42,7 @@ api.verifyAccessToken = { }; /** + * @apiIgnore Payments are considered part of the private API * @api {post} /amazon/createOrderReferenceId create order reference id * @apiVersion 3.0.0 * @apiName AmazonCreateOrderReferenceId @@ -51,7 +54,7 @@ api.verifyAccessToken = { **/ api.createOrderReferenceId = { method: 'POST', - url: '/payments/amazon/createOrderReferenceId', + url: '/amazon/createOrderReferenceId', middlewares: [authWithHeaders()], async handler (req, res) { try { @@ -70,6 +73,7 @@ api.createOrderReferenceId = { }; /** + * @apiIgnore Payments are considered part of the private API * @api {post} /amazon/checkout do checkout * @apiVersion 3.0.0 * @apiName AmazonCheckout @@ -81,7 +85,7 @@ api.createOrderReferenceId = { **/ api.checkout = { method: 'POST', - url: '/payments/amazon/checkout', + url: '/amazon/checkout', middlewares: [authWithHeaders()], async handler (req, res) { 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 * @apiVersion 3.0.0 * @apiName AmazonSubscribe @@ -161,7 +166,7 @@ api.checkout = { **/ api.subscribe = { method: 'POST', - url: '/payments/amazon/subscribe', + url: '/amazon/subscribe', middlewares: [authWithHeaders()], async handler (req, res) { 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 * @apiVersion 3.0.0 * @apiName AmazonSubscribe * @apiGroup Payments - * - * @apiSuccess {object} empty object **/ api.subscribeCancel = { method: 'GET', - url: '/payments/amazon/subscribe/cancel', + 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 BadRequest(res.t('missingSubscription')); + if (!billingAgreementId) throw new NotAuthorized(res.t('missingSubscription')); try { await amzLib.closeBillingAgreement({ @@ -257,7 +261,7 @@ api.subscribeCancel = { }; await payments.cancelSubscription(data); - res.respond(200, {}); + res.redirect('/'); } catch (error) { throw new BadRequest(error.message); } diff --git a/website/src/controllers/top-level/payments/iap.js b/website/src/controllers/top-level/payments/iap.js index 898b0b2015..60e50d6d39 100644 --- a/website/src/controllers/top-level/payments/iap.js +++ b/website/src/controllers/top-level/payments/iap.js @@ -1,5 +1,12 @@ 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 @@ -7,148 +14,178 @@ iap.config({ }); // Validation ERROR Codes -// const INVALID_PAYLOAD = 6778001; +const INVALID_PAYLOAD = 6778001; // const CONNECTION_FAILED = 6778002; -// const PURCHASE_EXPIRED = 6778003; +// const PURCHASE_EXPIRED = 6778003; let api = {}; -/* -api.androidVerify = function androidVerify (req, res) { - let iapBody = req.body; - let user = res.locals.user; +/** + * @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(function googleSetupResult (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) { + iap.setup((error) => { + if (error) { let resObj = { ok: false, - data: { - code: INVALID_PAYLOAD, - message: err.toString(), - }, + data: 'IAP Error', }; return res.json(resObj); } - if (iap.isValidated(googleRes)) { - let resObj = { - ok: true, - data: googleRes, - }; + // 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, + }; - 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; - let user = res.locals.user; +/** + * @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, function iosValidateResult (err, appleRes) { - if (err) { + iap.setup(function iosSetupResult (error) { + if (error) { let resObj = { ok: false, - data: { - code: INVALID_PAYLOAD, - message: err.toString(), - }, + data: 'IAP Error', }; 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; + // 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); } } - 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); } - // wrong receipt content + + // invalid receipt let resObj = { ok: false, data: { 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; diff --git a/website/src/controllers/top-level/payments/paypal.js b/website/src/controllers/top-level/payments/paypal.js index 841bc0546b..1a3240d82e 100644 --- a/website/src/controllers/top-level/payments/paypal.js +++ b/website/src/controllers/top-level/payments/paypal.js @@ -1,3 +1,5 @@ +/* eslint-disable camelcase */ + import nconf from 'nconf'; import moment from 'moment'; import _ from 'lodash'; @@ -17,6 +19,8 @@ import { } from '../../../libs/api-v3/errors'; 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 // 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 @@ -24,8 +28,6 @@ _.each(shared.content.subscriptionBlocks, (block) => { block.paypalKey = nconf.get(`PAYPAL:billing_plans:${block.key}`); }); -/* eslint-disable camelcase */ - paypal.configure({ mode: nconf.get('PAYPAL:mode'), // sandbox or live client_id: nconf.get('PAYPAL:client_id'), @@ -35,18 +37,18 @@ paypal.configure({ 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 * @apiName PaypalCheckout * @apiGroup Payments * - * @apiParam {string} gift The stringified object representing the user, the gift recepient. - * - * @apiSuccess {} redirect + * @apiParam {string} gift Query parameter - The stringified object representing the user, the gift recepient. **/ api.checkout = { method: 'GET', - url: '/payments/paypal/checkout', + url: '/paypal/checkout', middlewares: [authWithUrl], async handler (req, res) { let gift = req.query.gift ? JSON.parse(req.query.gift) : undefined; @@ -68,8 +70,8 @@ api.checkout = { intent: 'sale', payer: { payment_method: 'Paypal' }, redirect_urls: { - return_url: `${nconf.get('BASE_URL')}/paypal/checkout/success`, - cancel_url: `${nconf.get('BASE_URL')}`, + return_url: `${BASE_URL}/paypal/checkout/success`, + cancel_url: `${BASE_URL}`, }, transactions: [{ item_list: { @@ -87,6 +89,7 @@ api.checkout = { description, }], }; + try { let result = await paypal.payment.create(createPayment); 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 * @apiVersion 3.0.0 * @apiName PaypalCheckoutSuccess @@ -110,7 +114,7 @@ api.checkout = { **/ api.checkoutSuccess = { method: 'GET', - url: '/payments/paypal/checkout/success', + url: '/paypal/checkout/success', middlewares: [authWithSession], async handler (req, res) { 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 * @apiVersion 3.0.0 * @apiName PaypalSubscribe @@ -156,7 +161,7 @@ api.checkoutSuccess = { **/ api.subscribe = { method: 'GET', - url: '/payments/paypal/subscribe', + url: '/paypal/subscribe', middlewares: [authWithUrl], async handler (req, res) { 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 * @apiVersion 3.0.0 * @apiName PaypalSubscribeSuccess @@ -201,7 +207,7 @@ api.subscribe = { **/ api.subscribeSuccess = { method: 'GET', - url: '/payments/paypal/subscribe/success', + url: '/paypal/subscribe/success', middlewares: [authWithSession], async handler (req, res) { 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 * @apiVersion 3.0.0 * @apiName PaypalSubscribeCancel @@ -234,7 +241,7 @@ api.subscribeSuccess = { **/ api.subscribeCancel = { method: 'GET', - url: '/payments/paypal/subscribe/cancel', + url: '/paypal/subscribe/cancel', middlewares: [authWithUrl], async handler (req, res) { 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 * @apiVersion 3.0.0 * @apiName PaypalIpn @@ -273,7 +281,7 @@ api.subscribeCancel = { **/ api.ipn = { method: 'POST', - url: '/payments/paypal/ipn', + url: '/paypal/ipn', middlewares: [], async handler (req, res) { res.respond(200); diff --git a/website/src/controllers/top-level/payments/paypalBillingSetup.js b/website/src/controllers/top-level/payments/paypalBillingSetup.js deleted file mode 100644 index 1015e8f89e..0000000000 --- a/website/src/controllers/top-level/payments/paypalBillingSetup.js +++ /dev/null @@ -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 */ diff --git a/website/src/controllers/top-level/payments/stripe.js b/website/src/controllers/top-level/payments/stripe.js index d211a60d89..a319d2d4f9 100644 --- a/website/src/controllers/top-level/payments/stripe.js +++ b/website/src/controllers/top-level/payments/stripe.js @@ -2,6 +2,7 @@ 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'; @@ -18,22 +19,23 @@ 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 The token - * @apiParam {string} gift stringified json object, gift - * @apiParam {string} sub subscription, possible values are: basic_earned, basic_3mo, basic_6mo, google_6mo, basic_12mo - * @apiParam {string} coupon coupon for the matching subscription, required only for certain subscriptions - * @apiParam {string} email the customer email + * @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 {} empty object + * @apiSuccess {Object} data Empty object **/ api.checkout = { method: 'POST', - url: '/payments/stripe/checkout', + url: '/stripe/checkout', middlewares: [authWithHeaders()], async handler (req, res) { let token = req.body.id; @@ -49,15 +51,16 @@ api.checkout = { coupon = await Coupon.findOne({_id: cc.validate(req.query.coupon), event: sub.key}); if (!coupon) throw new BadRequest(res.t('invalidCoupon')); } - let customer = { + + response = await stripe.customers.create({ email: req.body.email, metadata: { uuid: user._id }, card: token, plan: sub.key, - }; - response = await stripe.customers.create(customer); + }); } else { let amount = 500; // $5 + if (gift) { if (gift.type === 'subscription') { amount = `${shared.content.subscriptionBlocks[gift.subscription.key].price * 100}`; @@ -65,6 +68,7 @@ api.checkout = { amount = `${gift.gems.amount / 4 * 100}`; } } + response = await stripe.charges.create({ amount, currency: 'usd', @@ -87,80 +91,74 @@ api.checkout = { 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, {}); }, }; /** - * @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 * @apiName StripeSubscribeEdit * @apiGroup Payments * - * @apiParam {string} id The token + * @apiParam {string} id Body parameter - The token * - * @apiSuccess {} + * @apiSuccess {Object} data Empty object **/ api.subscribeEdit = { method: 'POST', - url: '/payments/stripe/subscribe/edit', + 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 BadRequest(res.t('missingSubscription')); + if (!customerId) throw new NotAuthorized(res.t('missingSubscription')); - try { - let subscriptions = await stripe.customers.listSubscriptions(customerId); - let subscriptionId = subscriptions.data[0].id; - await stripe.customers.updateSubscription(customerId, subscriptionId, { card: token }); - res.respond(200, {}); - } catch (error) { - throw new BadRequest(error.message); - } + let subscriptions = await stripe.customers.listSubscriptions(customerId); + let subscriptionId = subscriptions.data[0].id; + await stripe.customers.updateSubscription(customerId, subscriptionId, { card: token }); + res.respond(200, {}); }, }; /** - * @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 * @apiName StripeSubscribeCancel * @apiGroup Payments - * - * @apiParam - * - * @apiSuccess {} **/ api.subscribeCancel = { method: 'GET', - url: '/payments/stripe/subscribe/cancel', + url: '/stripe/subscribe/cancel', middlewares: [authWithUrl], async handler (req, res) { let user = res.locals.user; - if (!user.purchased.plan.customerId) throw new BadRequest(res.t('missingSubscription')); - try { - let customer = await stripe.customers.retrieve(user.purchased.plan.customeerId); - await stripe.customers.del(user.purchased.plan.customerId); - let data = { - user, - nextBill: customer.subscription.current_period_end * 1000, // timestamp in seconds - paymentMethod: 'Stripe', - }; - await payments.cancelSubscriptoin(data); - res.respond(200, {}); - } catch (e) { - throw new BadRequest(e); - } + 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('/'); }, }; diff --git a/website/src/libs/api-v3/amazonPayments.js b/website/src/libs/api-v3/amazonPayments.js index 38403ba4d0..c22b3cfb3b 100644 --- a/website/src/libs/api-v3/amazonPayments.js +++ b/website/src/libs/api-v3/amazonPayments.js @@ -1,9 +1,13 @@ import amazonPayments from 'amazon-payments'; import nconf from 'nconf'; import common from '../../../../common'; -let t = common.i18n.t; -const IS_PROD = nconf.get('NODE_ENV') === 'production'; 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({ environment: amazonPayments.Environment[IS_PROD ? 'Production' : 'Sandbox'], @@ -13,10 +17,6 @@ let amzPayment = amazonPayments.connect({ 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 createOrderReferenceId = Q.nbind(amzPayment.offAmazonPayments.createOrderReferenceForId, amzPayment.offAmazonPayments); let setOrderReferenceDetails = Q.nbind(amzPayment.offAmazonPayments.setOrderReferenceDetails, amzPayment.offAmazonPayments); @@ -30,7 +30,7 @@ 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(t('paymentNotSuccessful')); + if (response.AuthorizationDetails.AuthorizationStatus.State === 'Declined') return reject(new BadRequest(t('paymentNotSuccessful'))); return resolve(response); }); }); @@ -40,7 +40,7 @@ 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(t('paymentNotSuccessful')); + if (response.AuthorizationDetails.AuthorizationStatus.State === 'Declined') return reject(new BadRequest(t('paymentNotSuccessful'))); return resolve(response); }); }); diff --git a/website/src/libs/api-v3/payments.js b/website/src/libs/api-v3/payments.js index 8a28e7d1a9..5a9c888972 100644 --- a/website/src/libs/api-v3/payments.js +++ b/website/src/libs/api-v3/payments.js @@ -10,10 +10,6 @@ import nconf from 'nconf'; import pushNotify from './pushNotifications'; 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'); let api = {}; @@ -45,6 +41,7 @@ api.createSubscription = async function createSubscription (data) { 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 @@ -73,12 +70,13 @@ api.createSubscription = async function createSubscription (data) { 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'); - let analyticsData = { + analytics.trackPurchase({ uuid: data.user._id, itemPurchased: 'Subscription', sku: `${data.paymentMethod.toLowerCase()}-subscription`, @@ -87,8 +85,7 @@ api.createSubscription = async function createSubscription (data) { quantity: 1, gift: Boolean(data.gift), purchaseValue: block.price, - }; - analytics.trackPurchase(analyticsData); + }); } data.user.purchased.txnCount++; @@ -114,9 +111,7 @@ api.createSubscription = async function createSubscription (data) { 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) { let plan = data.user.purchased.plan; let now = moment(); @@ -146,12 +141,14 @@ api.cancelSubscription = async function cancelSubscription (data) { 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'); - let analyticsData = { + analytics.trackPurchase({ uuid: data.user._id, itemPurchased: 'Gems', sku: `${data.paymentMethod.toLowerCase()}-checkout`, @@ -160,8 +157,7 @@ api.buyGems = async function buyGems (data) { quantity: 1, gift: Boolean(data.gift), purchaseValue: amt, - }; - analytics.trackPurchase(analyticsData); + }); } 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 pushNotify.sendNotify(data.gift.member, shared.i18n.t('giftedGems'), `${gemAmount} Gems - by ${byUsername}`); } + await data.gift.member.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; diff --git a/website/src/middlewares/api-v3/errorHandler.js b/website/src/middlewares/api-v3/errorHandler.js index d775292a5b..027e25301b 100644 --- a/website/src/middlewares/api-v3/errorHandler.js +++ b/website/src/middlewares/api-v3/errorHandler.js @@ -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... // ... diff --git a/website/src/middlewares/api-v3/v2.js b/website/src/middlewares/api-v3/v2.js index 4eb2686bdc..ec49e326a4 100644 --- a/website/src/middlewares/api-v3/v2.js +++ b/website/src/middlewares/api-v3/v2.js @@ -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); diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index caa823acc1..ee0f7209a1 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -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