diff --git a/.eslintignore b/.eslintignore index 5a862c4394..b4830a328f 100644 --- a/.eslintignore +++ b/.eslintignore @@ -20,9 +20,9 @@ website/src/routes/payments.js website/src/routes/pages.js website/src/middlewares/apiThrottle.js website/src/middlewares/forceRefresh.js -website/src/controllers/payments/ debug-scripts/* +scripts/* tasks/*.js gulpfile.js Gruntfile.js diff --git a/.eslintrc b/.eslintrc index 111772a5a3..bcccde1ef6 100644 --- a/.eslintrc +++ b/.eslintrc @@ -2,5 +2,8 @@ "extends": [ "habitrpg/server", "habitrpg/babel" - ] + ], + "globals": { + "Promise": true + } } diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 9f0df52127..e0e768adf8 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -1,5 +1,6 @@ { "missingAuthHeaders": "Missing authentication headers.", + "missingAuthParams": "Missing authentication parameters.", "missingUsernameEmail": "Missing username or email.", "missingEmail": "Missing email.", "missingUsername": "Missing username.", @@ -100,6 +101,8 @@ "noAdminAccess": "You don't have admin access.", "pageMustBeNumber": "req.query.page must be a number", "missingUnsubscriptionCode": "Missing unsubscription code.", + "missingSubscription": "User does not have a plan subscription", + "missingSubscriptionCode": "Missing subscription code. Possible values: basic_earned, basic_3mo, basic_6mo, google_6mo, basic_12mo.", "userNotFound": "User not found.", "spellNotFound": "Spell \"<%= spellId %>\" not found.", "partyNotFound": "Party not found", @@ -171,5 +174,8 @@ "pushDeviceAlreadyAdded": "The user already has the push device", "resetComplete": "Reset completed", "lvl10ChangeClass": "To change class you must be at least level 10.", - "equipmentAlreadyOwned": "You already own that piece of equipment" + "equipmentAlreadyOwned": "You already own that piece of equipment", + "paymentNotSuccessful": "The payment was not successful", + "planNotActive": "The plan hasn't activated yet (due to a PayPal bug). It will begin <%= nextBillingDate %>, after which you can cancel to retain your full benefits", + "cancelingSubscription": "Canceling the subscription" } 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/website/src/controllers/payments/paypalBillingSetup.js b/scripts/paypalBillingSetup.js similarity index 99% rename from website/src/controllers/payments/paypalBillingSetup.js rename to scripts/paypalBillingSetup.js index 2effcbd81d..d21cd80c1c 100644 --- a/website/src/controllers/payments/paypalBillingSetup.js +++ b/scripts/paypalBillingSetup.js @@ -2,14 +2,16 @@ // payment plan definitions, instead you have to create it via their REST SDK and keep it updated the same way. So this // file will be used once for initing your billing plan (then you get the resultant plan.id to store in config.json), // and once for any time you need to edit the plan thereafter + var path = require('path'); var nconf = require('nconf'); -_ = require('lodash'); -nconf.argv().env().file('user', path.join(path.resolve(__dirname, '../../../config.json'))); +var _ = require('lodash'); var paypal = require('paypal-rest-sdk'); var blocks = require('../../../../common').content.subscriptionBlocks; var live = nconf.get('PAYPAL:mode')=='live'; +nconf.argv().env().file('user', path.join(path.resolve(__dirname, '../../../config.json'))); + var OP = 'create'; // list create update remove paypal.configure({ diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js index d8a9917eef..8d2d49876a 100644 --- a/tasks/gulp-tests.js +++ b/tasks/gulp-tests.js @@ -358,6 +358,10 @@ gulp.task('test:api-v3:unit', (done) => { pipe(runner); }); +gulp.task('test:api-v3:unit:watch', () => { + gulp.watch(['website/src/libs/api-v3/*', 'test/api/v3/unit/**/*', 'website/src/controllers/**/*'], ['test:api-v3:unit']); +}); + gulp.task('test:api-v3:integration', (done) => { let runner = exec( testBin('mocha test/api/v3/integration --recursive'), @@ -369,7 +373,8 @@ gulp.task('test:api-v3:integration', (done) => { }); gulp.task('test:api-v3:integration:watch', () => { - gulp.watch(['website/src/controllers/api-v3/**/*', 'test/api/v3/integration/**/*', 'common/script/ops/*'], ['test:api-v3:integration']); + gulp.watch(['website/src/controllers/api-v3/**/*', 'common/script/ops/*', 'website/src/libs/api-v3/*.js', + 'test/api/v3/integration/**/*'], ['test:api-v3:integration']); }); gulp.task('test:api-v3:integration:separate-server', (done) => { diff --git a/test/api/v3/integration/payments/GET-payments_amazon_subscribe_cancel.test.js b/test/api/v3/integration/payments/GET-payments_amazon_subscribe_cancel.test.js new file mode 100644 index 0000000000..007c58f4f7 --- /dev/null +++ b/test/api/v3/integration/payments/GET-payments_amazon_subscribe_cancel.test.js @@ -0,0 +1,21 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +describe('payments : amazon #subscribeCancel', () => { + let endpoint = '/amazon/subscribe/cancel'; + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('verifies subscription', async () => { + await expect(user.get(endpoint)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('missingAuthParams'), + }); + }); +}); diff --git a/test/api/v3/integration/payments/GET-payments_paypal_checkout.test.js b/test/api/v3/integration/payments/GET-payments_paypal_checkout.test.js new file mode 100644 index 0000000000..25fc501000 --- /dev/null +++ b/test/api/v3/integration/payments/GET-payments_paypal_checkout.test.js @@ -0,0 +1,21 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +describe('payments : paypal #checkout', () => { + let endpoint = '/paypal/checkout'; + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('verifies subscription', async () => { + await expect(user.get(endpoint)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('missingAuthParams'), + }); + }); +}); diff --git a/test/api/v3/integration/payments/GET-payments_paypal_checkout_success.test.js b/test/api/v3/integration/payments/GET-payments_paypal_checkout_success.test.js new file mode 100644 index 0000000000..346b8ce847 --- /dev/null +++ b/test/api/v3/integration/payments/GET-payments_paypal_checkout_success.test.js @@ -0,0 +1,21 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +describe('payments : paypal #checkoutSuccess', () => { + let endpoint = '/paypal/checkout/success'; + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('verifies subscription', async () => { + await expect(user.get(endpoint)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('invalidCredentials'), + }); + }); +}); diff --git a/test/api/v3/integration/payments/GET-payments_paypal_subscribe.test.js b/test/api/v3/integration/payments/GET-payments_paypal_subscribe.test.js new file mode 100644 index 0000000000..c52309675a --- /dev/null +++ b/test/api/v3/integration/payments/GET-payments_paypal_subscribe.test.js @@ -0,0 +1,21 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +describe('payments : paypal #subscribe', () => { + let endpoint = '/paypal/subscribe'; + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('verifies credentials', async () => { + await expect(user.get(endpoint)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('missingAuthParams'), + }); + }); +}); diff --git a/test/api/v3/integration/payments/GET-payments_paypal_subscribe_cancel.test.js b/test/api/v3/integration/payments/GET-payments_paypal_subscribe_cancel.test.js new file mode 100644 index 0000000000..890bc864b6 --- /dev/null +++ b/test/api/v3/integration/payments/GET-payments_paypal_subscribe_cancel.test.js @@ -0,0 +1,21 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +describe('payments : paypal #subscribeCancel', () => { + let endpoint = '/paypal/subscribe/cancel'; + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('verifies credentials', async () => { + await expect(user.get(endpoint)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('missingAuthParams'), + }); + }); +}); diff --git a/test/api/v3/integration/payments/GET-payments_paypal_subscribe_success.test.js b/test/api/v3/integration/payments/GET-payments_paypal_subscribe_success.test.js new file mode 100644 index 0000000000..31bae03e40 --- /dev/null +++ b/test/api/v3/integration/payments/GET-payments_paypal_subscribe_success.test.js @@ -0,0 +1,21 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +describe('payments : paypal #subscribeSuccess', () => { + let endpoint = '/paypal/subscribe/success'; + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('verifies credentials', async () => { + await expect(user.get(endpoint)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('invalidCredentials'), + }); + }); +}); diff --git a/test/api/v3/integration/payments/GET-payments_stripe_subscribe_cancel.test.js b/test/api/v3/integration/payments/GET-payments_stripe_subscribe_cancel.test.js new file mode 100644 index 0000000000..68747eb535 --- /dev/null +++ b/test/api/v3/integration/payments/GET-payments_stripe_subscribe_cancel.test.js @@ -0,0 +1,21 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +describe('payments - stripe - #subscribeCancel', () => { + let endpoint = '/stripe/subscribe/cancel'; + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('verifies credentials', async () => { + await expect(user.get(endpoint)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('missingAuthParams'), + }); + }); +}); diff --git a/test/api/v3/integration/payments/POST-payments_amazon_checkout.test.js b/test/api/v3/integration/payments/POST-payments_amazon_checkout.test.js new file mode 100644 index 0000000000..8745a74e85 --- /dev/null +++ b/test/api/v3/integration/payments/POST-payments_amazon_checkout.test.js @@ -0,0 +1,20 @@ +import { + generateUser, +} from '../../../../helpers/api-integration/v3'; + +describe('payments - amazon - #checkout', () => { + let endpoint = '/amazon/checkout'; + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('verifies credentials', async () => { + await expect(user.post(endpoint)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: 'Missing req.body.orderReferenceId', + }); + }); +}); diff --git a/test/api/v3/integration/payments/POST-payments_amazon_createOrderReferenceId.test.js b/test/api/v3/integration/payments/POST-payments_amazon_createOrderReferenceId.test.js new file mode 100644 index 0000000000..17a50520eb --- /dev/null +++ b/test/api/v3/integration/payments/POST-payments_amazon_createOrderReferenceId.test.js @@ -0,0 +1,22 @@ +import { + generateUser, +} from '../../../../helpers/api-integration/v3'; + +describe('payments - amazon - #createOrderReferenceId', () => { + let endpoint = '/amazon/createOrderReferenceId'; + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('verifies billingAgreementId', async (done) => { + try { + await user.post(endpoint); + } catch (e) { + // Parameter AWSAccessKeyId cannot be empty. + expect(e.error).to.eql('BadRequest'); + done(); + } + }); +}); diff --git a/test/api/v3/integration/payments/POST-payments_amazon_subscribe.test.js b/test/api/v3/integration/payments/POST-payments_amazon_subscribe.test.js new file mode 100644 index 0000000000..5c3b98ad87 --- /dev/null +++ b/test/api/v3/integration/payments/POST-payments_amazon_subscribe.test.js @@ -0,0 +1,21 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +describe('payments - amazon - #subscribe', () => { + let endpoint = '/amazon/subscribe'; + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('verifies subscription code', async () => { + await expect(user.post(endpoint)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('missingSubscriptionCode'), + }); + }); +}); diff --git a/test/api/v3/integration/payments/POST-payments_amazon_verifyAccessToken.test.js b/test/api/v3/integration/payments/POST-payments_amazon_verifyAccessToken.test.js new file mode 100644 index 0000000000..51ccf8c41c --- /dev/null +++ b/test/api/v3/integration/payments/POST-payments_amazon_verifyAccessToken.test.js @@ -0,0 +1,20 @@ +import { + generateUser, +} from '../../../../helpers/api-integration/v3'; + +describe('payments : amazon', () => { + let endpoint = '/amazon/verifyAccessToken'; + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('verifies access token', async () => { + await expect(user.post(endpoint)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: 'Missing req.body.access_token', + }); + }); +}); diff --git a/test/api/v3/integration/payments/POST-payments_paypal_ipn.test.js b/test/api/v3/integration/payments/POST-payments_paypal_ipn.test.js new file mode 100644 index 0000000000..219e9ce35b --- /dev/null +++ b/test/api/v3/integration/payments/POST-payments_paypal_ipn.test.js @@ -0,0 +1,17 @@ +import { + generateUser, +} from '../../../../helpers/api-integration/v3'; + +describe('payments - paypal - #ipn', () => { + let endpoint = '/paypal/ipn'; + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('verifies credentials', async () => { + let result = await user.post(endpoint); + expect(result).to.eql('OK'); + }); +}); diff --git a/test/api/v3/integration/payments/POST-payments_stripe_checkout.test.js b/test/api/v3/integration/payments/POST-payments_stripe_checkout.test.js new file mode 100644 index 0000000000..1443a3af74 --- /dev/null +++ b/test/api/v3/integration/payments/POST-payments_stripe_checkout.test.js @@ -0,0 +1,20 @@ +import { + generateUser, +} from '../../../../helpers/api-integration/v3'; + +describe('payments - stripe - #checkout', () => { + let endpoint = '/stripe/checkout'; + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('verifies credentials', async () => { + await expect(user.post(endpoint, {id: 123})).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'Error', + message: 'Invalid API Key provided: ****************************1111', + }); + }); +}); diff --git a/test/api/v3/integration/payments/POST-payments_stripe_subscribe_edit.test.js b/test/api/v3/integration/payments/POST-payments_stripe_subscribe_edit.test.js new file mode 100644 index 0000000000..d6d568ace4 --- /dev/null +++ b/test/api/v3/integration/payments/POST-payments_stripe_subscribe_edit.test.js @@ -0,0 +1,21 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +describe('payments - stripe - #subscribeEdit', () => { + let endpoint = '/stripe/subscribe/edit'; + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('verifies credentials', async () => { + await expect(user.post(endpoint)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('missingSubscription'), + }); + }); +}); diff --git a/test/api/v3/unit/libs/payments.test.js b/test/api/v3/unit/libs/payments.test.js new file mode 100644 index 0000000000..bc4a3e647d --- /dev/null +++ b/test/api/v3/unit/libs/payments.test.js @@ -0,0 +1,72 @@ +import * as sender from '../../../../../website/src/libs/api-v3/email'; +import * as api from '../../../../../website/src/libs/api-v3/payments'; +import { model as User } from '../../../../../website/src/models/user'; +import moment from 'moment'; + +describe('payments/index', () => { + let fakeSend; + let data; + let user; + + describe('#createSubscription', () => { + beforeEach(async () => { + user = new User(); + }); + + it('succeeds', async () => { + data = { user, sub: { key: 'basic_3mo' } }; + expect(user.purchased.plan.planId).to.not.exist; + await api.createSubscription(data); + expect(user.purchased.plan.planId).to.exist; + }); + }); + + describe('#cancelSubscription', () => { + beforeEach(() => { + fakeSend = sinon.spy(sender, 'sendTxn'); + data = { user: new User() }; + }); + + afterEach(() => { + fakeSend.restore(); + }); + + it('plan.extraMonths is defined', () => { + api.cancelSubscription(data); + let terminated = data.user.purchased.plan.dateTerminated; + data.user.purchased.plan.extraMonths = 2; + api.cancelSubscription(data); + let difference = Math.abs(moment(terminated).diff(data.user.purchased.plan.dateTerminated, 'days')); + expect(difference - 60).to.be.lessThan(3); // the difference is approximately two months, +/- 2 days + }); + + it('plan.extraMonth is a fraction', () => { + api.cancelSubscription(data); + let terminated = data.user.purchased.plan.dateTerminated; + data.user.purchased.plan.extraMonths = 0.3; + api.cancelSubscription(data); + let difference = Math.abs(moment(terminated).diff(data.user.purchased.plan.dateTerminated, 'days')); + expect(difference - 10).to.be.lessThan(3); // the difference should be 10 days. + }); + + it('nextBill is defined', () => { + api.cancelSubscription(data); + let terminated = data.user.purchased.plan.dateTerminated; + data.nextBill = moment().add({ days: 25 }); + api.cancelSubscription(data); + let difference = Math.abs(moment(terminated).diff(data.user.purchased.plan.dateTerminated, 'days')); + expect(difference - 5).to.be.lessThan(2); // the difference should be 5 days, +/- 1 day + }); + + it('saves the canceled subscription for the user', () => { + expect(data.user.purchased.plan.dateTerminated).to.not.exist; + api.cancelSubscription(data); + expect(data.user.purchased.plan.dateTerminated).to.exist; + }); + + it('sends a text', async () => { + await api.cancelSubscription(data); + sinon.assert.called(fakeSend); + }); + }); +}); diff --git a/test/helpers/api-integration/requester.js b/test/helpers/api-integration/requester.js index 1be38efe16..ee3adf243f 100644 --- a/test/helpers/api-integration/requester.js +++ b/test/helpers/api-integration/requester.js @@ -33,7 +33,7 @@ function _requestMaker (user, method, additionalSets = {}) { let url = `http://localhost:${API_TEST_SERVER_PORT}`; // do not prefix with api/apiVersion requests to top level routes like dataexport and payments - if (route.indexOf('/export') === 0 || route.indexOf('/payments') === 0) { + if (route.indexOf('/export') === 0 || route.indexOf('/paypal') === 0 || route.indexOf('/amazon') === 0 || route.indexOf('/stripe') === 0) { url += `${route}`; } else { url += `/api/${apiVersion}${route}`; diff --git a/website/public/js/services/paymentServices.js b/website/public/js/services/paymentServices.js index a3fc832d24..fad384befc 100644 --- a/website/public/js/services/paymentServices.js +++ b/website/public/js/services/paymentServices.js @@ -37,7 +37,7 @@ function($rootScope, User, $http, Content) { $http.post(url, res).success(function() { window.location.reload(true); }).error(function(res) { - alert(res.err); + alert(res.message); }); } }); @@ -55,7 +55,7 @@ function($rootScope, User, $http, Content) { $http.post(url, data).success(function() { window.location.reload(true); }).error(function(data) { - alert(data.err); + alert(data.message); }); } }); @@ -127,12 +127,12 @@ function($rootScope, User, $http, Content) { var url = '/amazon/createOrderReferenceId' $http.post(url, { billingAgreementId: Payments.amazonPayments.billingAgreementId - }).success(function(data){ + }).success(function(res){ Payments.amazonPayments.loggedIn = true; - Payments.amazonPayments.orderReferenceId = data.orderReferenceId; + Payments.amazonPayments.orderReferenceId = res.data.orderReferenceId; Payments.amazonPayments.initWidgets(); }).error(function(res){ - alert(res.err); + alert(res.message); }); } }, @@ -146,7 +146,7 @@ function($rootScope, User, $http, Content) { var url = '/amazon/verifyAccessToken' $http.post(url, response).error(function(res){ - alert(res.err); + alert(res.message); }); }); }, @@ -232,7 +232,7 @@ function($rootScope, User, $http, Content) { Payments.amazonPayments.reset(); window.location.reload(true); }).error(function(res){ - alert(res.err); + alert(res.message); Payments.amazonPayments.reset(); }); }else if(Payments.amazonPayments.type === 'subscription'){ @@ -246,7 +246,7 @@ function($rootScope, User, $http, Content) { Payments.amazonPayments.reset(); window.location.reload(true); }).error(function(res){ - alert(res.err); + alert(res.message); Payments.amazonPayments.reset(); }); } 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/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/payments/amazon.js b/website/src/controllers/payments/amazon.js deleted file mode 100644 index 8c01663c10..0000000000 --- a/website/src/controllers/payments/amazon.js +++ /dev/null @@ -1,271 +0,0 @@ -var amazonPayments = require('amazon-payments'); -var mongoose = require('mongoose'); -var moment = require('moment'); -var nconf = require('nconf'); -var async = require('async'); -var User = require('mongoose').model('User'); -var shared = require('../../../../common'); -var payments = require('./index'); -var cc = require('coupon-code'); -var isProd = nconf.get('NODE_ENV') === 'production'; - -var amzPayment = amazonPayments.connect({ - environment: amazonPayments.Environment[isProd ? 'Production' : 'Sandbox'], - sellerId: nconf.get('AMAZON_PAYMENTS:SELLER_ID'), - mwsAccessKey: nconf.get('AMAZON_PAYMENTS:MWS_KEY'), - mwsSecretKey: nconf.get('AMAZON_PAYMENTS:MWS_SECRET'), - clientId: nconf.get('AMAZON_PAYMENTS:CLIENT_ID') -}); - -exports.verifyAccessToken = function(req, res, next){ - if(!req.body || !req.body['access_token']){ - return res.status(400).json({err: 'Access token not supplied.'}); - } - - amzPayment.api.getTokenInfo(req.body['access_token'], function(err, tokenInfo){ - if(err) return res.status(400).json({err:err}); - - res.sendStatus(200); - }); -}; - -exports.createOrderReferenceId = function(req, res, next){ - if(!req.body || !req.body.billingAgreementId){ - return res.status(400).json({err: 'Billing Agreement Id not supplied.'}); - } - - amzPayment.offAmazonPayments.createOrderReferenceForId({ - Id: req.body.billingAgreementId, - IdType: 'BillingAgreement', - ConfirmNow: false - }, function(err, response){ - if(err) return next(err); - if(!response.OrderReferenceDetails || !response.OrderReferenceDetails.AmazonOrderReferenceId){ - return next(new Error('Missing attributes in Amazon response.')); - } - - res.json({ - orderReferenceId: response.OrderReferenceDetails.AmazonOrderReferenceId - }); - }); -}; - -exports.checkout = function(req, res, next){ - if(!req.body || !req.body.orderReferenceId){ - return res.status(400).json({err: 'Billing Agreement Id not supplied.'}); - } - - var gift = req.body.gift; - var user = res.locals.user; - var orderReferenceId = req.body.orderReferenceId; - var amount = 5; - - if(gift){ - if(gift.type === 'gems'){ - amount = gift.gems.amount/4; - }else if(gift.type === 'subscription'){ - amount = shared.content.subscriptionBlocks[gift.subscription.key].price; - } - } - - async.series({ - setOrderReferenceDetails: function(cb){ - amzPayment.offAmazonPayments.setOrderReferenceDetails({ - AmazonOrderReferenceId: orderReferenceId, - OrderReferenceAttributes: { - OrderTotal: { - CurrencyCode: 'USD', - Amount: amount - }, - SellerNote: 'HabitRPG Payment', - SellerOrderAttributes: { - SellerOrderId: shared.uuid(), - StoreName: 'HabitRPG' - } - } - }, cb); - }, - - confirmOrderReference: function(cb){ - amzPayment.offAmazonPayments.confirmOrderReference({ - AmazonOrderReferenceId: orderReferenceId - }, cb); - }, - - authorize: function(cb){ - amzPayment.offAmazonPayments.authorize({ - AmazonOrderReferenceId: orderReferenceId, - AuthorizationReferenceId: shared.uuid().substring(0, 32), - AuthorizationAmount: { - CurrencyCode: 'USD', - Amount: amount - }, - SellerAuthorizationNote: 'HabitRPG Payment', - TransactionTimeout: 0, - CaptureNow: true - }, function(err, res){ - if(err) return cb(err); - - if(res.AuthorizationDetails.AuthorizationStatus.State === 'Declined'){ - return cb(new Error('The payment was not successfull.')); - } - - return cb(); - }); - }, - - closeOrderReference: function(cb){ - amzPayment.offAmazonPayments.closeOrderReference({ - AmazonOrderReferenceId: orderReferenceId - }, cb); - }, - - executePayment: function(cb){ - async.waterfall([ - function(cb2){ User.findById(gift ? gift.uuid : undefined, cb2); }, - function(member, cb2){ - var data = {user:user, paymentMethod:'Amazon Payments'}; - var method = 'buyGems'; - - if (gift){ - if (gift.type == 'subscription') method = 'createSubscription'; - gift.member = member; - data.gift = gift; - data.paymentMethod = 'Gift'; - } - - payments[method](data, cb2); - } - ], cb); - } - }, function(err, results){ - if(err) return next(err); - - res.sendStatus(200); - }); - -}; - -exports.subscribe = function(req, res, next){ - if(!req.body || !req.body['billingAgreementId']){ - return res.status(400).json({err: 'Billing Agreement Id not supplied.'}); - } - - var billingAgreementId = req.body.billingAgreementId; - var sub = req.body.subscription ? shared.content.subscriptionBlocks[req.body.subscription] : false; - var coupon = req.body.coupon; - var user = res.locals.user; - - if(!sub){ - return res.status(400).json({err: 'Subscription plan not found.'}); - } - - async.series({ - applyDiscount: function(cb){ - if (!sub.discount) return cb(); - if (!coupon) return cb(new Error('Please provide a coupon code for this plan.')); - mongoose.model('Coupon').findOne({_id:cc.validate(coupon), event:sub.key}, function(err, coupon){ - if(err) return cb(err); - if(!coupon) return cb(new Error('Coupon code not found.')); - cb(); - }); - }, - - setBillingAgreementDetails: function(cb){ - amzPayment.offAmazonPayments.setBillingAgreementDetails({ - AmazonBillingAgreementId: billingAgreementId, - BillingAgreementAttributes: { - SellerNote: 'HabitRPG Subscription', - SellerBillingAgreementAttributes: { - SellerBillingAgreementId: shared.uuid(), - StoreName: 'HabitRPG', - CustomInformation: 'HabitRPG Subscription' - } - } - }, cb); - }, - - confirmBillingAgreement: function(cb){ - amzPayment.offAmazonPayments.confirmBillingAgreement({ - AmazonBillingAgreementId: billingAgreementId - }, cb); - }, - - authorizeOnBillingAgreeement: function(cb){ - amzPayment.offAmazonPayments.authorizeOnBillingAgreement({ - AmazonBillingAgreementId: billingAgreementId, - AuthorizationReferenceId: shared.uuid().substring(0, 32), - AuthorizationAmount: { - CurrencyCode: 'USD', - Amount: sub.price - }, - SellerAuthorizationNote: 'HabitRPG Subscription Payment', - TransactionTimeout: 0, - CaptureNow: true, - SellerNote: 'HabitRPG Subscription Payment', - SellerOrderAttributes: { - SellerOrderId: shared.uuid(), - StoreName: 'HabitRPG' - } - }, function(err, res){ - if(err) return cb(err); - - if(res.AuthorizationDetails.AuthorizationStatus.State === 'Declined'){ - return cb(new Error('The payment was not successfull.')); - } - - return cb(); - }); - }, - - createSubscription: function(cb){ - payments.createSubscription({ - user: user, - customerId: billingAgreementId, - paymentMethod: 'Amazon Payments', - sub: sub - }, cb); - } - }, function(err, results){ - if(err) return next(err); - - res.sendStatus(200); - }); -}; - -exports.subscribeCancel = function(req, res, next){ - var user = res.locals.user; - if (!user.purchased.plan.customerId) - return res.status(401).json({err: 'User does not have a plan subscription'}); - - var billingAgreementId = user.purchased.plan.customerId; - - async.series({ - closeBillingAgreement: function(cb){ - amzPayment.offAmazonPayments.closeBillingAgreement({ - AmazonBillingAgreementId: billingAgreementId - }, cb); - }, - - cancelSubscription: function(cb){ - var data = { - user: user, - // Date of next bill - nextBill: moment(user.purchased.plan.lastBillingDate).add({days: 30}), - paymentMethod: 'Amazon Payments' - }; - - payments.cancelSubscription(data, cb); - } - }, function(err, results){ - if (err) return next(err); // don't json this, let toString() handle errors - - if(req.query.noRedirect){ - res.sendStatus(200); - }else{ - res.redirect('/'); - } - - user = null; - }); -}; diff --git a/website/src/controllers/payments/iap.js b/website/src/controllers/payments/iap.js deleted file mode 100644 index 829482ed67..0000000000 --- a/website/src/controllers/payments/iap.js +++ /dev/null @@ -1,155 +0,0 @@ -var iap = require('in-app-purchase'); -var async = require('async'); -var payments = require('./index'); -var nconf = require('nconf'); - -var inAppPurchase = require('in-app-purchase'); -inAppPurchase.config({ - // this is the path to the directory containing iap-sanbox/iap-live files - googlePublicKeyPath: nconf.get('IAP_GOOGLE_KEYDIR') -}); - -// Validation ERROR Codes -var INVALID_PAYLOAD = 6778001; -var CONNECTION_FAILED = 6778002; -var PURCHASE_EXPIRED = 6778003; - -exports.androidVerify = function(req, res, next) { - var iapBody = req.body; - var user = res.locals.user; - - iap.setup(function (error) { - if (error) { - var resObj = { - ok: false, - data: 'IAP Error' - }; - - return res.json(resObj); - - } - - /* - google receipt must be provided as an object - { - "data": "{stringified data object}", - "signature": "signature from google" - } - */ - var testObj = { - data: iapBody.transaction.receipt, - signature: iapBody.transaction.signature - }; - - // iap is ready - iap.validate(iap.GOOGLE, testObj, function (err, googleRes) { - if (err) { - var resObj = { - ok: false, - data: { - code: INVALID_PAYLOAD, - message: err.toString() - } - }; - - return res.json(resObj); - } - - if (iap.isValidated(googleRes)) { - var resObj = { - ok: true, - data: googleRes - }; - - payments.buyGems({user:user, paymentMethod:'IAP GooglePlay', amount: 5.25}); - - return res.json(resObj); - } - }); - }); -}; - -exports.iosVerify = function(req, res, next) { - var iapBody = req.body; - var user = res.locals.user; - - iap.setup(function (error) { - if (error) { - var resObj = { - ok: false, - data: 'IAP Error' - }; - - return res.json(resObj); - - } - - //iap is ready - iap.validate(iap.APPLE, iapBody.transaction.receipt, function (err, appleRes) { - if (err) { - var resObj = { - ok: false, - data: { - code: INVALID_PAYLOAD, - message: err.toString() - } - }; - - return res.json(resObj); - } - - if (iap.isValidated(appleRes)) { - var purchaseDataList = iap.getPurchaseData(appleRes); - if (purchaseDataList.length > 0) { - var correctReceipt = true; - for (var index in purchaseDataList) { - switch (purchaseDataList[index].productId) { - case 'com.habitrpg.ios.Habitica.4gems': - payments.buyGems({user:user, paymentMethod:'IAP AppleStore', amount: 1}); - break; - case 'com.habitrpg.ios.Habitica.8gems': - payments.buyGems({user:user, paymentMethod:'IAP AppleStore', amount: 2}); - break; - case 'com.habitrpg.ios.Habitica.20gems': - case 'com.habitrpg.ios.Habitica.21gems': - payments.buyGems({user:user, paymentMethod:'IAP AppleStore', amount: 5.25}); - break; - case 'com.habitrpg.ios.Habitica.42gems': - payments.buyGems({user:user, paymentMethod:'IAP AppleStore', amount: 10.5}); - break; - default: - correctReceipt = false; - } - } - if (correctReceipt) { - var resObj = { - ok: true, - data: appleRes - }; - // yay good! - return res.json(resObj); - } - } - //wrong receipt content - var resObj = { - ok: false, - data: { - code: INVALID_PAYLOAD, - message: 'Incorrect receipt content' - } - }; - return res.json(resObj); - } - //invalid receipt - var resObj = { - ok: false, - data: { - code: INVALID_PAYLOAD, - message: 'Invalid receipt' - } - }; - - return res.json(resObj); - }); - }); -}; diff --git a/website/src/controllers/payments/index.js b/website/src/controllers/payments/index.js deleted file mode 100644 index 1652e05b8d..0000000000 --- a/website/src/controllers/payments/index.js +++ /dev/null @@ -1,207 +0,0 @@ -var _ = require('lodash'); -var shared = require('../../../../common'); -var nconf = require('nconf'); -var utils = require('./../../libs/api-v2/utils'); -var moment = require('moment'); -var isProduction = nconf.get("NODE_ENV") === "production"; -var stripe = require('./stripe'); -var paypal = require('./paypal'); -var amazon = require('./amazon'); -var members = require('../api-v2/members') -var async = require('async'); -var iap = require('./iap'); -var mongoose= require('mongoose'); -var cc = require('coupon-code'); -var pushNotify = require('./../api-v2/pushNotifications'); - -function revealMysteryItems(user) { - _.each(shared.content.gear.flat, function(item) { - if ( - item.klass === 'mystery' && - moment().isAfter(shared.content.mystery[item.mystery].start) && - moment().isBefore(shared.content.mystery[item.mystery].end) && - !user.items.gear.owned[item.key] && - !~user.purchased.plan.mysteryItems.indexOf(item.key) - ) { - user.purchased.plan.mysteryItems.push(item.key); - } - }); -} - -exports.createSubscription = function(data, cb) { - var recipient = data.gift ? data.gift.member : data.user; - //if (!recipient.purchased.plan) recipient.purchased.plan = {}; // TODO double-check, this should never be the case - var p = recipient.purchased.plan; - var block = shared.content.subscriptionBlocks[data.gift ? data.gift.subscription.key : data.sub.key]; - var months = +block.months; - - if (data.gift) { - if (p.customerId && !p.dateTerminated) { // User has active plan - p.extraMonths += months; - } else { - p.dateTerminated = moment(p.dateTerminated).add({months: months}).toDate(); - if (!p.dateUpdated) p.dateUpdated = new Date(); - } - if (!p.customerId) p.customerId = 'Gift'; // don't override existing customer, but all sub need a customerId - } else { - _(p).merge({ // override with these values - planId: block.key, - customerId: data.customerId, - dateUpdated: new Date(), - gemsBought: 0, - paymentMethod: data.paymentMethod, - extraMonths: +p.extraMonths - + +(p.dateTerminated ? moment(p.dateTerminated).diff(new Date(),'months',true) : 0), - dateTerminated: null, - // Specify a lastBillingDate just for Amazon Payments - // Resetted every time the subscription restarts - lastBillingDate: data.paymentMethod === 'Amazon Payments' ? new Date() : undefined - }).defaults({ // allow non-override if a plan was previously used - dateCreated: new Date(), - mysteryItems: [] - }).value(); - } - - // Block sub perks - var perks = Math.floor(months/3); - if (perks) { - p.consecutive.offset += months; - p.consecutive.gemCapExtra += perks*5; - if (p.consecutive.gemCapExtra > 25) p.consecutive.gemCapExtra = 25; - p.consecutive.trinkets += perks; - } - revealMysteryItems(recipient); - if(isProduction) { - if (!data.gift) utils.txnEmail(data.user, 'subscription-begins'); - - var analyticsData = { - uuid: data.user._id, - itemPurchased: 'Subscription', - sku: data.paymentMethod.toLowerCase() + '-subscription', - purchaseType: 'subscribe', - paymentMethod: data.paymentMethod, - quantity: 1, - gift: !!data.gift, // coerced into a boolean - purchaseValue: block.price - } - utils.analytics.trackPurchase(analyticsData); - } - data.user.purchased.txnCount++; - if (data.gift){ - members.sendMessage(data.user, data.gift.member, data.gift); - - var byUserName = utils.getUserInfo(data.user, ['name']).name; - - if(data.gift.member.preferences.emailNotifications.giftedSubscription !== false){ - utils.txnEmail(data.gift.member, 'gifted-subscription', [ - {name: 'GIFTER', content: byUserName}, - {name: 'X_MONTHS_SUBSCRIPTION', content: months} - ]); - } - - if (data.gift.member._id != data.user._id) { // Only send push notifications if sending to a user other than yourself - pushNotify.sendNotify(data.gift.member, shared.i18n.t('giftedSubscription'), months + " months - by "+ byUserName); - } - } - async.parallel([ - function(cb2){data.user.save(cb2)}, - function(cb2){data.gift ? data.gift.member.save(cb2) : cb2(null);} - ], cb); -} - -/** - * Sets their subscription to be cancelled later - */ -exports.cancelSubscription = function(data, cb) { - var p = data.user.purchased.plan, - now = moment(), - remaining = data.nextBill ? moment(data.nextBill).diff(new Date, 'days') : 30; - - p.dateTerminated = - moment( now.format('MM') + '/' + moment(p.dateUpdated).format('DD') + '/' + now.format('YYYY') ) - .add({days: remaining}) // end their subscription 1mo from their last payment - .add({months: Math.ceil(p.extraMonths)})// plus any extra time (carry-over, gifted subscription, etc) they have. TODO: moment can't add months in fractions... - .toDate(); - p.extraMonths = 0; // clear extra time. If they subscribe again, it'll be recalculated from p.dateTerminated - - data.user.save(cb); - utils.txnEmail(data.user, 'cancel-subscription'); - var analyticsData = { - uuid: data.user._id, - gaCategory: 'commerce', - gaLabel: data.paymentMethod, - paymentMethod: data.paymentMethod - } - utils.analytics.track('unsubscribe', analyticsData); -} - -exports.buyGems = function(data, cb) { - var amt = data.amount || 5; - amt = data.gift ? data.gift.gems.amount/4 : amt; - (data.gift ? data.gift.member : data.user).balance += amt; - data.user.purchased.txnCount++; - if(isProduction) { - if (!data.gift) utils.txnEmail(data.user, 'donation'); - - var analyticsData = { - uuid: data.user._id, - itemPurchased: 'Gems', - sku: data.paymentMethod.toLowerCase() + '-checkout', - purchaseType: 'checkout', - paymentMethod: data.paymentMethod, - quantity: 1, - gift: !!data.gift, // coerced into a boolean - purchaseValue: amt - } - utils.analytics.trackPurchase(analyticsData); - } - - if (data.gift){ - var byUsername = utils.getUserInfo(data.user, ['name']).name; - var gemAmount = data.gift.gems.amount || 20; - - members.sendMessage(data.user, data.gift.member, data.gift); - if(data.gift.member.preferences.emailNotifications.giftedGems !== false){ - utils.txnEmail(data.gift.member, 'gifted-gems', [ - {name: 'GIFTER', content: byUsername}, - {name: 'X_GEMS_GIFTED', content: gemAmount} - ]); - } - - if (data.gift.member._id != data.user._id) { // Only send push notifications if sending to a user other than yourself - pushNotify.sendNotify(data.gift.member, shared.i18n.t('giftedGems'), gemAmount + ' Gems - by '+byUsername); - } - } - async.parallel([ - function(cb2){data.user.save(cb2)}, - function(cb2){data.gift ? data.gift.member.save(cb2) : cb2(null);} - ], cb); -} - -exports.validCoupon = function(req, res, next){ - mongoose.model('Coupon').findOne({_id:cc.validate(req.params.code), event:'google_6mo'}, function(err, coupon){ - if (err) return next(err); - if (!coupon) return res.status(401).json({err:"Invalid coupon code"}); - return res.sendStatus(200); - }); -} - -exports.stripeCheckout = stripe.checkout; -exports.stripeSubscribeCancel = stripe.subscribeCancel; -exports.stripeSubscribeEdit = stripe.subscribeEdit; - -exports.paypalSubscribe = paypal.createBillingAgreement; -exports.paypalSubscribeSuccess = paypal.executeBillingAgreement; -exports.paypalSubscribeCancel = paypal.cancelSubscription; -exports.paypalCheckout = paypal.createPayment; -exports.paypalCheckoutSuccess = paypal.executePayment; -exports.paypalIPN = paypal.ipn; - -exports.amazonVerifyAccessToken = amazon.verifyAccessToken; -exports.amazonCreateOrderReferenceId = amazon.createOrderReferenceId; -exports.amazonCheckout = amazon.checkout; -exports.amazonSubscribe = amazon.subscribe; -exports.amazonSubscribeCancel = amazon.subscribeCancel; - -exports.iapAndroidVerify = iap.androidVerify; -exports.iapIosVerify = iap.iosVerify; diff --git a/website/src/controllers/payments/paypal.js b/website/src/controllers/payments/paypal.js deleted file mode 100644 index 766ee85139..0000000000 --- a/website/src/controllers/payments/paypal.js +++ /dev/null @@ -1,216 +0,0 @@ -var nconf = require('nconf'); -var moment = require('moment'); -var async = require('async'); -var _ = require('lodash'); -var url = require('url'); -var User = require('mongoose').model('User'); -var payments = require('./index'); -var logger = require('../../libs/api-v2/logging'); -var ipn = require('paypal-ipn'); -var paypal = require('paypal-rest-sdk'); -var shared = require('../../../../common'); -var mongoose = require('mongoose'); -var cc = require('coupon-code'); - -// This is the plan.id for paypal subscriptions. You have to set up billing plans via their REST sdk (they don't have -// a web interface for billing-plan creation), see ./paypalBillingSetup.js for how. After the billing plan is created -// there, get it's plan.id and store it in config.json -_.each(shared.content.subscriptionBlocks, function(block){ - block.paypalKey = nconf.get("PAYPAL:billing_plans:"+block.key); -}); - -paypal.configure({ - 'mode': nconf.get("PAYPAL:mode"), //sandbox or live - 'client_id': nconf.get("PAYPAL:client_id"), - 'client_secret': nconf.get("PAYPAL:client_secret") -}); - -var parseErr = function(res, err){ - //var error = err.response ? err.response.message || err.response.details[0].issue : err; - var error = JSON.stringify(err); - return res.status(400).json({err:error}); -} - -exports.createBillingAgreement = function(req,res,next){ - var sub = shared.content.subscriptionBlocks[req.query.sub]; - async.waterfall([ - function(cb){ - if (!sub.discount) return cb(null, null); - if (!req.query.coupon) return cb('Please provide a coupon code for this plan.'); - mongoose.model('Coupon').findOne({_id:cc.validate(req.query.coupon), event:sub.key}, cb); - }, - function(coupon, cb){ - if (sub.discount && !coupon) return cb('Invalid coupon code.'); - var billingPlanTitle = "HabitRPG Subscription" + ' ($'+sub.price+' every '+sub.months+' months, recurring)'; - var billingAgreementAttributes = { - "name": billingPlanTitle, - "description": billingPlanTitle, - "start_date": moment().add({minutes:5}).format(), - "plan": { - "id": sub.paypalKey - }, - "payer": { - "payment_method": "paypal" - } - }; - paypal.billingAgreement.create(billingAgreementAttributes, cb); - } - ], function(err, billingAgreement){ - if (err) return parseErr(res, err); - // For approving subscription via Paypal, first redirect user to: approval_url - req.session.paypalBlock = req.query.sub; - var approval_url = _.find(billingAgreement.links, {rel:'approval_url'}).href; - res.redirect(approval_url); - }); -} - -exports.executeBillingAgreement = function(req,res,next){ - var block = shared.content.subscriptionBlocks[req.session.paypalBlock]; - delete req.session.paypalBlock; - async.auto({ - exec: function (cb) { - paypal.billingAgreement.execute(req.query.token, {}, cb); - }, - get_user: function (cb) { - User.findById(req.session.userId, cb); - }, - create_sub: ['exec', 'get_user', function (cb, results) { - payments.createSubscription({ - user: results.get_user, - customerId: results.exec.id, - paymentMethod: 'Paypal', - sub: block - }, cb); - }] - },function(err){ - if (err) return parseErr(res, err); - res.redirect('/'); - }) -} - -exports.createPayment = function(req, res) { - // if we're gifting to a user, put it in session for the `execute()` - req.session.gift = req.query.gift || undefined; - var gift = req.query.gift ? JSON.parse(req.query.gift) : undefined; - var price = !gift ? 5.00 - : gift.type=='gems' ? Number(gift.gems.amount/4).toFixed(2) - : Number(shared.content.subscriptionBlocks[gift.subscription.key].price).toFixed(2); - var description = !gift ? "HabitRPG Gems" - : gift.type=='gems' ? "HabitRPG Gems (Gift)" - : shared.content.subscriptionBlocks[gift.subscription.key].months + "mo. HabitRPG Subscription (Gift)"; - var create_payment = { - "intent": "sale", - "payer": { - "payment_method": "paypal" - }, - "redirect_urls": { - "return_url": nconf.get('BASE_URL') + '/paypal/checkout/success', - "cancel_url": nconf.get('BASE_URL') - }, - "transactions": [{ - "item_list": { - "items": [{ - "name": description, - //"sku": "1", - "price": price, - "currency": "USD", - "quantity": 1 - }] - }, - "amount": { - "currency": "USD", - "total": price - }, - "description": description - }] - }; - paypal.payment.create(create_payment, function (err, payment) { - if (err) return parseErr(res, err); - var link = _.find(payment.links, {rel: 'approval_url'}).href; - res.redirect(link); - }); -} - -exports.executePayment = function(req, res) { - var paymentId = req.query.paymentId, - PayerID = req.query.PayerID, - gift = req.session.gift ? JSON.parse(req.session.gift) : undefined; - delete req.session.gift; - async.waterfall([ - function(cb){ - paypal.payment.execute(paymentId, {payer_id: PayerID}, cb); - }, - function(payment, cb){ - async.parallel([ - function(cb2){ User.findById(req.session.userId, cb2); }, - function(cb2){ User.findById(gift ? gift.uuid : undefined, cb2); } - ], cb); - }, - function(results, cb){ - if (_.isEmpty(results[0])) return cb("User not found when completing paypal transaction"); - var data = {user:results[0], customerId:PayerID, paymentMethod:'Paypal', gift:gift} - var method = 'buyGems'; - if (gift) { - gift.member = results[1]; - if (gift.type=='subscription') method = 'createSubscription'; - data.paymentMethod = 'Gift'; - } - payments[method](data, cb); - } - ],function(err){ - if (err) return parseErr(res, err); - res.redirect('/'); - }) -} - -exports.cancelSubscription = function(req, res, next){ - var user = res.locals.user; - if (!user.purchased.plan.customerId) - return res.status(401).json({err: "User does not have a plan subscription"}); - async.auto({ - get_cus: function(cb){ - paypal.billingAgreement.get(user.purchased.plan.customerId, cb); - }, - verify_cus: ['get_cus', function(cb, results){ - var hasntBilledYet = results.get_cus.agreement_details.cycles_completed == "0"; - if (hasntBilledYet) - return cb("The plan hasn't activated yet (due to a PayPal bug). It will begin "+results.get_cus.agreement_details.next_billing_date+", after which you can cancel to retain your full benefits"); - cb(); - }], - del_cus: ['verify_cus', function(cb, results){ - paypal.billingAgreement.cancel(user.purchased.plan.customerId, {note: "Canceling the subscription"}, cb); - }], - cancel_sub: ['get_cus', 'verify_cus', function(cb, results){ - var data = {user: user, paymentMethod: 'Paypal', nextBill: results.get_cus.agreement_details.next_billing_date}; - payments.cancelSubscription(data, cb) - }] - }, function(err){ - if (err) return parseErr(res, err); - res.redirect('/'); - user = null; - }); -} - -/** - * General IPN handler. We catch cancelled HabitRPG subscriptions for users who manually cancel their - * recurring paypal payments in their paypal dashboard. Remove this when we can move to webhooks or some other solution - */ -exports.ipn = function(req, res, next) { - console.log('IPN Called'); - res.sendStatus(200); // Must respond to PayPal IPN request with an empty 200 first - ipn.verify(req.body, function(err, msg) { - if (err) return logger.error(msg); - switch (req.body.txn_type) { - // TODO what's the diff b/w the two data.txn_types below? The docs recommend subscr_cancel, but I'm getting the other one instead... - case 'recurring_payment_profile_cancel': - case 'subscr_cancel': - User.findOne({'purchased.plan.customerId':req.body.recurring_payment_id},function(err, user){ - if (err) return logger.error(err); - if (_.isEmpty(user)) return; // looks like the cancellation was already handled properly above (see api.paypalSubscribeCancel) - payments.cancelSubscription({user:user, paymentMethod: 'Paypal'}); - }); - break; - } - }); -}; - diff --git a/website/src/controllers/payments/stripe.js b/website/src/controllers/payments/stripe.js deleted file mode 100644 index 1a1085227c..0000000000 --- a/website/src/controllers/payments/stripe.js +++ /dev/null @@ -1,123 +0,0 @@ -var nconf = require('nconf'); -var stripe = require('stripe')(nconf.get('STRIPE_API_KEY')); -var async = require('async'); -var payments = require('./index'); -var User = require('mongoose').model('User'); -var shared = require('../../../../common'); -var mongoose = require('mongoose'); -var cc = require('coupon-code'); - -/* - Setup Stripe response when posting payment - */ -exports.checkout = function(req, res, next) { - var token = req.body.id; - var user = res.locals.user; - var gift = req.query.gift ? JSON.parse(req.query.gift) : undefined; - var sub = req.query.sub ? shared.content.subscriptionBlocks[req.query.sub] : false; - - async.waterfall([ - function(cb){ - if (sub) { - async.waterfall([ - function(cb2){ - if (!sub.discount) return cb2(null, null); - if (!req.query.coupon) return cb2('Please provide a coupon code for this plan.'); - mongoose.model('Coupon').findOne({_id:cc.validate(req.query.coupon), event:sub.key}, cb2); - }, - function(coupon, cb2){ - if (sub.discount && !coupon) return cb2('Invalid coupon code.'); - var customer = { - email: req.body.email, - metadata: {uuid: user._id}, - card: token, - plan: sub.key - }; - stripe.customers.create(customer, cb2); - } - ], cb); - } else { - stripe.charges.create({ - amount: !gift ? '500' //"500" = $5 - : gift.type=='subscription' ? ''+shared.content.subscriptionBlocks[gift.subscription.key].price*100 - : ''+gift.gems.amount/4*100, - currency: 'usd', - card: token - }, cb); - } - }, - function(response, cb) { - if (sub) return payments.createSubscription({user:user, customerId:response.id, paymentMethod:'Stripe', sub:sub}, cb); - async.waterfall([ - function(cb2){ User.findById(gift ? gift.uuid : undefined, cb2); }, - function(member, cb2){ - var data = {user:user, customerId:response.id, paymentMethod:'Stripe', gift:gift}; - var method = 'buyGems'; - if (gift) { - gift.member = member; - if (gift.type=='subscription') method = 'createSubscription'; - data.paymentMethod = 'Gift'; - } - payments[method](data, cb2); - } - ], cb); - } - ], function(err){ - if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors - res.sendStatus(200); - user = token = null; - }); -}; - -exports.subscribeCancel = function(req, res, next) { - var user = res.locals.user; - if (!user.purchased.plan.customerId) - return res.status(401).json({err: 'User does not have a plan subscription'}); - - async.auto({ - get_cus: function(cb){ - stripe.customers.retrieve(user.purchased.plan.customerId, cb); - }, - del_cus: ['get_cus', function(cb, results){ - stripe.customers.del(user.purchased.plan.customerId, cb); - }], - cancel_sub: ['get_cus', function(cb, results) { - var data = { - user: user, - nextBill: results.get_cus.subscription.current_period_end*1000, // timestamp is in seconds - paymentMethod: 'Stripe' - }; - payments.cancelSubscription(data, cb); - }] - }, function(err, results){ - if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors - res.redirect('/'); - user = null; - }); -}; - -exports.subscribeEdit = function(req, res, next) { - var token = req.body.id; - var user = res.locals.user; - var user_id = user.purchased.plan.customerId; - var sub_id; - - async.waterfall([ - function(cb){ - stripe.customers.listSubscriptions(user_id, cb); - }, - function(response, cb) { - sub_id = response.data[0].id; - console.warn(sub_id); - console.warn([user_id, sub_id, { card: token }]); - stripe.customers.updateSubscription(user_id, sub_id, { card: token }, cb); - }, - function(response, cb) { - user.save(cb); - } - ], function(err, saved){ - if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors - res.sendStatus(200); - token = user = user_id = sub_id; - }); -}; diff --git a/website/src/controllers/top-level/payments/amazon.js b/website/src/controllers/top-level/payments/amazon.js new file mode 100644 index 0000000000..a22618fa23 --- /dev/null +++ b/website/src/controllers/top-level/payments/amazon.js @@ -0,0 +1,256 @@ +import { + BadRequest, + NotAuthorized, +} from '../../../libs/api-v3/errors'; +import amzLib from '../../../libs/api-v3/amazonPayments'; +import { + authWithHeaders, + authWithUrl, +} from '../../../middlewares/api-v3/auth'; +import shared from '../../../../../common'; +import payments from '../../../libs/api-v3/payments'; +import moment from 'moment'; +import { model as Coupon } from '../../../models/coupon'; +import { model as User } from '../../../models/user'; +import cc from 'coupon-code'; + +let api = {}; + +/** + * @apiIgnore Payments are considered part of the private API + * @api {post} /amazon/verifyAccessToken Amazon Payments: verify access token + * @apiVersion 3.0.0 + * @apiName AmazonVerifyAccessToken + * @apiGroup Payments + * + * @apiSuccess {Object} data Empty object + **/ +api.verifyAccessToken = { + method: 'POST', + url: '/amazon/verifyAccessToken', + middlewares: [authWithHeaders()], + async handler (req, res) { + let accessToken = req.body.access_token; + + if (!accessToken) throw new BadRequest('Missing req.body.access_token'); + + await amzLib.getTokenInfo(accessToken); + res.respond(200, {}); + }, +}; + +/** + * @apiIgnore Payments are considered part of the private API + * @api {post} /amazon/createOrderReferenceId Amazon Payments: create order reference id + * @apiVersion 3.0.0 + * @apiName AmazonCreateOrderReferenceId + * @apiGroup Payments + * + * @apiSuccess {string} data.orderReferenceId The order reference id. + **/ +api.createOrderReferenceId = { + method: 'POST', + url: '/amazon/createOrderReferenceId', + middlewares: [authWithHeaders()], + async handler (req, res) { + let billingAgreementId = req.body.billingAgreementId; + + if (!billingAgreementId) throw new BadRequest('Missing req.body.billingAgreementId'); + + let response = await amzLib.createOrderReferenceId({ + Id: billingAgreementId, + IdType: 'BillingAgreement', + ConfirmNow: false, + }); + + res.respond(200, { + orderReferenceId: response.OrderReferenceDetails.AmazonOrderReferenceId, + }); + }, +}; + +/** + * @apiIgnore Payments are considered part of the private API + * @api {post} /amazon/checkout Amazon Payments: checkout + * @apiVersion 3.0.0 + * @apiName AmazonCheckout + * @apiGroup Payments + * + * @apiSuccess {object} data Empty object + **/ +api.checkout = { + method: 'POST', + url: '/amazon/checkout', + middlewares: [authWithHeaders()], + async handler (req, res) { + let gift = req.body.gift; + let user = res.locals.user; + let orderReferenceId = req.body.orderReferenceId; + let amount = 5; + + if (!orderReferenceId) throw new BadRequest('Missing req.body.orderReferenceId'); + + if (gift) { + if (gift.type === 'gems') { + amount = gift.gems.amount / 4; + } else if (gift.type === 'subscription') { + amount = shared.content.subscriptionBlocks[gift.subscription.key].price; + } + } + + await amzLib.setOrderReferenceDetails({ + AmazonOrderReferenceId: orderReferenceId, + OrderReferenceAttributes: { + OrderTotal: { + CurrencyCode: 'USD', + Amount: amount, + }, + SellerNote: 'HabitRPG Payment', + SellerOrderAttributes: { + SellerOrderId: shared.uuid(), + StoreName: 'HabitRPG', + }, + }, + }); + + await amzLib.confirmOrderReference({ AmazonOrderReferenceId: orderReferenceId }); + + await amzLib.authorize({ + AmazonOrderReferenceId: orderReferenceId, + AuthorizationReferenceId: shared.uuid().substring(0, 32), + AuthorizationAmount: { + CurrencyCode: 'USD', + Amount: amount, + }, + SellerAuthorizationNote: 'HabitRPG Payment', + TransactionTimeout: 0, + CaptureNow: true, + }); + + await amzLib.closeOrderReference({ AmazonOrderReferenceId: orderReferenceId }); + + // execute payment + let method = 'buyGems'; + let data = { user, paymentMethod: 'Amazon Payments' }; + + if (gift) { + if (gift.type === 'subscription') method = 'createSubscription'; + gift.member = await User.findById(gift ? gift.uuid : undefined); + data.gift = gift; + data.paymentMethod = 'Gift'; + } + + await payments[method](data); + + res.respond(200); + }, +}; + +/** + * @apiIgnore Payments are considered part of the private API + * @api {post} /amazon/subscribe Amazon Payments: subscribe + * @apiVersion 3.0.0 + * @apiName AmazonSubscribe + * @apiGroup Payments + * + * @apiSuccess {object} data Empty object + **/ +api.subscribe = { + method: 'POST', + url: '/amazon/subscribe', + middlewares: [authWithHeaders()], + async handler (req, res) { + let billingAgreementId = req.body.billingAgreementId; + let sub = req.body.subscription ? shared.content.subscriptionBlocks[req.body.subscription] : false; + let coupon = req.body.coupon; + let user = res.locals.user; + + if (!sub) throw new BadRequest(res.t('missingSubscriptionCode')); + if (!billingAgreementId) throw new BadRequest('Missing req.body.billingAgreementId'); + + if (sub.discount) { // apply discount + if (!coupon) throw new BadRequest(res.t('couponCodeRequired')); + let result = await Coupon.findOne({_id: cc.validate(coupon), event: sub.key}); + if (!result) throw new NotAuthorized(res.t('invalidCoupon')); + } + + await amzLib.setBillingAgreementDetails({ + AmazonBillingAgreementId: billingAgreementId, + BillingAgreementAttributes: { + SellerNote: 'HabitRPG Subscription', + SellerBillingAgreementAttributes: { + SellerBillingAgreementId: shared.uuid(), + StoreName: 'HabitRPG', + CustomInformation: 'HabitRPG Subscription', + }, + }, + }); + + await amzLib.confirmBillingAgreement({ + AmazonBillingAgreementId: billingAgreementId, + }); + + await amzLib.authorizeOnBillingAgreement({ + AmazonBillingAgreementId: billingAgreementId, + AuthorizationReferenceId: shared.uuid().substring(0, 32), + AuthorizationAmount: { + CurrencyCode: 'USD', + Amount: sub.price, + }, + SellerAuthorizationNote: 'HabitRPG Subscription Payment', + TransactionTimeout: 0, + CaptureNow: true, + SellerNote: 'HabitRPG Subscription Payment', + SellerOrderAttributes: { + SellerOrderId: shared.uuid(), + StoreName: 'HabitRPG', + }, + }); + + await payments.createSubscription({ + user, + customerId: billingAgreementId, + paymentMethod: 'Amazon Payments', + sub, + }); + + res.respond(200); + }, +}; + +/** + * @apiIgnore Payments are considered part of the private API + * @api {get} /amazon/subscribe/cancel Amazon Payments: subscribe cancel + * @apiVersion 3.0.0 + * @apiName AmazonSubscribe + * @apiGroup Payments + **/ +api.subscribeCancel = { + method: 'GET', + url: '/amazon/subscribe/cancel', + middlewares: [authWithUrl], + async handler (req, res) { + let user = res.locals.user; + let billingAgreementId = user.purchased.plan.customerId; + + if (!billingAgreementId) throw new NotAuthorized(res.t('missingSubscription')); + + await amzLib.closeBillingAgreement({ + AmazonBillingAgreementId: billingAgreementId, + }); + + await payments.cancelSubscription({ + user, + nextBill: moment(user.purchased.plan.lastBillingDate).add({ days: 30 }), + paymentMethod: 'Amazon Payments', + }); + + if (req.query.noRedirect) { + res.respond(200); + } else { + res.redirect('/'); + } + }, +}; + +module.exports = api; diff --git a/website/src/controllers/top-level/payments/iap.js b/website/src/controllers/top-level/payments/iap.js new file mode 100644 index 0000000000..60e50d6d39 --- /dev/null +++ b/website/src/controllers/top-level/payments/iap.js @@ -0,0 +1,191 @@ +import iap from 'in-app-purchase'; +import nconf from 'nconf'; +import { + authWithHeaders, + authWithUrl, +} from '../../../middlewares/api-v3/auth'; +import payments from '../../../libs/api-v3/payments'; + +// NOT PORTED TO v3 + +iap.config({ + // this is the path to the directory containing iap-sanbox/iap-live files + googlePublicKeyPath: nconf.get('IAP_GOOGLE_KEYDIR'), +}); + +// Validation ERROR Codes +const INVALID_PAYLOAD = 6778001; +// const CONNECTION_FAILED = 6778002; +// const PURCHASE_EXPIRED = 6778003; + +let api = {}; + +/** + * @apiIgnore Payments are considered part of the private API + * @api {post} /iap/android/verify Android Verify IAP + * @apiVersion 3.0.0 + * @apiName IapAndroidVerify + * @apiGroup Payments + **/ +api.iapAndroidVerify = { + method: 'POST', + url: '/iap/android/verify', + middlewares: [authWithUrl], + async handler (req, res) { + let user = res.locals.user; + let iapBody = req.body; + + iap.setup((error) => { + if (error) { + let resObj = { + ok: false, + data: 'IAP Error', + }; + + return res.json(resObj); + } + + // google receipt must be provided as an object + // { + // "data": "{stringified data object}", + // "signature": "signature from google" + // } + let testObj = { + data: iapBody.transaction.receipt, + signature: iapBody.transaction.signature, + }; + + // iap is ready + iap.validate(iap.GOOGLE, testObj, (err, googleRes) => { + if (err) { + let resObj = { + ok: false, + data: { + code: INVALID_PAYLOAD, + message: err.toString(), + }, + }; + + return res.json(resObj); + } + + if (iap.isValidated(googleRes)) { + let resObj = { + ok: true, + data: googleRes, + }; + + payments.buyGems({ + user, + paymentMethod: 'IAP GooglePlay', + amount: 5.25, + }).then(() => res.json(resObj)); + } + }); + }); + }, +}; + +/** + * @apiIgnore Payments are considered part of the private API + * @api {post} /iap/ios/verify iOS Verify IAP + * @apiVersion 3.0.0 + * @apiName IapiOSVerify + * @apiGroup Payments + **/ +api.iapiOSVerify = { + method: 'POST', + url: '/iap/android/verify', + middlewares: [authWithHeaders()], + async handler (req, res) { + let iapBody = req.body; + let user = res.locals.user; + + iap.setup(function iosSetupResult (error) { + if (error) { + let resObj = { + ok: false, + data: 'IAP Error', + }; + + return res.json(resObj); + } + + // iap is ready + iap.validate(iap.APPLE, iapBody.transaction.receipt, (err, appleRes) => { + if (err) { + let resObj = { + ok: false, + data: { + code: INVALID_PAYLOAD, + message: err.toString(), + }, + }; + + return res.json(resObj); + } + + if (iap.isValidated(appleRes)) { + let purchaseDataList = iap.getPurchaseData(appleRes); + if (purchaseDataList.length > 0) { + let correctReceipt = true; + + for (let index of purchaseDataList) { + switch (purchaseDataList[index].productId) { + case 'com.habitrpg.ios.Habitica.4gems': + payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 1}); + break; + case 'com.habitrpg.ios.Habitica.8gems': + payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 2}); + break; + case 'com.habitrpg.ios.Habitica.20gems': + case 'com.habitrpg.ios.Habitica.21gems': + payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 5.25}); + break; + case 'com.habitrpg.ios.Habitica.42gems': + payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 10.5}); + break; + default: + correctReceipt = false; + } + } + + if (correctReceipt) { + let resObj = { + ok: true, + data: appleRes, + }; + + // yay good! + return res.json(resObj); + } + } + + // wrong receipt content + let resObj = { + ok: false, + data: { + code: INVALID_PAYLOAD, + message: 'Incorrect receipt content', + }, + }; + + return res.json(resObj); + } + + // invalid receipt + let resObj = { + ok: false, + data: { + code: INVALID_PAYLOAD, + message: 'Invalid receipt', + }, + }; + + return res.json(resObj); + }); + }); + }, +}; + +module.exports = api; diff --git a/website/src/controllers/top-level/payments/paypal.js b/website/src/controllers/top-level/payments/paypal.js new file mode 100644 index 0000000000..a940511c64 --- /dev/null +++ b/website/src/controllers/top-level/payments/paypal.js @@ -0,0 +1,278 @@ +/* eslint-disable camelcase */ + +import nconf from 'nconf'; +import moment from 'moment'; +import _ from 'lodash'; +import payments from '../../../libs/api-v3/payments'; +import ipn from 'paypal-ipn'; +import paypal from 'paypal-rest-sdk'; +import shared from '../../../../../common'; +import cc from 'coupon-code'; +import Q from 'q'; +import { model as Coupon } from '../../../models/coupon'; +import { model as User } from '../../../models/user'; +import { + authWithUrl, + authWithSession, +} from '../../../middlewares/api-v3/auth'; +import { + BadRequest, + NotAuthorized, +} from '../../../libs/api-v3/errors'; + +const BASE_URL = nconf.get('BASE_URL'); + +// This is the plan.id for paypal subscriptions. You have to set up billing plans via their REST sdk (they don't have +// a web interface for billing-plan creation), see ./paypalBillingSetup.js for how. After the billing plan is created +// there, get it's plan.id and store it in config.json +_.each(shared.content.subscriptionBlocks, (block) => { + block.paypalKey = nconf.get(`PAYPAL:billing_plans:${block.key}`); +}); + +paypal.configure({ + mode: nconf.get('PAYPAL:mode'), // sandbox or live + client_id: nconf.get('PAYPAL:client_id'), + client_secret: nconf.get('PAYPAL:client_secret'), +}); + +// TODO better handling of errors +const paypalPaymentCreate = Q.nbind(paypal.payment.create, paypal.payment); +const paypalPaymentExecute = Q.nbind(paypal.payment.execute, paypal.payment); +const paypalBillingAgreementCreate = Q.nbind(paypal.billingAgreement.create, paypal.billingAgreement); +const paypalBillingAgreementExecute = Q.nbind(paypal.billingAgreement.execute, paypal.billingAgreement); +const paypalBillingAgreementGet = Q.nbind(paypal.billingAgreement.get, paypal.billingAgreement); +const paypalBillingAgreementCancel = Q.nbind(paypal.billingAgreement.cancel, paypal.billingAgreement); + +const ipnVerifyAsync = Q.nbind(ipn.verify, ipn); + +let api = {}; + +/** + * @apiIgnore Payments are considered part of the private API + * @api {get} /paypal/checkout Paypal: checkout + * @apiVersion 3.0.0 + * @apiName PaypalCheckout + * @apiGroup Payments + **/ +api.checkout = { + method: 'GET', + url: '/paypal/checkout', + middlewares: [authWithUrl], + async handler (req, res) { + let gift = req.query.gift ? JSON.parse(req.query.gift) : undefined; + req.session.gift = req.query.gift; + + let amount = 5.00; + let description = 'HabitRPG gems'; + if (gift) { + if (gift.type === 'gems') { + amount = Number(gift.gems.amount / 4).toFixed(2); + description = `${description} (Gift)`; + } else { + amount = Number(shared.content.subscriptionBlocks[gift.subscription.key].price).toFixed(2); + description = 'mo. HabitRPG Subscription (Gift)'; + } + } + + let createPayment = { + intent: 'sale', + payer: { payment_method: 'Paypal' }, + redirect_urls: { + return_url: `${BASE_URL}/paypal/checkout/success`, + cancel_url: `${BASE_URL}`, + }, + transactions: [{ + item_list: { + items: [{ + name: description, + // sku: 1, + price: amount, + currency: 'USD', + quality: 1, + }], + }, + amount: { + currency: 'USD', + total: amount, + }, + description, + }], + }; + + let result = await paypalPaymentCreate(createPayment); + let link = _.find(result.links, { rel: 'approval_url' }).href; + res.redirect(link); + }, +}; + +/** + * @apiIgnore Payments are considered part of the private API + * @api {get} /paypal/checkout/success Paypal: checkout success + * @apiVersion 3.0.0 + * @apiName PaypalCheckoutSuccess + * @apiGroup Payments + **/ +api.checkoutSuccess = { + method: 'GET', + url: '/paypal/checkout/success', + middlewares: [authWithSession], + async handler (req, res) { + let paymentId = req.query.paymentId; + let customerId = req.query.payerID; + + let method = 'buyGems'; + let data = { + user: res.locals.user, + customerId, + paymentMethod: 'Paypal', + }; + + let gift = req.session.gift ? JSON.parse(req.session.gift) : undefined; + delete req.session.gift; + + if (gift) { + gift.member = await User.findById(gift.uuid); + if (gift.type === 'subscription') { + method = 'createSubscription'; + } + + data.paymentMethod = 'Gift'; + data.gift = gift; + } + + await paypalPaymentExecute(paymentId, { payer_id: customerId }); + await payments[method](data); + res.redirect('/'); + }, +}; + +/** + * @apiIgnore Payments are considered part of the private API + * @api {get} /paypal/subscribe Paypal: subscribe + * @apiVersion 3.0.0 + * @apiName PaypalSubscribe + * @apiGroup Payments + **/ +api.subscribe = { + method: 'GET', + url: '/paypal/subscribe', + middlewares: [authWithUrl], + async handler (req, res) { + let sub = shared.content.subscriptionBlocks[req.query.sub]; + + if (sub.discount) { + if (!req.query.coupon) throw new BadRequest(res.t('couponCodeRequired')); + let coupon = await Coupon.findOne({_id: cc.validate(req.query.coupon), event: sub.key}); + if (!coupon) throw new NotAuthorized(res.t('invalidCoupon')); + } + + let billingPlanTitle = `HabitRPG Subscription ($${sub.price} every ${sub.months} months, recurring)`; + let billingAgreementAttributes = { + name: billingPlanTitle, + description: billingPlanTitle, + start_date: moment().add({ minutes: 5 }).format(), + plan: { + id: sub.paypalKey, + }, + payer: { + payment_method: 'Paypal', + }, + }; + let billingAgreement = await paypalBillingAgreementCreate(billingAgreementAttributes); + + req.session.paypalBlock = req.query.sub; + let link = _.find(billingAgreement.links, { rel: 'approval_url' }).href; + res.redirect(link); + }, +}; + +/** + * @apiIgnore Payments are considered part of the private API + * @api {get} /paypal/subscribe/success Paypal: subscribe success + * @apiVersion 3.0.0 + * @apiName PaypalSubscribeSuccess + * @apiGroup Payments + **/ +api.subscribeSuccess = { + method: 'GET', + url: '/paypal/subscribe/success', + middlewares: [authWithSession], + async handler (req, res) { + let user = res.locals.user; + let block = shared.content.subscriptionBlocks[req.session.paypalBlock]; + delete req.session.paypalBlock; + + let result = await paypalBillingAgreementExecute(req.query.token, {}); + await payments.createSubscription({ + user, + customerId: result.id, + paymentMethod: 'Paypal', + sub: block, + }); + + res.redirect('/'); + }, +}; + +/** + * @apiIgnore Payments are considered part of the private API + * @api {get} /paypal/subscribe/cancel Paypal: subscribe cancel + * @apiVersion 3.0.0 + * @apiName PaypalSubscribeCancel + * @apiGroup Payments + **/ +api.subscribeCancel = { + method: 'GET', + url: '/paypal/subscribe/cancel', + middlewares: [authWithUrl], + async handler (req, res) { + let user = res.locals.user; + let customerId = user.purchased.plan.customerId; + if (!user.purchased.plan.customerId) throw new NotAuthorized(res.t('missingSubscription')); + + let customer = await paypalBillingAgreementGet(customerId); + + let nextBillingDate = customer.agreement_details.next_billing_date; + if (customer.agreement_details.cycles_completed === '0') { // hasn't billed yet + throw new BadRequest(res.t('planNotActive', { nextBillingDate })); + } + + await paypalBillingAgreementCancel(customerId, { note: res.t('cancelingSubscription') }); + await payments.cancelSubscription({ + user, + paymentMethod: 'Paypal', + nextBill: nextBillingDate, + }); + + res.redirect('/'); + }, +}; + +// General IPN handler. We catch cancelled HabitRPG subscriptions for users who manually cancel their +// recurring paypal payments in their paypal dashboard. TODO ? Remove this when we can move to webhooks or some other solution + +/** + * @apiIgnore Payments are considered part of the private API + * @api {post} /paypal/ipn Paypal IPN + * @apiVersion 3.0.0 + * @apiName PaypalIpn + * @apiGroup Payments + **/ +api.ipn = { + method: 'POST', + url: '/paypal/ipn', + async handler (req, res) { + res.sendStatus(200); + + await ipnVerifyAsync(req.body); + + if (req.body.txn_type === 'recurring_payment_profile_cancel' || req.body.txn_type === 'subscr_cancel') { + let user = await User.findOne({ 'purchased.plan.customerId': req.body.recurring_payment_id }); + if (user) { + await payments.cancelSubscription({ user, paymentMethod: 'Paypal' }); + } + } + }, +}; + +module.exports = api; diff --git a/website/src/controllers/top-level/payments/stripe.js b/website/src/controllers/top-level/payments/stripe.js new file mode 100644 index 0000000000..2ac8c863f7 --- /dev/null +++ b/website/src/controllers/top-level/payments/stripe.js @@ -0,0 +1,169 @@ +import stripeModule from 'stripe'; +import shared from '../../../../../common'; +import { + BadRequest, + NotAuthorized, +} from '../../../libs/api-v3/errors'; +import { model as Coupon } from '../../../models/coupon'; +import payments from '../../../libs/api-v3/payments'; +import nconf from 'nconf'; +import { model as User } from '../../../models/user'; +import cc from 'coupon-code'; +import { + authWithHeaders, + authWithUrl, +} from '../../../middlewares/api-v3/auth'; + +const stripe = stripeModule(nconf.get('STRIPE_API_KEY')); + +let api = {}; + +/** + * @apiIgnore Payments are considered part of the private API + * @api {post} /stripe/checkout Stripe checkout + * @apiVersion 3.0.0 + * @apiName StripeCheckout + * @apiGroup Payments + * + * @apiParam {string} id Body parameter - The token + * @apiParam {string} email Body parameter - the customer email + * @apiParam {string} gift Query parameter - stringified json object, gift + * @apiParam {string} sub Query parameter - subscription, possible values are: basic_earned, basic_3mo, basic_6mo, google_6mo, basic_12mo + * @apiParam {string} coupon Query parameter - coupon for the matching subscription, required only for certain subscriptions + * + * @apiSuccess {Object} data Empty object + **/ +api.checkout = { + method: 'POST', + url: '/stripe/checkout', + middlewares: [authWithHeaders()], + async handler (req, res) { + let token = req.body.id; + let user = res.locals.user; + let gift = req.query.gift ? JSON.parse(req.query.gift) : undefined; + let sub = req.query.sub ? shared.content.subscriptionBlocks[req.query.sub] : false; + let coupon; + let response; + + if (!token) throw new BadRequest('Missing req.body.id'); + + if (sub) { + if (sub.discount) { + if (!req.query.coupon) throw new BadRequest(res.t('couponCodeRequired')); + coupon = await Coupon.findOne({_id: cc.validate(req.query.coupon), event: sub.key}); + if (!coupon) throw new BadRequest(res.t('invalidCoupon')); + } + + response = await stripe.customers.create({ + email: req.body.email, + metadata: { uuid: user._id }, + card: token, + plan: sub.key, + }); + } else { + let amount = 500; // $5 + + if (gift) { + if (gift.type === 'subscription') { + amount = `${shared.content.subscriptionBlocks[gift.subscription.key].price * 100}`; + } else { + amount = `${gift.gems.amount / 4 * 100}`; + } + } + + response = await stripe.charges.create({ + amount, + currency: 'usd', + card: token, + }); + } + + if (sub) { + await payments.createSubscription({ + user, + customerId: response.id, + paymentMethod: 'Stripe', + sub, + }); + } else { + let method = 'buyGems'; + let data = { + user, + customerId: response.id, + paymentMethod: 'Stripe', + gift, + }; + + if (gift) { + let member = await User.findById(gift.uuid); + gift.member = member; + if (gift.type === 'subscription') method = 'createSubscription'; + data.paymentMethod = 'Gift'; + } + + await payments[method](data); + } + + res.respond(200, {}); + }, +}; + +/** + * @apiIgnore Payments are considered part of the private API + * @api {post} /stripe/subscribe/edit Edit Stripe subscription + * @apiVersion 3.0.0 + * @apiName StripeSubscribeEdit + * @apiGroup Payments + * + * @apiParam {string} id Body parameter - The token + * + * @apiSuccess {Object} data Empty object + **/ +api.subscribeEdit = { + method: 'POST', + url: '/stripe/subscribe/edit', + middlewares: [authWithHeaders()], + async handler (req, res) { + let token = req.body.id; + let user = res.locals.user; + let customerId = user.purchased.plan.customerId; + + if (!customerId) throw new NotAuthorized(res.t('missingSubscription')); + if (!token) throw new BadRequest('Missing req.body.id'); + + let subscriptions = await stripe.customers.listSubscriptions(customerId); + let subscriptionId = subscriptions.data[0].id; + await stripe.customers.updateSubscription(customerId, subscriptionId, { card: token }); + + res.respond(200, {}); + }, +}; + +/** + * @apiIgnore Payments are considered part of the private API + * @api {get} /stripe/subscribe/cancel Cancel Stripe subscription + * @apiVersion 3.0.0 + * @apiName StripeSubscribeCancel + * @apiGroup Payments + **/ +api.subscribeCancel = { + method: 'GET', + url: '/stripe/subscribe/cancel', + middlewares: [authWithUrl], + async handler (req, res) { + let user = res.locals.user; + if (!user.purchased.plan.customerId) throw new NotAuthorized(res.t('missingSubscription')); + + let customer = await stripe.customers.retrieve(user.purchased.plan.customeerId); + await stripe.customers.del(user.purchased.plan.customerId); + await payments.cancelSubscriptoin({ + user, + nextBill: customer.subscription.current_period_end * 1000, // timestamp in seconds + paymentMethod: 'Stripe', + }); + + res.redirect('/'); + }, +}; + +module.exports = api; diff --git a/website/src/libs/api-v3/amazonPayments.js b/website/src/libs/api-v3/amazonPayments.js new file mode 100644 index 0000000000..338a6acd08 --- /dev/null +++ b/website/src/libs/api-v3/amazonPayments.js @@ -0,0 +1,62 @@ +import amazonPayments from 'amazon-payments'; +import nconf from 'nconf'; +import common from '../../../../common'; +import Q from 'q'; +import { + BadRequest, +} from './errors'; + +// TODO better handling of errors + +const i18n = common.i18n; +const IS_PROD = nconf.get('NODE_ENV') === 'production'; + +let amzPayment = amazonPayments.connect({ + environment: amazonPayments.Environment[IS_PROD ? 'Production' : 'Sandbox'], + sellerId: nconf.get('AMAZON_PAYMENTS:SELLER_ID'), + mwsAccessKey: nconf.get('AMAZON_PAYMENTS:MWS_KEY'), + mwsSecretKey: nconf.get('AMAZON_PAYMENTS:MWS_SECRET'), + clientId: nconf.get('AMAZON_PAYMENTS:CLIENT_ID'), +}); + +let getTokenInfo = Q.nbind(amzPayment.api.getTokenInfo, amzPayment.api); +let createOrderReferenceId = Q.nbind(amzPayment.offAmazonPayments.createOrderReferenceForId, amzPayment.offAmazonPayments); +let setOrderReferenceDetails = Q.nbind(amzPayment.offAmazonPayments.setOrderReferenceDetails, amzPayment.offAmazonPayments); +let confirmOrderReference = Q.nbind(amzPayment.offAmazonPayments.confirmOrderReference, amzPayment.offAmazonPayments); +let closeOrderReference = Q.nbind(amzPayment.offAmazonPayments.closeOrderReference, amzPayment.offAmazonPayments); +let setBillingAgreementDetails = Q.nbind(amzPayment.offAmazonPayments.setBillingAgreementDetails, amzPayment.offAmazonPayments); +let confirmBillingAgreement = Q.nbind(amzPayment.offAmazonPayments.confirmBillingAgreement, amzPayment.offAmazonPayments); +let closeBillingAgreement = Q.nbind(amzPayment.offAmazonPayments.closeBillingAgreement, amzPayment.offAmazonPayments); + +let authorizeOnBillingAgreement = (inputSet) => { + return new Promise((resolve, reject) => { + amzPayment.offAmazonPayments.authorizeOnBillingAgreement(inputSet, (err, response) => { + if (err) return reject(err); + if (response.AuthorizationDetails.AuthorizationStatus.State === 'Declined') return reject(new BadRequest(i18n.t('paymentNotSuccessful'))); + return resolve(response); + }); + }); +}; + +let authorize = (inputSet) => { + return new Promise((resolve, reject) => { + amzPayment.offAmazonPayments.authorize(inputSet, (err, response) => { + if (err) return reject(err); + if (response.AuthorizationDetails.AuthorizationStatus.State === 'Declined') return reject(new BadRequest(i18n.t('paymentNotSuccessful'))); + return resolve(response); + }); + }); +}; + +module.exports = { + getTokenInfo, + createOrderReferenceId, + setOrderReferenceDetails, + confirmOrderReference, + closeOrderReference, + confirmBillingAgreement, + setBillingAgreementDetails, + closeBillingAgreement, + authorizeOnBillingAgreement, + authorize, +}; diff --git a/website/src/libs/api-v3/payments.js b/website/src/libs/api-v3/payments.js new file mode 100644 index 0000000000..5a9c888972 --- /dev/null +++ b/website/src/libs/api-v3/payments.js @@ -0,0 +1,185 @@ +import _ from 'lodash' ; +import analytics from './analyticsService'; +import { + getUserInfo, + sendTxn as txnEmail, +} from './email'; +import members from '../../controllers/api-v3/members'; +import moment from 'moment'; +import nconf from 'nconf'; +import pushNotify from './pushNotifications'; +import shared from '../../../../common' ; + +const IS_PROD = nconf.get('IS_PROD'); + +let api = {}; + +function revealMysteryItems (user) { + _.each(shared.content.gear.flat, function findMysteryItems (item) { + if ( + item.klass === 'mystery' && + moment().isAfter(shared.content.mystery[item.mystery].start) && + moment().isBefore(shared.content.mystery[item.mystery].end) && + !user.items.gear.owned[item.key] && + user.purchased.plan.mysteryItems.indexOf(item.key) !== -1 + ) { + user.purchased.plan.mysteryItems.push(item.key); + } + }); +} + +api.createSubscription = async function createSubscription (data) { + let recipient = data.gift ? data.gift.member : data.user; + let plan = recipient.purchased.plan; + let block = shared.content.subscriptionBlocks[data.gift ? data.gift.subscription.key : data.sub.key]; + let months = Number(block.months); + + if (data.gift) { + if (plan.customerId && !plan.dateTerminated) { // User has active plan + plan.extraMonths += months; + } else { + plan.dateTerminated = moment(plan.dateTerminated).add({months}).toDate(); + if (!plan.dateUpdated) plan.dateUpdated = new Date(); + } + + if (!plan.customerId) plan.customerId = 'Gift'; // don't override existing customer, but all sub need a customerId + } else { + _(plan).merge({ // override with these values + planId: block.key, + customerId: data.customerId, + dateUpdated: new Date(), + gemsBought: 0, + paymentMethod: data.paymentMethod, + extraMonths: Number(plan.extraMonths) + + Number(plan.dateTerminated ? moment(plan.dateTerminated).diff(new Date(), 'months', true) : 0), + dateTerminated: null, + // Specify a lastBillingDate just for Amazon Payments + // Resetted every time the subscription restarts + lastBillingDate: data.paymentMethod === 'Amazon Payments' ? new Date() : undefined, + }).defaults({ // allow non-override if a plan was previously used + dateCreated: new Date(), + mysteryItems: [], + }).value(); + } + + // Block sub perks + let perks = Math.floor(months / 3); + if (perks) { + plan.consecutive.offset += months; + plan.consecutive.gemCapExtra += perks * 5; + if (plan.consecutive.gemCapExtra > 25) plan.consecutive.gemCapExtra = 25; + plan.consecutive.trinkets += perks; + } + + revealMysteryItems(recipient); + + if (IS_PROD) { + if (!data.gift) txnEmail(data.user, 'subscription-begins'); + + analytics.trackPurchase({ + uuid: data.user._id, + itemPurchased: 'Subscription', + sku: `${data.paymentMethod.toLowerCase()}-subscription`, + purchaseType: 'subscribe', + paymentMethod: data.paymentMethod, + quantity: 1, + gift: Boolean(data.gift), + purchaseValue: block.price, + }); + } + + data.user.purchased.txnCount++; + + if (data.gift) { + members.sendMessage(data.user, data.gift.member, data.gift); + + let byUserName = getUserInfo(data.user, ['name']).name; + + if (data.gift.member.preferences.emailNotifications.giftedSubscription !== false) { + txnEmail(data.gift.member, 'gifted-subscription', [ + {name: 'GIFTER', content: byUserName}, + {name: 'X_MONTHS_SUBSCRIPTION', content: months}, + ]); + } + + if (data.gift.member._id !== data.user._id) { // Only send push notifications if sending to a user other than yourself + pushNotify.sendNotify(data.gift.member, shared.i18n.t('giftedSubscription'), `${months} months - by ${byUserName}`); + } + } + + await data.user.save(); + if (data.gift) await data.gift.member.save(); +}; + +// Sets their subscription to be cancelled later +api.cancelSubscription = async function cancelSubscription (data) { + let plan = data.user.purchased.plan; + let now = moment(); + let remaining = data.nextBill ? moment(data.nextBill).diff(new Date(), 'days') : 30; + let nowStr = `${now.format('MM')}/${moment(plan.dateUpdated).format('DD')}/${now.format('YYYY')}`; + let nowStrFormat = 'MM/DD/YYYY'; + + plan.dateTerminated = + moment(nowStr, nowStrFormat) + .add({days: remaining}) // end their subscription 1mo from their last payment + .add({days: Math.ceil(30 * plan.extraMonths)}) // plus any extra time (carry-over, gifted subscription, etc) they have. + .toDate(); + plan.extraMonths = 0; // clear extra time. If they subscribe again, it'll be recalculated from p.dateTerminated + + await data.user.save(); + + txnEmail(data.user, 'cancel-subscription'); + + analytics.track('unsubscribe', { + uuid: data.user._id, + gaCategory: 'commerce', + gaLabel: data.paymentMethod, + paymentMethod: data.paymentMethod, + }); +}; + +api.buyGems = async function buyGems (data) { + let amt = data.amount || 5; + amt = data.gift ? data.gift.gems.amount / 4 : amt; + + (data.gift ? data.gift.member : data.user).balance += amt; + data.user.purchased.txnCount++; + + if (IS_PROD) { + if (!data.gift) txnEmail(data.user, 'donation'); + + analytics.trackPurchase({ + uuid: data.user._id, + itemPurchased: 'Gems', + sku: `${data.paymentMethod.toLowerCase()}-checkout`, + purchaseType: 'checkout', + paymentMethod: data.paymentMethod, + quantity: 1, + gift: Boolean(data.gift), + purchaseValue: amt, + }); + } + + if (data.gift) { + let byUsername = getUserInfo(data.user, ['name']).name; + let gemAmount = data.gift.gems.amount || 20; + + members.sendMessage(data.user, data.gift.member, data.gift); + if (data.gift.member.preferences.emailNotifications.giftedGems !== false) { + txnEmail(data.gift.member, 'gifted-gems', [ + {name: 'GIFTER', content: byUsername}, + {name: 'X_GEMS_GIFTED', content: gemAmount}, + ]); + } + + if (data.gift.member._id !== data.user._id) { // Only send push notifications if sending to a user other than yourself + pushNotify.sendNotify(data.gift.member, shared.i18n.t('giftedGems'), `${gemAmount} Gems - by ${byUsername}`); + } + + await data.gift.member.save(); + } + + await data.user.save(); +}; + +module.exports = api; diff --git a/website/src/middlewares/api-v3/auth.js b/website/src/middlewares/api-v3/auth.js index 21b0032714..c2d0e052b6 100644 --- a/website/src/middlewares/api-v3/auth.js +++ b/website/src/middlewares/api-v3/auth.js @@ -55,3 +55,21 @@ export function authWithSession (req, res, next) { }) .catch(next); } + +export function authWithUrl (req, res, next) { + let userId = req.query._id; + let apiToken = req.query.apiToken; + + if (!userId || !apiToken) { + throw new NotAuthorized(res.t('missingAuthParams')); + } + + User.findOne({ _id: userId, apiToken }).exec() + .then((user) => { + if (!user) throw new NotAuthorized(res.t('invalidCredentials')); + + res.locals.user = user; + next(); + }) + .catch(next); +} 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