diff --git a/.eslintignore b/.eslintignore index 2acf38ea8d..606a950295 100644 --- a/.eslintignore +++ b/.eslintignore @@ -19,7 +19,6 @@ website/src/routes/payments.js website/src/routes/pages.js website/src/middlewares/apiThrottle.js website/src/middlewares/forceRefresh.js -website/src/controllers/top-level/payments/paypal.js debug-scripts/* tasks/*.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 0a3662bf80..74eb217ec5 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.", @@ -175,5 +176,7 @@ "equipmentAlreadyOwned": "You already own that piece of equipment", "missingAccessToken": "The request is missing a required parameter : access_token", "missingBillingAgreementId": "Missing billing agreement id", - "paymentNotSuccessful": "The payment was not successful" + "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/tasks/gulp-tests.js b/tasks/gulp-tests.js index a58113b77d..8d2d49876a 100644 --- a/tasks/gulp-tests.js +++ b/tasks/gulp-tests.js @@ -359,7 +359,7 @@ gulp.task('test:api-v3:unit', (done) => { }); gulp.task('test:api-v3:unit:watch', () => { - gulp.watch(['website/src/libs/api-v3/*', 'test/api/v3/unit/libs/*', 'website/src/controllers/**/*'], ['test:api-v3:unit']); + 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) => { diff --git a/test/api/v3/integration/payments/GET-payments_amazon_subscribeCancel.test.js b/test/api/v3/integration/payments/GET-payments_amazon_subscribe_cancel.test.js similarity index 72% rename from test/api/v3/integration/payments/GET-payments_amazon_subscribeCancel.test.js rename to test/api/v3/integration/payments/GET-payments_amazon_subscribe_cancel.test.js index 66562c3721..1f05739bb9 100644 --- a/test/api/v3/integration/payments/GET-payments_amazon_subscribeCancel.test.js +++ b/test/api/v3/integration/payments/GET-payments_amazon_subscribe_cancel.test.js @@ -4,7 +4,7 @@ import { } from '../../../../helpers/api-integration/v3'; describe('payments : amazon #subscribeCancel', () => { - let endpoint = '/payments/amazon/subscribeCancel'; + let endpoint = '/payments/amazon/subscribe/cancel'; let user; beforeEach(async () => { @@ -13,9 +13,9 @@ describe('payments : amazon #subscribeCancel', () => { it('verifies subscription', async () => { await expect(user.get(endpoint)).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('missingSubscription'), + 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..12ea7c8ee9 --- /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 = '/payments/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..4dae9d8485 --- /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 = '/payments/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..7640cfdf92 --- /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 = '/payments/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..2e4ccedf01 --- /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 = '/payments/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..961556ff8b --- /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 = '/payments/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..b65d4ea6c2 --- /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 = '/payments/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_paypal_ipn.test.js b/test/api/v3/integration/payments/POST-payments_paypal_ipn.test.js new file mode 100644 index 0000000000..dcdbd14c44 --- /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 = '/payments/paypal/ipn'; + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('verifies credentials', async () => { + let result = await user.post(endpoint); + expect(result).to.eql({}); + }); +}); 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..bc4d857a03 --- /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 = '/payments/stripe/checkout'; + 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: '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..c456d389a4 --- /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 = '/payments/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: 400, + error: 'BadRequest', + message: t('missingSubscription'), + }); + }); +}); diff --git a/test/api/v3/unit/libs/payments.test.js b/test/api/v3/unit/libs/payments.test.js index ad846e6488..bc4a3e647d 100644 --- a/test/api/v3/unit/libs/payments.test.js +++ b/test/api/v3/unit/libs/payments.test.js @@ -66,10 +66,7 @@ describe('payments/index', () => { it('sends a text', async () => { await api.cancelSubscription(data); - sinon.assert.calledOnce(fakeSend); + sinon.assert.called(fakeSend); }); }); - - describe('#buyGems', async () => { - }); }); diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index 08c405424e..73b0bdab3d 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -2,6 +2,9 @@ import validator from 'validator'; import moment from 'moment'; import passport from 'passport'; import nconf from 'nconf'; +import setupNconf from '../../libs/api-v3/setupNconf'; +setupNconf(); + import { authWithHeaders, } from '../../middlewares/api-v3/auth'; diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index 42864fdab7..6aa8319f7c 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -12,6 +12,8 @@ import _ from 'lodash'; import { removeFromArray } from '../../libs/api-v3/collectionManipulators'; import { sendTxn } from '../../libs/api-v3/email'; import nconf from 'nconf'; +import setupNconf from '../../libs/api-v3/setupNconf'; +setupNconf(); const FLAG_REPORT_EMAILS = nconf.get('FLAG_REPORT_EMAIL').split(',').map((email) => { return { email, canSend: true }; diff --git a/website/src/controllers/top-level/payments/amazon.js b/website/src/controllers/top-level/payments/amazon.js index 74998a13f4..3be2673b79 100644 --- a/website/src/controllers/top-level/payments/amazon.js +++ b/website/src/controllers/top-level/payments/amazon.js @@ -1,11 +1,11 @@ -/* -import mongoose from 'mongoose'; -import { model as User } from '../../../models/user'; */ import { BadRequest, } from '../../../libs/api-v3/errors'; import amzLib from '../../../libs/api-v3/amazonPayments'; -import { authWithHeaders } from '../../../middlewares/api-v3/auth'; +import { + authWithHeaders, + authWithUrl, +} from '../../../middlewares/api-v3/auth'; import shared from '../../../../../common'; import payments from '../../../libs/api-v3/payments'; import moment from 'moment'; @@ -16,7 +16,7 @@ import cc from 'coupon-code'; let api = {}; /** - * @api {post} /api/v3/payments/amazon/verifyAccessToken verify access token + * @api {post} /amazon/verifyAccessToken verify access token * @apiVersion 3.0.0 * @apiName AmazonVerifyAccessToken * @apiGroup Payments @@ -40,7 +40,7 @@ api.verifyAccessToken = { }; /** - * @api {post} /api/v3/payments/amazon/createOrderReferenceId create order reference id + * @api {post} /amazon/createOrderReferenceId create order reference id * @apiVersion 3.0.0 * @apiName AmazonCreateOrderReferenceId * @apiGroup Payments @@ -70,7 +70,7 @@ api.createOrderReferenceId = { }; /** - * @api {post} /api/v3/payments/amazon/checkout do checkout + * @api {post} /amazon/checkout do checkout * @apiVersion 3.0.0 * @apiName AmazonCheckout * @apiGroup Payments @@ -148,7 +148,7 @@ api.checkout = { }; /** - * @api {post} /api/v3/payments/amazon/subscribe Subscribe + * @api {post} /amazon/subscribe Subscribe * @apiVersion 3.0.0 * @apiName AmazonSubscribe * @apiGroup Payments @@ -228,7 +228,7 @@ api.subscribe = { }; /** - * @api {get} /api/v3/payments/amazon/subscribe/cancel SubscribeCancel + * @api {get} /amazon/subscribe/cancel SubscribeCancel * @apiVersion 3.0.0 * @apiName AmazonSubscribe * @apiGroup Payments @@ -238,7 +238,7 @@ api.subscribe = { api.subscribeCancel = { method: 'GET', url: '/payments/amazon/subscribe/cancel', - middlewares: [authWithHeaders()], + middlewares: [authWithUrl], async handler (req, res) { let user = res.locals.user; let billingAgreementId = user.purchased.plan.customerId; diff --git a/website/src/controllers/top-level/payments/paypal.js b/website/src/controllers/top-level/payments/paypal.js index c23fa6247d..841bc0546b 100644 --- a/website/src/controllers/top-level/payments/paypal.js +++ b/website/src/controllers/top-level/payments/paypal.js @@ -1,218 +1,296 @@ -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('../../../libs/api-v3/payments'); -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'); +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 { model as Coupon } from '../../../models/coupon'; +import { model as User } from '../../../models/user'; +import { + authWithUrl, + authWithSession, +} from '../../../middlewares/api-v3/auth'; +import { + BadRequest, +} from '../../../libs/api-v3/errors'; +import * as logger from '../../../libs/api-v3/logger'; // 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); +_.each(shared.content.subscriptionBlocks, (block) => { + block.paypalKey = nconf.get(`PAYPAL:billing_plans:${block.key}`); }); +/* eslint-disable camelcase */ + paypal.configure({ - 'mode': nconf.get("PAYPAL:mode"), //sandbox or live - 'client_id': nconf.get("PAYPAL:client_id"), - 'client_secret': nconf.get("PAYPAL:client_secret") + 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; - }); -} // */ +let api = {}; /** - * 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; + * @api {get} /paypal/checkout checkout + * @apiVersion 3.0.0 + * @apiName PaypalCheckout + * @apiGroup Payments + * + * @apiParam {string} gift The stringified object representing the user, the gift recepient. + * + * @apiSuccess {} redirect + **/ +api.checkout = { + method: 'GET', + url: '/payments/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 = 'monthly HabitRPG Subscription (Gift)'; + } } - }); + + let createPayment = { + 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, + price: amount, + currency: 'USD', + quality: 1, + }], + }, + amount: { + currency: 'USD', + total: amount, + }, + description, + }], + }; + try { + let result = await paypal.payment.create(createPayment); + let link = _.find(result.links, { rel: 'approval_url' }).href; + res.redirect(link); + } catch (e) { + throw new BadRequest(e); + } + }, }; -*/ + +/** + * @api {get} /paypal/checkout/success Paypal checkout success + * @apiVersion 3.0.0 + * @apiName PaypalCheckoutSuccess + * @apiGroup Payments + * + * @apiParam {string} paymentId The payment id + * @apiParam {string} payerID The payer id, notice ID not id + * + * @apiSuccess {} redirect + **/ +api.checkoutSuccess = { + method: 'GET', + url: '/payments/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', + }; + + try { + 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 paypal.payment.execute(paymentId, { payer_id: customerId }); + await payments[method](data); + res.redirect('/'); + } catch (e) { + throw new BadRequest(e); + } + }, +}; + +/** + * @api {get} /paypal/subscribe Paypal subscribe + * @apiVersion 3.0.0 + * @apiName PaypalSubscribe + * @apiGroup Payments + * + * @apiParam {string} sub subscription, possible values are: basic_earned, basic_3mo, basic_6mo, google_6mo, basic_12mo + * @apiParam {string} coupon coupon for the matching subscription, required only for certain subscriptions + * + * @apiSuccess {} empty object + **/ +api.subscribe = { + method: 'GET', + url: '/payments/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 BadRequest(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', + }, + }; + try { + let billingAgreement = await paypal.billingAgreement.create(billingAgreementAttributes); + req.session.paypalBlock = req.query.sub; + let link = _.find(billingAgreement.links, { rel: 'approval_url' }).href; + res.redirect(link); + } catch (e) { + throw new BadRequest(e); + } + }, +}; + +/** + * @api {get} /paypal/subscribe/success Paypal subscribe success + * @apiVersion 3.0.0 + * @apiName PaypalSubscribeSuccess + * @apiGroup Payments + * + * @apiParam {string} token The token in query + * + * @apiSuccess {} redirect + **/ +api.subscribeSuccess = { + method: 'GET', + url: '/payments/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; + try { + let result = await paypal.billingAgreement.execute(req.query.token, {}); + await payments.createSubscription({ + user, + customerId: result.id, + paymentMethod: 'Paypal', + sub: block, + }); + res.redirect('/'); + } catch (e) { + throw new BadRequest(e); + } + }, +}; + +/** + * @api {get} /paypal/subscribe/cancel Paypal subscribe cancel + * @apiVersion 3.0.0 + * @apiName PaypalSubscribeCancel + * @apiGroup Payments + * + * @apiParam {string} token The token in query + * + * @apiSuccess {} redirect + **/ +api.subscribeCancel = { + method: 'GET', + url: '/payments/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 BadRequest(res.t('missingSubscription')); + try { + let customer = await paypal.billingAgreement.get(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 paypal.billingAgreement.cancel(customerId, { note: res.t('cancelingSubscription') }); + let data = { + user, + paymentMethod: 'Paypal', + nextBill: nextBillingDate, + }; + await payments.cancelSubscription(data); + res.redirect('/'); + } catch (e) { + throw new BadRequest(e); + } + }, +}; + +/** + * @api {post} /paypal/ipn Paypal IPN + * @apiVersion 3.0.0 + * @apiName PaypalIpn + * @apiGroup Payments + * + * @apiParam {string} txn_type txn_type + * @apiParam {string} recurring_payment_id recurring_payment_id + * + * @apiSuccess {} empty object + **/ +api.ipn = { + method: 'POST', + url: '/payments/paypal/ipn', + middlewares: [], + async handler (req, res) { + res.respond(200); + try { + await ipn.verify(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) { + payments.cancelSubscriptoin({ user, paymentMethod: 'Paypal' }); + } + } + } catch (e) { + logger.error(e); + } + }, +}; + +/* eslint-disable camelcase */ + +module.exports = api; diff --git a/website/src/controllers/top-level/payments/paypalBillingSetup.js b/website/src/controllers/top-level/payments/paypalBillingSetup.js index 0e8170ff51..1015e8f89e 100644 --- a/website/src/controllers/top-level/payments/paypalBillingSetup.js +++ b/website/src/controllers/top-level/payments/paypalBillingSetup.js @@ -38,6 +38,7 @@ let billingPlanAttributes = { cycles: '0', }], }; + _.each(blocks, function defineBlock (block) { block.definition = _.cloneDeep(billingPlanAttributes); _.merge(block.definition.payment_definitions[0], { diff --git a/website/src/controllers/top-level/payments/stripe.js b/website/src/controllers/top-level/payments/stripe.js index 6a553dacd5..d211a60d89 100644 --- a/website/src/controllers/top-level/payments/stripe.js +++ b/website/src/controllers/top-level/payments/stripe.js @@ -1,137 +1,167 @@ -/* import nconf from 'nconf'; import stripeModule from 'stripe'; -import async from 'async'; -import payments from '../../../libs/api-v3/payments'; -import { model as User } from '../../../models/user'; import shared from '../../../../../common'; -import mongoose from 'mongoose'; -import cc from 'coupon-code'; */ +import { + BadRequest, +} 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')); +const stripe = stripeModule(nconf.get('STRIPE_API_KEY')); let api = {}; -/* - Setup Stripe response when posting payment - */ -/* -api.checkout = function checkout (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; - async.waterfall([ - function stripeCharge (cb) { - if (sub) { - async.waterfall([ - function handleCoupon (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 createCustomer (coupon, cb2) { - if (sub.discount && !coupon) return cb2('Invalid coupon code.'); - let customer = { - email: req.body.email, - metadata: {uuid: user._id}, - card: token, - plan: sub.key, - }; - stripe.customers.create(customer, cb2); - }, - ], cb); - } else { - let amount; - if (!gift) { - amount = '500'; - } else if (gift.type === 'subscription') { +/** + * @api {post} /stripe/checkout Stripe checkout + * @apiVersion 3.0.0 + * @apiName StripeCheckout + * @apiGroup Payments + * + * @apiParam {string} id The token + * @apiParam {string} gift stringified json object, gift + * @apiParam {string} sub subscription, possible values are: basic_earned, basic_3mo, basic_6mo, google_6mo, basic_12mo + * @apiParam {string} coupon coupon for the matching subscription, required only for certain subscriptions + * @apiParam {string} email the customer email + * + * @apiSuccess {} empty object + **/ +api.checkout = { + method: 'POST', + url: '/payments/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 (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')); + } + let customer = { + email: req.body.email, + metadata: { uuid: user._id }, + card: token, + plan: sub.key, + }; + response = await stripe.customers.create(customer); + } else { + let amount = 500; // $5 + if (gift) { + if (gift.type === 'subscription') { amount = `${shared.content.subscriptionBlocks[gift.subscription.key].price * 100}`; } else { amount = `${gift.gems.amount / 4 * 100}`; } - stripe.charges.create({ - amount, - currency: 'usd', - card: token, - }, cb); } - }, - function saveUserData (response, cb) { - if (sub) return payments.createSubscription({user, customerId: response.id, paymentMethod: 'Stripe', sub}, cb); - async.waterfall([ - function findUser (cb2) { - User.findById(gift ? gift.uuid : undefined, cb2); - }, - function prepData (member, cb2) { - let data = {user, customerId: response.id, paymentMethod: 'Stripe', gift}; - let method = 'buyGems'; - if (gift) { - gift.member = member; - if (gift.type === 'subscription') method = 'createSubscription'; - data.paymentMethod = 'Gift'; - } - payments[method](data, cb2); - }, - ], cb); - }, - ], function handleResponse (err) { - if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors - res.sendStatus(200); - user = token = null; - }); -}; + response = await stripe.charges.create({ + amount, + currency: 'usd', + card: token, + }); + } -api.subscribeCancel = function subscribeCancel (req, res) { - let user = res.locals.user; - if (!user.purchased.plan.customerId) { - return res.status(401).json({err: 'User does not have a plan subscription'}); - } - - async.auto({ - getCustomer: function getCustomer (cb) { - stripe.customers.retrieve(user.purchased.plan.customerId, cb); - }, - deleteCustomer: ['getCustomer', function deleteCustomer (cb) { - stripe.customers.del(user.purchased.plan.customerId, cb); - }], - cancelSubscription: ['getCustomer', function cancelSubscription (cb, results) { + if (sub) { + await payments.createSubscription({ + user, + customerId: response.id, + paymentMethod: 'Stripe', + sub, + }); + } else { + let method = 'buyGems'; let data = { user, - nextBill: results.get_cus.subscription.current_period_end * 1000, // timestamp is in seconds + 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, {}); + }, +}; + +/** + * @api {post} /stripe/subscribe/edit Stripe subscribeEdit + * @apiVersion 3.0.0 + * @apiName StripeSubscribeEdit + * @apiGroup Payments + * + * @apiParam {string} id The token + * + * @apiSuccess {} + **/ +api.subscribeEdit = { + method: 'POST', + url: '/payments/stripe/subscribe/edit', + middlewares: [authWithHeaders()], + async handler (req, res) { + let token = req.body.id; + let user = res.locals.user; + let customerId = user.purchased.plan.customerId; + + if (!customerId) throw new BadRequest(res.t('missingSubscription')); + + try { + let subscriptions = await stripe.customers.listSubscriptions(customerId); + let subscriptionId = subscriptions.data[0].id; + await stripe.customers.updateSubscription(customerId, subscriptionId, { card: token }); + res.respond(200, {}); + } catch (error) { + throw new BadRequest(error.message); + } + }, +}; + +/** + * @api {get} /stripe/subscribe/cancel Stripe subscribeCancel + * @apiVersion 3.0.0 + * @apiName StripeSubscribeCancel + * @apiGroup Payments + * + * @apiParam + * + * @apiSuccess {} + **/ +api.subscribeCancel = { + method: 'GET', + url: '/payments/stripe/subscribe/cancel', + middlewares: [authWithUrl], + async handler (req, res) { + let user = res.locals.user; + if (!user.purchased.plan.customerId) throw new BadRequest(res.t('missingSubscription')); + try { + let customer = await stripe.customers.retrieve(user.purchased.plan.customeerId); + await stripe.customers.del(user.purchased.plan.customerId); + let data = { + user, + nextBill: customer.subscription.current_period_end * 1000, // timestamp in seconds paymentMethod: 'Stripe', }; - payments.cancelSubscription(data, cb); - }], - }, function handleResponse (err) { - if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors - res.redirect('/'); - user = null; - }); + await payments.cancelSubscriptoin(data); + res.respond(200, {}); + } catch (e) { + throw new BadRequest(e); + } + }, }; -api.subscribeEdit = function subscribeEdit (req, res) { - let token = req.body.id; - let user = res.locals.user; - let userId = user.purchased.plan.customerId; - let subscriptionId; - - async.waterfall([ - function listSubscriptions (cb) { - stripe.customers.listSubscriptions(userId, cb); - }, - function updateSubscription (response, cb) { - subscriptionId = response.data[0].id; - stripe.customers.updateSubscription(userId, subscriptionId, { card: token }, cb); - }, - function saveUser (response, cb) { - user.save(cb); - }, - ], function handleResponse (err) { - if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors - res.sendStatus(200); - token = user = userId = subscriptionId; - }); -}; -*/ - module.exports = api; diff --git a/website/src/libs/api-v3/payments.js b/website/src/libs/api-v3/payments.js index 0e94153150..8a28e7d1a9 100644 --- a/website/src/libs/api-v3/payments.js +++ b/website/src/libs/api-v3/payments.js @@ -1,13 +1,11 @@ import _ from 'lodash' ; import analytics from './analyticsService'; -import cc from 'coupon-code'; import { getUserInfo, sendTxn as txnEmail, } from './email'; import members from '../../controllers/api-v3/members'; import moment from 'moment'; -import mongoose from 'mongoose'; import nconf from 'nconf'; import pushNotify from './pushNotifications'; import shared from '../../../../common' ; diff --git a/website/src/middlewares/api-v3/auth.js b/website/src/middlewares/api-v3/auth.js index d1fcfe2e7b..0b595a56a3 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); +}