From 626d8d6e73b886182bed4b1b38478c7f3fecfb0b Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Wed, 6 Apr 2016 21:18:36 +0000 Subject: [PATCH 01/34] WIP(payments): lint Amazon Payments file --- .../src/controllers/api-v3/payments/amazon.js | 277 ++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 website/src/controllers/api-v3/payments/amazon.js diff --git a/website/src/controllers/api-v3/payments/amazon.js b/website/src/controllers/api-v3/payments/amazon.js new file mode 100644 index 0000000000..c1056c1224 --- /dev/null +++ b/website/src/controllers/api-v3/payments/amazon.js @@ -0,0 +1,277 @@ +import amazonPayments from 'amazon-payments'; +import async from 'async'; +import cc from 'coupon-code'; +import mongoose from 'mongoose'; +import moment from 'moment'; +import nconf from 'nconf'; +import payments from './index'; +import shared from '../../../../common'; +import { model as User } from '../../models/user'; + +const IS_PROD = nconf.get('NODE_ENV') === 'production'; + +let api = {}; + +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'), +}); + +api.verifyAccessToken = function verifyAccessToken (req, res) { + 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 getTokenInfo (err) { + if (err) return res.status(400).json({err}); + + res.sendStatus(200); + }); +}; + +api.createOrderReferenceId = function createOrderReferenceId (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 createOrderReferenceForId (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, + }); + }); +}; + +api.checkout = function checkout (req, res, next) { + if (!req.body || !req.body.orderReferenceId) { + return res.status(400).json({err: 'Billing Agreement Id not supplied.'}); + } + + let gift = req.body.gift; + let user = res.locals.user; + let orderReferenceId = req.body.orderReferenceId; + let 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 (cb) { + amzPayment.offAmazonPayments.setOrderReferenceDetails({ + AmazonOrderReferenceId: orderReferenceId, + OrderReferenceAttributes: { + OrderTotal: { + CurrencyCode: 'USD', + Amount: amount, + }, + SellerNote: 'HabitRPG Payment', + SellerOrderAttributes: { + SellerOrderId: shared.uuid(), + StoreName: 'HabitRPG', + }, + }, + }, cb); + }, + + confirmOrderReference (cb) { + amzPayment.offAmazonPayments.confirmOrderReference({ + AmazonOrderReferenceId: orderReferenceId, + }, cb); + }, + + authorize (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 checkAuthorizationStatus (err) { + if (err) return cb(err); + + if (res.AuthorizationDetails.AuthorizationStatus.State === 'Declined') { + return cb(new Error('The payment was not successfull.')); + } + + return cb(); + }); + }, + + closeOrderReference (cb) { + amzPayment.offAmazonPayments.closeOrderReference({ + AmazonOrderReferenceId: orderReferenceId, + }, cb); + }, + + executePayment (cb) { + async.waterfall([ + function findUser (cb2) { + User.findById(gift ? gift.uuid : undefined, cb2); + }, + function executeAmazonPayment (member, cb2) { + let data = {user, paymentMethod: 'Amazon Payments'}; + let 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 result (err) { + if (err) return next(err); + + res.sendStatus(200); + }); +}; + +api.subscribe = function subscribe (req, res, next) { + if (!req.body || !req.body.billingAgreementId) { + return res.status(400).json({err: 'Billing Agreement Id not supplied.'}); + } + + 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) { + return res.status(400).json({err: 'Subscription plan not found.'}); + } + + async.series({ + applyDiscount (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 couponResult (err) { + if (err) return cb(err); + if (!coupon) return cb(new Error('Coupon code not found.')); + cb(); + }); + }, + + setBillingAgreementDetails (cb) { + amzPayment.offAmazonPayments.setBillingAgreementDetails({ + AmazonBillingAgreementId: billingAgreementId, + BillingAgreementAttributes: { + SellerNote: 'HabitRPG Subscription', + SellerBillingAgreementAttributes: { + SellerBillingAgreementId: shared.uuid(), + StoreName: 'HabitRPG', + CustomInformation: 'HabitRPG Subscription', + }, + }, + }, cb); + }, + + confirmBillingAgreement (cb) { + amzPayment.offAmazonPayments.confirmBillingAgreement({ + AmazonBillingAgreementId: billingAgreementId, + }, cb); + }, + + authorizeOnBillingAgreement (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 billingAgreementResult (err) { + if (err) return cb(err); + + if (res.AuthorizationDetails.AuthorizationStatus.State === 'Declined') { + return cb(new Error('The payment was not successful.')); + } + + return cb(); + }); + }, + + createSubscription (cb) { + payments.createSubscription({ + user, + customerId: billingAgreementId, + paymentMethod: 'Amazon Payments', + sub, + }, cb); + }, + }, function subscribeResult (err) { + if (err) return next(err); + + res.sendStatus(200); + }); +}; + +api.subscribeCancel = function subscribeCancel (req, res, next) { + let user = res.locals.user; + if (!user.purchased.plan.customerId) + return res.status(401).json({err: 'User does not have a plan subscription'}); + + let billingAgreementId = user.purchased.plan.customerId; + + async.series({ + closeBillingAgreement (cb) { + amzPayment.offAmazonPayments.closeBillingAgreement({ + AmazonBillingAgreementId: billingAgreementId, + }, cb); + }, + + cancelSubscription (cb) { + let data = { + user, + // Date of next bill + nextBill: moment(user.purchased.plan.lastBillingDate).add({days: 30}), + paymentMethod: 'Amazon Payments', + }; + + payments.cancelSubscription(data, cb); + }, + }, function subscribeCancelResult (err) { + 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; + }); +}; + +module.exports = api; From c5549787b44edd8bf5e72c90166e63c316ebe326 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Thu, 7 Apr 2016 20:51:40 +0000 Subject: [PATCH 02/34] refactor(payments): index.js lint pass --- .../src/controllers/api-v3/payments/amazon.js | 2 +- .../src/controllers/api-v3/payments/index.js | 232 ++++++++++++++++++ 2 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 website/src/controllers/api-v3/payments/index.js diff --git a/website/src/controllers/api-v3/payments/amazon.js b/website/src/controllers/api-v3/payments/amazon.js index c1056c1224..bc8e3b5177 100644 --- a/website/src/controllers/api-v3/payments/amazon.js +++ b/website/src/controllers/api-v3/payments/amazon.js @@ -5,7 +5,7 @@ import mongoose from 'mongoose'; import moment from 'moment'; import nconf from 'nconf'; import payments from './index'; -import shared from '../../../../common'; +import shared from '../../../../../common'; import { model as User } from '../../models/user'; const IS_PROD = nconf.get('NODE_ENV') === 'production'; diff --git a/website/src/controllers/api-v3/payments/index.js b/website/src/controllers/api-v3/payments/index.js new file mode 100644 index 0000000000..f6e0a8ebe3 --- /dev/null +++ b/website/src/controllers/api-v3/payments/index.js @@ -0,0 +1,232 @@ +import _ from 'lodash' ; +import analytics from '../../../libs/api-v3/analyticsService'; +import async from 'async'; +import cc from 'coupon-code'; +import { + getUserInfo, + sendTxn as txnEmail, +} from '../../../libs/api-v3/email'; +import members from '../members'; +import moment from 'moment'; +import mongoose from 'mongoose'; +import nconf from 'nconf'; +import pushNotify from '../../../libs/api-v3/pushNotifications'; +import shared from '../../../../../common' ; + +import amazon from './amazon'; +import iap from './iap'; +import paypal from './paypal'; +import stripe from './stripe'; + +const IS_PROD = nconf.get('NODE_ENV') === 'production'; + +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 = function createSubscription (data, cb) { + 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'); + + let analyticsData = { + 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, + }; + analytics.trackPurchase(analyticsData); + } + 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}`); + } + } + async.parallel([ + function saveGiftingUserData (cb2) { + data.user.save(cb2); + }, + function saveRecipientUserData (cb2) { + if (data.gift) { + data.gift.member.save(cb2); + } else { + cb2(null); + } + }, + ], cb); +}; + +/** + * Sets their subscription to be cancelled later + */ +api.cancelSubscription = function cancelSubscription (data, cb) { + let plan = data.user.purchased.plan; + let now = moment(); + let remaining = data.nextBill ? moment(data.nextBill).diff(new Date(), 'days') : 30; + + plan.dateTerminated = + moment(`${now.format('MM')}/${moment(plan.dateUpdated).format('DD')}/${now.format('YYYY')}`) + .add({days: remaining}) // end their subscription 1mo from their last payment + .add({months: Math.ceil(plan.extraMonths)})// plus any extra time (carry-over, gifted subscription, etc) they have. FIXME: moment can't add months in fractions... + .toDate(); + plan.extraMonths = 0; // clear extra time. If they subscribe again, it'll be recalculated from p.dateTerminated + + data.user.save(cb); + txnEmail(data.user, 'cancel-subscription'); + let analyticsData = { + uuid: data.user._id, + gaCategory: 'commerce', + gaLabel: data.paymentMethod, + paymentMethod: data.paymentMethod, + }; + analytics.track('unsubscribe', analyticsData); +}; + +api.buyGems = function buyGems (data, cb) { + let amt = data.amount || 5; + amt = data.gift ? data.gift.gems.amount / 4 : amt; + (data.gift ? data.gift.member : data.user).balance += amt; + data.user.purchased.txnCount++; + if (IS_PROD) { + if (!data.gift) txnEmail(data.user, 'donation'); + + let analyticsData = { + uuid: data.user._id, + itemPurchased: 'Gems', + sku: `${data.paymentMethod.toLowerCase()}-checkout`, + purchaseType: 'checkout', + paymentMethod: data.paymentMethod, + quantity: 1, + gift: Boolean(data.gift), + purchaseValue: amt, + }; + analytics.trackPurchase(analyticsData); + } + + 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}`); + } + } + async.parallel([ + function saveGiftingUserData (cb2) { + data.user.save(cb2); + }, + function saveRecipientUserData (cb2) { + if (data.gift) { + data.gift.member.save(cb2); + } else { + cb2(null); + } + }, + ], cb); +}; + +api.validCoupon = function validCoupon (req, res, next) { + mongoose.model('Coupon').findOne({_id: cc.validate(req.params.code), event: 'google_6mo'}, function couponErrorCheck (err, coupon) { + if (err) return next(err); + if (!coupon) return res.status(401).json({err: 'Invalid coupon code'}); + return res.sendStatus(200); + }); +}; + +api.stripeCheckout = stripe.checkout; +api.stripeSubscribeCancel = stripe.subscribeCancel; +api.stripeSubscribeEdit = stripe.subscribeEdit; + +api.paypalSubscribe = paypal.createBillingAgreement; +api.paypalSubscribeSuccess = paypal.executeBillingAgreement; +api.paypalSubscribeCancel = paypal.cancelSubscription; +api.paypalCheckout = paypal.createPayment; +api.paypalCheckoutSuccess = paypal.executePayment; +api.paypalIPN = paypal.ipn; + +api.amazonVerifyAccessToken = amazon.verifyAccessToken; +api.amazonCreateOrderReferenceId = amazon.createOrderReferenceId; +api.amazonCheckout = amazon.checkout; +api.amazonSubscribe = amazon.subscribe; +api.amazonSubscribeCancel = amazon.subscribeCancel; + +api.iapAndroidVerify = iap.androidVerify; +api.iapIosVerify = iap.iosVerify; + +module.exports = api; From 71e0792da88a262729a6e27a5c68ea5bd6228c80 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Tue, 12 Apr 2016 21:15:20 +0000 Subject: [PATCH 03/34] refactor(payments): IAP linting pass --- .../src/controllers/api-v3/payments/iap.js | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 website/src/controllers/api-v3/payments/iap.js diff --git a/website/src/controllers/api-v3/payments/iap.js b/website/src/controllers/api-v3/payments/iap.js new file mode 100644 index 0000000000..94cf21fcca --- /dev/null +++ b/website/src/controllers/api-v3/payments/iap.js @@ -0,0 +1,158 @@ +import { + iap, + inAppPurchase, } +from 'in-app-purchase'; +import payments from './index'; +import nconf from 'nconf'; + +inAppPurchase.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; */ // These variables were never used?? + +let api = {}; + +api.androidVerify = function androidVerify (req, res) { + let iapBody = req.body; + let user = res.locals.user; + + iap.setup(function googleSetupResult (error) { + if (error) { + let resObj = { + ok: false, + data: 'IAP Error', + }; + + return res.json(resObj); + } + + /* + google receipt must be provided as an object + { + "data": "{stringified data object}", + "signature": "signature from google" + } + */ + let testObj = { + data: iapBody.transaction.receipt, + signature: iapBody.transaction.signature, + }; + + // iap is ready + iap.validate(iap.GOOGLE, testObj, function googleValidateResult (err, googleRes) { + if (err) { + 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}); + + return res.json(resObj); + } + }); + }); +}; + +exports.iosVerify = function iosVerify (req, res) { + let iapBody = req.body; + let user = res.locals.user; + + iap.setup(function iosSetupResult (error) { + if (error) { + let resObj = { + ok: false, + data: 'IAP Error', + }; + + return res.json(resObj); + } + + // iap is ready + iap.validate(iap.APPLE, iapBody.transaction.receipt, function iosValidateResult (err, appleRes) { + if (err) { + 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; From da84f631e9f89ab2537621e29d4e7c0a001a670d Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Wed, 13 Apr 2016 19:29:13 +0000 Subject: [PATCH 04/34] refactor(payments): Stripe linting pass --- .../src/controllers/api-v3/payments/stripe.js | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 website/src/controllers/api-v3/payments/stripe.js diff --git a/website/src/controllers/api-v3/payments/stripe.js b/website/src/controllers/api-v3/payments/stripe.js new file mode 100644 index 0000000000..5582d33ca1 --- /dev/null +++ b/website/src/controllers/api-v3/payments/stripe.js @@ -0,0 +1,135 @@ +import nconf from 'nconf'; +import stripeModule from 'stripe'; +import async from 'async'; +import payments from './index'; +import { model as User } from '../../models/user'; +import shared from '../../../../../common'; +import mongoose from 'mongoose'; +import cc from 'coupon-code'; + +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') { + 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; + }); +}; + +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) { + let data = { + user, + nextBill: results.get_cus.subscription.current_period_end * 1000, // timestamp is 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; + }); +}; + +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; From c9e3e0e68c5689b00aa508be3ba08678425f072f Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 14 Apr 2016 18:18:57 +0200 Subject: [PATCH 05/34] move payments to /top-level --- website/src/controllers/{ => top-level}/payments/amazon.js | 0 website/src/controllers/{ => top-level}/payments/iap.js | 0 website/src/controllers/{ => top-level}/payments/index.js | 0 website/src/controllers/{ => top-level}/payments/paypal.js | 0 .../controllers/{ => top-level}/payments/paypalBillingSetup.js | 0 website/src/controllers/{ => top-level}/payments/stripe.js | 0 6 files changed, 0 insertions(+), 0 deletions(-) rename website/src/controllers/{ => top-level}/payments/amazon.js (100%) rename website/src/controllers/{ => top-level}/payments/iap.js (100%) rename website/src/controllers/{ => top-level}/payments/index.js (100%) rename website/src/controllers/{ => top-level}/payments/paypal.js (100%) rename website/src/controllers/{ => top-level}/payments/paypalBillingSetup.js (100%) rename website/src/controllers/{ => top-level}/payments/stripe.js (100%) diff --git a/website/src/controllers/payments/amazon.js b/website/src/controllers/top-level/payments/amazon.js similarity index 100% rename from website/src/controllers/payments/amazon.js rename to website/src/controllers/top-level/payments/amazon.js diff --git a/website/src/controllers/payments/iap.js b/website/src/controllers/top-level/payments/iap.js similarity index 100% rename from website/src/controllers/payments/iap.js rename to website/src/controllers/top-level/payments/iap.js diff --git a/website/src/controllers/payments/index.js b/website/src/controllers/top-level/payments/index.js similarity index 100% rename from website/src/controllers/payments/index.js rename to website/src/controllers/top-level/payments/index.js diff --git a/website/src/controllers/payments/paypal.js b/website/src/controllers/top-level/payments/paypal.js similarity index 100% rename from website/src/controllers/payments/paypal.js rename to website/src/controllers/top-level/payments/paypal.js diff --git a/website/src/controllers/payments/paypalBillingSetup.js b/website/src/controllers/top-level/payments/paypalBillingSetup.js similarity index 100% rename from website/src/controllers/payments/paypalBillingSetup.js rename to website/src/controllers/top-level/payments/paypalBillingSetup.js diff --git a/website/src/controllers/payments/stripe.js b/website/src/controllers/top-level/payments/stripe.js similarity index 100% rename from website/src/controllers/payments/stripe.js rename to website/src/controllers/top-level/payments/stripe.js From a13f25e07ce5d39ad06cd853e0ee1b1b42e7eeff Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Wed, 6 Apr 2016 21:18:36 +0000 Subject: [PATCH 06/34] WIP(payments): lint Amazon Payments file --- .../src/controllers/api-v3/payments/amazon.js | 277 ++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 website/src/controllers/api-v3/payments/amazon.js diff --git a/website/src/controllers/api-v3/payments/amazon.js b/website/src/controllers/api-v3/payments/amazon.js new file mode 100644 index 0000000000..c1056c1224 --- /dev/null +++ b/website/src/controllers/api-v3/payments/amazon.js @@ -0,0 +1,277 @@ +import amazonPayments from 'amazon-payments'; +import async from 'async'; +import cc from 'coupon-code'; +import mongoose from 'mongoose'; +import moment from 'moment'; +import nconf from 'nconf'; +import payments from './index'; +import shared from '../../../../common'; +import { model as User } from '../../models/user'; + +const IS_PROD = nconf.get('NODE_ENV') === 'production'; + +let api = {}; + +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'), +}); + +api.verifyAccessToken = function verifyAccessToken (req, res) { + 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 getTokenInfo (err) { + if (err) return res.status(400).json({err}); + + res.sendStatus(200); + }); +}; + +api.createOrderReferenceId = function createOrderReferenceId (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 createOrderReferenceForId (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, + }); + }); +}; + +api.checkout = function checkout (req, res, next) { + if (!req.body || !req.body.orderReferenceId) { + return res.status(400).json({err: 'Billing Agreement Id not supplied.'}); + } + + let gift = req.body.gift; + let user = res.locals.user; + let orderReferenceId = req.body.orderReferenceId; + let 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 (cb) { + amzPayment.offAmazonPayments.setOrderReferenceDetails({ + AmazonOrderReferenceId: orderReferenceId, + OrderReferenceAttributes: { + OrderTotal: { + CurrencyCode: 'USD', + Amount: amount, + }, + SellerNote: 'HabitRPG Payment', + SellerOrderAttributes: { + SellerOrderId: shared.uuid(), + StoreName: 'HabitRPG', + }, + }, + }, cb); + }, + + confirmOrderReference (cb) { + amzPayment.offAmazonPayments.confirmOrderReference({ + AmazonOrderReferenceId: orderReferenceId, + }, cb); + }, + + authorize (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 checkAuthorizationStatus (err) { + if (err) return cb(err); + + if (res.AuthorizationDetails.AuthorizationStatus.State === 'Declined') { + return cb(new Error('The payment was not successfull.')); + } + + return cb(); + }); + }, + + closeOrderReference (cb) { + amzPayment.offAmazonPayments.closeOrderReference({ + AmazonOrderReferenceId: orderReferenceId, + }, cb); + }, + + executePayment (cb) { + async.waterfall([ + function findUser (cb2) { + User.findById(gift ? gift.uuid : undefined, cb2); + }, + function executeAmazonPayment (member, cb2) { + let data = {user, paymentMethod: 'Amazon Payments'}; + let 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 result (err) { + if (err) return next(err); + + res.sendStatus(200); + }); +}; + +api.subscribe = function subscribe (req, res, next) { + if (!req.body || !req.body.billingAgreementId) { + return res.status(400).json({err: 'Billing Agreement Id not supplied.'}); + } + + 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) { + return res.status(400).json({err: 'Subscription plan not found.'}); + } + + async.series({ + applyDiscount (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 couponResult (err) { + if (err) return cb(err); + if (!coupon) return cb(new Error('Coupon code not found.')); + cb(); + }); + }, + + setBillingAgreementDetails (cb) { + amzPayment.offAmazonPayments.setBillingAgreementDetails({ + AmazonBillingAgreementId: billingAgreementId, + BillingAgreementAttributes: { + SellerNote: 'HabitRPG Subscription', + SellerBillingAgreementAttributes: { + SellerBillingAgreementId: shared.uuid(), + StoreName: 'HabitRPG', + CustomInformation: 'HabitRPG Subscription', + }, + }, + }, cb); + }, + + confirmBillingAgreement (cb) { + amzPayment.offAmazonPayments.confirmBillingAgreement({ + AmazonBillingAgreementId: billingAgreementId, + }, cb); + }, + + authorizeOnBillingAgreement (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 billingAgreementResult (err) { + if (err) return cb(err); + + if (res.AuthorizationDetails.AuthorizationStatus.State === 'Declined') { + return cb(new Error('The payment was not successful.')); + } + + return cb(); + }); + }, + + createSubscription (cb) { + payments.createSubscription({ + user, + customerId: billingAgreementId, + paymentMethod: 'Amazon Payments', + sub, + }, cb); + }, + }, function subscribeResult (err) { + if (err) return next(err); + + res.sendStatus(200); + }); +}; + +api.subscribeCancel = function subscribeCancel (req, res, next) { + let user = res.locals.user; + if (!user.purchased.plan.customerId) + return res.status(401).json({err: 'User does not have a plan subscription'}); + + let billingAgreementId = user.purchased.plan.customerId; + + async.series({ + closeBillingAgreement (cb) { + amzPayment.offAmazonPayments.closeBillingAgreement({ + AmazonBillingAgreementId: billingAgreementId, + }, cb); + }, + + cancelSubscription (cb) { + let data = { + user, + // Date of next bill + nextBill: moment(user.purchased.plan.lastBillingDate).add({days: 30}), + paymentMethod: 'Amazon Payments', + }; + + payments.cancelSubscription(data, cb); + }, + }, function subscribeCancelResult (err) { + 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; + }); +}; + +module.exports = api; From fc46bdf1841efe784accd85ad94d9ce6d657c134 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Thu, 7 Apr 2016 20:51:40 +0000 Subject: [PATCH 07/34] refactor(payments): index.js lint pass --- .../src/controllers/api-v3/payments/amazon.js | 2 +- .../src/controllers/api-v3/payments/index.js | 232 ++++++++++++++++++ 2 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 website/src/controllers/api-v3/payments/index.js diff --git a/website/src/controllers/api-v3/payments/amazon.js b/website/src/controllers/api-v3/payments/amazon.js index c1056c1224..bc8e3b5177 100644 --- a/website/src/controllers/api-v3/payments/amazon.js +++ b/website/src/controllers/api-v3/payments/amazon.js @@ -5,7 +5,7 @@ import mongoose from 'mongoose'; import moment from 'moment'; import nconf from 'nconf'; import payments from './index'; -import shared from '../../../../common'; +import shared from '../../../../../common'; import { model as User } from '../../models/user'; const IS_PROD = nconf.get('NODE_ENV') === 'production'; diff --git a/website/src/controllers/api-v3/payments/index.js b/website/src/controllers/api-v3/payments/index.js new file mode 100644 index 0000000000..f6e0a8ebe3 --- /dev/null +++ b/website/src/controllers/api-v3/payments/index.js @@ -0,0 +1,232 @@ +import _ from 'lodash' ; +import analytics from '../../../libs/api-v3/analyticsService'; +import async from 'async'; +import cc from 'coupon-code'; +import { + getUserInfo, + sendTxn as txnEmail, +} from '../../../libs/api-v3/email'; +import members from '../members'; +import moment from 'moment'; +import mongoose from 'mongoose'; +import nconf from 'nconf'; +import pushNotify from '../../../libs/api-v3/pushNotifications'; +import shared from '../../../../../common' ; + +import amazon from './amazon'; +import iap from './iap'; +import paypal from './paypal'; +import stripe from './stripe'; + +const IS_PROD = nconf.get('NODE_ENV') === 'production'; + +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 = function createSubscription (data, cb) { + 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'); + + let analyticsData = { + 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, + }; + analytics.trackPurchase(analyticsData); + } + 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}`); + } + } + async.parallel([ + function saveGiftingUserData (cb2) { + data.user.save(cb2); + }, + function saveRecipientUserData (cb2) { + if (data.gift) { + data.gift.member.save(cb2); + } else { + cb2(null); + } + }, + ], cb); +}; + +/** + * Sets their subscription to be cancelled later + */ +api.cancelSubscription = function cancelSubscription (data, cb) { + let plan = data.user.purchased.plan; + let now = moment(); + let remaining = data.nextBill ? moment(data.nextBill).diff(new Date(), 'days') : 30; + + plan.dateTerminated = + moment(`${now.format('MM')}/${moment(plan.dateUpdated).format('DD')}/${now.format('YYYY')}`) + .add({days: remaining}) // end their subscription 1mo from their last payment + .add({months: Math.ceil(plan.extraMonths)})// plus any extra time (carry-over, gifted subscription, etc) they have. FIXME: moment can't add months in fractions... + .toDate(); + plan.extraMonths = 0; // clear extra time. If they subscribe again, it'll be recalculated from p.dateTerminated + + data.user.save(cb); + txnEmail(data.user, 'cancel-subscription'); + let analyticsData = { + uuid: data.user._id, + gaCategory: 'commerce', + gaLabel: data.paymentMethod, + paymentMethod: data.paymentMethod, + }; + analytics.track('unsubscribe', analyticsData); +}; + +api.buyGems = function buyGems (data, cb) { + let amt = data.amount || 5; + amt = data.gift ? data.gift.gems.amount / 4 : amt; + (data.gift ? data.gift.member : data.user).balance += amt; + data.user.purchased.txnCount++; + if (IS_PROD) { + if (!data.gift) txnEmail(data.user, 'donation'); + + let analyticsData = { + uuid: data.user._id, + itemPurchased: 'Gems', + sku: `${data.paymentMethod.toLowerCase()}-checkout`, + purchaseType: 'checkout', + paymentMethod: data.paymentMethod, + quantity: 1, + gift: Boolean(data.gift), + purchaseValue: amt, + }; + analytics.trackPurchase(analyticsData); + } + + 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}`); + } + } + async.parallel([ + function saveGiftingUserData (cb2) { + data.user.save(cb2); + }, + function saveRecipientUserData (cb2) { + if (data.gift) { + data.gift.member.save(cb2); + } else { + cb2(null); + } + }, + ], cb); +}; + +api.validCoupon = function validCoupon (req, res, next) { + mongoose.model('Coupon').findOne({_id: cc.validate(req.params.code), event: 'google_6mo'}, function couponErrorCheck (err, coupon) { + if (err) return next(err); + if (!coupon) return res.status(401).json({err: 'Invalid coupon code'}); + return res.sendStatus(200); + }); +}; + +api.stripeCheckout = stripe.checkout; +api.stripeSubscribeCancel = stripe.subscribeCancel; +api.stripeSubscribeEdit = stripe.subscribeEdit; + +api.paypalSubscribe = paypal.createBillingAgreement; +api.paypalSubscribeSuccess = paypal.executeBillingAgreement; +api.paypalSubscribeCancel = paypal.cancelSubscription; +api.paypalCheckout = paypal.createPayment; +api.paypalCheckoutSuccess = paypal.executePayment; +api.paypalIPN = paypal.ipn; + +api.amazonVerifyAccessToken = amazon.verifyAccessToken; +api.amazonCreateOrderReferenceId = amazon.createOrderReferenceId; +api.amazonCheckout = amazon.checkout; +api.amazonSubscribe = amazon.subscribe; +api.amazonSubscribeCancel = amazon.subscribeCancel; + +api.iapAndroidVerify = iap.androidVerify; +api.iapIosVerify = iap.iosVerify; + +module.exports = api; From f4be29952beeab009e302697f05318b4f65e0df2 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Tue, 12 Apr 2016 21:15:20 +0000 Subject: [PATCH 08/34] refactor(payments): IAP linting pass --- .../src/controllers/api-v3/payments/iap.js | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 website/src/controllers/api-v3/payments/iap.js diff --git a/website/src/controllers/api-v3/payments/iap.js b/website/src/controllers/api-v3/payments/iap.js new file mode 100644 index 0000000000..94cf21fcca --- /dev/null +++ b/website/src/controllers/api-v3/payments/iap.js @@ -0,0 +1,158 @@ +import { + iap, + inAppPurchase, } +from 'in-app-purchase'; +import payments from './index'; +import nconf from 'nconf'; + +inAppPurchase.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; */ // These variables were never used?? + +let api = {}; + +api.androidVerify = function androidVerify (req, res) { + let iapBody = req.body; + let user = res.locals.user; + + iap.setup(function googleSetupResult (error) { + if (error) { + let resObj = { + ok: false, + data: 'IAP Error', + }; + + return res.json(resObj); + } + + /* + google receipt must be provided as an object + { + "data": "{stringified data object}", + "signature": "signature from google" + } + */ + let testObj = { + data: iapBody.transaction.receipt, + signature: iapBody.transaction.signature, + }; + + // iap is ready + iap.validate(iap.GOOGLE, testObj, function googleValidateResult (err, googleRes) { + if (err) { + 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}); + + return res.json(resObj); + } + }); + }); +}; + +exports.iosVerify = function iosVerify (req, res) { + let iapBody = req.body; + let user = res.locals.user; + + iap.setup(function iosSetupResult (error) { + if (error) { + let resObj = { + ok: false, + data: 'IAP Error', + }; + + return res.json(resObj); + } + + // iap is ready + iap.validate(iap.APPLE, iapBody.transaction.receipt, function iosValidateResult (err, appleRes) { + if (err) { + 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; From 7f89f8b936325f8c8ee7945ee81c72fc316e2103 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Wed, 13 Apr 2016 19:29:13 +0000 Subject: [PATCH 09/34] refactor(payments): Stripe linting pass --- .../src/controllers/api-v3/payments/stripe.js | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 website/src/controllers/api-v3/payments/stripe.js diff --git a/website/src/controllers/api-v3/payments/stripe.js b/website/src/controllers/api-v3/payments/stripe.js new file mode 100644 index 0000000000..5582d33ca1 --- /dev/null +++ b/website/src/controllers/api-v3/payments/stripe.js @@ -0,0 +1,135 @@ +import nconf from 'nconf'; +import stripeModule from 'stripe'; +import async from 'async'; +import payments from './index'; +import { model as User } from '../../models/user'; +import shared from '../../../../../common'; +import mongoose from 'mongoose'; +import cc from 'coupon-code'; + +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') { + 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; + }); +}; + +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) { + let data = { + user, + nextBill: results.get_cus.subscription.current_period_end * 1000, // timestamp is 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; + }); +}; + +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; From 14d3abdd908cd57e20f2d59a6334bff044c339b5 Mon Sep 17 00:00:00 2001 From: Victor Piousbox Date: Thu, 14 Apr 2016 16:09:51 +0000 Subject: [PATCH 10/34] moved payments/ to controllers/top-level/ --- website/src/controllers/{ => top-level}/payments/amazon.js | 0 website/src/controllers/{ => top-level}/payments/iap.js | 0 website/src/controllers/{ => top-level}/payments/index.js | 0 website/src/controllers/{ => top-level}/payments/paypal.js | 0 .../controllers/{ => top-level}/payments/paypalBillingSetup.js | 0 website/src/controllers/{ => top-level}/payments/stripe.js | 0 6 files changed, 0 insertions(+), 0 deletions(-) rename website/src/controllers/{ => top-level}/payments/amazon.js (100%) rename website/src/controllers/{ => top-level}/payments/iap.js (100%) rename website/src/controllers/{ => top-level}/payments/index.js (100%) rename website/src/controllers/{ => top-level}/payments/paypal.js (100%) rename website/src/controllers/{ => top-level}/payments/paypalBillingSetup.js (100%) rename website/src/controllers/{ => top-level}/payments/stripe.js (100%) diff --git a/website/src/controllers/payments/amazon.js b/website/src/controllers/top-level/payments/amazon.js similarity index 100% rename from website/src/controllers/payments/amazon.js rename to website/src/controllers/top-level/payments/amazon.js diff --git a/website/src/controllers/payments/iap.js b/website/src/controllers/top-level/payments/iap.js similarity index 100% rename from website/src/controllers/payments/iap.js rename to website/src/controllers/top-level/payments/iap.js diff --git a/website/src/controllers/payments/index.js b/website/src/controllers/top-level/payments/index.js similarity index 100% rename from website/src/controllers/payments/index.js rename to website/src/controllers/top-level/payments/index.js diff --git a/website/src/controllers/payments/paypal.js b/website/src/controllers/top-level/payments/paypal.js similarity index 100% rename from website/src/controllers/payments/paypal.js rename to website/src/controllers/top-level/payments/paypal.js diff --git a/website/src/controllers/payments/paypalBillingSetup.js b/website/src/controllers/top-level/payments/paypalBillingSetup.js similarity index 100% rename from website/src/controllers/payments/paypalBillingSetup.js rename to website/src/controllers/top-level/payments/paypalBillingSetup.js diff --git a/website/src/controllers/payments/stripe.js b/website/src/controllers/top-level/payments/stripe.js similarity index 100% rename from website/src/controllers/payments/stripe.js rename to website/src/controllers/top-level/payments/stripe.js From 74fc45524bc5813b6843be4c895c367853c5c71b Mon Sep 17 00:00:00 2001 From: Victor Piousbox Date: Thu, 14 Apr 2016 22:38:41 +0000 Subject: [PATCH 11/34] working on promisifying amazonPayments --- common/locales/en/api-v3.json | 6 +- tasks/gulp-tests.js | 4 + ...ayments_amazon_verify_access_token.test.js | 21 ++ test/api/v3/unit/libs/amazonPayments.test.js | 24 ++ .../src/controllers/api-v3/payments/amazon.js | 277 ------------------ .../src/controllers/api-v3/payments/iap.js | 158 ---------- .../src/controllers/api-v3/payments/index.js | 232 --------------- .../src/controllers/api-v3/payments/stripe.js | 135 --------- .../controllers/top-level/payments/amazon.js | 268 +++++++++-------- .../src/controllers/top-level/payments/iap.js | 125 ++++---- .../controllers/top-level/payments/index.js | 252 +++++++++------- .../controllers/top-level/payments/paypal.js | 9 +- .../controllers/top-level/payments/stripe.js | 138 +++++---- website/src/libs/api-v3/amazonPayments.js | 38 +++ 14 files changed, 520 insertions(+), 1167 deletions(-) create mode 100644 test/api/v3/integration/payments/POST-payments_amazon_verify_access_token.test.js create mode 100644 test/api/v3/unit/libs/amazonPayments.test.js delete mode 100644 website/src/controllers/api-v3/payments/amazon.js delete mode 100644 website/src/controllers/api-v3/payments/iap.js delete mode 100644 website/src/controllers/api-v3/payments/index.js delete mode 100644 website/src/controllers/api-v3/payments/stripe.js create mode 100644 website/src/libs/api-v3/amazonPayments.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 3bd4f194cd..ed5908e1ba 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -169,5 +169,9 @@ "regIdRequired": "RegId is required", "pushDeviceAdded": "Push device added successfully", "pushDeviceAlreadyAdded": "The user already has the push device", - "resetComplete": "Reset has completed" + "resetComplete": "Reset has completed", + "missingAccessToken": "The request is missing a required parameter : access_token", + "missingBillingAgreementId": "Missing billing agreement id", + "missingAttributesFromAmazon": "Missing attributes from Amazon", + "errorFromAmazon": "Error from Amazon" } diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js index 9cb654ce21..ac5d65a925 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/libs/*'], ['test:api-v3:unit']); +}); + gulp.task('test:api-v3:integration', (done) => { let runner = exec( testBin('mocha test/api/v3/integration --recursive'), diff --git a/test/api/v3/integration/payments/POST-payments_amazon_verify_access_token.test.js b/test/api/v3/integration/payments/POST-payments_amazon_verify_access_token.test.js new file mode 100644 index 0000000000..494c387f14 --- /dev/null +++ b/test/api/v3/integration/payments/POST-payments_amazon_verify_access_token.test.js @@ -0,0 +1,21 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +describe('payments : amazon', () => { + let endpoint = '/payments/amazon/verifyAccessToken'; + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('verify access token', async () => { + await expect(user.post(endpoint)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('missingAccessToken'), + }); + }); +}); diff --git a/test/api/v3/unit/libs/amazonPayments.test.js b/test/api/v3/unit/libs/amazonPayments.test.js new file mode 100644 index 0000000000..a60a8b4633 --- /dev/null +++ b/test/api/v3/unit/libs/amazonPayments.test.js @@ -0,0 +1,24 @@ +import * as amz from '../../../../../website/src/libs/api-v3/amazonPayments'; + +describe.only('amazonPayments', () => { + + beforeEach(() => { + }); + + describe('#getTokenInfo', () => { + it('validates access_token parameter', async (done) => { + try { + let result = await amz.getTokenInfo(); + } catch (e) { + expect(e.type).to.eql('invalid_request'); + done(); + } + }); + }); + + describe('#createOrderReferenceId', () => { + it('is sane', () => { + expect(false).to.eql(true); // @TODO + }); + }); +}); diff --git a/website/src/controllers/api-v3/payments/amazon.js b/website/src/controllers/api-v3/payments/amazon.js deleted file mode 100644 index bc8e3b5177..0000000000 --- a/website/src/controllers/api-v3/payments/amazon.js +++ /dev/null @@ -1,277 +0,0 @@ -import amazonPayments from 'amazon-payments'; -import async from 'async'; -import cc from 'coupon-code'; -import mongoose from 'mongoose'; -import moment from 'moment'; -import nconf from 'nconf'; -import payments from './index'; -import shared from '../../../../../common'; -import { model as User } from '../../models/user'; - -const IS_PROD = nconf.get('NODE_ENV') === 'production'; - -let api = {}; - -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'), -}); - -api.verifyAccessToken = function verifyAccessToken (req, res) { - 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 getTokenInfo (err) { - if (err) return res.status(400).json({err}); - - res.sendStatus(200); - }); -}; - -api.createOrderReferenceId = function createOrderReferenceId (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 createOrderReferenceForId (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, - }); - }); -}; - -api.checkout = function checkout (req, res, next) { - if (!req.body || !req.body.orderReferenceId) { - return res.status(400).json({err: 'Billing Agreement Id not supplied.'}); - } - - let gift = req.body.gift; - let user = res.locals.user; - let orderReferenceId = req.body.orderReferenceId; - let 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 (cb) { - amzPayment.offAmazonPayments.setOrderReferenceDetails({ - AmazonOrderReferenceId: orderReferenceId, - OrderReferenceAttributes: { - OrderTotal: { - CurrencyCode: 'USD', - Amount: amount, - }, - SellerNote: 'HabitRPG Payment', - SellerOrderAttributes: { - SellerOrderId: shared.uuid(), - StoreName: 'HabitRPG', - }, - }, - }, cb); - }, - - confirmOrderReference (cb) { - amzPayment.offAmazonPayments.confirmOrderReference({ - AmazonOrderReferenceId: orderReferenceId, - }, cb); - }, - - authorize (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 checkAuthorizationStatus (err) { - if (err) return cb(err); - - if (res.AuthorizationDetails.AuthorizationStatus.State === 'Declined') { - return cb(new Error('The payment was not successfull.')); - } - - return cb(); - }); - }, - - closeOrderReference (cb) { - amzPayment.offAmazonPayments.closeOrderReference({ - AmazonOrderReferenceId: orderReferenceId, - }, cb); - }, - - executePayment (cb) { - async.waterfall([ - function findUser (cb2) { - User.findById(gift ? gift.uuid : undefined, cb2); - }, - function executeAmazonPayment (member, cb2) { - let data = {user, paymentMethod: 'Amazon Payments'}; - let 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 result (err) { - if (err) return next(err); - - res.sendStatus(200); - }); -}; - -api.subscribe = function subscribe (req, res, next) { - if (!req.body || !req.body.billingAgreementId) { - return res.status(400).json({err: 'Billing Agreement Id not supplied.'}); - } - - 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) { - return res.status(400).json({err: 'Subscription plan not found.'}); - } - - async.series({ - applyDiscount (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 couponResult (err) { - if (err) return cb(err); - if (!coupon) return cb(new Error('Coupon code not found.')); - cb(); - }); - }, - - setBillingAgreementDetails (cb) { - amzPayment.offAmazonPayments.setBillingAgreementDetails({ - AmazonBillingAgreementId: billingAgreementId, - BillingAgreementAttributes: { - SellerNote: 'HabitRPG Subscription', - SellerBillingAgreementAttributes: { - SellerBillingAgreementId: shared.uuid(), - StoreName: 'HabitRPG', - CustomInformation: 'HabitRPG Subscription', - }, - }, - }, cb); - }, - - confirmBillingAgreement (cb) { - amzPayment.offAmazonPayments.confirmBillingAgreement({ - AmazonBillingAgreementId: billingAgreementId, - }, cb); - }, - - authorizeOnBillingAgreement (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 billingAgreementResult (err) { - if (err) return cb(err); - - if (res.AuthorizationDetails.AuthorizationStatus.State === 'Declined') { - return cb(new Error('The payment was not successful.')); - } - - return cb(); - }); - }, - - createSubscription (cb) { - payments.createSubscription({ - user, - customerId: billingAgreementId, - paymentMethod: 'Amazon Payments', - sub, - }, cb); - }, - }, function subscribeResult (err) { - if (err) return next(err); - - res.sendStatus(200); - }); -}; - -api.subscribeCancel = function subscribeCancel (req, res, next) { - let user = res.locals.user; - if (!user.purchased.plan.customerId) - return res.status(401).json({err: 'User does not have a plan subscription'}); - - let billingAgreementId = user.purchased.plan.customerId; - - async.series({ - closeBillingAgreement (cb) { - amzPayment.offAmazonPayments.closeBillingAgreement({ - AmazonBillingAgreementId: billingAgreementId, - }, cb); - }, - - cancelSubscription (cb) { - let data = { - user, - // Date of next bill - nextBill: moment(user.purchased.plan.lastBillingDate).add({days: 30}), - paymentMethod: 'Amazon Payments', - }; - - payments.cancelSubscription(data, cb); - }, - }, function subscribeCancelResult (err) { - 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; - }); -}; - -module.exports = api; diff --git a/website/src/controllers/api-v3/payments/iap.js b/website/src/controllers/api-v3/payments/iap.js deleted file mode 100644 index 94cf21fcca..0000000000 --- a/website/src/controllers/api-v3/payments/iap.js +++ /dev/null @@ -1,158 +0,0 @@ -import { - iap, - inAppPurchase, } -from 'in-app-purchase'; -import payments from './index'; -import nconf from 'nconf'; - -inAppPurchase.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; */ // These variables were never used?? - -let api = {}; - -api.androidVerify = function androidVerify (req, res) { - let iapBody = req.body; - let user = res.locals.user; - - iap.setup(function googleSetupResult (error) { - if (error) { - let resObj = { - ok: false, - data: 'IAP Error', - }; - - return res.json(resObj); - } - - /* - google receipt must be provided as an object - { - "data": "{stringified data object}", - "signature": "signature from google" - } - */ - let testObj = { - data: iapBody.transaction.receipt, - signature: iapBody.transaction.signature, - }; - - // iap is ready - iap.validate(iap.GOOGLE, testObj, function googleValidateResult (err, googleRes) { - if (err) { - 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}); - - return res.json(resObj); - } - }); - }); -}; - -exports.iosVerify = function iosVerify (req, res) { - let iapBody = req.body; - let user = res.locals.user; - - iap.setup(function iosSetupResult (error) { - if (error) { - let resObj = { - ok: false, - data: 'IAP Error', - }; - - return res.json(resObj); - } - - // iap is ready - iap.validate(iap.APPLE, iapBody.transaction.receipt, function iosValidateResult (err, appleRes) { - if (err) { - 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/api-v3/payments/index.js b/website/src/controllers/api-v3/payments/index.js deleted file mode 100644 index f6e0a8ebe3..0000000000 --- a/website/src/controllers/api-v3/payments/index.js +++ /dev/null @@ -1,232 +0,0 @@ -import _ from 'lodash' ; -import analytics from '../../../libs/api-v3/analyticsService'; -import async from 'async'; -import cc from 'coupon-code'; -import { - getUserInfo, - sendTxn as txnEmail, -} from '../../../libs/api-v3/email'; -import members from '../members'; -import moment from 'moment'; -import mongoose from 'mongoose'; -import nconf from 'nconf'; -import pushNotify from '../../../libs/api-v3/pushNotifications'; -import shared from '../../../../../common' ; - -import amazon from './amazon'; -import iap from './iap'; -import paypal from './paypal'; -import stripe from './stripe'; - -const IS_PROD = nconf.get('NODE_ENV') === 'production'; - -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 = function createSubscription (data, cb) { - 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'); - - let analyticsData = { - 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, - }; - analytics.trackPurchase(analyticsData); - } - 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}`); - } - } - async.parallel([ - function saveGiftingUserData (cb2) { - data.user.save(cb2); - }, - function saveRecipientUserData (cb2) { - if (data.gift) { - data.gift.member.save(cb2); - } else { - cb2(null); - } - }, - ], cb); -}; - -/** - * Sets their subscription to be cancelled later - */ -api.cancelSubscription = function cancelSubscription (data, cb) { - let plan = data.user.purchased.plan; - let now = moment(); - let remaining = data.nextBill ? moment(data.nextBill).diff(new Date(), 'days') : 30; - - plan.dateTerminated = - moment(`${now.format('MM')}/${moment(plan.dateUpdated).format('DD')}/${now.format('YYYY')}`) - .add({days: remaining}) // end their subscription 1mo from their last payment - .add({months: Math.ceil(plan.extraMonths)})// plus any extra time (carry-over, gifted subscription, etc) they have. FIXME: moment can't add months in fractions... - .toDate(); - plan.extraMonths = 0; // clear extra time. If they subscribe again, it'll be recalculated from p.dateTerminated - - data.user.save(cb); - txnEmail(data.user, 'cancel-subscription'); - let analyticsData = { - uuid: data.user._id, - gaCategory: 'commerce', - gaLabel: data.paymentMethod, - paymentMethod: data.paymentMethod, - }; - analytics.track('unsubscribe', analyticsData); -}; - -api.buyGems = function buyGems (data, cb) { - let amt = data.amount || 5; - amt = data.gift ? data.gift.gems.amount / 4 : amt; - (data.gift ? data.gift.member : data.user).balance += amt; - data.user.purchased.txnCount++; - if (IS_PROD) { - if (!data.gift) txnEmail(data.user, 'donation'); - - let analyticsData = { - uuid: data.user._id, - itemPurchased: 'Gems', - sku: `${data.paymentMethod.toLowerCase()}-checkout`, - purchaseType: 'checkout', - paymentMethod: data.paymentMethod, - quantity: 1, - gift: Boolean(data.gift), - purchaseValue: amt, - }; - analytics.trackPurchase(analyticsData); - } - - 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}`); - } - } - async.parallel([ - function saveGiftingUserData (cb2) { - data.user.save(cb2); - }, - function saveRecipientUserData (cb2) { - if (data.gift) { - data.gift.member.save(cb2); - } else { - cb2(null); - } - }, - ], cb); -}; - -api.validCoupon = function validCoupon (req, res, next) { - mongoose.model('Coupon').findOne({_id: cc.validate(req.params.code), event: 'google_6mo'}, function couponErrorCheck (err, coupon) { - if (err) return next(err); - if (!coupon) return res.status(401).json({err: 'Invalid coupon code'}); - return res.sendStatus(200); - }); -}; - -api.stripeCheckout = stripe.checkout; -api.stripeSubscribeCancel = stripe.subscribeCancel; -api.stripeSubscribeEdit = stripe.subscribeEdit; - -api.paypalSubscribe = paypal.createBillingAgreement; -api.paypalSubscribeSuccess = paypal.executeBillingAgreement; -api.paypalSubscribeCancel = paypal.cancelSubscription; -api.paypalCheckout = paypal.createPayment; -api.paypalCheckoutSuccess = paypal.executePayment; -api.paypalIPN = paypal.ipn; - -api.amazonVerifyAccessToken = amazon.verifyAccessToken; -api.amazonCreateOrderReferenceId = amazon.createOrderReferenceId; -api.amazonCheckout = amazon.checkout; -api.amazonSubscribe = amazon.subscribe; -api.amazonSubscribeCancel = amazon.subscribeCancel; - -api.iapAndroidVerify = iap.androidVerify; -api.iapIosVerify = iap.iosVerify; - -module.exports = api; diff --git a/website/src/controllers/api-v3/payments/stripe.js b/website/src/controllers/api-v3/payments/stripe.js deleted file mode 100644 index 5582d33ca1..0000000000 --- a/website/src/controllers/api-v3/payments/stripe.js +++ /dev/null @@ -1,135 +0,0 @@ -import nconf from 'nconf'; -import stripeModule from 'stripe'; -import async from 'async'; -import payments from './index'; -import { model as User } from '../../models/user'; -import shared from '../../../../../common'; -import mongoose from 'mongoose'; -import cc from 'coupon-code'; - -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') { - 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; - }); -}; - -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) { - let data = { - user, - nextBill: results.get_cus.subscription.current_period_end * 1000, // timestamp is 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; - }); -}; - -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/controllers/top-level/payments/amazon.js b/website/src/controllers/top-level/payments/amazon.js index 8c01663c10..63b589f6fc 100644 --- a/website/src/controllers/top-level/payments/amazon.js +++ b/website/src/controllers/top-level/payments/amazon.js @@ -1,112 +1,128 @@ -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'; +import async from 'async'; +import cc from 'coupon-code'; +import mongoose from 'mongoose'; +import moment from 'moment'; +import payments from './index'; +import shared from '../../../../../common'; +import { model as User } from '../../../models/user'; +import { + NotFound, + NotAuthorized, + BadRequest, +} from '../../../libs/api-v3/errors'; +import amz from '../../../libs/api-v3/amazonPayments'; -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') -}); +let api = {}; -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); - }); +/** + * @api {post} /api/v3/payments/amazon/verifyAccessToken verify access token + * @apiVersion 3.0.0 + * @apiName AmazonVerifyAccessToken + * @apiGroup Payments + * @apiParam {string} access_token the access token + * @apiSuccess {} empty + **/ +api.verifyAccessToken = { + method: 'POST', + url: '/payments/amazon/verifyAccessToken', + async handler (req, res) { + await amz.getTokenInfo(req.body.access_token) + .then(() => { + res.respond(200, {}); + }).catch( (error) => { + throw new BadRequest(error.body.error_description); + }); + }, }; -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.')); +/** + * @api {post} /api/v3/payments/amazon/createOrderReferenceId create order reference id + * @apiVersion 3.0.0 + * @apiName AmazonCreateOrderReferenceId + * @apiGroup Payments + * @apiParam {string} billingAgreementId billing agreement id + * @apiSuccess {object} object containing { orderReferenceId } + **/ +api.createOrderReferenceId = { + method: 'POST', + url: '/payments/amazon/createOrderReferenceId', + async handler (req, res) { + if (!req.body.billingAgreementId) { + throw new BadRequest(res.t('missingBillingAgreementId')); } - res.json({ - orderReferenceId: response.OrderReferenceDetails.AmazonOrderReferenceId + let response = await amz.createOrderReferenceId({ + Id: req.body.billingAgreementId, + IdType: 'BillingAgreement', + ConfirmNow: false, + }).then(() => { + res.respond(200, { + orderReferenceId: response.OrderReferenceDetails.AmazonOrderReferenceId, + }); + }).catch(errStr => { + throw new BadRequest(res.t(errStr)); }); - }); + }, }; -exports.checkout = function(req, res, next){ - if(!req.body || !req.body.orderReferenceId){ +/* +api.checkout = function checkout (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; + let gift = req.body.gift; + let user = res.locals.user; + let orderReferenceId = req.body.orderReferenceId; + let amount = 5; - if(gift){ - if(gift.type === 'gems'){ - amount = gift.gems.amount/4; - }else if(gift.type === 'subscription'){ + 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){ + setOrderReferenceDetails (cb) { amzPayment.offAmazonPayments.setOrderReferenceDetails({ AmazonOrderReferenceId: orderReferenceId, OrderReferenceAttributes: { OrderTotal: { CurrencyCode: 'USD', - Amount: amount + Amount: amount, }, SellerNote: 'HabitRPG Payment', SellerOrderAttributes: { SellerOrderId: shared.uuid(), - StoreName: 'HabitRPG' - } - } + StoreName: 'HabitRPG', + }, + }, }, cb); }, - confirmOrderReference: function(cb){ + confirmOrderReference (cb) { amzPayment.offAmazonPayments.confirmOrderReference({ - AmazonOrderReferenceId: orderReferenceId + AmazonOrderReferenceId: orderReferenceId, }, cb); }, - authorize: function(cb){ + authorize (cb) { amzPayment.offAmazonPayments.authorize({ AmazonOrderReferenceId: orderReferenceId, AuthorizationReferenceId: shared.uuid().substring(0, 32), AuthorizationAmount: { CurrencyCode: 'USD', - Amount: amount + Amount: amount, }, SellerAuthorizationNote: 'HabitRPG Payment', TransactionTimeout: 0, - CaptureNow: true - }, function(err, res){ - if(err) return cb(err); + CaptureNow: true, + }, function checkAuthorizationStatus (err) { + if (err) return cb(err); - if(res.AuthorizationDetails.AuthorizationStatus.State === 'Declined'){ + if (res.AuthorizationDetails.AuthorizationStatus.State === 'Declined') { return cb(new Error('The payment was not successfull.')); } @@ -114,64 +130,65 @@ exports.checkout = function(req, res, next){ }); }, - closeOrderReference: function(cb){ + closeOrderReference (cb) { amzPayment.offAmazonPayments.closeOrderReference({ - AmazonOrderReferenceId: orderReferenceId + AmazonOrderReferenceId: orderReferenceId, }, cb); }, - executePayment: function(cb){ + executePayment (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'; + function findUser (cb2) { + User.findById(gift ? gift.uuid : undefined, cb2); + }, + function executeAmazonPayment (member, cb2) { + let data = {user, paymentMethod: 'Amazon Payments'}; + let method = 'buyGems'; - if (gift){ - if (gift.type == 'subscription') method = 'createSubscription'; + 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); + }, + }, function result (err) { + if (err) return next(err); res.sendStatus(200); }); - }; -exports.subscribe = function(req, res, next){ - if(!req.body || !req.body['billingAgreementId']){ +api.subscribe = function subscribe (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; + 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){ + if (!sub) { return res.status(400).json({err: 'Subscription plan not found.'}); } async.series({ - applyDiscount: function(cb){ + applyDiscount (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.')); + mongoose.model('Coupon').findOne({_id: cc.validate(coupon), event: sub.key}, function couponResult (err) { + if (err) return cb(err); + if (!coupon) return cb(new Error('Coupon code not found.')); cb(); }); }, - setBillingAgreementDetails: function(cb){ + setBillingAgreementDetails (cb) { amzPayment.offAmazonPayments.setBillingAgreementDetails({ AmazonBillingAgreementId: billingAgreementId, BillingAgreementAttributes: { @@ -179,25 +196,25 @@ exports.subscribe = function(req, res, next){ SellerBillingAgreementAttributes: { SellerBillingAgreementId: shared.uuid(), StoreName: 'HabitRPG', - CustomInformation: 'HabitRPG Subscription' - } - } + CustomInformation: 'HabitRPG Subscription', + }, + }, }, cb); }, - confirmBillingAgreement: function(cb){ + confirmBillingAgreement (cb) { amzPayment.offAmazonPayments.confirmBillingAgreement({ - AmazonBillingAgreementId: billingAgreementId + AmazonBillingAgreementId: billingAgreementId, }, cb); }, - authorizeOnBillingAgreeement: function(cb){ + authorizeOnBillingAgreement (cb) { amzPayment.offAmazonPayments.authorizeOnBillingAgreement({ AmazonBillingAgreementId: billingAgreementId, AuthorizationReferenceId: shared.uuid().substring(0, 32), AuthorizationAmount: { CurrencyCode: 'USD', - Amount: sub.price + Amount: sub.price, }, SellerAuthorizationNote: 'HabitRPG Subscription Payment', TransactionTimeout: 0, @@ -205,67 +222,70 @@ exports.subscribe = function(req, res, next){ SellerNote: 'HabitRPG Subscription Payment', SellerOrderAttributes: { SellerOrderId: shared.uuid(), - StoreName: 'HabitRPG' - } - }, function(err, res){ - if(err) return cb(err); + StoreName: 'HabitRPG', + }, + }, function billingAgreementResult (err) { + if (err) return cb(err); - if(res.AuthorizationDetails.AuthorizationStatus.State === 'Declined'){ - return cb(new Error('The payment was not successfull.')); + if (res.AuthorizationDetails.AuthorizationStatus.State === 'Declined') { + return cb(new Error('The payment was not successful.')); } return cb(); }); }, - createSubscription: function(cb){ + createSubscription (cb) { payments.createSubscription({ - user: user, + user, customerId: billingAgreementId, paymentMethod: 'Amazon Payments', - sub: sub + sub, }, cb); - } - }, function(err, results){ - if(err) return next(err); + }, + }, function subscribeResult (err) { + if (err) return next(err); res.sendStatus(200); }); }; -exports.subscribeCancel = function(req, res, next){ - var user = res.locals.user; +api.subscribeCancel = function subscribeCancel (req, res, next) { + let 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; + let billingAgreementId = user.purchased.plan.customerId; async.series({ - closeBillingAgreement: function(cb){ + closeBillingAgreement (cb) { amzPayment.offAmazonPayments.closeBillingAgreement({ - AmazonBillingAgreementId: billingAgreementId + AmazonBillingAgreementId: billingAgreementId, }, cb); }, - cancelSubscription: function(cb){ - var data = { - user: user, + cancelSubscription (cb) { + let data = { + user, // Date of next bill nextBill: moment(user.purchased.plan.lastBillingDate).add({days: 30}), - paymentMethod: 'Amazon Payments' + paymentMethod: 'Amazon Payments', }; payments.cancelSubscription(data, cb); - } - }, function(err, results){ + }, + }, function subscribeCancelResult (err) { if (err) return next(err); // don't json this, let toString() handle errors - if(req.query.noRedirect){ + if (req.query.noRedirect) { res.sendStatus(200); - }else{ + } else { res.redirect('/'); } user = null; }); }; +*/ + +module.exports = api; diff --git a/website/src/controllers/top-level/payments/iap.js b/website/src/controllers/top-level/payments/iap.js index 829482ed67..5de66b0452 100644 --- a/website/src/controllers/top-level/payments/iap.js +++ b/website/src/controllers/top-level/payments/iap.js @@ -1,67 +1,66 @@ -var iap = require('in-app-purchase'); -var async = require('async'); -var payments = require('./index'); -var nconf = require('nconf'); +import iap from 'in-app-purchase'; +import whatThis from 'in-app-purchase'; +import payments from './index'; +import nconf from 'nconf'; -var inAppPurchase = require('in-app-purchase'); -inAppPurchase.config({ +iap.config({ // this is the path to the directory containing iap-sanbox/iap-live files - googlePublicKeyPath: nconf.get('IAP_GOOGLE_KEYDIR') + googlePublicKeyPath: nconf.get('IAP_GOOGLE_KEYDIR'), }); // Validation ERROR Codes -var INVALID_PAYLOAD = 6778001; -var CONNECTION_FAILED = 6778002; -var PURCHASE_EXPIRED = 6778003; +const INVALID_PAYLOAD = 6778001; +/* const CONNECTION_FAILED = 6778002; +const PURCHASE_EXPIRED = 6778003; */ // These variables were never used?? -exports.androidVerify = function(req, res, next) { - var iapBody = req.body; - var user = res.locals.user; +let api = {}; - iap.setup(function (error) { +/* +api.androidVerify = function androidVerify (req, res) { + let iapBody = req.body; + let user = res.locals.user; + + iap.setup(function googleSetupResult (error) { if (error) { - var resObj = { + let resObj = { ok: false, - data: 'IAP Error' + 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 = { + // 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 + signature: iapBody.transaction.signature, }; // iap is ready - iap.validate(iap.GOOGLE, testObj, function (err, googleRes) { + iap.validate(iap.GOOGLE, testObj, function googleValidateResult (err, googleRes) { if (err) { - var resObj = { + let resObj = { ok: false, data: { code: INVALID_PAYLOAD, - message: err.toString() - } + message: err.toString(), + }, }; return res.json(resObj); } if (iap.isValidated(googleRes)) { - var resObj = { + let resObj = { ok: true, - data: googleRes + data: googleRes, }; - payments.buyGems({user:user, paymentMethod:'IAP GooglePlay', amount: 5.25}); + payments.buyGems({user, paymentMethod: 'IAP GooglePlay', amount: 5.25}); return res.json(resObj); } @@ -69,87 +68,89 @@ exports.androidVerify = function(req, res, next) { }); }; -exports.iosVerify = function(req, res, next) { - var iapBody = req.body; - var user = res.locals.user; +exports.iosVerify = function iosVerify (req, res) { + let iapBody = req.body; + let user = res.locals.user; - iap.setup(function (error) { + iap.setup(function iosSetupResult (error) { if (error) { - var resObj = { + let resObj = { ok: false, - data: 'IAP Error' + data: 'IAP Error', }; return res.json(resObj); - } - //iap is ready - iap.validate(iap.APPLE, iapBody.transaction.receipt, function (err, appleRes) { + // iap is ready + iap.validate(iap.APPLE, iapBody.transaction.receipt, function iosValidateResult (err, appleRes) { if (err) { - var resObj = { + let resObj = { ok: false, data: { code: INVALID_PAYLOAD, - message: err.toString() - } + message: err.toString(), + }, }; return res.json(resObj); } if (iap.isValidated(appleRes)) { - var purchaseDataList = iap.getPurchaseData(appleRes); + let purchaseDataList = iap.getPurchaseData(appleRes); if (purchaseDataList.length > 0) { - var correctReceipt = true; - for (var index in purchaseDataList) { + let correctReceipt = true; + for (let index of purchaseDataList) { switch (purchaseDataList[index].productId) { case 'com.habitrpg.ios.Habitica.4gems': - payments.buyGems({user:user, paymentMethod:'IAP AppleStore', amount: 1}); + payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 1}); break; case 'com.habitrpg.ios.Habitica.8gems': - payments.buyGems({user:user, paymentMethod:'IAP AppleStore', amount: 2}); + payments.buyGems({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}); + payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 5.25}); break; case 'com.habitrpg.ios.Habitica.42gems': - payments.buyGems({user:user, paymentMethod:'IAP AppleStore', amount: 10.5}); + payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 10.5}); break; default: correctReceipt = false; } } if (correctReceipt) { - var resObj = { + let resObj = { ok: true, - data: appleRes + data: appleRes, }; // yay good! return res.json(resObj); } } - //wrong receipt content - var resObj = { + // wrong receipt content + let resObj = { ok: false, data: { code: INVALID_PAYLOAD, - message: 'Incorrect receipt content' - } + message: 'Incorrect receipt content', + }, }; return res.json(resObj); } - //invalid receipt - var resObj = { + // invalid receipt + let resObj = { ok: false, data: { code: INVALID_PAYLOAD, - message: 'Invalid receipt' - } + message: 'Invalid receipt', + }, }; return res.json(resObj); }); }); }; +*/ + +module.exports = api; diff --git a/website/src/controllers/top-level/payments/index.js b/website/src/controllers/top-level/payments/index.js index 1652e05b8d..5e413fd47c 100644 --- a/website/src/controllers/top-level/payments/index.js +++ b/website/src/controllers/top-level/payments/index.js @@ -1,207 +1,233 @@ -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'); +import _ from 'lodash' ; +import analytics from '../../../libs/api-v3/analyticsService'; +import async from 'async'; +import cc from 'coupon-code'; +import { + getUserInfo, + sendTxn as txnEmail, +} from '../../../libs/api-v3/email'; +import members from '../members'; +import moment from 'moment'; +import mongoose from 'mongoose'; +import nconf from 'nconf'; +import pushNotify from '../../../libs/api-v3/pushNotifications'; +import shared from '../../../../../common' ; -function revealMysteryItems(user) { - _.each(shared.content.gear.flat, function(item) { +import amazon from './amazon'; +import iap from './iap'; +import paypal from './paypal'; +import stripe from './stripe'; + +const IS_PROD = nconf.get('NODE_ENV') === 'production'; + +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) + user.purchased.plan.mysteryItems.indexOf(item.key) !== -1 ) { 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; +api.createSubscription = function createSubscription (data, cb) { + 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 (p.customerId && !p.dateTerminated) { // User has active plan - p.extraMonths += months; + if (plan.customerId && !plan.dateTerminated) { // User has active plan + plan.extraMonths += months; } else { - p.dateTerminated = moment(p.dateTerminated).add({months: months}).toDate(); - if (!p.dateUpdated) p.dateUpdated = new Date(); + plan.dateTerminated = moment(plan.dateTerminated).add({months}).toDate(); + if (!plan.dateUpdated) plan.dateUpdated = new Date(); } - if (!p.customerId) p.customerId = 'Gift'; // don't override existing customer, but all sub need a customerId + if (!plan.customerId) plan.customerId = 'Gift'; // don't override existing customer, but all sub need a customerId } else { - _(p).merge({ // override with these values + _(plan).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), + 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 + lastBillingDate: data.paymentMethod === 'Amazon Payments' ? new Date() : undefined, }).defaults({ // allow non-override if a plan was previously used dateCreated: new Date(), - mysteryItems: [] + mysteryItems: [], }).value(); } // Block sub perks - var perks = Math.floor(months/3); + let 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; + 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(isProduction) { - if (!data.gift) utils.txnEmail(data.user, 'subscription-begins'); + if (IS_PROD) { + if (!data.gift) txnEmail(data.user, 'subscription-begins'); - var analyticsData = { + let analyticsData = { uuid: data.user._id, itemPurchased: 'Subscription', - sku: data.paymentMethod.toLowerCase() + '-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); + gift: Boolean(data.gift), + purchaseValue: block.price, + }; + analytics.trackPurchase(analyticsData); } data.user.purchased.txnCount++; - if (data.gift){ + if (data.gift) { members.sendMessage(data.user, data.gift.member, data.gift); - var byUserName = utils.getUserInfo(data.user, ['name']).name; + let byUserName = getUserInfo(data.user, ['name']).name; - if(data.gift.member.preferences.emailNotifications.giftedSubscription !== false){ - utils.txnEmail(data.gift.member, 'gifted-subscription', [ + if (data.gift.member.preferences.emailNotifications.giftedSubscription !== false) { + txnEmail(data.gift.member, 'gifted-subscription', [ {name: 'GIFTER', content: byUserName}, - {name: 'X_MONTHS_SUBSCRIPTION', content: months} + {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); + 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);} + function saveGiftingUserData (cb2) { + data.user.save(cb2); + }, + function saveRecipientUserData (cb2) { + if (data.gift) { + data.gift.member.save(cb2); + } else { + 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; +api.cancelSubscription = function cancelSubscription (data, cb) { + let plan = data.user.purchased.plan; + let now = moment(); + let 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') ) + plan.dateTerminated = + moment(`${now.format('MM')}/${moment(plan.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... + .add({months: Math.ceil(plan.extraMonths)})// plus any extra time (carry-over, gifted subscription, etc) they have. FIXME: 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 + plan.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 = { + txnEmail(data.user, 'cancel-subscription'); + let analyticsData = { uuid: data.user._id, gaCategory: 'commerce', gaLabel: data.paymentMethod, - paymentMethod: data.paymentMethod - } - utils.analytics.track('unsubscribe', analyticsData); -} + paymentMethod: data.paymentMethod, + }; + analytics.track('unsubscribe', analyticsData); +}; -exports.buyGems = function(data, cb) { - var amt = data.amount || 5; - amt = data.gift ? data.gift.gems.amount/4 : amt; +api.buyGems = function buyGems (data, cb) { + 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(isProduction) { - if (!data.gift) utils.txnEmail(data.user, 'donation'); + if (IS_PROD) { + if (!data.gift) txnEmail(data.user, 'donation'); - var analyticsData = { + let analyticsData = { uuid: data.user._id, itemPurchased: 'Gems', - sku: data.paymentMethod.toLowerCase() + '-checkout', + 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); + gift: Boolean(data.gift), + purchaseValue: amt, + }; + analytics.trackPurchase(analyticsData); } - if (data.gift){ - var byUsername = utils.getUserInfo(data.user, ['name']).name; - var gemAmount = data.gift.gems.amount || 20; + 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){ - utils.txnEmail(data.gift.member, 'gifted-gems', [ + if (data.gift.member.preferences.emailNotifications.giftedGems !== false) { + txnEmail(data.gift.member, 'gifted-gems', [ {name: 'GIFTER', content: byUsername}, - {name: 'X_GEMS_GIFTED', content: gemAmount} + {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); + 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);} + function saveGiftingUserData (cb2) { + data.user.save(cb2); + }, + function saveRecipientUserData (cb2) { + if (data.gift) { + data.gift.member.save(cb2); + } else { + 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){ +api.validCoupon = function validCoupon (req, res, next) { + mongoose.model('Coupon').findOne({_id: cc.validate(req.params.code), event: 'google_6mo'}, function couponErrorCheck (err, coupon) { if (err) return next(err); - if (!coupon) return res.status(401).json({err:"Invalid coupon code"}); + 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; +api.stripeCheckout = stripe.checkout; +api.stripeSubscribeCancel = stripe.subscribeCancel; +api.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; +api.paypalSubscribe = paypal.createBillingAgreement; +api.paypalSubscribeSuccess = paypal.executeBillingAgreement; +api.paypalSubscribeCancel = paypal.cancelSubscription; +api.paypalCheckout = paypal.createPayment; +api.paypalCheckoutSuccess = paypal.executePayment; +api.paypalIPN = paypal.ipn; -exports.amazonVerifyAccessToken = amazon.verifyAccessToken; -exports.amazonCreateOrderReferenceId = amazon.createOrderReferenceId; -exports.amazonCheckout = amazon.checkout; -exports.amazonSubscribe = amazon.subscribe; -exports.amazonSubscribeCancel = amazon.subscribeCancel; +api.amazonVerifyAccessToken = amazon.verifyAccessToken; +api.amazonCreateOrderReferenceId = amazon.createOrderReferenceId; +api.amazonCheckout = amazon.checkout; +api.amazonSubscribe = amazon.subscribe; +api.amazonSubscribeCancel = amazon.subscribeCancel; -exports.iapAndroidVerify = iap.androidVerify; -exports.iapIosVerify = iap.iosVerify; +api.iapAndroidVerify = iap.androidVerify; +api.iapIosVerify = iap.iosVerify; + +// module.exports = api; +module.exports = {}; // @TODO HEREHERE diff --git a/website/src/controllers/top-level/payments/paypal.js b/website/src/controllers/top-level/payments/paypal.js index 766ee85139..046a6f52cc 100644 --- a/website/src/controllers/top-level/payments/paypal.js +++ b/website/src/controllers/top-level/payments/paypal.js @@ -5,10 +5,10 @@ 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 logger = require('../../../libs/api-v2/logging'); var ipn = require('paypal-ipn'); var paypal = require('paypal-rest-sdk'); -var shared = require('../../../../common'); +var shared = require('../../../../../common'); var mongoose = require('mongoose'); var cc = require('coupon-code'); @@ -31,6 +31,7 @@ var parseErr = function(res, err){ return res.status(400).json({err:error}); } +/* exports.createBillingAgreement = function(req,res,next){ var sub = shared.content.subscriptionBlocks[req.query.sub]; async.waterfall([ @@ -190,11 +191,13 @@ exports.cancelSubscription = function(req, res, next){ 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 @@ -213,4 +216,4 @@ exports.ipn = function(req, res, next) { } }); }; - +*/ diff --git a/website/src/controllers/top-level/payments/stripe.js b/website/src/controllers/top-level/payments/stripe.js index 1a1085227c..765ccba8f6 100644 --- a/website/src/controllers/top-level/payments/stripe.js +++ b/website/src/controllers/top-level/payments/stripe.js @@ -1,123 +1,137 @@ -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'); +import nconf from 'nconf'; +import stripeModule from 'stripe'; +import async from 'async'; +import payments from './index'; +import { model as User } from '../../../models/user'; +import shared from '../../../../../common'; +import mongoose from 'mongoose'; +import cc from 'coupon-code'; +const stripe = stripeModule(nconf.get('STRIPE_API_KEY')); + +let api = {}; /* 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; +/* +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(cb){ + function stripeCharge (cb) { if (sub) { async.waterfall([ - function(cb2){ + 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); + mongoose.model('Coupon').findOne({_id: cc.validate(req.query.coupon), event: sub.key}, cb2); }, - function(coupon, cb2){ + function createCustomer (coupon, cb2) { if (sub.discount && !coupon) return cb2('Invalid coupon code.'); - var customer = { + let customer = { email: req.body.email, metadata: {uuid: user._id}, card: token, - plan: sub.key + plan: sub.key, }; stripe.customers.create(customer, cb2); - } + }, ], cb); } else { + let amount; + if (!gift) { + amount = '500'; + } else if (gift.type === 'subscription') { + amount = `${shared.content.subscriptionBlocks[gift.subscription.key].price * 100}`; + } else { + amount = `${gift.gems.amount / 4 * 100}`; + } stripe.charges.create({ - amount: !gift ? '500' //"500" = $5 - : gift.type=='subscription' ? ''+shared.content.subscriptionBlocks[gift.subscription.key].price*100 - : ''+gift.gems.amount/4*100, + amount, currency: 'usd', - card: token + card: token, }, cb); } }, - function(response, cb) { - if (sub) return payments.createSubscription({user:user, customerId:response.id, paymentMethod:'Stripe', sub:sub}, cb); + function saveUserData (response, cb) { + if (sub) return payments.createSubscription({user, customerId: response.id, paymentMethod: 'Stripe', 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'; + 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'; + if (gift.type === 'subscription') method = 'createSubscription'; data.paymentMethod = 'Gift'; } payments[method](data, cb2); - } + }, ], cb); - } - ], function(err){ + }, + ], 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; }); }; -exports.subscribeCancel = function(req, res, next) { - var user = res.locals.user; - if (!user.purchased.plan.customerId) +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({ - get_cus: function(cb){ + getCustomer: function getCustomer (cb) { stripe.customers.retrieve(user.purchased.plan.customerId, cb); }, - del_cus: ['get_cus', function(cb, results){ + deleteCustomer: ['getCustomer', function deleteCustomer (cb) { 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' + cancelSubscription: ['getCustomer', function cancelSubscription (cb, results) { + let data = { + user, + nextBill: results.get_cus.subscription.current_period_end * 1000, // timestamp is in seconds + paymentMethod: 'Stripe', }; payments.cancelSubscription(data, cb); - }] - }, function(err, results){ + }], + }, function handleResponse (err) { 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; +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(cb){ - stripe.customers.listSubscriptions(user_id, cb); + function listSubscriptions (cb) { + stripe.customers.listSubscriptions(userId, 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 updateSubscription (response, cb) { + subscriptionId = response.data[0].id; + stripe.customers.updateSubscription(userId, subscriptionId, { card: token }, cb); }, - function(response, cb) { + function saveUser (response, cb) { user.save(cb); - } - ], function(err, saved){ + }, + ], function handleResponse (err) { 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; + token = user = userId = subscriptionId; }); }; +*/ + +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..9cff7cc763 --- /dev/null +++ b/website/src/libs/api-v3/amazonPayments.js @@ -0,0 +1,38 @@ +import amazonPayments from 'amazon-payments'; +import nconf from 'nconf'; +import Q from 'q'; + +const IS_PROD = nconf.get('NODE_ENV') === 'production'; + +let api = {}; + +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'), +}); + +api.getTokenInfo = (token) => { + return new Promise((resolve, reject) => { + amzPayment.api.getTokenInfo(token, (err, tokenInfo) => { + if (err) return reject(err); + return resolve(tokenInfo); + }); + }); +}; + +api.createOrderReferenceId = (inputSet) => { + return new Promise((resolve, reject) => { + amzPayment.offAmazonPayments.createOrderReferenceForId(inputSet, (err, response) => { + if (err) return reject(err); + if (!response.OrderReferenceDetails || !response.OrderReferenceDetails.AmazonOrderReferenceId) { + return reject('missingAttributesFromAmazon'); + } + return resolve(response); + }); + }); +}; + +module.exports = api; From 8870cef0e2783ccbcc4978b88acf47b25cc7652d Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Wed, 6 Apr 2016 21:18:36 +0000 Subject: [PATCH 12/34] WIP(payments): lint Amazon Payments file --- .../src/controllers/api-v3/payments/amazon.js | 277 ++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 website/src/controllers/api-v3/payments/amazon.js diff --git a/website/src/controllers/api-v3/payments/amazon.js b/website/src/controllers/api-v3/payments/amazon.js new file mode 100644 index 0000000000..c1056c1224 --- /dev/null +++ b/website/src/controllers/api-v3/payments/amazon.js @@ -0,0 +1,277 @@ +import amazonPayments from 'amazon-payments'; +import async from 'async'; +import cc from 'coupon-code'; +import mongoose from 'mongoose'; +import moment from 'moment'; +import nconf from 'nconf'; +import payments from './index'; +import shared from '../../../../common'; +import { model as User } from '../../models/user'; + +const IS_PROD = nconf.get('NODE_ENV') === 'production'; + +let api = {}; + +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'), +}); + +api.verifyAccessToken = function verifyAccessToken (req, res) { + 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 getTokenInfo (err) { + if (err) return res.status(400).json({err}); + + res.sendStatus(200); + }); +}; + +api.createOrderReferenceId = function createOrderReferenceId (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 createOrderReferenceForId (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, + }); + }); +}; + +api.checkout = function checkout (req, res, next) { + if (!req.body || !req.body.orderReferenceId) { + return res.status(400).json({err: 'Billing Agreement Id not supplied.'}); + } + + let gift = req.body.gift; + let user = res.locals.user; + let orderReferenceId = req.body.orderReferenceId; + let 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 (cb) { + amzPayment.offAmazonPayments.setOrderReferenceDetails({ + AmazonOrderReferenceId: orderReferenceId, + OrderReferenceAttributes: { + OrderTotal: { + CurrencyCode: 'USD', + Amount: amount, + }, + SellerNote: 'HabitRPG Payment', + SellerOrderAttributes: { + SellerOrderId: shared.uuid(), + StoreName: 'HabitRPG', + }, + }, + }, cb); + }, + + confirmOrderReference (cb) { + amzPayment.offAmazonPayments.confirmOrderReference({ + AmazonOrderReferenceId: orderReferenceId, + }, cb); + }, + + authorize (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 checkAuthorizationStatus (err) { + if (err) return cb(err); + + if (res.AuthorizationDetails.AuthorizationStatus.State === 'Declined') { + return cb(new Error('The payment was not successfull.')); + } + + return cb(); + }); + }, + + closeOrderReference (cb) { + amzPayment.offAmazonPayments.closeOrderReference({ + AmazonOrderReferenceId: orderReferenceId, + }, cb); + }, + + executePayment (cb) { + async.waterfall([ + function findUser (cb2) { + User.findById(gift ? gift.uuid : undefined, cb2); + }, + function executeAmazonPayment (member, cb2) { + let data = {user, paymentMethod: 'Amazon Payments'}; + let 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 result (err) { + if (err) return next(err); + + res.sendStatus(200); + }); +}; + +api.subscribe = function subscribe (req, res, next) { + if (!req.body || !req.body.billingAgreementId) { + return res.status(400).json({err: 'Billing Agreement Id not supplied.'}); + } + + 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) { + return res.status(400).json({err: 'Subscription plan not found.'}); + } + + async.series({ + applyDiscount (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 couponResult (err) { + if (err) return cb(err); + if (!coupon) return cb(new Error('Coupon code not found.')); + cb(); + }); + }, + + setBillingAgreementDetails (cb) { + amzPayment.offAmazonPayments.setBillingAgreementDetails({ + AmazonBillingAgreementId: billingAgreementId, + BillingAgreementAttributes: { + SellerNote: 'HabitRPG Subscription', + SellerBillingAgreementAttributes: { + SellerBillingAgreementId: shared.uuid(), + StoreName: 'HabitRPG', + CustomInformation: 'HabitRPG Subscription', + }, + }, + }, cb); + }, + + confirmBillingAgreement (cb) { + amzPayment.offAmazonPayments.confirmBillingAgreement({ + AmazonBillingAgreementId: billingAgreementId, + }, cb); + }, + + authorizeOnBillingAgreement (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 billingAgreementResult (err) { + if (err) return cb(err); + + if (res.AuthorizationDetails.AuthorizationStatus.State === 'Declined') { + return cb(new Error('The payment was not successful.')); + } + + return cb(); + }); + }, + + createSubscription (cb) { + payments.createSubscription({ + user, + customerId: billingAgreementId, + paymentMethod: 'Amazon Payments', + sub, + }, cb); + }, + }, function subscribeResult (err) { + if (err) return next(err); + + res.sendStatus(200); + }); +}; + +api.subscribeCancel = function subscribeCancel (req, res, next) { + let user = res.locals.user; + if (!user.purchased.plan.customerId) + return res.status(401).json({err: 'User does not have a plan subscription'}); + + let billingAgreementId = user.purchased.plan.customerId; + + async.series({ + closeBillingAgreement (cb) { + amzPayment.offAmazonPayments.closeBillingAgreement({ + AmazonBillingAgreementId: billingAgreementId, + }, cb); + }, + + cancelSubscription (cb) { + let data = { + user, + // Date of next bill + nextBill: moment(user.purchased.plan.lastBillingDate).add({days: 30}), + paymentMethod: 'Amazon Payments', + }; + + payments.cancelSubscription(data, cb); + }, + }, function subscribeCancelResult (err) { + 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; + }); +}; + +module.exports = api; From a06f9954dd7e78bd7196b30ae04230cc67e057d6 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Thu, 7 Apr 2016 20:51:40 +0000 Subject: [PATCH 13/34] refactor(payments): index.js lint pass --- .../src/controllers/api-v3/payments/amazon.js | 2 +- .../src/controllers/api-v3/payments/index.js | 232 ++++++++++++++++++ 2 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 website/src/controllers/api-v3/payments/index.js diff --git a/website/src/controllers/api-v3/payments/amazon.js b/website/src/controllers/api-v3/payments/amazon.js index c1056c1224..bc8e3b5177 100644 --- a/website/src/controllers/api-v3/payments/amazon.js +++ b/website/src/controllers/api-v3/payments/amazon.js @@ -5,7 +5,7 @@ import mongoose from 'mongoose'; import moment from 'moment'; import nconf from 'nconf'; import payments from './index'; -import shared from '../../../../common'; +import shared from '../../../../../common'; import { model as User } from '../../models/user'; const IS_PROD = nconf.get('NODE_ENV') === 'production'; diff --git a/website/src/controllers/api-v3/payments/index.js b/website/src/controllers/api-v3/payments/index.js new file mode 100644 index 0000000000..f6e0a8ebe3 --- /dev/null +++ b/website/src/controllers/api-v3/payments/index.js @@ -0,0 +1,232 @@ +import _ from 'lodash' ; +import analytics from '../../../libs/api-v3/analyticsService'; +import async from 'async'; +import cc from 'coupon-code'; +import { + getUserInfo, + sendTxn as txnEmail, +} from '../../../libs/api-v3/email'; +import members from '../members'; +import moment from 'moment'; +import mongoose from 'mongoose'; +import nconf from 'nconf'; +import pushNotify from '../../../libs/api-v3/pushNotifications'; +import shared from '../../../../../common' ; + +import amazon from './amazon'; +import iap from './iap'; +import paypal from './paypal'; +import stripe from './stripe'; + +const IS_PROD = nconf.get('NODE_ENV') === 'production'; + +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 = function createSubscription (data, cb) { + 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'); + + let analyticsData = { + 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, + }; + analytics.trackPurchase(analyticsData); + } + 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}`); + } + } + async.parallel([ + function saveGiftingUserData (cb2) { + data.user.save(cb2); + }, + function saveRecipientUserData (cb2) { + if (data.gift) { + data.gift.member.save(cb2); + } else { + cb2(null); + } + }, + ], cb); +}; + +/** + * Sets their subscription to be cancelled later + */ +api.cancelSubscription = function cancelSubscription (data, cb) { + let plan = data.user.purchased.plan; + let now = moment(); + let remaining = data.nextBill ? moment(data.nextBill).diff(new Date(), 'days') : 30; + + plan.dateTerminated = + moment(`${now.format('MM')}/${moment(plan.dateUpdated).format('DD')}/${now.format('YYYY')}`) + .add({days: remaining}) // end their subscription 1mo from their last payment + .add({months: Math.ceil(plan.extraMonths)})// plus any extra time (carry-over, gifted subscription, etc) they have. FIXME: moment can't add months in fractions... + .toDate(); + plan.extraMonths = 0; // clear extra time. If they subscribe again, it'll be recalculated from p.dateTerminated + + data.user.save(cb); + txnEmail(data.user, 'cancel-subscription'); + let analyticsData = { + uuid: data.user._id, + gaCategory: 'commerce', + gaLabel: data.paymentMethod, + paymentMethod: data.paymentMethod, + }; + analytics.track('unsubscribe', analyticsData); +}; + +api.buyGems = function buyGems (data, cb) { + let amt = data.amount || 5; + amt = data.gift ? data.gift.gems.amount / 4 : amt; + (data.gift ? data.gift.member : data.user).balance += amt; + data.user.purchased.txnCount++; + if (IS_PROD) { + if (!data.gift) txnEmail(data.user, 'donation'); + + let analyticsData = { + uuid: data.user._id, + itemPurchased: 'Gems', + sku: `${data.paymentMethod.toLowerCase()}-checkout`, + purchaseType: 'checkout', + paymentMethod: data.paymentMethod, + quantity: 1, + gift: Boolean(data.gift), + purchaseValue: amt, + }; + analytics.trackPurchase(analyticsData); + } + + 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}`); + } + } + async.parallel([ + function saveGiftingUserData (cb2) { + data.user.save(cb2); + }, + function saveRecipientUserData (cb2) { + if (data.gift) { + data.gift.member.save(cb2); + } else { + cb2(null); + } + }, + ], cb); +}; + +api.validCoupon = function validCoupon (req, res, next) { + mongoose.model('Coupon').findOne({_id: cc.validate(req.params.code), event: 'google_6mo'}, function couponErrorCheck (err, coupon) { + if (err) return next(err); + if (!coupon) return res.status(401).json({err: 'Invalid coupon code'}); + return res.sendStatus(200); + }); +}; + +api.stripeCheckout = stripe.checkout; +api.stripeSubscribeCancel = stripe.subscribeCancel; +api.stripeSubscribeEdit = stripe.subscribeEdit; + +api.paypalSubscribe = paypal.createBillingAgreement; +api.paypalSubscribeSuccess = paypal.executeBillingAgreement; +api.paypalSubscribeCancel = paypal.cancelSubscription; +api.paypalCheckout = paypal.createPayment; +api.paypalCheckoutSuccess = paypal.executePayment; +api.paypalIPN = paypal.ipn; + +api.amazonVerifyAccessToken = amazon.verifyAccessToken; +api.amazonCreateOrderReferenceId = amazon.createOrderReferenceId; +api.amazonCheckout = amazon.checkout; +api.amazonSubscribe = amazon.subscribe; +api.amazonSubscribeCancel = amazon.subscribeCancel; + +api.iapAndroidVerify = iap.androidVerify; +api.iapIosVerify = iap.iosVerify; + +module.exports = api; From d49115eff60f9fea657172e66abf9d7e55309273 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Tue, 12 Apr 2016 21:15:20 +0000 Subject: [PATCH 14/34] refactor(payments): IAP linting pass --- .../src/controllers/api-v3/payments/iap.js | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 website/src/controllers/api-v3/payments/iap.js diff --git a/website/src/controllers/api-v3/payments/iap.js b/website/src/controllers/api-v3/payments/iap.js new file mode 100644 index 0000000000..94cf21fcca --- /dev/null +++ b/website/src/controllers/api-v3/payments/iap.js @@ -0,0 +1,158 @@ +import { + iap, + inAppPurchase, } +from 'in-app-purchase'; +import payments from './index'; +import nconf from 'nconf'; + +inAppPurchase.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; */ // These variables were never used?? + +let api = {}; + +api.androidVerify = function androidVerify (req, res) { + let iapBody = req.body; + let user = res.locals.user; + + iap.setup(function googleSetupResult (error) { + if (error) { + let resObj = { + ok: false, + data: 'IAP Error', + }; + + return res.json(resObj); + } + + /* + google receipt must be provided as an object + { + "data": "{stringified data object}", + "signature": "signature from google" + } + */ + let testObj = { + data: iapBody.transaction.receipt, + signature: iapBody.transaction.signature, + }; + + // iap is ready + iap.validate(iap.GOOGLE, testObj, function googleValidateResult (err, googleRes) { + if (err) { + 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}); + + return res.json(resObj); + } + }); + }); +}; + +exports.iosVerify = function iosVerify (req, res) { + let iapBody = req.body; + let user = res.locals.user; + + iap.setup(function iosSetupResult (error) { + if (error) { + let resObj = { + ok: false, + data: 'IAP Error', + }; + + return res.json(resObj); + } + + // iap is ready + iap.validate(iap.APPLE, iapBody.transaction.receipt, function iosValidateResult (err, appleRes) { + if (err) { + 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; From c850ccf463d1dac93bcca49fbc35f62710c59b71 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Wed, 13 Apr 2016 19:29:13 +0000 Subject: [PATCH 15/34] refactor(payments): Stripe linting pass --- .../src/controllers/api-v3/payments/stripe.js | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 website/src/controllers/api-v3/payments/stripe.js diff --git a/website/src/controllers/api-v3/payments/stripe.js b/website/src/controllers/api-v3/payments/stripe.js new file mode 100644 index 0000000000..5582d33ca1 --- /dev/null +++ b/website/src/controllers/api-v3/payments/stripe.js @@ -0,0 +1,135 @@ +import nconf from 'nconf'; +import stripeModule from 'stripe'; +import async from 'async'; +import payments from './index'; +import { model as User } from '../../models/user'; +import shared from '../../../../../common'; +import mongoose from 'mongoose'; +import cc from 'coupon-code'; + +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') { + 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; + }); +}; + +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) { + let data = { + user, + nextBill: results.get_cus.subscription.current_period_end * 1000, // timestamp is 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; + }); +}; + +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; From 1d5d6e91463bba3c6e40a5d8fe9fdc571faebf62 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 14 Apr 2016 18:18:57 +0200 Subject: [PATCH 16/34] move payments to /top-level --- website/src/controllers/{ => top-level}/payments/amazon.js | 0 website/src/controllers/{ => top-level}/payments/iap.js | 0 website/src/controllers/{ => top-level}/payments/index.js | 0 website/src/controllers/{ => top-level}/payments/paypal.js | 0 .../controllers/{ => top-level}/payments/paypalBillingSetup.js | 0 website/src/controllers/{ => top-level}/payments/stripe.js | 0 6 files changed, 0 insertions(+), 0 deletions(-) rename website/src/controllers/{ => top-level}/payments/amazon.js (100%) rename website/src/controllers/{ => top-level}/payments/iap.js (100%) rename website/src/controllers/{ => top-level}/payments/index.js (100%) rename website/src/controllers/{ => top-level}/payments/paypal.js (100%) rename website/src/controllers/{ => top-level}/payments/paypalBillingSetup.js (100%) rename website/src/controllers/{ => top-level}/payments/stripe.js (100%) diff --git a/website/src/controllers/payments/amazon.js b/website/src/controllers/top-level/payments/amazon.js similarity index 100% rename from website/src/controllers/payments/amazon.js rename to website/src/controllers/top-level/payments/amazon.js diff --git a/website/src/controllers/payments/iap.js b/website/src/controllers/top-level/payments/iap.js similarity index 100% rename from website/src/controllers/payments/iap.js rename to website/src/controllers/top-level/payments/iap.js diff --git a/website/src/controllers/payments/index.js b/website/src/controllers/top-level/payments/index.js similarity index 100% rename from website/src/controllers/payments/index.js rename to website/src/controllers/top-level/payments/index.js diff --git a/website/src/controllers/payments/paypal.js b/website/src/controllers/top-level/payments/paypal.js similarity index 100% rename from website/src/controllers/payments/paypal.js rename to website/src/controllers/top-level/payments/paypal.js diff --git a/website/src/controllers/payments/paypalBillingSetup.js b/website/src/controllers/top-level/payments/paypalBillingSetup.js similarity index 100% rename from website/src/controllers/payments/paypalBillingSetup.js rename to website/src/controllers/top-level/payments/paypalBillingSetup.js diff --git a/website/src/controllers/payments/stripe.js b/website/src/controllers/top-level/payments/stripe.js similarity index 100% rename from website/src/controllers/payments/stripe.js rename to website/src/controllers/top-level/payments/stripe.js From 261a5a66b1c9bf449b2323849232103a361cdeed Mon Sep 17 00:00:00 2001 From: Victor Piousbox Date: Thu, 14 Apr 2016 22:38:41 +0000 Subject: [PATCH 17/34] working on promisifying amazonPayments --- common/locales/en/api-v3.json | 7 +- tasks/gulp-tests.js | 4 + ...ayments_amazon_verify_access_token.test.js | 21 ++ test/api/v3/unit/libs/amazonPayments.test.js | 24 ++ .../src/controllers/api-v3/payments/amazon.js | 277 ------------------ .../src/controllers/api-v3/payments/iap.js | 158 ---------- .../src/controllers/api-v3/payments/index.js | 232 --------------- .../src/controllers/api-v3/payments/stripe.js | 135 --------- .../controllers/top-level/payments/amazon.js | 268 +++++++++-------- .../src/controllers/top-level/payments/iap.js | 125 ++++---- .../controllers/top-level/payments/index.js | 252 +++++++++------- .../controllers/top-level/payments/paypal.js | 9 +- .../controllers/top-level/payments/stripe.js | 138 +++++---- website/src/libs/api-v3/amazonPayments.js | 38 +++ 14 files changed, 521 insertions(+), 1167 deletions(-) create mode 100644 test/api/v3/integration/payments/POST-payments_amazon_verify_access_token.test.js create mode 100644 test/api/v3/unit/libs/amazonPayments.test.js delete mode 100644 website/src/controllers/api-v3/payments/amazon.js delete mode 100644 website/src/controllers/api-v3/payments/iap.js delete mode 100644 website/src/controllers/api-v3/payments/index.js delete mode 100644 website/src/controllers/api-v3/payments/stripe.js create mode 100644 website/src/libs/api-v3/amazonPayments.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 7af8dc9af0..b68a72b936 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -170,5 +170,10 @@ "pushDeviceAdded": "Push device added successfully", "pushDeviceAlreadyAdded": "The user already has the push device", "resetComplete": "Reset completed", - "lvl10ChangeClass": "To change class you must be at least level 10." + "lvl10ChangeClass": "To change class you must be at least level 10.", + "resetComplete": "Reset has completed", + "missingAccessToken": "The request is missing a required parameter : access_token", + "missingBillingAgreementId": "Missing billing agreement id", + "missingAttributesFromAmazon": "Missing attributes from Amazon", + "errorFromAmazon": "Error from Amazon" } diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js index d8a9917eef..fba9d85721 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/libs/*'], ['test:api-v3:unit']); +}); + gulp.task('test:api-v3:integration', (done) => { let runner = exec( testBin('mocha test/api/v3/integration --recursive'), diff --git a/test/api/v3/integration/payments/POST-payments_amazon_verify_access_token.test.js b/test/api/v3/integration/payments/POST-payments_amazon_verify_access_token.test.js new file mode 100644 index 0000000000..494c387f14 --- /dev/null +++ b/test/api/v3/integration/payments/POST-payments_amazon_verify_access_token.test.js @@ -0,0 +1,21 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +describe('payments : amazon', () => { + let endpoint = '/payments/amazon/verifyAccessToken'; + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('verify access token', async () => { + await expect(user.post(endpoint)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('missingAccessToken'), + }); + }); +}); diff --git a/test/api/v3/unit/libs/amazonPayments.test.js b/test/api/v3/unit/libs/amazonPayments.test.js new file mode 100644 index 0000000000..a60a8b4633 --- /dev/null +++ b/test/api/v3/unit/libs/amazonPayments.test.js @@ -0,0 +1,24 @@ +import * as amz from '../../../../../website/src/libs/api-v3/amazonPayments'; + +describe.only('amazonPayments', () => { + + beforeEach(() => { + }); + + describe('#getTokenInfo', () => { + it('validates access_token parameter', async (done) => { + try { + let result = await amz.getTokenInfo(); + } catch (e) { + expect(e.type).to.eql('invalid_request'); + done(); + } + }); + }); + + describe('#createOrderReferenceId', () => { + it('is sane', () => { + expect(false).to.eql(true); // @TODO + }); + }); +}); diff --git a/website/src/controllers/api-v3/payments/amazon.js b/website/src/controllers/api-v3/payments/amazon.js deleted file mode 100644 index bc8e3b5177..0000000000 --- a/website/src/controllers/api-v3/payments/amazon.js +++ /dev/null @@ -1,277 +0,0 @@ -import amazonPayments from 'amazon-payments'; -import async from 'async'; -import cc from 'coupon-code'; -import mongoose from 'mongoose'; -import moment from 'moment'; -import nconf from 'nconf'; -import payments from './index'; -import shared from '../../../../../common'; -import { model as User } from '../../models/user'; - -const IS_PROD = nconf.get('NODE_ENV') === 'production'; - -let api = {}; - -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'), -}); - -api.verifyAccessToken = function verifyAccessToken (req, res) { - 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 getTokenInfo (err) { - if (err) return res.status(400).json({err}); - - res.sendStatus(200); - }); -}; - -api.createOrderReferenceId = function createOrderReferenceId (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 createOrderReferenceForId (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, - }); - }); -}; - -api.checkout = function checkout (req, res, next) { - if (!req.body || !req.body.orderReferenceId) { - return res.status(400).json({err: 'Billing Agreement Id not supplied.'}); - } - - let gift = req.body.gift; - let user = res.locals.user; - let orderReferenceId = req.body.orderReferenceId; - let 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 (cb) { - amzPayment.offAmazonPayments.setOrderReferenceDetails({ - AmazonOrderReferenceId: orderReferenceId, - OrderReferenceAttributes: { - OrderTotal: { - CurrencyCode: 'USD', - Amount: amount, - }, - SellerNote: 'HabitRPG Payment', - SellerOrderAttributes: { - SellerOrderId: shared.uuid(), - StoreName: 'HabitRPG', - }, - }, - }, cb); - }, - - confirmOrderReference (cb) { - amzPayment.offAmazonPayments.confirmOrderReference({ - AmazonOrderReferenceId: orderReferenceId, - }, cb); - }, - - authorize (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 checkAuthorizationStatus (err) { - if (err) return cb(err); - - if (res.AuthorizationDetails.AuthorizationStatus.State === 'Declined') { - return cb(new Error('The payment was not successfull.')); - } - - return cb(); - }); - }, - - closeOrderReference (cb) { - amzPayment.offAmazonPayments.closeOrderReference({ - AmazonOrderReferenceId: orderReferenceId, - }, cb); - }, - - executePayment (cb) { - async.waterfall([ - function findUser (cb2) { - User.findById(gift ? gift.uuid : undefined, cb2); - }, - function executeAmazonPayment (member, cb2) { - let data = {user, paymentMethod: 'Amazon Payments'}; - let 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 result (err) { - if (err) return next(err); - - res.sendStatus(200); - }); -}; - -api.subscribe = function subscribe (req, res, next) { - if (!req.body || !req.body.billingAgreementId) { - return res.status(400).json({err: 'Billing Agreement Id not supplied.'}); - } - - 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) { - return res.status(400).json({err: 'Subscription plan not found.'}); - } - - async.series({ - applyDiscount (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 couponResult (err) { - if (err) return cb(err); - if (!coupon) return cb(new Error('Coupon code not found.')); - cb(); - }); - }, - - setBillingAgreementDetails (cb) { - amzPayment.offAmazonPayments.setBillingAgreementDetails({ - AmazonBillingAgreementId: billingAgreementId, - BillingAgreementAttributes: { - SellerNote: 'HabitRPG Subscription', - SellerBillingAgreementAttributes: { - SellerBillingAgreementId: shared.uuid(), - StoreName: 'HabitRPG', - CustomInformation: 'HabitRPG Subscription', - }, - }, - }, cb); - }, - - confirmBillingAgreement (cb) { - amzPayment.offAmazonPayments.confirmBillingAgreement({ - AmazonBillingAgreementId: billingAgreementId, - }, cb); - }, - - authorizeOnBillingAgreement (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 billingAgreementResult (err) { - if (err) return cb(err); - - if (res.AuthorizationDetails.AuthorizationStatus.State === 'Declined') { - return cb(new Error('The payment was not successful.')); - } - - return cb(); - }); - }, - - createSubscription (cb) { - payments.createSubscription({ - user, - customerId: billingAgreementId, - paymentMethod: 'Amazon Payments', - sub, - }, cb); - }, - }, function subscribeResult (err) { - if (err) return next(err); - - res.sendStatus(200); - }); -}; - -api.subscribeCancel = function subscribeCancel (req, res, next) { - let user = res.locals.user; - if (!user.purchased.plan.customerId) - return res.status(401).json({err: 'User does not have a plan subscription'}); - - let billingAgreementId = user.purchased.plan.customerId; - - async.series({ - closeBillingAgreement (cb) { - amzPayment.offAmazonPayments.closeBillingAgreement({ - AmazonBillingAgreementId: billingAgreementId, - }, cb); - }, - - cancelSubscription (cb) { - let data = { - user, - // Date of next bill - nextBill: moment(user.purchased.plan.lastBillingDate).add({days: 30}), - paymentMethod: 'Amazon Payments', - }; - - payments.cancelSubscription(data, cb); - }, - }, function subscribeCancelResult (err) { - 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; - }); -}; - -module.exports = api; diff --git a/website/src/controllers/api-v3/payments/iap.js b/website/src/controllers/api-v3/payments/iap.js deleted file mode 100644 index 94cf21fcca..0000000000 --- a/website/src/controllers/api-v3/payments/iap.js +++ /dev/null @@ -1,158 +0,0 @@ -import { - iap, - inAppPurchase, } -from 'in-app-purchase'; -import payments from './index'; -import nconf from 'nconf'; - -inAppPurchase.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; */ // These variables were never used?? - -let api = {}; - -api.androidVerify = function androidVerify (req, res) { - let iapBody = req.body; - let user = res.locals.user; - - iap.setup(function googleSetupResult (error) { - if (error) { - let resObj = { - ok: false, - data: 'IAP Error', - }; - - return res.json(resObj); - } - - /* - google receipt must be provided as an object - { - "data": "{stringified data object}", - "signature": "signature from google" - } - */ - let testObj = { - data: iapBody.transaction.receipt, - signature: iapBody.transaction.signature, - }; - - // iap is ready - iap.validate(iap.GOOGLE, testObj, function googleValidateResult (err, googleRes) { - if (err) { - 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}); - - return res.json(resObj); - } - }); - }); -}; - -exports.iosVerify = function iosVerify (req, res) { - let iapBody = req.body; - let user = res.locals.user; - - iap.setup(function iosSetupResult (error) { - if (error) { - let resObj = { - ok: false, - data: 'IAP Error', - }; - - return res.json(resObj); - } - - // iap is ready - iap.validate(iap.APPLE, iapBody.transaction.receipt, function iosValidateResult (err, appleRes) { - if (err) { - 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/api-v3/payments/index.js b/website/src/controllers/api-v3/payments/index.js deleted file mode 100644 index f6e0a8ebe3..0000000000 --- a/website/src/controllers/api-v3/payments/index.js +++ /dev/null @@ -1,232 +0,0 @@ -import _ from 'lodash' ; -import analytics from '../../../libs/api-v3/analyticsService'; -import async from 'async'; -import cc from 'coupon-code'; -import { - getUserInfo, - sendTxn as txnEmail, -} from '../../../libs/api-v3/email'; -import members from '../members'; -import moment from 'moment'; -import mongoose from 'mongoose'; -import nconf from 'nconf'; -import pushNotify from '../../../libs/api-v3/pushNotifications'; -import shared from '../../../../../common' ; - -import amazon from './amazon'; -import iap from './iap'; -import paypal from './paypal'; -import stripe from './stripe'; - -const IS_PROD = nconf.get('NODE_ENV') === 'production'; - -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 = function createSubscription (data, cb) { - 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'); - - let analyticsData = { - 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, - }; - analytics.trackPurchase(analyticsData); - } - 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}`); - } - } - async.parallel([ - function saveGiftingUserData (cb2) { - data.user.save(cb2); - }, - function saveRecipientUserData (cb2) { - if (data.gift) { - data.gift.member.save(cb2); - } else { - cb2(null); - } - }, - ], cb); -}; - -/** - * Sets their subscription to be cancelled later - */ -api.cancelSubscription = function cancelSubscription (data, cb) { - let plan = data.user.purchased.plan; - let now = moment(); - let remaining = data.nextBill ? moment(data.nextBill).diff(new Date(), 'days') : 30; - - plan.dateTerminated = - moment(`${now.format('MM')}/${moment(plan.dateUpdated).format('DD')}/${now.format('YYYY')}`) - .add({days: remaining}) // end their subscription 1mo from their last payment - .add({months: Math.ceil(plan.extraMonths)})// plus any extra time (carry-over, gifted subscription, etc) they have. FIXME: moment can't add months in fractions... - .toDate(); - plan.extraMonths = 0; // clear extra time. If they subscribe again, it'll be recalculated from p.dateTerminated - - data.user.save(cb); - txnEmail(data.user, 'cancel-subscription'); - let analyticsData = { - uuid: data.user._id, - gaCategory: 'commerce', - gaLabel: data.paymentMethod, - paymentMethod: data.paymentMethod, - }; - analytics.track('unsubscribe', analyticsData); -}; - -api.buyGems = function buyGems (data, cb) { - let amt = data.amount || 5; - amt = data.gift ? data.gift.gems.amount / 4 : amt; - (data.gift ? data.gift.member : data.user).balance += amt; - data.user.purchased.txnCount++; - if (IS_PROD) { - if (!data.gift) txnEmail(data.user, 'donation'); - - let analyticsData = { - uuid: data.user._id, - itemPurchased: 'Gems', - sku: `${data.paymentMethod.toLowerCase()}-checkout`, - purchaseType: 'checkout', - paymentMethod: data.paymentMethod, - quantity: 1, - gift: Boolean(data.gift), - purchaseValue: amt, - }; - analytics.trackPurchase(analyticsData); - } - - 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}`); - } - } - async.parallel([ - function saveGiftingUserData (cb2) { - data.user.save(cb2); - }, - function saveRecipientUserData (cb2) { - if (data.gift) { - data.gift.member.save(cb2); - } else { - cb2(null); - } - }, - ], cb); -}; - -api.validCoupon = function validCoupon (req, res, next) { - mongoose.model('Coupon').findOne({_id: cc.validate(req.params.code), event: 'google_6mo'}, function couponErrorCheck (err, coupon) { - if (err) return next(err); - if (!coupon) return res.status(401).json({err: 'Invalid coupon code'}); - return res.sendStatus(200); - }); -}; - -api.stripeCheckout = stripe.checkout; -api.stripeSubscribeCancel = stripe.subscribeCancel; -api.stripeSubscribeEdit = stripe.subscribeEdit; - -api.paypalSubscribe = paypal.createBillingAgreement; -api.paypalSubscribeSuccess = paypal.executeBillingAgreement; -api.paypalSubscribeCancel = paypal.cancelSubscription; -api.paypalCheckout = paypal.createPayment; -api.paypalCheckoutSuccess = paypal.executePayment; -api.paypalIPN = paypal.ipn; - -api.amazonVerifyAccessToken = amazon.verifyAccessToken; -api.amazonCreateOrderReferenceId = amazon.createOrderReferenceId; -api.amazonCheckout = amazon.checkout; -api.amazonSubscribe = amazon.subscribe; -api.amazonSubscribeCancel = amazon.subscribeCancel; - -api.iapAndroidVerify = iap.androidVerify; -api.iapIosVerify = iap.iosVerify; - -module.exports = api; diff --git a/website/src/controllers/api-v3/payments/stripe.js b/website/src/controllers/api-v3/payments/stripe.js deleted file mode 100644 index 5582d33ca1..0000000000 --- a/website/src/controllers/api-v3/payments/stripe.js +++ /dev/null @@ -1,135 +0,0 @@ -import nconf from 'nconf'; -import stripeModule from 'stripe'; -import async from 'async'; -import payments from './index'; -import { model as User } from '../../models/user'; -import shared from '../../../../../common'; -import mongoose from 'mongoose'; -import cc from 'coupon-code'; - -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') { - 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; - }); -}; - -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) { - let data = { - user, - nextBill: results.get_cus.subscription.current_period_end * 1000, // timestamp is 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; - }); -}; - -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/controllers/top-level/payments/amazon.js b/website/src/controllers/top-level/payments/amazon.js index 8c01663c10..63b589f6fc 100644 --- a/website/src/controllers/top-level/payments/amazon.js +++ b/website/src/controllers/top-level/payments/amazon.js @@ -1,112 +1,128 @@ -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'; +import async from 'async'; +import cc from 'coupon-code'; +import mongoose from 'mongoose'; +import moment from 'moment'; +import payments from './index'; +import shared from '../../../../../common'; +import { model as User } from '../../../models/user'; +import { + NotFound, + NotAuthorized, + BadRequest, +} from '../../../libs/api-v3/errors'; +import amz from '../../../libs/api-v3/amazonPayments'; -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') -}); +let api = {}; -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); - }); +/** + * @api {post} /api/v3/payments/amazon/verifyAccessToken verify access token + * @apiVersion 3.0.0 + * @apiName AmazonVerifyAccessToken + * @apiGroup Payments + * @apiParam {string} access_token the access token + * @apiSuccess {} empty + **/ +api.verifyAccessToken = { + method: 'POST', + url: '/payments/amazon/verifyAccessToken', + async handler (req, res) { + await amz.getTokenInfo(req.body.access_token) + .then(() => { + res.respond(200, {}); + }).catch( (error) => { + throw new BadRequest(error.body.error_description); + }); + }, }; -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.')); +/** + * @api {post} /api/v3/payments/amazon/createOrderReferenceId create order reference id + * @apiVersion 3.0.0 + * @apiName AmazonCreateOrderReferenceId + * @apiGroup Payments + * @apiParam {string} billingAgreementId billing agreement id + * @apiSuccess {object} object containing { orderReferenceId } + **/ +api.createOrderReferenceId = { + method: 'POST', + url: '/payments/amazon/createOrderReferenceId', + async handler (req, res) { + if (!req.body.billingAgreementId) { + throw new BadRequest(res.t('missingBillingAgreementId')); } - res.json({ - orderReferenceId: response.OrderReferenceDetails.AmazonOrderReferenceId + let response = await amz.createOrderReferenceId({ + Id: req.body.billingAgreementId, + IdType: 'BillingAgreement', + ConfirmNow: false, + }).then(() => { + res.respond(200, { + orderReferenceId: response.OrderReferenceDetails.AmazonOrderReferenceId, + }); + }).catch(errStr => { + throw new BadRequest(res.t(errStr)); }); - }); + }, }; -exports.checkout = function(req, res, next){ - if(!req.body || !req.body.orderReferenceId){ +/* +api.checkout = function checkout (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; + let gift = req.body.gift; + let user = res.locals.user; + let orderReferenceId = req.body.orderReferenceId; + let amount = 5; - if(gift){ - if(gift.type === 'gems'){ - amount = gift.gems.amount/4; - }else if(gift.type === 'subscription'){ + 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){ + setOrderReferenceDetails (cb) { amzPayment.offAmazonPayments.setOrderReferenceDetails({ AmazonOrderReferenceId: orderReferenceId, OrderReferenceAttributes: { OrderTotal: { CurrencyCode: 'USD', - Amount: amount + Amount: amount, }, SellerNote: 'HabitRPG Payment', SellerOrderAttributes: { SellerOrderId: shared.uuid(), - StoreName: 'HabitRPG' - } - } + StoreName: 'HabitRPG', + }, + }, }, cb); }, - confirmOrderReference: function(cb){ + confirmOrderReference (cb) { amzPayment.offAmazonPayments.confirmOrderReference({ - AmazonOrderReferenceId: orderReferenceId + AmazonOrderReferenceId: orderReferenceId, }, cb); }, - authorize: function(cb){ + authorize (cb) { amzPayment.offAmazonPayments.authorize({ AmazonOrderReferenceId: orderReferenceId, AuthorizationReferenceId: shared.uuid().substring(0, 32), AuthorizationAmount: { CurrencyCode: 'USD', - Amount: amount + Amount: amount, }, SellerAuthorizationNote: 'HabitRPG Payment', TransactionTimeout: 0, - CaptureNow: true - }, function(err, res){ - if(err) return cb(err); + CaptureNow: true, + }, function checkAuthorizationStatus (err) { + if (err) return cb(err); - if(res.AuthorizationDetails.AuthorizationStatus.State === 'Declined'){ + if (res.AuthorizationDetails.AuthorizationStatus.State === 'Declined') { return cb(new Error('The payment was not successfull.')); } @@ -114,64 +130,65 @@ exports.checkout = function(req, res, next){ }); }, - closeOrderReference: function(cb){ + closeOrderReference (cb) { amzPayment.offAmazonPayments.closeOrderReference({ - AmazonOrderReferenceId: orderReferenceId + AmazonOrderReferenceId: orderReferenceId, }, cb); }, - executePayment: function(cb){ + executePayment (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'; + function findUser (cb2) { + User.findById(gift ? gift.uuid : undefined, cb2); + }, + function executeAmazonPayment (member, cb2) { + let data = {user, paymentMethod: 'Amazon Payments'}; + let method = 'buyGems'; - if (gift){ - if (gift.type == 'subscription') method = 'createSubscription'; + 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); + }, + }, function result (err) { + if (err) return next(err); res.sendStatus(200); }); - }; -exports.subscribe = function(req, res, next){ - if(!req.body || !req.body['billingAgreementId']){ +api.subscribe = function subscribe (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; + 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){ + if (!sub) { return res.status(400).json({err: 'Subscription plan not found.'}); } async.series({ - applyDiscount: function(cb){ + applyDiscount (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.')); + mongoose.model('Coupon').findOne({_id: cc.validate(coupon), event: sub.key}, function couponResult (err) { + if (err) return cb(err); + if (!coupon) return cb(new Error('Coupon code not found.')); cb(); }); }, - setBillingAgreementDetails: function(cb){ + setBillingAgreementDetails (cb) { amzPayment.offAmazonPayments.setBillingAgreementDetails({ AmazonBillingAgreementId: billingAgreementId, BillingAgreementAttributes: { @@ -179,25 +196,25 @@ exports.subscribe = function(req, res, next){ SellerBillingAgreementAttributes: { SellerBillingAgreementId: shared.uuid(), StoreName: 'HabitRPG', - CustomInformation: 'HabitRPG Subscription' - } - } + CustomInformation: 'HabitRPG Subscription', + }, + }, }, cb); }, - confirmBillingAgreement: function(cb){ + confirmBillingAgreement (cb) { amzPayment.offAmazonPayments.confirmBillingAgreement({ - AmazonBillingAgreementId: billingAgreementId + AmazonBillingAgreementId: billingAgreementId, }, cb); }, - authorizeOnBillingAgreeement: function(cb){ + authorizeOnBillingAgreement (cb) { amzPayment.offAmazonPayments.authorizeOnBillingAgreement({ AmazonBillingAgreementId: billingAgreementId, AuthorizationReferenceId: shared.uuid().substring(0, 32), AuthorizationAmount: { CurrencyCode: 'USD', - Amount: sub.price + Amount: sub.price, }, SellerAuthorizationNote: 'HabitRPG Subscription Payment', TransactionTimeout: 0, @@ -205,67 +222,70 @@ exports.subscribe = function(req, res, next){ SellerNote: 'HabitRPG Subscription Payment', SellerOrderAttributes: { SellerOrderId: shared.uuid(), - StoreName: 'HabitRPG' - } - }, function(err, res){ - if(err) return cb(err); + StoreName: 'HabitRPG', + }, + }, function billingAgreementResult (err) { + if (err) return cb(err); - if(res.AuthorizationDetails.AuthorizationStatus.State === 'Declined'){ - return cb(new Error('The payment was not successfull.')); + if (res.AuthorizationDetails.AuthorizationStatus.State === 'Declined') { + return cb(new Error('The payment was not successful.')); } return cb(); }); }, - createSubscription: function(cb){ + createSubscription (cb) { payments.createSubscription({ - user: user, + user, customerId: billingAgreementId, paymentMethod: 'Amazon Payments', - sub: sub + sub, }, cb); - } - }, function(err, results){ - if(err) return next(err); + }, + }, function subscribeResult (err) { + if (err) return next(err); res.sendStatus(200); }); }; -exports.subscribeCancel = function(req, res, next){ - var user = res.locals.user; +api.subscribeCancel = function subscribeCancel (req, res, next) { + let 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; + let billingAgreementId = user.purchased.plan.customerId; async.series({ - closeBillingAgreement: function(cb){ + closeBillingAgreement (cb) { amzPayment.offAmazonPayments.closeBillingAgreement({ - AmazonBillingAgreementId: billingAgreementId + AmazonBillingAgreementId: billingAgreementId, }, cb); }, - cancelSubscription: function(cb){ - var data = { - user: user, + cancelSubscription (cb) { + let data = { + user, // Date of next bill nextBill: moment(user.purchased.plan.lastBillingDate).add({days: 30}), - paymentMethod: 'Amazon Payments' + paymentMethod: 'Amazon Payments', }; payments.cancelSubscription(data, cb); - } - }, function(err, results){ + }, + }, function subscribeCancelResult (err) { if (err) return next(err); // don't json this, let toString() handle errors - if(req.query.noRedirect){ + if (req.query.noRedirect) { res.sendStatus(200); - }else{ + } else { res.redirect('/'); } user = null; }); }; +*/ + +module.exports = api; diff --git a/website/src/controllers/top-level/payments/iap.js b/website/src/controllers/top-level/payments/iap.js index 829482ed67..5de66b0452 100644 --- a/website/src/controllers/top-level/payments/iap.js +++ b/website/src/controllers/top-level/payments/iap.js @@ -1,67 +1,66 @@ -var iap = require('in-app-purchase'); -var async = require('async'); -var payments = require('./index'); -var nconf = require('nconf'); +import iap from 'in-app-purchase'; +import whatThis from 'in-app-purchase'; +import payments from './index'; +import nconf from 'nconf'; -var inAppPurchase = require('in-app-purchase'); -inAppPurchase.config({ +iap.config({ // this is the path to the directory containing iap-sanbox/iap-live files - googlePublicKeyPath: nconf.get('IAP_GOOGLE_KEYDIR') + googlePublicKeyPath: nconf.get('IAP_GOOGLE_KEYDIR'), }); // Validation ERROR Codes -var INVALID_PAYLOAD = 6778001; -var CONNECTION_FAILED = 6778002; -var PURCHASE_EXPIRED = 6778003; +const INVALID_PAYLOAD = 6778001; +/* const CONNECTION_FAILED = 6778002; +const PURCHASE_EXPIRED = 6778003; */ // These variables were never used?? -exports.androidVerify = function(req, res, next) { - var iapBody = req.body; - var user = res.locals.user; +let api = {}; - iap.setup(function (error) { +/* +api.androidVerify = function androidVerify (req, res) { + let iapBody = req.body; + let user = res.locals.user; + + iap.setup(function googleSetupResult (error) { if (error) { - var resObj = { + let resObj = { ok: false, - data: 'IAP Error' + 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 = { + // 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 + signature: iapBody.transaction.signature, }; // iap is ready - iap.validate(iap.GOOGLE, testObj, function (err, googleRes) { + iap.validate(iap.GOOGLE, testObj, function googleValidateResult (err, googleRes) { if (err) { - var resObj = { + let resObj = { ok: false, data: { code: INVALID_PAYLOAD, - message: err.toString() - } + message: err.toString(), + }, }; return res.json(resObj); } if (iap.isValidated(googleRes)) { - var resObj = { + let resObj = { ok: true, - data: googleRes + data: googleRes, }; - payments.buyGems({user:user, paymentMethod:'IAP GooglePlay', amount: 5.25}); + payments.buyGems({user, paymentMethod: 'IAP GooglePlay', amount: 5.25}); return res.json(resObj); } @@ -69,87 +68,89 @@ exports.androidVerify = function(req, res, next) { }); }; -exports.iosVerify = function(req, res, next) { - var iapBody = req.body; - var user = res.locals.user; +exports.iosVerify = function iosVerify (req, res) { + let iapBody = req.body; + let user = res.locals.user; - iap.setup(function (error) { + iap.setup(function iosSetupResult (error) { if (error) { - var resObj = { + let resObj = { ok: false, - data: 'IAP Error' + data: 'IAP Error', }; return res.json(resObj); - } - //iap is ready - iap.validate(iap.APPLE, iapBody.transaction.receipt, function (err, appleRes) { + // iap is ready + iap.validate(iap.APPLE, iapBody.transaction.receipt, function iosValidateResult (err, appleRes) { if (err) { - var resObj = { + let resObj = { ok: false, data: { code: INVALID_PAYLOAD, - message: err.toString() - } + message: err.toString(), + }, }; return res.json(resObj); } if (iap.isValidated(appleRes)) { - var purchaseDataList = iap.getPurchaseData(appleRes); + let purchaseDataList = iap.getPurchaseData(appleRes); if (purchaseDataList.length > 0) { - var correctReceipt = true; - for (var index in purchaseDataList) { + let correctReceipt = true; + for (let index of purchaseDataList) { switch (purchaseDataList[index].productId) { case 'com.habitrpg.ios.Habitica.4gems': - payments.buyGems({user:user, paymentMethod:'IAP AppleStore', amount: 1}); + payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 1}); break; case 'com.habitrpg.ios.Habitica.8gems': - payments.buyGems({user:user, paymentMethod:'IAP AppleStore', amount: 2}); + payments.buyGems({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}); + payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 5.25}); break; case 'com.habitrpg.ios.Habitica.42gems': - payments.buyGems({user:user, paymentMethod:'IAP AppleStore', amount: 10.5}); + payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 10.5}); break; default: correctReceipt = false; } } if (correctReceipt) { - var resObj = { + let resObj = { ok: true, - data: appleRes + data: appleRes, }; // yay good! return res.json(resObj); } } - //wrong receipt content - var resObj = { + // wrong receipt content + let resObj = { ok: false, data: { code: INVALID_PAYLOAD, - message: 'Incorrect receipt content' - } + message: 'Incorrect receipt content', + }, }; return res.json(resObj); } - //invalid receipt - var resObj = { + // invalid receipt + let resObj = { ok: false, data: { code: INVALID_PAYLOAD, - message: 'Invalid receipt' - } + message: 'Invalid receipt', + }, }; return res.json(resObj); }); }); }; +*/ + +module.exports = api; diff --git a/website/src/controllers/top-level/payments/index.js b/website/src/controllers/top-level/payments/index.js index 1652e05b8d..5e413fd47c 100644 --- a/website/src/controllers/top-level/payments/index.js +++ b/website/src/controllers/top-level/payments/index.js @@ -1,207 +1,233 @@ -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'); +import _ from 'lodash' ; +import analytics from '../../../libs/api-v3/analyticsService'; +import async from 'async'; +import cc from 'coupon-code'; +import { + getUserInfo, + sendTxn as txnEmail, +} from '../../../libs/api-v3/email'; +import members from '../members'; +import moment from 'moment'; +import mongoose from 'mongoose'; +import nconf from 'nconf'; +import pushNotify from '../../../libs/api-v3/pushNotifications'; +import shared from '../../../../../common' ; -function revealMysteryItems(user) { - _.each(shared.content.gear.flat, function(item) { +import amazon from './amazon'; +import iap from './iap'; +import paypal from './paypal'; +import stripe from './stripe'; + +const IS_PROD = nconf.get('NODE_ENV') === 'production'; + +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) + user.purchased.plan.mysteryItems.indexOf(item.key) !== -1 ) { 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; +api.createSubscription = function createSubscription (data, cb) { + 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 (p.customerId && !p.dateTerminated) { // User has active plan - p.extraMonths += months; + if (plan.customerId && !plan.dateTerminated) { // User has active plan + plan.extraMonths += months; } else { - p.dateTerminated = moment(p.dateTerminated).add({months: months}).toDate(); - if (!p.dateUpdated) p.dateUpdated = new Date(); + plan.dateTerminated = moment(plan.dateTerminated).add({months}).toDate(); + if (!plan.dateUpdated) plan.dateUpdated = new Date(); } - if (!p.customerId) p.customerId = 'Gift'; // don't override existing customer, but all sub need a customerId + if (!plan.customerId) plan.customerId = 'Gift'; // don't override existing customer, but all sub need a customerId } else { - _(p).merge({ // override with these values + _(plan).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), + 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 + lastBillingDate: data.paymentMethod === 'Amazon Payments' ? new Date() : undefined, }).defaults({ // allow non-override if a plan was previously used dateCreated: new Date(), - mysteryItems: [] + mysteryItems: [], }).value(); } // Block sub perks - var perks = Math.floor(months/3); + let 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; + 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(isProduction) { - if (!data.gift) utils.txnEmail(data.user, 'subscription-begins'); + if (IS_PROD) { + if (!data.gift) txnEmail(data.user, 'subscription-begins'); - var analyticsData = { + let analyticsData = { uuid: data.user._id, itemPurchased: 'Subscription', - sku: data.paymentMethod.toLowerCase() + '-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); + gift: Boolean(data.gift), + purchaseValue: block.price, + }; + analytics.trackPurchase(analyticsData); } data.user.purchased.txnCount++; - if (data.gift){ + if (data.gift) { members.sendMessage(data.user, data.gift.member, data.gift); - var byUserName = utils.getUserInfo(data.user, ['name']).name; + let byUserName = getUserInfo(data.user, ['name']).name; - if(data.gift.member.preferences.emailNotifications.giftedSubscription !== false){ - utils.txnEmail(data.gift.member, 'gifted-subscription', [ + if (data.gift.member.preferences.emailNotifications.giftedSubscription !== false) { + txnEmail(data.gift.member, 'gifted-subscription', [ {name: 'GIFTER', content: byUserName}, - {name: 'X_MONTHS_SUBSCRIPTION', content: months} + {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); + 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);} + function saveGiftingUserData (cb2) { + data.user.save(cb2); + }, + function saveRecipientUserData (cb2) { + if (data.gift) { + data.gift.member.save(cb2); + } else { + 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; +api.cancelSubscription = function cancelSubscription (data, cb) { + let plan = data.user.purchased.plan; + let now = moment(); + let 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') ) + plan.dateTerminated = + moment(`${now.format('MM')}/${moment(plan.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... + .add({months: Math.ceil(plan.extraMonths)})// plus any extra time (carry-over, gifted subscription, etc) they have. FIXME: 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 + plan.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 = { + txnEmail(data.user, 'cancel-subscription'); + let analyticsData = { uuid: data.user._id, gaCategory: 'commerce', gaLabel: data.paymentMethod, - paymentMethod: data.paymentMethod - } - utils.analytics.track('unsubscribe', analyticsData); -} + paymentMethod: data.paymentMethod, + }; + analytics.track('unsubscribe', analyticsData); +}; -exports.buyGems = function(data, cb) { - var amt = data.amount || 5; - amt = data.gift ? data.gift.gems.amount/4 : amt; +api.buyGems = function buyGems (data, cb) { + 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(isProduction) { - if (!data.gift) utils.txnEmail(data.user, 'donation'); + if (IS_PROD) { + if (!data.gift) txnEmail(data.user, 'donation'); - var analyticsData = { + let analyticsData = { uuid: data.user._id, itemPurchased: 'Gems', - sku: data.paymentMethod.toLowerCase() + '-checkout', + 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); + gift: Boolean(data.gift), + purchaseValue: amt, + }; + analytics.trackPurchase(analyticsData); } - if (data.gift){ - var byUsername = utils.getUserInfo(data.user, ['name']).name; - var gemAmount = data.gift.gems.amount || 20; + 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){ - utils.txnEmail(data.gift.member, 'gifted-gems', [ + if (data.gift.member.preferences.emailNotifications.giftedGems !== false) { + txnEmail(data.gift.member, 'gifted-gems', [ {name: 'GIFTER', content: byUsername}, - {name: 'X_GEMS_GIFTED', content: gemAmount} + {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); + 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);} + function saveGiftingUserData (cb2) { + data.user.save(cb2); + }, + function saveRecipientUserData (cb2) { + if (data.gift) { + data.gift.member.save(cb2); + } else { + 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){ +api.validCoupon = function validCoupon (req, res, next) { + mongoose.model('Coupon').findOne({_id: cc.validate(req.params.code), event: 'google_6mo'}, function couponErrorCheck (err, coupon) { if (err) return next(err); - if (!coupon) return res.status(401).json({err:"Invalid coupon code"}); + 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; +api.stripeCheckout = stripe.checkout; +api.stripeSubscribeCancel = stripe.subscribeCancel; +api.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; +api.paypalSubscribe = paypal.createBillingAgreement; +api.paypalSubscribeSuccess = paypal.executeBillingAgreement; +api.paypalSubscribeCancel = paypal.cancelSubscription; +api.paypalCheckout = paypal.createPayment; +api.paypalCheckoutSuccess = paypal.executePayment; +api.paypalIPN = paypal.ipn; -exports.amazonVerifyAccessToken = amazon.verifyAccessToken; -exports.amazonCreateOrderReferenceId = amazon.createOrderReferenceId; -exports.amazonCheckout = amazon.checkout; -exports.amazonSubscribe = amazon.subscribe; -exports.amazonSubscribeCancel = amazon.subscribeCancel; +api.amazonVerifyAccessToken = amazon.verifyAccessToken; +api.amazonCreateOrderReferenceId = amazon.createOrderReferenceId; +api.amazonCheckout = amazon.checkout; +api.amazonSubscribe = amazon.subscribe; +api.amazonSubscribeCancel = amazon.subscribeCancel; -exports.iapAndroidVerify = iap.androidVerify; -exports.iapIosVerify = iap.iosVerify; +api.iapAndroidVerify = iap.androidVerify; +api.iapIosVerify = iap.iosVerify; + +// module.exports = api; +module.exports = {}; // @TODO HEREHERE diff --git a/website/src/controllers/top-level/payments/paypal.js b/website/src/controllers/top-level/payments/paypal.js index 766ee85139..046a6f52cc 100644 --- a/website/src/controllers/top-level/payments/paypal.js +++ b/website/src/controllers/top-level/payments/paypal.js @@ -5,10 +5,10 @@ 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 logger = require('../../../libs/api-v2/logging'); var ipn = require('paypal-ipn'); var paypal = require('paypal-rest-sdk'); -var shared = require('../../../../common'); +var shared = require('../../../../../common'); var mongoose = require('mongoose'); var cc = require('coupon-code'); @@ -31,6 +31,7 @@ var parseErr = function(res, err){ return res.status(400).json({err:error}); } +/* exports.createBillingAgreement = function(req,res,next){ var sub = shared.content.subscriptionBlocks[req.query.sub]; async.waterfall([ @@ -190,11 +191,13 @@ exports.cancelSubscription = function(req, res, next){ 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 @@ -213,4 +216,4 @@ exports.ipn = function(req, res, next) { } }); }; - +*/ diff --git a/website/src/controllers/top-level/payments/stripe.js b/website/src/controllers/top-level/payments/stripe.js index 1a1085227c..765ccba8f6 100644 --- a/website/src/controllers/top-level/payments/stripe.js +++ b/website/src/controllers/top-level/payments/stripe.js @@ -1,123 +1,137 @@ -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'); +import nconf from 'nconf'; +import stripeModule from 'stripe'; +import async from 'async'; +import payments from './index'; +import { model as User } from '../../../models/user'; +import shared from '../../../../../common'; +import mongoose from 'mongoose'; +import cc from 'coupon-code'; +const stripe = stripeModule(nconf.get('STRIPE_API_KEY')); + +let api = {}; /* 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; +/* +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(cb){ + function stripeCharge (cb) { if (sub) { async.waterfall([ - function(cb2){ + 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); + mongoose.model('Coupon').findOne({_id: cc.validate(req.query.coupon), event: sub.key}, cb2); }, - function(coupon, cb2){ + function createCustomer (coupon, cb2) { if (sub.discount && !coupon) return cb2('Invalid coupon code.'); - var customer = { + let customer = { email: req.body.email, metadata: {uuid: user._id}, card: token, - plan: sub.key + plan: sub.key, }; stripe.customers.create(customer, cb2); - } + }, ], cb); } else { + let amount; + if (!gift) { + amount = '500'; + } else if (gift.type === 'subscription') { + amount = `${shared.content.subscriptionBlocks[gift.subscription.key].price * 100}`; + } else { + amount = `${gift.gems.amount / 4 * 100}`; + } stripe.charges.create({ - amount: !gift ? '500' //"500" = $5 - : gift.type=='subscription' ? ''+shared.content.subscriptionBlocks[gift.subscription.key].price*100 - : ''+gift.gems.amount/4*100, + amount, currency: 'usd', - card: token + card: token, }, cb); } }, - function(response, cb) { - if (sub) return payments.createSubscription({user:user, customerId:response.id, paymentMethod:'Stripe', sub:sub}, cb); + function saveUserData (response, cb) { + if (sub) return payments.createSubscription({user, customerId: response.id, paymentMethod: 'Stripe', 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'; + 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'; + if (gift.type === 'subscription') method = 'createSubscription'; data.paymentMethod = 'Gift'; } payments[method](data, cb2); - } + }, ], cb); - } - ], function(err){ + }, + ], 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; }); }; -exports.subscribeCancel = function(req, res, next) { - var user = res.locals.user; - if (!user.purchased.plan.customerId) +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({ - get_cus: function(cb){ + getCustomer: function getCustomer (cb) { stripe.customers.retrieve(user.purchased.plan.customerId, cb); }, - del_cus: ['get_cus', function(cb, results){ + deleteCustomer: ['getCustomer', function deleteCustomer (cb) { 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' + cancelSubscription: ['getCustomer', function cancelSubscription (cb, results) { + let data = { + user, + nextBill: results.get_cus.subscription.current_period_end * 1000, // timestamp is in seconds + paymentMethod: 'Stripe', }; payments.cancelSubscription(data, cb); - }] - }, function(err, results){ + }], + }, function handleResponse (err) { 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; +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(cb){ - stripe.customers.listSubscriptions(user_id, cb); + function listSubscriptions (cb) { + stripe.customers.listSubscriptions(userId, 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 updateSubscription (response, cb) { + subscriptionId = response.data[0].id; + stripe.customers.updateSubscription(userId, subscriptionId, { card: token }, cb); }, - function(response, cb) { + function saveUser (response, cb) { user.save(cb); - } - ], function(err, saved){ + }, + ], function handleResponse (err) { 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; + token = user = userId = subscriptionId; }); }; +*/ + +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..9cff7cc763 --- /dev/null +++ b/website/src/libs/api-v3/amazonPayments.js @@ -0,0 +1,38 @@ +import amazonPayments from 'amazon-payments'; +import nconf from 'nconf'; +import Q from 'q'; + +const IS_PROD = nconf.get('NODE_ENV') === 'production'; + +let api = {}; + +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'), +}); + +api.getTokenInfo = (token) => { + return new Promise((resolve, reject) => { + amzPayment.api.getTokenInfo(token, (err, tokenInfo) => { + if (err) return reject(err); + return resolve(tokenInfo); + }); + }); +}; + +api.createOrderReferenceId = (inputSet) => { + return new Promise((resolve, reject) => { + amzPayment.offAmazonPayments.createOrderReferenceForId(inputSet, (err, response) => { + if (err) return reject(err); + if (!response.OrderReferenceDetails || !response.OrderReferenceDetails.AmazonOrderReferenceId) { + return reject('missingAttributesFromAmazon'); + } + return resolve(response); + }); + }); +}; + +module.exports = api; From d27bbbe99423ea913e1b55e24d4bdde9271fac11 Mon Sep 17 00:00:00 2001 From: Victor Piousbox Date: Fri, 15 Apr 2016 15:40:44 +0000 Subject: [PATCH 18/34] some locales work --- common/locales/en/api-v3.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index b68a72b936..1d29c9e762 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -174,6 +174,5 @@ "resetComplete": "Reset has completed", "missingAccessToken": "The request is missing a required parameter : access_token", "missingBillingAgreementId": "Missing billing agreement id", - "missingAttributesFromAmazon": "Missing attributes from Amazon", - "errorFromAmazon": "Error from Amazon" + "missingAttributesFromAmazon": "Missing attributes from Amazon" } From 2bb36b5e0427f0ac40560462c325b7a4978131bc Mon Sep 17 00:00:00 2001 From: Victor Piousbox Date: Fri, 15 Apr 2016 16:14:14 +0000 Subject: [PATCH 19/34] things are broken with nconf --- tasks/gulp-tests.js | 2 +- test/api/v3/unit/libs/amazonPayments.test.js | 14 +++++++++++++- website/src/controllers/api-v3/auth.js | 3 +++ .../src/controllers/top-level/payments/index.js | 2 +- .../top-level/payments/paypalBillingSetup.js | 2 +- 5 files changed, 19 insertions(+), 4 deletions(-) diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js index fba9d85721..2cdc301b44 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/*'], ['test:api-v3:unit']); + gulp.watch(['website/src/libs/api-v3/*', 'test/api/v3/unit/libs/*', 'website/src/controllers/**/*'], ['test:api-v3:unit']); }); gulp.task('test:api-v3:integration', (done) => { diff --git a/test/api/v3/unit/libs/amazonPayments.test.js b/test/api/v3/unit/libs/amazonPayments.test.js index a60a8b4633..922029aae4 100644 --- a/test/api/v3/unit/libs/amazonPayments.test.js +++ b/test/api/v3/unit/libs/amazonPayments.test.js @@ -1,6 +1,7 @@ import * as amz from '../../../../../website/src/libs/api-v3/amazonPayments'; +import amazonPayments as amzStub from 'amazon-payments'; -describe.only('amazonPayments', () => { +describe('amazonPayments', () => { beforeEach(() => { }); @@ -14,6 +15,17 @@ describe.only('amazonPayments', () => { done(); } }); + + it.only('returns tokenInfo', (done) => { + let thisToken = 'this token info'; + let amzStubInstance = amzStub.connect({}); + amzStubInstance.api.getTokenInfo = (token, cb) => { + return cb(undefined, thisToken); + }; + let result = await amz.getTokenInfo; + console.log('+++ +++ result:', result); + expect(result).to.eql(thisToken); + }); }); describe('#createOrderReferenceId', () => { diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index 640003bb6d..aceaaa5590 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -2,6 +2,8 @@ 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'; @@ -459,6 +461,7 @@ api.updateEmail = { }, }; +// console.log('+++ ++ secret:', nconf.get('USER')); const firebaseTokenGenerator = new FirebaseTokenGenerator(nconf.get('FIREBASE:SECRET')); // Internal route diff --git a/website/src/controllers/top-level/payments/index.js b/website/src/controllers/top-level/payments/index.js index 5e413fd47c..6c9ddea60d 100644 --- a/website/src/controllers/top-level/payments/index.js +++ b/website/src/controllers/top-level/payments/index.js @@ -6,7 +6,7 @@ import { getUserInfo, sendTxn as txnEmail, } from '../../../libs/api-v3/email'; -import members from '../members'; +import members from '../../api-v3/members'; import moment from 'moment'; import mongoose from 'mongoose'; import nconf from 'nconf'; diff --git a/website/src/controllers/top-level/payments/paypalBillingSetup.js b/website/src/controllers/top-level/payments/paypalBillingSetup.js index 2effcbd81d..d8a2081a8e 100644 --- a/website/src/controllers/top-level/payments/paypalBillingSetup.js +++ b/website/src/controllers/top-level/payments/paypalBillingSetup.js @@ -7,7 +7,7 @@ var nconf = require('nconf'); _ = require('lodash'); nconf.argv().env().file('user', path.join(path.resolve(__dirname, '../../../config.json'))); var paypal = require('paypal-rest-sdk'); -var blocks = require('../../../../common').content.subscriptionBlocks; +var blocks = require('../../../../../common').content.subscriptionBlocks; var live = nconf.get('PAYPAL:mode')=='live'; var OP = 'create'; // list create update remove From 4a45fc1c37d11cd72fa52a6b75be3f1f0a106553 Mon Sep 17 00:00:00 2001 From: Victor Piousbox Date: Fri, 15 Apr 2016 16:40:24 +0000 Subject: [PATCH 20/34] working on amazon payment promisification --- test/api/v3/unit/libs/amazonPayments.test.js | 12 ++++++------ website/src/controllers/api-v3/auth.js | 5 ++--- website/src/controllers/api-v3/chat.js | 3 +++ website/src/libs/api-v3/amazonPayments.js | 1 - 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/test/api/v3/unit/libs/amazonPayments.test.js b/test/api/v3/unit/libs/amazonPayments.test.js index 922029aae4..7cc3683947 100644 --- a/test/api/v3/unit/libs/amazonPayments.test.js +++ b/test/api/v3/unit/libs/amazonPayments.test.js @@ -1,30 +1,30 @@ import * as amz from '../../../../../website/src/libs/api-v3/amazonPayments'; -import amazonPayments as amzStub from 'amazon-payments'; +import * as amzStub from 'amazon-payments'; describe('amazonPayments', () => { - beforeEach(() => { }); describe('#getTokenInfo', () => { it('validates access_token parameter', async (done) => { try { - let result = await amz.getTokenInfo(); + await amz.getTokenInfo(); } catch (e) { expect(e.type).to.eql('invalid_request'); done(); } }); - it.only('returns tokenInfo', (done) => { + it('returns tokenInfo', async (done) => { let thisToken = 'this token info'; let amzStubInstance = amzStub.connect({}); amzStubInstance.api.getTokenInfo = (token, cb) => { return cb(undefined, thisToken); }; - let result = await amz.getTokenInfo; - console.log('+++ +++ result:', result); + let result = await amz.getTokenInfo(); + // console.log('+++ +++ result:', result); expect(result).to.eql(thisToken); + done(); }); }); diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index aceaaa5590..050b36455b 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -2,8 +2,8 @@ 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 setupNconf from '../../libs/api-v3/setupNconf'; +setupNconf(); import { authWithHeaders, } from '../../middlewares/api-v3/auth'; @@ -461,7 +461,6 @@ api.updateEmail = { }, }; -// console.log('+++ ++ secret:', nconf.get('USER')); const firebaseTokenGenerator = new FirebaseTokenGenerator(nconf.get('FIREBASE:SECRET')); // Internal route diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index 24e4ea8bab..8a8e433aad 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -12,7 +12,10 @@ 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(); +console.log('+++ +++ this:', nconf.get('FLAG_REPORT_EMAIL')); const FLAG_REPORT_EMAILS = nconf.get('FLAG_REPORT_EMAIL').split(',').map((email) => { return { email, canSend: true }; }); diff --git a/website/src/libs/api-v3/amazonPayments.js b/website/src/libs/api-v3/amazonPayments.js index 9cff7cc763..e89272287b 100644 --- a/website/src/libs/api-v3/amazonPayments.js +++ b/website/src/libs/api-v3/amazonPayments.js @@ -1,6 +1,5 @@ import amazonPayments from 'amazon-payments'; import nconf from 'nconf'; -import Q from 'q'; const IS_PROD = nconf.get('NODE_ENV') === 'production'; From ee6092d7d262bd6bdc9b91de6af46e32de1be8be Mon Sep 17 00:00:00 2001 From: Sabe Jones Date: Fri, 15 Apr 2016 21:31:40 +0000 Subject: [PATCH 21/34] refactor(payments): PayPal setup linting pass --- .../top-level/payments/paypalBillingSetup.js | 135 +++++++++--------- 1 file changed, 70 insertions(+), 65 deletions(-) diff --git a/website/src/controllers/top-level/payments/paypalBillingSetup.js b/website/src/controllers/top-level/payments/paypalBillingSetup.js index d8a2081a8e..0e8170ff51 100644 --- a/website/src/controllers/top-level/payments/paypalBillingSetup.js +++ b/website/src/controllers/top-level/payments/paypalBillingSetup.js @@ -2,91 +2,96 @@ // 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'); +import path from 'path'; +import nconf from 'nconf'; +import _ from 'lodash'; +import paypal from 'paypal-rest-sdk'; +import shared from '../../../../../common'; + +let blocks = shared.content.subscriptionBlocks; +const BILLING_PLAN_TITLE = 'Habitica Subscription'; +const LIVE = nconf.get('PAYPAL:mode') === 'live'; +const OP = 'create'; // list create update remove + nconf.argv().env().file('user', path.join(path.resolve(__dirname, '../../../config.json'))); -var paypal = require('paypal-rest-sdk'); -var blocks = require('../../../../../common').content.subscriptionBlocks; -var live = nconf.get('PAYPAL:mode')=='live'; - -var OP = 'create'; // list create update remove +/* 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'), }); // https://developer.paypal.com/docs/api/#billing-plans-and-agreements -var billingPlanTitle ="Habitica Subscription"; -var billingPlanAttributes = { - "name": billingPlanTitle, - "description": billingPlanTitle, - "type": "INFINITE", - "merchant_preferences": { - "auto_bill_amount": "yes", - "cancel_url": live ? 'https://habitica.com' : 'http://localhost:3000', - "return_url": (live ? 'https://habitica.com' : 'http://localhost:3000') + '/paypal/subscribe/success' +let billingPlanAttributes = { + name: BILLING_PLAN_TITLE, + description: BILLING_PLAN_TITLE, + type: 'INFINITE', + merchant_preferences: { + auto_bill_amount: 'yes', + cancel_url: LIVE ? 'https://habitica.com' : 'http://localhost:3000', + return_url: LIVE ? 'https://habitica.com/paypal/subscribe/success' : 'http://localhost:3000/paypal/subscribe/success', }, payment_definitions: [{ - "type": "REGULAR", - "frequency": "MONTH", - "cycles": "0" - }] + type: 'REGULAR', + frequency: 'MONTH', + cycles: '0', + }], }; -_.each(blocks, function(block){ +_.each(blocks, function defineBlock (block) { block.definition = _.cloneDeep(billingPlanAttributes); _.merge(block.definition.payment_definitions[0], { - "name": billingPlanTitle + ' ($'+block.price+' every '+block.months+' months, recurring)', - "frequency_interval": ""+block.months, - "amount": { - "currency": "USD", - "value": ""+block.price - } + name: `${BILLING_PLAN_TITLE} (\$${block.price} every ${block.months} months, recurring)`, + frequency_interval: `${block.months}`, + amount: { + currency: 'USD', + value: `${block.price}`, + }, }); -}) +}); -switch(OP) { - case "list": - paypal.billingPlan.list({status: 'ACTIVE'}, function(err, plans){ - console.log({err:err, plans:plans}); +let update = { + op: 'replace', + path: '/merchant_preferences', + value: { + cancel_url: 'https://habitica.com', + }, +}; + +switch (OP) { + case 'list': + paypal.billingPlan.list({status: 'ACTIVE'}, function listPlans () { + // TODO Was a console.log statement. Need proper response output }); break; - case "get": - paypal.billingPlan.get(nconf.get("PAYPAL:billing_plans:12"), function (err, plan) { - console.log({err:err, plan:plan}); - }) - break; - case "update": - var update = { - "op": "replace", - "path": "/merchant_preferences", - "value": { - "cancel_url": "https://habitica.com" - } - }; - paypal.billingPlan.update(nconf.get("PAYPAL:billing_plans:12"), update, function (err, res) { - console.log({err:err, plan:res}); + case 'get': + paypal.billingPlan.get(nconf.get('PAYPAL:billing_plans:12'), function getPlan () { + // TODO Was a console.log statement. Need proper response output }); break; - case "create": - paypal.billingPlan.create(blocks["google_6mo"].definition, function(err,plan){ - if (err) return console.log(err); - if (plan.state == "ACTIVE") - return console.log({err:err, plan:plan}); - var billingPlanUpdateAttributes = [{ - "op": "replace", - "path": "/", - "value": { - "state": "ACTIVE" - } + case 'update': + paypal.billingPlan.update(nconf.get('PAYPAL:billing_plans:12'), update, function updatePlan () { + // TODO Was a console.log statement. Need proper response output + }); + break; + case 'create': + paypal.billingPlan.create(blocks.google_6mo.definition, function createPlan (err, plan) { + if (err) return; // TODO Was a console.log statement. Need proper response output + if (plan.state === 'ACTIVE') + return; // TODO Was a console.log statement. Need proper response output + let billingPlanUpdateAttributes = [{ + op: 'replace', + path: '/', + value: { + state: 'ACTIVE', + }, }]; // Activate the plan by changing status to Active - paypal.billingPlan.update(plan.id, billingPlanUpdateAttributes, function(err, response){ - console.log({err:err, response:response, id:plan.id}); + paypal.billingPlan.update(plan.id, billingPlanUpdateAttributes, function activatePlan () { + // TODO Was a console.log statement. Need proper response output }); }); break; - case "remove": break; + case 'remove': break; } +/* eslint-enable camelcase */ From 62b059d4d80877e50026a3cb560865cef1f5f65a Mon Sep 17 00:00:00 2001 From: Victor Piousbox Date: Wed, 20 Apr 2016 22:27:37 +0000 Subject: [PATCH 22/34] little changes, no console.log --- website/src/controllers/api-v3/chat.js | 1 - 1 file changed, 1 deletion(-) diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index 2b20f80cf5..6aa8319f7c 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -15,7 +15,6 @@ import nconf from 'nconf'; import setupNconf from '../../libs/api-v3/setupNconf'; setupNconf(); -console.log('+++ +++ this:', nconf.get('FLAG_REPORT_EMAIL')); const FLAG_REPORT_EMAILS = nconf.get('FLAG_REPORT_EMAIL').split(',').map((email) => { return { email, canSend: true }; }); From f9915c3f77fca08924819f978fe4abd03a3164e6 Mon Sep 17 00:00:00 2001 From: Victor Piousbox Date: Thu, 21 Apr 2016 00:07:58 +0000 Subject: [PATCH 23/34] recovering last weeks work on promisifying amazon payments --- .eslintignore | 2 +- .eslintrc | 5 ++- test/api/v3/unit/libs/amazonPayments.test.js | 32 +++++++++++++------ .../controllers/top-level/payments/amazon.js | 10 +++--- .../src/controllers/top-level/payments/iap.js | 8 ++--- .../controllers/top-level/payments/paypal.js | 5 ++- .../controllers/top-level/payments/stripe.js | 6 ++-- website/src/libs/api-v3/amazonPayments.js | 18 +++++++---- 8 files changed, 51 insertions(+), 35 deletions(-) diff --git a/.eslintignore b/.eslintignore index 93a77392f1..2acf38ea8d 100644 --- a/.eslintignore +++ b/.eslintignore @@ -19,7 +19,7 @@ website/src/routes/payments.js website/src/routes/pages.js website/src/middlewares/apiThrottle.js website/src/middlewares/forceRefresh.js -website/src/controllers/payments/ +website/src/controllers/top-level/payments/paypal.js debug-scripts/* tasks/*.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/test/api/v3/unit/libs/amazonPayments.test.js b/test/api/v3/unit/libs/amazonPayments.test.js index 7cc3683947..a4548af66b 100644 --- a/test/api/v3/unit/libs/amazonPayments.test.js +++ b/test/api/v3/unit/libs/amazonPayments.test.js @@ -1,11 +1,29 @@ import * as amz from '../../../../../website/src/libs/api-v3/amazonPayments'; -import * as amzStub from 'amazon-payments'; +// import * as amzStub from 'amazon-payments'; +import amazonPayments from 'amazon-payments'; describe('amazonPayments', () => { beforeEach(() => { }); - describe('#getTokenInfo', () => { + describe('#getTokenInfo stubbed', () => { + let thisToken = 'this token info'; + let amzOldConnect; + + beforeEach(() => { + amzOldConnect = amazonPayments.connect; + amazonPayments.connect = () => { + let api = { getTokenInfo: (token, cb) => { + return cb(undefined, thisToken); + } }; + return { api }; + }; + }); + + afterEach(() => { + amazonPayments.connect = amzOldConnect; + }); + it('validates access_token parameter', async (done) => { try { await amz.getTokenInfo(); @@ -16,21 +34,15 @@ describe('amazonPayments', () => { }); it('returns tokenInfo', async (done) => { - let thisToken = 'this token info'; - let amzStubInstance = amzStub.connect({}); - amzStubInstance.api.getTokenInfo = (token, cb) => { - return cb(undefined, thisToken); - }; let result = await amz.getTokenInfo(); - // console.log('+++ +++ result:', result); expect(result).to.eql(thisToken); done(); }); }); describe('#createOrderReferenceId', () => { - it('is sane', () => { - expect(false).to.eql(true); // @TODO + it('succeeds', () => { }); }); + }); diff --git a/website/src/controllers/top-level/payments/amazon.js b/website/src/controllers/top-level/payments/amazon.js index 63b589f6fc..a2316f21e6 100644 --- a/website/src/controllers/top-level/payments/amazon.js +++ b/website/src/controllers/top-level/payments/amazon.js @@ -1,13 +1,13 @@ -import async from 'async'; +/* import async from 'async'; import cc from 'coupon-code'; import mongoose from 'mongoose'; import moment from 'moment'; import payments from './index'; import shared from '../../../../../common'; -import { model as User } from '../../../models/user'; +import { model as User } from '../../../models/user'; */ import { - NotFound, - NotAuthorized, + // NotFound, + // NotAuthorized, BadRequest, } from '../../../libs/api-v3/errors'; import amz from '../../../libs/api-v3/amazonPayments'; @@ -29,7 +29,7 @@ api.verifyAccessToken = { await amz.getTokenInfo(req.body.access_token) .then(() => { res.respond(200, {}); - }).catch( (error) => { + }).catch((error) => { throw new BadRequest(error.body.error_description); }); }, diff --git a/website/src/controllers/top-level/payments/iap.js b/website/src/controllers/top-level/payments/iap.js index 5de66b0452..898b0b2015 100644 --- a/website/src/controllers/top-level/payments/iap.js +++ b/website/src/controllers/top-level/payments/iap.js @@ -1,6 +1,4 @@ import iap from 'in-app-purchase'; -import whatThis from 'in-app-purchase'; -import payments from './index'; import nconf from 'nconf'; iap.config({ @@ -9,9 +7,9 @@ iap.config({ }); // Validation ERROR Codes -const INVALID_PAYLOAD = 6778001; -/* const CONNECTION_FAILED = 6778002; -const PURCHASE_EXPIRED = 6778003; */ // These variables were never used?? +// const INVALID_PAYLOAD = 6778001; +// const CONNECTION_FAILED = 6778002; +// const PURCHASE_EXPIRED = 6778003; let api = {}; diff --git a/website/src/controllers/top-level/payments/paypal.js b/website/src/controllers/top-level/payments/paypal.js index 046a6f52cc..05e8594ac1 100644 --- a/website/src/controllers/top-level/payments/paypal.js +++ b/website/src/controllers/top-level/payments/paypal.js @@ -25,7 +25,7 @@ paypal.configure({ 'client_secret': nconf.get("PAYPAL:client_secret") }); -var parseErr = function(res, err){ +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}); @@ -190,8 +190,7 @@ exports.cancelSubscription = function(req, res, next){ res.redirect('/'); user = null; }); -} -*/ +} // */ /** * General IPN handler. We catch cancelled HabitRPG subscriptions for users who manually cancel their diff --git a/website/src/controllers/top-level/payments/stripe.js b/website/src/controllers/top-level/payments/stripe.js index 765ccba8f6..34bc598b3e 100644 --- a/website/src/controllers/top-level/payments/stripe.js +++ b/website/src/controllers/top-level/payments/stripe.js @@ -1,13 +1,13 @@ -import nconf from 'nconf'; +/* import nconf from 'nconf'; import stripeModule from 'stripe'; import async from 'async'; import payments from './index'; import { model as User } from '../../../models/user'; import shared from '../../../../../common'; import mongoose from 'mongoose'; -import cc from 'coupon-code'; +import cc from 'coupon-code'; */ -const stripe = stripeModule(nconf.get('STRIPE_API_KEY')); +// const stripe = stripeModule(nconf.get('STRIPE_API_KEY')); let api = {}; /* diff --git a/website/src/libs/api-v3/amazonPayments.js b/website/src/libs/api-v3/amazonPayments.js index e89272287b..e469d1c121 100644 --- a/website/src/libs/api-v3/amazonPayments.js +++ b/website/src/libs/api-v3/amazonPayments.js @@ -5,15 +5,18 @@ const IS_PROD = nconf.get('NODE_ENV') === 'production'; let api = {}; -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'), -}); +function connect (amazonPayments) { + return 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'), + }); +} api.getTokenInfo = (token) => { + let amzPayment = connect(amazonPayments); return new Promise((resolve, reject) => { amzPayment.api.getTokenInfo(token, (err, tokenInfo) => { if (err) return reject(err); @@ -23,6 +26,7 @@ api.getTokenInfo = (token) => { }; api.createOrderReferenceId = (inputSet) => { + let amzPayment = connect(amazonPayments); return new Promise((resolve, reject) => { amzPayment.offAmazonPayments.createOrderReferenceForId(inputSet, (err, response) => { if (err) return reject(err); From 612e3b725f541d23906788246d229aa3ba9c305b Mon Sep 17 00:00:00 2001 From: Victor Piousbox Date: Thu, 21 Apr 2016 00:14:14 +0000 Subject: [PATCH 24/34] little changes to lint --- test/api/v3/unit/libs/amazonPayments.test.js | 15 ++++++++------- website/src/libs/api-v3/amazonPayments.js | 2 +- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/test/api/v3/unit/libs/amazonPayments.test.js b/test/api/v3/unit/libs/amazonPayments.test.js index a4548af66b..aa5a9588c6 100644 --- a/test/api/v3/unit/libs/amazonPayments.test.js +++ b/test/api/v3/unit/libs/amazonPayments.test.js @@ -24,6 +24,14 @@ describe('amazonPayments', () => { amazonPayments.connect = amzOldConnect; }); + it('returns tokenInfo', async (done) => { + let result = await amz.getTokenInfo(); + expect(result).to.eql(thisToken); + done(); + }); + }); + + describe('#getTokenInfo', () => { it('validates access_token parameter', async (done) => { try { await amz.getTokenInfo(); @@ -32,17 +40,10 @@ describe('amazonPayments', () => { done(); } }); - - it('returns tokenInfo', async (done) => { - let result = await amz.getTokenInfo(); - expect(result).to.eql(thisToken); - done(); - }); }); describe('#createOrderReferenceId', () => { it('succeeds', () => { }); }); - }); diff --git a/website/src/libs/api-v3/amazonPayments.js b/website/src/libs/api-v3/amazonPayments.js index e469d1c121..c7bf5202b3 100644 --- a/website/src/libs/api-v3/amazonPayments.js +++ b/website/src/libs/api-v3/amazonPayments.js @@ -5,7 +5,7 @@ const IS_PROD = nconf.get('NODE_ENV') === 'production'; let api = {}; -function connect (amazonPayments) { +function connect (amazonPayments) { // eslint-disable-line no-shadow return amazonPayments.connect({ environment: amazonPayments.Environment[IS_PROD ? 'Production' : 'Sandbox'], sellerId: nconf.get('AMAZON_PAYMENTS:SELLER_ID'), From 54a94db2de729049e3d70f71a509192640d4d357 Mon Sep 17 00:00:00 2001 From: Victor Piousbox Date: Thu, 21 Apr 2016 03:52:29 +0000 Subject: [PATCH 25/34] working on amazon payments --- common/locales/en/api-v3.json | 3 +- ...ayments_amazon_verify_access_token.test.js | 2 +- test/api/v3/unit/libs/amazonPayments.test.js | 62 ++++++++- .../controllers/top-level/payments/amazon.js | 123 +++++++++--------- website/src/libs/api-v3/amazonPayments.js | 56 +++++++- 5 files changed, 175 insertions(+), 71 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 8c0ec78de0..5b1d57383e 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -173,5 +173,6 @@ "equipmentAlreadyOwned": "You already own that piece of equipment", "missingAccessToken": "The request is missing a required parameter : access_token", "missingBillingAgreementId": "Missing billing agreement id", - "missingAttributesFromAmazon": "Missing attributes from Amazon" + "missingAttributesFromAmazon": "Missing attributes from Amazon", + "paymentNotSuccessful": "The payment was not successful" } diff --git a/test/api/v3/integration/payments/POST-payments_amazon_verify_access_token.test.js b/test/api/v3/integration/payments/POST-payments_amazon_verify_access_token.test.js index 494c387f14..ecc021e25d 100644 --- a/test/api/v3/integration/payments/POST-payments_amazon_verify_access_token.test.js +++ b/test/api/v3/integration/payments/POST-payments_amazon_verify_access_token.test.js @@ -11,7 +11,7 @@ describe('payments : amazon', () => { user = await generateUser(); }); - it('verify access token', async () => { + it('verifies access token', async () => { await expect(user.post(endpoint)).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', diff --git a/test/api/v3/unit/libs/amazonPayments.test.js b/test/api/v3/unit/libs/amazonPayments.test.js index aa5a9588c6..9529c3e985 100644 --- a/test/api/v3/unit/libs/amazonPayments.test.js +++ b/test/api/v3/unit/libs/amazonPayments.test.js @@ -1,6 +1,7 @@ -import * as amz from '../../../../../website/src/libs/api-v3/amazonPayments'; +import * as amzLib from '../../../../../website/src/libs/api-v3/amazonPayments'; // import * as amzStub from 'amazon-payments'; import amazonPayments from 'amazon-payments'; +var User = require('mongoose').model('User'); describe('amazonPayments', () => { beforeEach(() => { @@ -25,7 +26,7 @@ describe('amazonPayments', () => { }); it('returns tokenInfo', async (done) => { - let result = await amz.getTokenInfo(); + let result = await amzLib.getTokenInfo(); expect(result).to.eql(thisToken); done(); }); @@ -34,7 +35,7 @@ describe('amazonPayments', () => { describe('#getTokenInfo', () => { it('validates access_token parameter', async (done) => { try { - await amz.getTokenInfo(); + await amzLib.getTokenInfo(); } catch (e) { expect(e.type).to.eql('invalid_request'); done(); @@ -43,7 +44,60 @@ describe('amazonPayments', () => { }); describe('#createOrderReferenceId', () => { - it('succeeds', () => { + it('verifies billingAgreementId', async (done) => { + try { + let inputSet = {}; + delete inputSet.Id; + await amzLib.createOrderReferenceId(inputSet); + } catch (e) { + + /* console.log('error!', e); + console.log('error keys!', Object.keys(e)); + for (var key in e) { + console.log(e[key]); + } // */ + + expect(e.type).to.eql('InvalidParameterValue'); + expect(e.body.ErrorResponse.Error.Message).to.eql('Parameter AWSAccessKeyId cannot be empty.'); + done(); + } + }); + + xit('succeeds', () => { }); }); + + describe('#checkout', () => { + xit('succeeds'); + }); + + describe('#setOrderReferenceDetails', () => { + xit('succeeds'); + }); + + describe('#confirmOrderReference', () => { + xit('succeeds'); + }); + + describe('#authorize', () => { + xit('succeeds'); + + xit('was declined'); + + xit('had an error'); + }); + + describe('#closeOrderReference', () => { + xit('succeeds'); + }); + + describe.only('#executePayment', () => { + it('succeeds', () => { + }); + + it('succeeds as a gift', () => { + }); + }); + + }); diff --git a/website/src/controllers/top-level/payments/amazon.js b/website/src/controllers/top-level/payments/amazon.js index a2316f21e6..16c835c2c3 100644 --- a/website/src/controllers/top-level/payments/amazon.js +++ b/website/src/controllers/top-level/payments/amazon.js @@ -10,7 +10,8 @@ import { // NotAuthorized, BadRequest, } from '../../../libs/api-v3/errors'; -import amz from '../../../libs/api-v3/amazonPayments'; +import amzLib from '../../../libs/api-v3/amazonPayments'; +import { authWithHeaders } from '../../../middlewares/api-v3/auth'; let api = {}; @@ -26,7 +27,7 @@ api.verifyAccessToken = { method: 'POST', url: '/payments/amazon/verifyAccessToken', async handler (req, res) { - await amz.getTokenInfo(req.body.access_token) + await amzLib.getTokenInfo(req.body.access_token) .then(() => { res.respond(200, {}); }).catch((error) => { @@ -46,47 +47,59 @@ api.verifyAccessToken = { api.createOrderReferenceId = { method: 'POST', url: '/payments/amazon/createOrderReferenceId', + // middlewares: [authWithHeaders()], async handler (req, res) { - if (!req.body.billingAgreementId) { - throw new BadRequest(res.t('missingBillingAgreementId')); - } - let response = await amz.createOrderReferenceId({ - Id: req.body.billingAgreementId, - IdType: 'BillingAgreement', - ConfirmNow: false, - }).then(() => { + try { + let response = await amzLib.createOrderReferenceId({ + Id: req.body.billingAgreementId, + IdType: 'BillingAgreement', + ConfirmNow: false, + AWSAccessKeyId: 'something', + }); res.respond(200, { orderReferenceId: response.OrderReferenceDetails.AmazonOrderReferenceId, }); - }).catch(errStr => { - throw new BadRequest(res.t(errStr)); - }); + } catch (error) { + throw new BadRequest(error); + } + }, }; -/* -api.checkout = function checkout (req, res, next) { - if (!req.body || !req.body.orderReferenceId) { - return res.status(400).json({err: 'Billing Agreement Id not supplied.'}); - } +/** + * @api {post} /api/v3/payments/amazon/checkout do checkout + * @apiVersion 3.0.0 + * @apiName AmazonCheckout + * @apiGroup Payments + * + * @apiParam {string} billingAgreementId billing agreement id + * @apiSuccess {object} object containing { orderReferenceId } + **/ +api.checkout = { + method: 'POST', + url: '/payments/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; - let gift = req.body.gift; - let user = res.locals.user; - let orderReferenceId = req.body.orderReferenceId; - let 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; + 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 (cb) { - amzPayment.offAmazonPayments.setOrderReferenceDetails({ + /* if (!req.body || !req.body.orderReferenceId) { + return res.status(400).json({err: 'Billing Agreement Id not supplied.'}); + } */ + + try { + await amzLib.setOrderReferenceDetails({ AmazonOrderReferenceId: orderReferenceId, OrderReferenceAttributes: { OrderTotal: { @@ -99,17 +112,11 @@ api.checkout = function checkout (req, res, next) { StoreName: 'HabitRPG', }, }, - }, cb); - }, + }); - confirmOrderReference (cb) { - amzPayment.offAmazonPayments.confirmOrderReference({ - AmazonOrderReferenceId: orderReferenceId, - }, cb); - }, + await amzLib.confirmOrderReference({ AmazonOrderReferenceId: orderReferenceId }); - authorize (cb) { - amzPayment.offAmazonPayments.authorize({ + await amzLib.authorize({ AmazonOrderReferenceId: orderReferenceId, AuthorizationReferenceId: shared.uuid().substring(0, 32), AuthorizationAmount: { @@ -119,23 +126,16 @@ api.checkout = function checkout (req, res, next) { SellerAuthorizationNote: 'HabitRPG Payment', TransactionTimeout: 0, CaptureNow: true, - }, function checkAuthorizationStatus (err) { - if (err) return cb(err); - - if (res.AuthorizationDetails.AuthorizationStatus.State === 'Declined') { - return cb(new Error('The payment was not successfull.')); - } - - return cb(); }); - }, - closeOrderReference (cb) { - amzPayment.offAmazonPayments.closeOrderReference({ - AmazonOrderReferenceId: orderReferenceId, - }, cb); - }, + await amzLib.closeOrderReference({ AmazonOrderReferenceId: orderReferenceId }); + res.respond(200); + } catch(error) { + throw new BadRequest(error); + } + + /* executePayment (cb) { async.waterfall([ function findUser (cb2) { @@ -153,16 +153,13 @@ api.checkout = function checkout (req, res, next) { } payments[method](data, cb2); - }, - ], cb); - }, - }, function result (err) { - if (err) return next(err); - - res.sendStatus(200); - }); + }, */ + }, }; + + +/* api.subscribe = function subscribe (req, res, next) { if (!req.body || !req.body.billingAgreementId) { return res.status(400).json({err: 'Billing Agreement Id not supplied.'}); diff --git a/website/src/libs/api-v3/amazonPayments.js b/website/src/libs/api-v3/amazonPayments.js index c7bf5202b3..3d92132f2f 100644 --- a/website/src/libs/api-v3/amazonPayments.js +++ b/website/src/libs/api-v3/amazonPayments.js @@ -1,6 +1,7 @@ import amazonPayments from 'amazon-payments'; import nconf from 'nconf'; - +import common from '../../../../common'; +let t = common.i18n.t; const IS_PROD = nconf.get('NODE_ENV') === 'production'; let api = {}; @@ -31,11 +32,62 @@ api.createOrderReferenceId = (inputSet) => { amzPayment.offAmazonPayments.createOrderReferenceForId(inputSet, (err, response) => { if (err) return reject(err); if (!response.OrderReferenceDetails || !response.OrderReferenceDetails.AmazonOrderReferenceId) { - return reject('missingAttributesFromAmazon'); + return reject(t('missingAttributesFromAmazon')); } return resolve(response); }); }); }; +api.setOrderReferenceDetails = (inputSet) => { + let amzPayment = connect(amazonPayments); + return new Promise((resolve, reject) => { + amzPayment.offAmazonPayments.setOrderReferenceDetails(inputSet, (err, response) => { + if (err) return reject(err); + return resolve(response); + }); + }); +}; + +api.confirmOrderReference = (inputSet) => { + let amzPayment = connect(amazonPayments); + return new Promise((resolve, reject) => { + amzPayment.offAmazonPayments.confirmOrderReference(inputSet, (err, response) => { + if (err) return reject(err); + return resolve(response); + }); + }); +}; + +api.authorize = (inputSet) => { + let amzPayment = connect(amazonPayments); + return new Promize((resolve, reject) => { + amzPayment.offAmazonPayments.authorize(inputSet, (err, response) => { + if (err) return reject(err); + if (response.AuthorizationDetails.AuthorizationStatus.State === 'Declined') return reject(t('paymentNotSuccessful')); + return resolve(response); + }); + }); +}; + +api.closeOrderReference = (inputSet) => { + let amzPayment = connect(amazonPayments); + return new Promize((resolve, reject) => { + amzPayment.offAmazonPayments.closeOrderReference(inputSet, (err, response) => { + if (err) return reject(err); + return resolve(response); + }); + }); +}; + +api.executePayment = (inputSet) => { + let amzPayment = connect(amazonPayments); + return new Promize((resolve, reject) => { + amzPayment.offAmazonPayments.closeOrderReference(inputSet, (err, response) => { + if (err) return reject(err); + return resolve(response); + }); + }); +}; + module.exports = api; From cbf1a4c8d32e82765ff07cb4e29fc596de4d91e7 Mon Sep 17 00:00:00 2001 From: Victor Piousbox Date: Thu, 21 Apr 2016 04:37:25 +0000 Subject: [PATCH 26/34] breaking changes promisifying amazon payments --- test/api/v3/unit/libs/amazonPayments.test.js | 2 +- test/api/v3/unit/libs/paymentsIndex.test.js | 11 ++++++ .../controllers/top-level/payments/amazon.js | 34 +++++++------------ .../controllers/top-level/payments/index.js | 9 +++-- 4 files changed, 32 insertions(+), 24 deletions(-) create mode 100644 test/api/v3/unit/libs/paymentsIndex.test.js diff --git a/test/api/v3/unit/libs/amazonPayments.test.js b/test/api/v3/unit/libs/amazonPayments.test.js index 9529c3e985..b2bf480e01 100644 --- a/test/api/v3/unit/libs/amazonPayments.test.js +++ b/test/api/v3/unit/libs/amazonPayments.test.js @@ -92,7 +92,7 @@ describe('amazonPayments', () => { }); describe.only('#executePayment', () => { - it('succeeds', () => { + it('succeeds not as a gift', () => { }); it('succeeds as a gift', () => { diff --git a/test/api/v3/unit/libs/paymentsIndex.test.js b/test/api/v3/unit/libs/paymentsIndex.test.js new file mode 100644 index 0000000000..74b9d29273 --- /dev/null +++ b/test/api/v3/unit/libs/paymentsIndex.test.js @@ -0,0 +1,11 @@ + +describe('payments/index', () => { + beforeEach(() => { + }); + + describe('#createSubscription', async () => { + }); + + describe('#buyGems', async () => { + }); +}); diff --git a/website/src/controllers/top-level/payments/amazon.js b/website/src/controllers/top-level/payments/amazon.js index 16c835c2c3..785efb06b9 100644 --- a/website/src/controllers/top-level/payments/amazon.js +++ b/website/src/controllers/top-level/payments/amazon.js @@ -12,6 +12,7 @@ import { } from '../../../libs/api-v3/errors'; import amzLib from '../../../libs/api-v3/amazonPayments'; import { authWithHeaders } from '../../../middlewares/api-v3/auth'; +var payments = require('./index'); let api = {}; @@ -130,31 +131,22 @@ api.checkout = { await amzLib.closeOrderReference({ AmazonOrderReferenceId: orderReferenceId }); + // execute payment + let giftUser = await User.findById(gift ? gift.uuid : undefined); + let data = { giftUser, paymentMethod: 'Amazon Payments' }; + let method = 'buyGems'; + if (gift) { + if (gift.type === 'subscription') method = 'createSubscription'; + gift.member = giftUser; + data.gift = gift; + data.paymentMethod = 'Gift'; + } + await payments[method](data); + res.respond(200); } catch(error) { throw new BadRequest(error); } - - /* - executePayment (cb) { - async.waterfall([ - function findUser (cb2) { - User.findById(gift ? gift.uuid : undefined, cb2); - }, - function executeAmazonPayment (member, cb2) { - let data = {user, paymentMethod: 'Amazon Payments'}; - let method = 'buyGems'; - - if (gift) { - if (gift.type === 'subscription') method = 'createSubscription'; - gift.member = member; - data.gift = gift; - data.paymentMethod = 'Gift'; - } - - payments[method](data, cb2); - }, */ - }, }; diff --git a/website/src/controllers/top-level/payments/index.js b/website/src/controllers/top-level/payments/index.js index 6c9ddea60d..6ee56bf0d2 100644 --- a/website/src/controllers/top-level/payments/index.js +++ b/website/src/controllers/top-level/payments/index.js @@ -36,6 +36,9 @@ function revealMysteryItems (user) { }); } +// @TODO: HEREHERE +api.createSubscription = async function createSubscription (data) { +} api.createSubscription = function createSubscription (data, cb) { let recipient = data.gift ? data.gift.member : data.user; let plan = recipient.purchased.plan; @@ -150,6 +153,9 @@ api.cancelSubscription = function cancelSubscription (data, cb) { analytics.track('unsubscribe', analyticsData); }; +// @TODO: HEREHERE +api.buyGems = async function buyGems (data) { +}; api.buyGems = function buyGems (data, cb) { let amt = data.amount || 5; amt = data.gift ? data.gift.gems.amount / 4 : amt; @@ -229,5 +235,4 @@ api.amazonSubscribeCancel = amazon.subscribeCancel; api.iapAndroidVerify = iap.androidVerify; api.iapIosVerify = iap.iosVerify; -// module.exports = api; -module.exports = {}; // @TODO HEREHERE +module.exports = api; From fa21577c46600b0a40fe11bae209aa27815d0ecc Mon Sep 17 00:00:00 2001 From: Victor Pudeyev Date: Wed, 27 Apr 2016 14:26:32 -0500 Subject: [PATCH 27/34] V3 payments 6 (#7104) * payments api: cancelSubscription * some more tests for amazon payments * promisifying amazon payments * somehow payment stub is not working * cleaning up tests * renaming tests in api/v3/integration/payments * improvements * cleanup, lint * fixes as per comments * moment.zone() is back in. --- common/locales/en/api-v3.json | 3 +- tasks/gulp-tests.js | 3 +- ...ET-payments_amazon_subscribeCancel.test.js | 21 ++ .../POST-payments_amazon_checkout.test.js | 22 ++ ...ents_amazon_createOrderReferenceId.test.js | 22 ++ .../POST-payments_amazon_subscribe.test.js | 21 ++ ...payments_amazon_verifyAccessToken.test.js} | 0 test/api/v3/unit/libs/amazonPayments.test.js | 103 ---------- test/api/v3/unit/libs/payments.test.js | 75 +++++++ test/api/v3/unit/libs/paymentsIndex.test.js | 11 - website/src/controllers/api-v3/auth.js | 2 - website/src/controllers/api-v3/chat.js | 2 - .../controllers/top-level/payments/amazon.js | 193 ++++++++---------- .../controllers/top-level/payments/paypal.js | 2 +- .../controllers/top-level/payments/stripe.js | 2 +- website/src/libs/api-v3/amazonPayments.js | 103 ++++------ .../index.js => libs/api-v3/payments.js} | 89 +++----- 17 files changed, 319 insertions(+), 355 deletions(-) create mode 100644 test/api/v3/integration/payments/GET-payments_amazon_subscribeCancel.test.js create mode 100644 test/api/v3/integration/payments/POST-payments_amazon_checkout.test.js create mode 100644 test/api/v3/integration/payments/POST-payments_amazon_createOrderReferenceId.test.js create mode 100644 test/api/v3/integration/payments/POST-payments_amazon_subscribe.test.js rename test/api/v3/integration/payments/{POST-payments_amazon_verify_access_token.test.js => POST-payments_amazon_verifyAccessToken.test.js} (100%) delete mode 100644 test/api/v3/unit/libs/amazonPayments.test.js create mode 100644 test/api/v3/unit/libs/payments.test.js delete mode 100644 test/api/v3/unit/libs/paymentsIndex.test.js rename website/src/{controllers/top-level/payments/index.js => libs/api-v3/payments.js} (74%) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 5b1d57383e..0a3662bf80 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -100,6 +100,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", @@ -173,6 +175,5 @@ "equipmentAlreadyOwned": "You already own that piece of equipment", "missingAccessToken": "The request is missing a required parameter : access_token", "missingBillingAgreementId": "Missing billing agreement id", - "missingAttributesFromAmazon": "Missing attributes from Amazon", "paymentNotSuccessful": "The payment was not successful" } diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js index 2cdc301b44..a58113b77d 100644 --- a/tasks/gulp-tests.js +++ b/tasks/gulp-tests.js @@ -373,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_subscribeCancel.test.js b/test/api/v3/integration/payments/GET-payments_amazon_subscribeCancel.test.js new file mode 100644 index 0000000000..66562c3721 --- /dev/null +++ b/test/api/v3/integration/payments/GET-payments_amazon_subscribeCancel.test.js @@ -0,0 +1,21 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +describe('payments : amazon #subscribeCancel', () => { + let endpoint = '/payments/amazon/subscribeCancel'; + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('verifies subscription', async () => { + await expect(user.get(endpoint)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('missingSubscription'), + }); + }); +}); 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..846416ed5d --- /dev/null +++ b/test/api/v3/integration/payments/POST-payments_amazon_checkout.test.js @@ -0,0 +1,22 @@ +import { + generateUser, +} from '../../../../helpers/api-integration/v3'; + +describe('payments - amazon - #checkout', () => { + let endpoint = '/payments/amazon/checkout'; + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('verifies credentials', async (done) => { + try { + await user.post(endpoint); + } catch (e) { + expect(e.error).to.eql('BadRequest'); + expect(e.message.type).to.eql('InvalidParameterValue'); + done(); + } + }); +}); 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..3eb00b7c3c --- /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 = '/payments/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..02a30a7ce5 --- /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 = '/payments/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_verify_access_token.test.js b/test/api/v3/integration/payments/POST-payments_amazon_verifyAccessToken.test.js similarity index 100% rename from test/api/v3/integration/payments/POST-payments_amazon_verify_access_token.test.js rename to test/api/v3/integration/payments/POST-payments_amazon_verifyAccessToken.test.js diff --git a/test/api/v3/unit/libs/amazonPayments.test.js b/test/api/v3/unit/libs/amazonPayments.test.js deleted file mode 100644 index b2bf480e01..0000000000 --- a/test/api/v3/unit/libs/amazonPayments.test.js +++ /dev/null @@ -1,103 +0,0 @@ -import * as amzLib from '../../../../../website/src/libs/api-v3/amazonPayments'; -// import * as amzStub from 'amazon-payments'; -import amazonPayments from 'amazon-payments'; -var User = require('mongoose').model('User'); - -describe('amazonPayments', () => { - beforeEach(() => { - }); - - describe('#getTokenInfo stubbed', () => { - let thisToken = 'this token info'; - let amzOldConnect; - - beforeEach(() => { - amzOldConnect = amazonPayments.connect; - amazonPayments.connect = () => { - let api = { getTokenInfo: (token, cb) => { - return cb(undefined, thisToken); - } }; - return { api }; - }; - }); - - afterEach(() => { - amazonPayments.connect = amzOldConnect; - }); - - it('returns tokenInfo', async (done) => { - let result = await amzLib.getTokenInfo(); - expect(result).to.eql(thisToken); - done(); - }); - }); - - describe('#getTokenInfo', () => { - it('validates access_token parameter', async (done) => { - try { - await amzLib.getTokenInfo(); - } catch (e) { - expect(e.type).to.eql('invalid_request'); - done(); - } - }); - }); - - describe('#createOrderReferenceId', () => { - it('verifies billingAgreementId', async (done) => { - try { - let inputSet = {}; - delete inputSet.Id; - await amzLib.createOrderReferenceId(inputSet); - } catch (e) { - - /* console.log('error!', e); - console.log('error keys!', Object.keys(e)); - for (var key in e) { - console.log(e[key]); - } // */ - - expect(e.type).to.eql('InvalidParameterValue'); - expect(e.body.ErrorResponse.Error.Message).to.eql('Parameter AWSAccessKeyId cannot be empty.'); - done(); - } - }); - - xit('succeeds', () => { - }); - }); - - describe('#checkout', () => { - xit('succeeds'); - }); - - describe('#setOrderReferenceDetails', () => { - xit('succeeds'); - }); - - describe('#confirmOrderReference', () => { - xit('succeeds'); - }); - - describe('#authorize', () => { - xit('succeeds'); - - xit('was declined'); - - xit('had an error'); - }); - - describe('#closeOrderReference', () => { - xit('succeeds'); - }); - - describe.only('#executePayment', () => { - it('succeeds not as a gift', () => { - }); - - it('succeeds as a gift', () => { - }); - }); - - -}); 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..ad846e6488 --- /dev/null +++ b/test/api/v3/unit/libs/payments.test.js @@ -0,0 +1,75 @@ +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.calledOnce(fakeSend); + }); + }); + + describe('#buyGems', async () => { + }); +}); diff --git a/test/api/v3/unit/libs/paymentsIndex.test.js b/test/api/v3/unit/libs/paymentsIndex.test.js deleted file mode 100644 index 74b9d29273..0000000000 --- a/test/api/v3/unit/libs/paymentsIndex.test.js +++ /dev/null @@ -1,11 +0,0 @@ - -describe('payments/index', () => { - beforeEach(() => { - }); - - describe('#createSubscription', async () => { - }); - - describe('#buyGems', async () => { - }); -}); diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index 106cc95987..08c405424e 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -2,8 +2,6 @@ import validator from 'validator'; import moment from 'moment'; import passport from 'passport'; import nconf from 'nconf'; -import setupNconf from '../../libs/api-v3/setupNconf'; -setupNconf(); import { authWithHeaders, } from '../../middlewares/api-v3/auth'; diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index 6aa8319f7c..42864fdab7 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -12,8 +12,6 @@ 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 785efb06b9..74998a13f4 100644 --- a/website/src/controllers/top-level/payments/amazon.js +++ b/website/src/controllers/top-level/payments/amazon.js @@ -1,18 +1,17 @@ -/* import async from 'async'; -import cc from 'coupon-code'; +/* import mongoose from 'mongoose'; -import moment from 'moment'; -import payments from './index'; -import shared from '../../../../../common'; import { model as User } from '../../../models/user'; */ import { - // NotFound, - // NotAuthorized, BadRequest, } from '../../../libs/api-v3/errors'; import amzLib from '../../../libs/api-v3/amazonPayments'; import { authWithHeaders } from '../../../middlewares/api-v3/auth'; -var payments = require('./index'); +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 = {}; @@ -21,19 +20,22 @@ let api = {}; * @apiVersion 3.0.0 * @apiName AmazonVerifyAccessToken * @apiGroup Payments + * * @apiParam {string} access_token the access token + * * @apiSuccess {} empty **/ api.verifyAccessToken = { method: 'POST', url: '/payments/amazon/verifyAccessToken', + middlewares: [authWithHeaders()], async handler (req, res) { - await amzLib.getTokenInfo(req.body.access_token) - .then(() => { + try { + await amzLib.getTokenInfo(req.body.access_token); res.respond(200, {}); - }).catch((error) => { + } catch (error) { throw new BadRequest(error.body.error_description); - }); + } }, }; @@ -42,21 +44,21 @@ api.verifyAccessToken = { * @apiVersion 3.0.0 * @apiName AmazonCreateOrderReferenceId * @apiGroup Payments + * * @apiParam {string} billingAgreementId billing agreement id - * @apiSuccess {object} object containing { orderReferenceId } + * + * @apiSuccess {object} data.orderReferenceId The order reference id. **/ api.createOrderReferenceId = { method: 'POST', url: '/payments/amazon/createOrderReferenceId', - // middlewares: [authWithHeaders()], + middlewares: [authWithHeaders()], async handler (req, res) { - try { let response = await amzLib.createOrderReferenceId({ Id: req.body.billingAgreementId, IdType: 'BillingAgreement', ConfirmNow: false, - AWSAccessKeyId: 'something', }); res.respond(200, { orderReferenceId: response.OrderReferenceDetails.AmazonOrderReferenceId, @@ -64,7 +66,6 @@ api.createOrderReferenceId = { } catch (error) { throw new BadRequest(error); } - }, }; @@ -75,6 +76,7 @@ api.createOrderReferenceId = { * @apiGroup Payments * * @apiParam {string} billingAgreementId billing agreement id + * * @apiSuccess {object} object containing { orderReferenceId } **/ api.checkout = { @@ -95,10 +97,6 @@ api.checkout = { } } - /* if (!req.body || !req.body.orderReferenceId) { - return res.status(400).json({err: 'Billing Agreement Id not supplied.'}); - } */ - try { await amzLib.setOrderReferenceDetails({ AmazonOrderReferenceId: orderReferenceId, @@ -132,53 +130,57 @@ api.checkout = { await amzLib.closeOrderReference({ AmazonOrderReferenceId: orderReferenceId }); // execute payment - let giftUser = await User.findById(gift ? gift.uuid : undefined); - let data = { giftUser, paymentMethod: 'Amazon Payments' }; let method = 'buyGems'; + let data = { user, paymentMethod: 'Amazon Payments' }; if (gift) { if (gift.type === 'subscription') method = 'createSubscription'; - gift.member = giftUser; + gift.member = await User.findById(gift ? gift.uuid : undefined); data.gift = gift; data.paymentMethod = 'Gift'; } await payments[method](data); res.respond(200); - } catch(error) { + } catch (error) { throw new BadRequest(error); } + }, }; +/** + * @api {post} /api/v3/payments/amazon/subscribe Subscribe + * @apiVersion 3.0.0 + * @apiName AmazonSubscribe + * @apiGroup Payments + * + * @apiParam {string} billingAgreementId billing agreement id + * @apiParam {string} subscription Subscription plan + * @apiParam {string} coupon Coupon + * + * @apiSuccess {object} data.orderReferenceId The order reference id. + **/ +api.subscribe = { + method: 'POST', + url: '/payments/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')); + } -/* -api.subscribe = function subscribe (req, res, next) { - if (!req.body || !req.body.billingAgreementId) { - return res.status(400).json({err: 'Billing Agreement Id not supplied.'}); - } + try { + 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 BadRequest(res.t('invalidCoupon')); + } - 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) { - return res.status(400).json({err: 'Subscription plan not found.'}); - } - - async.series({ - applyDiscount (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 couponResult (err) { - if (err) return cb(err); - if (!coupon) return cb(new Error('Coupon code not found.')); - cb(); - }); - }, - - setBillingAgreementDetails (cb) { - amzPayment.offAmazonPayments.setBillingAgreementDetails({ + await amzLib.setBillingAgreementDetails({ AmazonBillingAgreementId: billingAgreementId, BillingAgreementAttributes: { SellerNote: 'HabitRPG Subscription', @@ -188,17 +190,13 @@ api.subscribe = function subscribe (req, res, next) { CustomInformation: 'HabitRPG Subscription', }, }, - }, cb); - }, + }); - confirmBillingAgreement (cb) { - amzPayment.offAmazonPayments.confirmBillingAgreement({ + await amzLib.confirmBillingAgreement({ AmazonBillingAgreementId: billingAgreementId, - }, cb); - }, + }); - authorizeOnBillingAgreement (cb) { - amzPayment.offAmazonPayments.authorizeOnBillingAgreement({ + await amzLib.authorizeOnBillingAgreement({ AmazonBillingAgreementId: billingAgreementId, AuthorizationReferenceId: shared.uuid().substring(0, 32), AuthorizationAmount: { @@ -213,68 +211,57 @@ api.subscribe = function subscribe (req, res, next) { SellerOrderId: shared.uuid(), StoreName: 'HabitRPG', }, - }, function billingAgreementResult (err) { - if (err) return cb(err); - - if (res.AuthorizationDetails.AuthorizationStatus.State === 'Declined') { - return cb(new Error('The payment was not successful.')); - } - - return cb(); }); - }, - createSubscription (cb) { - payments.createSubscription({ + await payments.createSubscription({ user, customerId: billingAgreementId, paymentMethod: 'Amazon Payments', sub, - }, cb); - }, - }, function subscribeResult (err) { - if (err) return next(err); + }); - res.sendStatus(200); - }); + res.respond(200); + } catch (error) { + throw new BadRequest(error); + } + }, }; -api.subscribeCancel = function subscribeCancel (req, res, next) { - let user = res.locals.user; - if (!user.purchased.plan.customerId) - return res.status(401).json({err: 'User does not have a plan subscription'}); +/** + * @api {get} /api/v3/payments/amazon/subscribe/cancel SubscribeCancel + * @apiVersion 3.0.0 + * @apiName AmazonSubscribe + * @apiGroup Payments + * + * @apiSuccess {object} empty object + **/ +api.subscribeCancel = { + method: 'GET', + url: '/payments/amazon/subscribe/cancel', + middlewares: [authWithHeaders()], + async handler (req, res) { + let user = res.locals.user; + let billingAgreementId = user.purchased.plan.customerId; - let billingAgreementId = user.purchased.plan.customerId; + if (!billingAgreementId) throw new BadRequest(res.t('missingSubscription')); - async.series({ - closeBillingAgreement (cb) { - amzPayment.offAmazonPayments.closeBillingAgreement({ + try { + await amzLib.closeBillingAgreement({ AmazonBillingAgreementId: billingAgreementId, - }, cb); - }, + }); - cancelSubscription (cb) { let data = { user, - // Date of next bill - nextBill: moment(user.purchased.plan.lastBillingDate).add({days: 30}), + nextBill: moment(user.purchased.plan.lastBillingDate).add({ days: 30 }), paymentMethod: 'Amazon Payments', }; + await payments.cancelSubscription(data); - payments.cancelSubscription(data, cb); - }, - }, function subscribeCancelResult (err) { - if (err) return next(err); // don't json this, let toString() handle errors - - if (req.query.noRedirect) { - res.sendStatus(200); - } else { - res.redirect('/'); + res.respond(200, {}); + } catch (error) { + throw new BadRequest(error.message); } - - user = null; - }); + }, }; -*/ module.exports = api; diff --git a/website/src/controllers/top-level/payments/paypal.js b/website/src/controllers/top-level/payments/paypal.js index 05e8594ac1..c23fa6247d 100644 --- a/website/src/controllers/top-level/payments/paypal.js +++ b/website/src/controllers/top-level/payments/paypal.js @@ -4,7 +4,7 @@ var async = require('async'); var _ = require('lodash'); var url = require('url'); var User = require('mongoose').model('User'); -var payments = require('./index'); +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'); diff --git a/website/src/controllers/top-level/payments/stripe.js b/website/src/controllers/top-level/payments/stripe.js index 34bc598b3e..6a553dacd5 100644 --- a/website/src/controllers/top-level/payments/stripe.js +++ b/website/src/controllers/top-level/payments/stripe.js @@ -1,7 +1,7 @@ /* import nconf from 'nconf'; import stripeModule from 'stripe'; import async from 'async'; -import payments from './index'; +import payments from '../../../libs/api-v3/payments'; import { model as User } from '../../../models/user'; import shared from '../../../../../common'; import mongoose from 'mongoose'; diff --git a/website/src/libs/api-v3/amazonPayments.js b/website/src/libs/api-v3/amazonPayments.js index 3d92132f2f..38403ba4d0 100644 --- a/website/src/libs/api-v3/amazonPayments.js +++ b/website/src/libs/api-v3/amazonPayments.js @@ -3,65 +3,41 @@ import nconf from 'nconf'; import common from '../../../../common'; let t = common.i18n.t; const IS_PROD = nconf.get('NODE_ENV') === 'production'; +import Q from 'q'; -let api = {}; +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'), +}); -function connect (amazonPayments) { // eslint-disable-line no-shadow - return 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'), - }); -} +/** + * From: https://payments.amazon.com/documentation/apireference/201751670#201751670 + */ -api.getTokenInfo = (token) => { - let amzPayment = connect(amazonPayments); +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.api.getTokenInfo(token, (err, tokenInfo) => { + amzPayment.offAmazonPayments.authorizeOnBillingAgreement(inputSet, (err, response) => { if (err) return reject(err); - return resolve(tokenInfo); - }); - }); -}; - -api.createOrderReferenceId = (inputSet) => { - let amzPayment = connect(amazonPayments); - return new Promise((resolve, reject) => { - amzPayment.offAmazonPayments.createOrderReferenceForId(inputSet, (err, response) => { - if (err) return reject(err); - if (!response.OrderReferenceDetails || !response.OrderReferenceDetails.AmazonOrderReferenceId) { - return reject(t('missingAttributesFromAmazon')); - } + if (response.AuthorizationDetails.AuthorizationStatus.State === 'Declined') return reject(t('paymentNotSuccessful')); return resolve(response); }); }); }; -api.setOrderReferenceDetails = (inputSet) => { - let amzPayment = connect(amazonPayments); +let authorize = (inputSet) => { return new Promise((resolve, reject) => { - amzPayment.offAmazonPayments.setOrderReferenceDetails(inputSet, (err, response) => { - if (err) return reject(err); - return resolve(response); - }); - }); -}; - -api.confirmOrderReference = (inputSet) => { - let amzPayment = connect(amazonPayments); - return new Promise((resolve, reject) => { - amzPayment.offAmazonPayments.confirmOrderReference(inputSet, (err, response) => { - if (err) return reject(err); - return resolve(response); - }); - }); -}; - -api.authorize = (inputSet) => { - let amzPayment = connect(amazonPayments); - return new Promize((resolve, reject) => { amzPayment.offAmazonPayments.authorize(inputSet, (err, response) => { if (err) return reject(err); if (response.AuthorizationDetails.AuthorizationStatus.State === 'Declined') return reject(t('paymentNotSuccessful')); @@ -70,24 +46,15 @@ api.authorize = (inputSet) => { }); }; -api.closeOrderReference = (inputSet) => { - let amzPayment = connect(amazonPayments); - return new Promize((resolve, reject) => { - amzPayment.offAmazonPayments.closeOrderReference(inputSet, (err, response) => { - if (err) return reject(err); - return resolve(response); - }); - }); +module.exports = { + getTokenInfo, + createOrderReferenceId, + setOrderReferenceDetails, + confirmOrderReference, + closeOrderReference, + confirmBillingAgreement, + setBillingAgreementDetails, + closeBillingAgreement, + authorizeOnBillingAgreement, + authorize, }; - -api.executePayment = (inputSet) => { - let amzPayment = connect(amazonPayments); - return new Promize((resolve, reject) => { - amzPayment.offAmazonPayments.closeOrderReference(inputSet, (err, response) => { - if (err) return reject(err); - return resolve(response); - }); - }); -}; - -module.exports = api; diff --git a/website/src/controllers/top-level/payments/index.js b/website/src/libs/api-v3/payments.js similarity index 74% rename from website/src/controllers/top-level/payments/index.js rename to website/src/libs/api-v3/payments.js index 6ee56bf0d2..0e94153150 100644 --- a/website/src/controllers/top-level/payments/index.js +++ b/website/src/libs/api-v3/payments.js @@ -1,24 +1,22 @@ import _ from 'lodash' ; -import analytics from '../../../libs/api-v3/analyticsService'; -import async from 'async'; +import analytics from './analyticsService'; import cc from 'coupon-code'; import { getUserInfo, sendTxn as txnEmail, -} from '../../../libs/api-v3/email'; -import members from '../../api-v3/members'; +} from './email'; +import members from '../../controllers/api-v3/members'; import moment from 'moment'; import mongoose from 'mongoose'; import nconf from 'nconf'; -import pushNotify from '../../../libs/api-v3/pushNotifications'; -import shared from '../../../../../common' ; +import pushNotify from './pushNotifications'; +import shared from '../../../../common' ; -import amazon from './amazon'; -import iap from './iap'; -import paypal from './paypal'; -import stripe from './stripe'; +import iap from '../../controllers/top-level/payments/iap'; +import paypal from '../../controllers/top-level/payments/paypal'; +import stripe from '../../controllers/top-level/payments/stripe'; -const IS_PROD = nconf.get('NODE_ENV') === 'production'; +const IS_PROD = nconf.get('IS_PROD'); let api = {}; @@ -36,10 +34,7 @@ function revealMysteryItems (user) { }); } -// @TODO: HEREHERE api.createSubscription = async function createSubscription (data) { -} -api.createSubscription = function createSubscription (data, cb) { 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]; @@ -81,6 +76,7 @@ api.createSubscription = function createSubscription (data, cb) { plan.consecutive.trinkets += perks; } revealMysteryItems(recipient); + if (IS_PROD) { if (!data.gift) txnEmail(data.user, 'subscription-begins'); @@ -96,7 +92,9 @@ api.createSubscription = function createSubscription (data, cb) { }; analytics.trackPurchase(analyticsData); } + data.user.purchased.txnCount++; + if (data.gift) { members.sendMessage(data.user, data.gift.member, data.gift); @@ -113,50 +111,41 @@ api.createSubscription = function createSubscription (data, cb) { pushNotify.sendNotify(data.gift.member, shared.i18n.t('giftedSubscription'), `${months} months - by ${byUserName}`); } } - async.parallel([ - function saveGiftingUserData (cb2) { - data.user.save(cb2); - }, - function saveRecipientUserData (cb2) { - if (data.gift) { - data.gift.member.save(cb2); - } else { - cb2(null); - } - }, - ], cb); + + await data.user.save(); + if (data.gift) await data.gift.member.save(); }; /** * Sets their subscription to be cancelled later */ -api.cancelSubscription = function cancelSubscription (data, cb) { +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(`${now.format('MM')}/${moment(plan.dateUpdated).format('DD')}/${now.format('YYYY')}`) + moment(nowStr, nowStrFormat) .add({days: remaining}) // end their subscription 1mo from their last payment - .add({months: Math.ceil(plan.extraMonths)})// plus any extra time (carry-over, gifted subscription, etc) they have. FIXME: moment can't add months in fractions... + .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 - data.user.save(cb); + await data.user.save(); + txnEmail(data.user, 'cancel-subscription'); - let analyticsData = { + + analytics.track('unsubscribe', { uuid: data.user._id, gaCategory: 'commerce', gaLabel: data.paymentMethod, paymentMethod: data.paymentMethod, - }; - analytics.track('unsubscribe', analyticsData); + }); }; -// @TODO: HEREHERE api.buyGems = async function buyGems (data) { -}; -api.buyGems = function buyGems (data, cb) { let amt = data.amount || 5; amt = data.gift ? data.gift.gems.amount / 4 : amt; (data.gift ? data.gift.member : data.user).balance += amt; @@ -192,27 +181,9 @@ api.buyGems = function buyGems (data, cb) { 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(); } - async.parallel([ - function saveGiftingUserData (cb2) { - data.user.save(cb2); - }, - function saveRecipientUserData (cb2) { - if (data.gift) { - data.gift.member.save(cb2); - } else { - cb2(null); - } - }, - ], cb); -}; - -api.validCoupon = function validCoupon (req, res, next) { - mongoose.model('Coupon').findOne({_id: cc.validate(req.params.code), event: 'google_6mo'}, function couponErrorCheck (err, coupon) { - if (err) return next(err); - if (!coupon) return res.status(401).json({err: 'Invalid coupon code'}); - return res.sendStatus(200); - }); + await data.user.save(); }; api.stripeCheckout = stripe.checkout; @@ -226,12 +197,6 @@ api.paypalCheckout = paypal.createPayment; api.paypalCheckoutSuccess = paypal.executePayment; api.paypalIPN = paypal.ipn; -api.amazonVerifyAccessToken = amazon.verifyAccessToken; -api.amazonCreateOrderReferenceId = amazon.createOrderReferenceId; -api.amazonCheckout = amazon.checkout; -api.amazonSubscribe = amazon.subscribe; -api.amazonSubscribeCancel = amazon.subscribeCancel; - api.iapAndroidVerify = iap.androidVerify; api.iapIosVerify = iap.iosVerify; From a567476bb7f3fcdf557847106c81d2af77813f81 Mon Sep 17 00:00:00 2001 From: Victor Pudeyev Date: Sat, 30 Apr 2016 09:42:10 -0500 Subject: [PATCH 28/34] V3 payments 7 stripe (#7124) * payments api: cancelSubscription * some more tests for amazon payments * promisifying amazon payments * somehow payment stub is not working * cleaning up tests * renaming tests in api/v3/integration/payments * improvements * cleanup, lint * fixes as per comments * moment.zone() is back in. * basic controller for stripe payments * authWithUrl is in * stripe cleanup * making tests pass * stripe bug fixes * 400 error is right * cleanup of sinon spy for fakeSend * paypal payments * lint of paypal * require -> import --- .eslintignore | 1 - common/locales/en/api-v3.json | 5 +- tasks/gulp-tests.js | 2 +- ...-payments_amazon_subscribe_cancel.test.js} | 8 +- .../GET-payments_paypal_checkout.test.js | 21 + ...T-payments_paypal_checkout_success.test.js | 21 + .../GET-payments_paypal_subscribe.test.js | 21 + ...T-payments_paypal_subscribe_cancel.test.js | 21 + ...-payments_paypal_subscribe_success.test.js | 21 + ...T-payments_stripe_subscribe_cancel.test.js | 21 + .../payments/POST-payments_paypal_ipn.test.js | 17 + .../POST-payments_stripe_checkout.test.js | 20 + ...OST-payments_stripe_subscribe_edit.test.js | 21 + test/api/v3/unit/libs/payments.test.js | 5 +- website/src/controllers/api-v3/auth.js | 3 + website/src/controllers/api-v3/chat.js | 2 + .../controllers/top-level/payments/amazon.js | 20 +- .../controllers/top-level/payments/paypal.js | 488 ++++++++++-------- .../top-level/payments/paypalBillingSetup.js | 1 + .../controllers/top-level/payments/stripe.js | 266 +++++----- website/src/libs/api-v3/payments.js | 2 - website/src/middlewares/api-v3/auth.js | 18 + 22 files changed, 659 insertions(+), 346 deletions(-) rename test/api/v3/integration/payments/{GET-payments_amazon_subscribeCancel.test.js => GET-payments_amazon_subscribe_cancel.test.js} (72%) create mode 100644 test/api/v3/integration/payments/GET-payments_paypal_checkout.test.js create mode 100644 test/api/v3/integration/payments/GET-payments_paypal_checkout_success.test.js create mode 100644 test/api/v3/integration/payments/GET-payments_paypal_subscribe.test.js create mode 100644 test/api/v3/integration/payments/GET-payments_paypal_subscribe_cancel.test.js create mode 100644 test/api/v3/integration/payments/GET-payments_paypal_subscribe_success.test.js create mode 100644 test/api/v3/integration/payments/GET-payments_stripe_subscribe_cancel.test.js create mode 100644 test/api/v3/integration/payments/POST-payments_paypal_ipn.test.js create mode 100644 test/api/v3/integration/payments/POST-payments_stripe_checkout.test.js create mode 100644 test/api/v3/integration/payments/POST-payments_stripe_subscribe_edit.test.js 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); +} From 0114e310eb72122bdd6e4f38332f208e4d238248 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 9 May 2016 22:58:15 +0200 Subject: [PATCH 29/34] v3 payments: working IAP and Stripe, move paypalBillingSetup to its own file, closeChal is now a challenge instance method --- .eslintignore | 1 + migrations/api_v3/challenges.js | 2 + migrations/api_v3/users.js | 2 + scripts/paypalBillingSetup.js | 94 +++++++ website/src/controllers/api-v2/challenges.js | 6 +- website/src/controllers/api-v3/auth.js | 3 - website/src/controllers/api-v3/challenges.js | 65 +---- website/src/controllers/api-v3/chat.js | 3 - .../controllers/top-level/payments/amazon.js | 24 +- .../src/controllers/top-level/payments/iap.js | 249 ++++++++++-------- .../controllers/top-level/payments/paypal.js | 36 ++- .../top-level/payments/paypalBillingSetup.js | 98 ------- .../controllers/top-level/payments/stripe.js | 84 +++--- website/src/libs/api-v3/amazonPayments.js | 16 +- website/src/libs/api-v3/payments.js | 38 +-- .../src/middlewares/api-v3/errorHandler.js | 6 + website/src/middlewares/api-v3/v2.js | 2 - website/src/models/challenge.js | 66 +++++ 18 files changed, 414 insertions(+), 381 deletions(-) create mode 100644 scripts/paypalBillingSetup.js delete mode 100644 website/src/controllers/top-level/payments/paypalBillingSetup.js diff --git a/.eslintignore b/.eslintignore index eaef8f567d..b4830a328f 100644 --- a/.eslintignore +++ b/.eslintignore @@ -22,6 +22,7 @@ website/src/middlewares/apiThrottle.js website/src/middlewares/forceRefresh.js debug-scripts/* +scripts/* tasks/*.js gulpfile.js Gruntfile.js diff --git a/migrations/api_v3/challenges.js b/migrations/api_v3/challenges.js index 77972172dd..727492c04a 100644 --- a/migrations/api_v3/challenges.js +++ b/migrations/api_v3/challenges.js @@ -143,6 +143,8 @@ function processChallenges (afterId) { oldTask.tags = _.map(oldTask.tags || {}, function (tagPresent, tagId) { return tagPresent && tagId; + }).filter(function (tag) { + return tag !== false; }); if (!oldTask.text) oldTask.text = 'task text'; // required diff --git a/migrations/api_v3/users.js b/migrations/api_v3/users.js index be22aee5e0..13db28e768 100644 --- a/migrations/api_v3/users.js +++ b/migrations/api_v3/users.js @@ -165,6 +165,8 @@ function processUsers (afterId) { if (!oldTask.text) oldTask.text = 'task text'; // required oldTask.tags = _.map(oldTask.tags, function (tagPresent, tagId) { return tagPresent && tagId; + }).filter(function (tag) { + return tag !== false; }); if (oldTask.type !== 'todo' || (oldTask.type === 'todo' && !oldTask.completed)) { diff --git a/scripts/paypalBillingSetup.js b/scripts/paypalBillingSetup.js new file mode 100644 index 0000000000..d21cd80c1c --- /dev/null +++ b/scripts/paypalBillingSetup.js @@ -0,0 +1,94 @@ +// This file is used for creating paypal billing plans. PayPal doesn't have a web interface for setting up recurring +// payment plan definitions, instead you have to create it via their REST SDK and keep it updated the same way. So this +// file will be used once for initing your billing plan (then you get the resultant plan.id to store in config.json), +// and once for any time you need to edit the plan thereafter + +var path = require('path'); +var nconf = require('nconf'); +var _ = require('lodash'); +var paypal = require('paypal-rest-sdk'); +var blocks = require('../../../../common').content.subscriptionBlocks; +var live = nconf.get('PAYPAL:mode')=='live'; + +nconf.argv().env().file('user', path.join(path.resolve(__dirname, '../../../config.json'))); + +var OP = 'create'; // list create update remove + +paypal.configure({ + 'mode': nconf.get("PAYPAL:mode"), //sandbox or live + 'client_id': nconf.get("PAYPAL:client_id"), + 'client_secret': nconf.get("PAYPAL:client_secret") +}); + +// https://developer.paypal.com/docs/api/#billing-plans-and-agreements +var billingPlanTitle ="Habitica Subscription"; +var billingPlanAttributes = { + "name": billingPlanTitle, + "description": billingPlanTitle, + "type": "INFINITE", + "merchant_preferences": { + "auto_bill_amount": "yes", + "cancel_url": live ? 'https://habitica.com' : 'http://localhost:3000', + "return_url": (live ? 'https://habitica.com' : 'http://localhost:3000') + '/paypal/subscribe/success' + }, + payment_definitions: [{ + "type": "REGULAR", + "frequency": "MONTH", + "cycles": "0" + }] +}; +_.each(blocks, function(block){ + block.definition = _.cloneDeep(billingPlanAttributes); + _.merge(block.definition.payment_definitions[0], { + "name": billingPlanTitle + ' ($'+block.price+' every '+block.months+' months, recurring)', + "frequency_interval": ""+block.months, + "amount": { + "currency": "USD", + "value": ""+block.price + } + }); +}) + +switch(OP) { + case "list": + paypal.billingPlan.list({status: 'ACTIVE'}, function(err, plans){ + console.log({err:err, plans:plans}); + }); + break; + case "get": + paypal.billingPlan.get(nconf.get("PAYPAL:billing_plans:12"), function (err, plan) { + console.log({err:err, plan:plan}); + }) + break; + case "update": + var update = { + "op": "replace", + "path": "/merchant_preferences", + "value": { + "cancel_url": "https://habitica.com" + } + }; + paypal.billingPlan.update(nconf.get("PAYPAL:billing_plans:12"), update, function (err, res) { + console.log({err:err, plan:res}); + }); + break; + case "create": + paypal.billingPlan.create(blocks["google_6mo"].definition, function(err,plan){ + if (err) return console.log(err); + if (plan.state == "ACTIVE") + return console.log({err:err, plan:plan}); + var billingPlanUpdateAttributes = [{ + "op": "replace", + "path": "/", + "value": { + "state": "ACTIVE" + } + }]; + // Activate the plan by changing status to Active + paypal.billingPlan.update(plan.id, billingPlanUpdateAttributes, function(err, response){ + console.log({err:err, response:response, id:plan.id}); + }); + }); + break; + case "remove": break; +} diff --git a/website/src/controllers/api-v2/challenges.js b/website/src/controllers/api-v2/challenges.js index 404fa379d1..aff7b53a80 100644 --- a/website/src/controllers/api-v2/challenges.js +++ b/website/src/controllers/api-v2/challenges.js @@ -289,8 +289,6 @@ api.update = function(req, res, next){ }); } -import { _closeChal } from '../api-v3/challenges'; - /** * Delete & close */ @@ -304,7 +302,7 @@ api.delete = async function(req, res, next){ if (!challenge.canModify(user)) return next(shared.i18n.t('noPermissionCloseChallenge')); // Close channel in background, some ops are run in the background without `await`ing - await _closeChal(challenge, {broken: 'CHALLENGE_DELETED'}); + await challenge.closeChal({broken: 'CHALLENGE_DELETED'}); res.sendStatus(200); } catch (err) { next(err); @@ -326,7 +324,7 @@ api.selectWinner = async function(req, res, next) { if (!winner || winner.challenges.indexOf(challenge._id) === -1) return next('Winner ' + req.query.uid + ' not found.'); // Close channel in background, some ops are run in the background without `await`ing - await _closeChal(challenge, {broken: 'CHALLENGE_CLOSED', winner}); + await challenge.closeChal({broken: 'CHALLENGE_CLOSED', winner}); res.respond(200, {}); } catch (err) { next(err); diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index 867457c07a..8616f889f0 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -2,9 +2,6 @@ import validator from 'validator'; import moment from 'moment'; import passport from 'passport'; import nconf from 'nconf'; -import setupNconf from '../../libs/api-v3/setupNconf'; -setupNconf(); - import { authWithHeaders, } from '../../middlewares/api-v3/auth'; diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index b334dedc41..bb21ced189 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -14,10 +14,7 @@ import { NotFound, NotAuthorized, } from '../../libs/api-v3/errors'; -import shared from '../../../../common'; import * as Tasks from '../../models/task'; -import { sendTxn as txnEmail } from '../../libs/api-v3/email'; -import sendPushNotification from '../../libs/api-v3/pushNotifications'; import Q from 'q'; import csvStringify from '../../libs/api-v3/csvStringify'; @@ -449,64 +446,6 @@ api.updateChallenge = { }, }; -// TODO everything here should be moved to a worker -// actually even for a worker it's probably just too big and will kill mongo -// Exported because it's used in v2 controller -export async function _closeChal (challenge, broken = {}) { - let winner = broken.winner; - let brokenReason = broken.broken; - - // Delete the challenge - await Challenge.remove({_id: challenge._id}).exec(); - - // Refund the leader if the challenge is closed and the group not the tavern - if (challenge.group !== TAVERN_ID && brokenReason === 'CHALLENGE_DELETED') { - await User.update({_id: challenge.leader}, {$inc: {balance: challenge.prize / 4}}).exec(); - } - - // Update the challengeCount on the group - await Group.update({_id: challenge.group}, {$inc: {challengeCount: -1}}).exec(); - - // Award prize to winner and notify - if (winner) { - winner.achievements.challenges.push(challenge.name); - winner.balance += challenge.prize / 4; - let savedWinner = await winner.save(); - if (savedWinner.preferences.emailNotifications.wonChallenge !== false) { - txnEmail(savedWinner, 'won-challenge', [ - {name: 'CHALLENGE_NAME', content: challenge.name}, - ]); - } - - sendPushNotification(savedWinner, shared.i18n.t('wonChallenge'), challenge.name); - } - - // Run some operations in the background withouth blocking the thread - let backgroundTasks = [ - // And it's tasks - Tasks.Task.remove({'challenge.id': challenge._id, userId: {$exists: false}}).exec(), - // Set the challenge tag to non-challenge status and remove the challenge from the user's challenges - User.update({ - challenges: challenge._id, - 'tags._id': challenge._id, - }, { - $set: {'tags.$.challenge': false}, - $pull: {challenges: challenge._id}, - }, {multi: true}).exec(), - // Break users' tasks - Tasks.Task.update({ - 'challenge.id': challenge._id, - }, { - $set: { - 'challenge.broken': brokenReason, - 'challenge.winner': winner && winner.profile.name, - }, - }, {multi: true}).exec(), - ]; - - Q.all(backgroundTasks); -} - /** * @api {delete} /api/v3/challenges/:challengeId Delete a challenge * @apiVersion 3.0.0 @@ -534,7 +473,7 @@ api.deleteChallenge = { if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyLeaderDeleteChal')); // Close channel in background, some ops are run in the background without `await`ing - await _closeChal(challenge, {broken: 'CHALLENGE_DELETED'}); + await challenge.closeChal({broken: 'CHALLENGE_DELETED'}); res.respond(200, {}); }, }; @@ -571,7 +510,7 @@ api.selectChallengeWinner = { if (!winner || winner.challenges.indexOf(challenge._id) === -1) throw new NotFound(res.t('winnerNotFound', {userId: req.params.winnerId})); // Close channel in background, some ops are run in the background without `await`ing - await _closeChal(challenge, {broken: 'CHALLENGE_CLOSED', winner}); + await challenge.closeChal({broken: 'CHALLENGE_CLOSED', winner}); res.respond(200, {}); }, }; diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index e07c831f20..d074cb4c7b 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -14,9 +14,6 @@ import { sendTxn } from '../../libs/api-v3/email'; import nconf from 'nconf'; import Q from 'q'; -import setupNconf from '../../libs/api-v3/setupNconf'; -setupNconf(); - const FLAG_REPORT_EMAILS = nconf.get('FLAG_REPORT_EMAIL').split(',').map((email) => { return { email, canSend: true }; }); diff --git a/website/src/controllers/top-level/payments/amazon.js b/website/src/controllers/top-level/payments/amazon.js index 3be2673b79..17b8486389 100644 --- a/website/src/controllers/top-level/payments/amazon.js +++ b/website/src/controllers/top-level/payments/amazon.js @@ -1,5 +1,6 @@ import { BadRequest, + NotAuthorized, } from '../../../libs/api-v3/errors'; import amzLib from '../../../libs/api-v3/amazonPayments'; import { @@ -16,6 +17,7 @@ import cc from 'coupon-code'; let api = {}; /** + * @apiIgnore Payments are considered part of the private API * @api {post} /amazon/verifyAccessToken verify access token * @apiVersion 3.0.0 * @apiName AmazonVerifyAccessToken @@ -23,11 +25,11 @@ let api = {}; * * @apiParam {string} access_token the access token * - * @apiSuccess {} empty + * @apiSuccess {Object} data Empty object **/ api.verifyAccessToken = { method: 'POST', - url: '/payments/amazon/verifyAccessToken', + url: '/amazon/verifyAccessToken', middlewares: [authWithHeaders()], async handler (req, res) { try { @@ -40,6 +42,7 @@ api.verifyAccessToken = { }; /** + * @apiIgnore Payments are considered part of the private API * @api {post} /amazon/createOrderReferenceId create order reference id * @apiVersion 3.0.0 * @apiName AmazonCreateOrderReferenceId @@ -51,7 +54,7 @@ api.verifyAccessToken = { **/ api.createOrderReferenceId = { method: 'POST', - url: '/payments/amazon/createOrderReferenceId', + url: '/amazon/createOrderReferenceId', middlewares: [authWithHeaders()], async handler (req, res) { try { @@ -70,6 +73,7 @@ api.createOrderReferenceId = { }; /** + * @apiIgnore Payments are considered part of the private API * @api {post} /amazon/checkout do checkout * @apiVersion 3.0.0 * @apiName AmazonCheckout @@ -81,7 +85,7 @@ api.createOrderReferenceId = { **/ api.checkout = { method: 'POST', - url: '/payments/amazon/checkout', + url: '/amazon/checkout', middlewares: [authWithHeaders()], async handler (req, res) { let gift = req.body.gift; @@ -148,6 +152,7 @@ api.checkout = { }; /** + * @apiIgnore Payments are considered part of the private API * @api {post} /amazon/subscribe Subscribe * @apiVersion 3.0.0 * @apiName AmazonSubscribe @@ -161,7 +166,7 @@ api.checkout = { **/ api.subscribe = { method: 'POST', - url: '/payments/amazon/subscribe', + url: '/amazon/subscribe', middlewares: [authWithHeaders()], async handler (req, res) { let billingAgreementId = req.body.billingAgreementId; @@ -228,22 +233,21 @@ api.subscribe = { }; /** + * @apiIgnore Payments are considered part of the private API * @api {get} /amazon/subscribe/cancel SubscribeCancel * @apiVersion 3.0.0 * @apiName AmazonSubscribe * @apiGroup Payments - * - * @apiSuccess {object} empty object **/ api.subscribeCancel = { method: 'GET', - url: '/payments/amazon/subscribe/cancel', + url: '/amazon/subscribe/cancel', middlewares: [authWithUrl], async handler (req, res) { let user = res.locals.user; let billingAgreementId = user.purchased.plan.customerId; - if (!billingAgreementId) throw new BadRequest(res.t('missingSubscription')); + if (!billingAgreementId) throw new NotAuthorized(res.t('missingSubscription')); try { await amzLib.closeBillingAgreement({ @@ -257,7 +261,7 @@ api.subscribeCancel = { }; await payments.cancelSubscription(data); - res.respond(200, {}); + res.redirect('/'); } catch (error) { throw new BadRequest(error.message); } diff --git a/website/src/controllers/top-level/payments/iap.js b/website/src/controllers/top-level/payments/iap.js index 898b0b2015..60e50d6d39 100644 --- a/website/src/controllers/top-level/payments/iap.js +++ b/website/src/controllers/top-level/payments/iap.js @@ -1,5 +1,12 @@ import iap from 'in-app-purchase'; import nconf from 'nconf'; +import { + authWithHeaders, + authWithUrl, +} from '../../../middlewares/api-v3/auth'; +import payments from '../../../libs/api-v3/payments'; + +// NOT PORTED TO v3 iap.config({ // this is the path to the directory containing iap-sanbox/iap-live files @@ -7,148 +14,178 @@ iap.config({ }); // Validation ERROR Codes -// const INVALID_PAYLOAD = 6778001; +const INVALID_PAYLOAD = 6778001; // const CONNECTION_FAILED = 6778002; -// const PURCHASE_EXPIRED = 6778003; +// const PURCHASE_EXPIRED = 6778003; let api = {}; -/* -api.androidVerify = function androidVerify (req, res) { - let iapBody = req.body; - let user = res.locals.user; +/** + * @apiIgnore Payments are considered part of the private API + * @api {post} /iap/android/verify Android Verify IAP + * @apiVersion 3.0.0 + * @apiName IapAndroidVerify + * @apiGroup Payments + **/ +api.iapAndroidVerify = { + method: 'POST', + url: '/iap/android/verify', + middlewares: [authWithUrl], + async handler (req, res) { + let user = res.locals.user; + let iapBody = req.body; - iap.setup(function googleSetupResult (error) { - if (error) { - let resObj = { - ok: false, - data: 'IAP Error', - }; - - return res.json(resObj); - } - - // google receipt must be provided as an object - // { - // "data": "{stringified data object}", - // "signature": "signature from google" - // } - let testObj = { - data: iapBody.transaction.receipt, - signature: iapBody.transaction.signature, - }; - - // iap is ready - iap.validate(iap.GOOGLE, testObj, function googleValidateResult (err, googleRes) { - if (err) { + iap.setup((error) => { + if (error) { let resObj = { ok: false, - data: { - code: INVALID_PAYLOAD, - message: err.toString(), - }, + data: 'IAP Error', }; return res.json(resObj); } - if (iap.isValidated(googleRes)) { - let resObj = { - ok: true, - data: googleRes, - }; + // google receipt must be provided as an object + // { + // "data": "{stringified data object}", + // "signature": "signature from google" + // } + let testObj = { + data: iapBody.transaction.receipt, + signature: iapBody.transaction.signature, + }; - payments.buyGems({user, paymentMethod: 'IAP GooglePlay', amount: 5.25}); + // iap is ready + iap.validate(iap.GOOGLE, testObj, (err, googleRes) => { + if (err) { + let resObj = { + ok: false, + data: { + code: INVALID_PAYLOAD, + message: err.toString(), + }, + }; - return res.json(resObj); - } + return res.json(resObj); + } + + if (iap.isValidated(googleRes)) { + let resObj = { + ok: true, + data: googleRes, + }; + + payments.buyGems({ + user, + paymentMethod: 'IAP GooglePlay', + amount: 5.25, + }).then(() => res.json(resObj)); + } + }); }); - }); + }, }; -exports.iosVerify = function iosVerify (req, res) { - let iapBody = req.body; - let user = res.locals.user; +/** + * @apiIgnore Payments are considered part of the private API + * @api {post} /iap/ios/verify iOS Verify IAP + * @apiVersion 3.0.0 + * @apiName IapiOSVerify + * @apiGroup Payments + **/ +api.iapiOSVerify = { + method: 'POST', + url: '/iap/android/verify', + middlewares: [authWithHeaders()], + async handler (req, res) { + let iapBody = req.body; + let user = res.locals.user; - iap.setup(function iosSetupResult (error) { - if (error) { - let resObj = { - ok: false, - data: 'IAP Error', - }; - - return res.json(resObj); - } - - // iap is ready - iap.validate(iap.APPLE, iapBody.transaction.receipt, function iosValidateResult (err, appleRes) { - if (err) { + iap.setup(function iosSetupResult (error) { + if (error) { let resObj = { ok: false, - data: { - code: INVALID_PAYLOAD, - message: err.toString(), - }, + data: 'IAP Error', }; return res.json(resObj); } - if (iap.isValidated(appleRes)) { - let purchaseDataList = iap.getPurchaseData(appleRes); - if (purchaseDataList.length > 0) { - let correctReceipt = true; - for (let index of purchaseDataList) { - switch (purchaseDataList[index].productId) { - case 'com.habitrpg.ios.Habitica.4gems': - payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 1}); - break; - case 'com.habitrpg.ios.Habitica.8gems': - payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 2}); - break; - case 'com.habitrpg.ios.Habitica.20gems': - case 'com.habitrpg.ios.Habitica.21gems': - payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 5.25}); - break; - case 'com.habitrpg.ios.Habitica.42gems': - payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 10.5}); - break; - default: - correctReceipt = false; + // iap is ready + iap.validate(iap.APPLE, iapBody.transaction.receipt, (err, appleRes) => { + if (err) { + let resObj = { + ok: false, + data: { + code: INVALID_PAYLOAD, + message: err.toString(), + }, + }; + + return res.json(resObj); + } + + if (iap.isValidated(appleRes)) { + let purchaseDataList = iap.getPurchaseData(appleRes); + if (purchaseDataList.length > 0) { + let correctReceipt = true; + + for (let index of purchaseDataList) { + switch (purchaseDataList[index].productId) { + case 'com.habitrpg.ios.Habitica.4gems': + payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 1}); + break; + case 'com.habitrpg.ios.Habitica.8gems': + payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 2}); + break; + case 'com.habitrpg.ios.Habitica.20gems': + case 'com.habitrpg.ios.Habitica.21gems': + payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 5.25}); + break; + case 'com.habitrpg.ios.Habitica.42gems': + payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 10.5}); + break; + default: + correctReceipt = false; + } + } + + if (correctReceipt) { + let resObj = { + ok: true, + data: appleRes, + }; + + // yay good! + return res.json(resObj); } } - if (correctReceipt) { - let resObj = { - ok: true, - data: appleRes, - }; - // yay good! - return res.json(resObj); - } + + // wrong receipt content + let resObj = { + ok: false, + data: { + code: INVALID_PAYLOAD, + message: 'Incorrect receipt content', + }, + }; + + return res.json(resObj); } - // wrong receipt content + + // invalid receipt let resObj = { ok: false, data: { code: INVALID_PAYLOAD, - message: 'Incorrect receipt content', + message: 'Invalid receipt', }, }; - return res.json(resObj); - } - // invalid receipt - let resObj = { - ok: false, - data: { - code: INVALID_PAYLOAD, - message: 'Invalid receipt', - }, - }; - return res.json(resObj); + return res.json(resObj); + }); }); - }); + }, }; -*/ module.exports = api; diff --git a/website/src/controllers/top-level/payments/paypal.js b/website/src/controllers/top-level/payments/paypal.js index 841bc0546b..1a3240d82e 100644 --- a/website/src/controllers/top-level/payments/paypal.js +++ b/website/src/controllers/top-level/payments/paypal.js @@ -1,3 +1,5 @@ +/* eslint-disable camelcase */ + import nconf from 'nconf'; import moment from 'moment'; import _ from 'lodash'; @@ -17,6 +19,8 @@ import { } from '../../../libs/api-v3/errors'; import * as logger from '../../../libs/api-v3/logger'; +const BASE_URL = nconf.get('BASE_URL'); + // This is the plan.id for paypal subscriptions. You have to set up billing plans via their REST sdk (they don't have // a web interface for billing-plan creation), see ./paypalBillingSetup.js for how. After the billing plan is created // there, get it's plan.id and store it in config.json @@ -24,8 +28,6 @@ _.each(shared.content.subscriptionBlocks, (block) => { block.paypalKey = nconf.get(`PAYPAL:billing_plans:${block.key}`); }); -/* eslint-disable camelcase */ - paypal.configure({ mode: nconf.get('PAYPAL:mode'), // sandbox or live client_id: nconf.get('PAYPAL:client_id'), @@ -35,18 +37,18 @@ paypal.configure({ let api = {}; /** - * @api {get} /paypal/checkout checkout + * @apiIgnore Payments are considered part of the private API + * @api {get} /paypal/checkout Paypal checkout + * @apiDescription Redirects to Paypal * @apiVersion 3.0.0 * @apiName PaypalCheckout * @apiGroup Payments * - * @apiParam {string} gift The stringified object representing the user, the gift recepient. - * - * @apiSuccess {} redirect + * @apiParam {string} gift Query parameter - The stringified object representing the user, the gift recepient. **/ api.checkout = { method: 'GET', - url: '/payments/paypal/checkout', + url: '/paypal/checkout', middlewares: [authWithUrl], async handler (req, res) { let gift = req.query.gift ? JSON.parse(req.query.gift) : undefined; @@ -68,8 +70,8 @@ api.checkout = { intent: 'sale', payer: { payment_method: 'Paypal' }, redirect_urls: { - return_url: `${nconf.get('BASE_URL')}/paypal/checkout/success`, - cancel_url: `${nconf.get('BASE_URL')}`, + return_url: `${BASE_URL}/paypal/checkout/success`, + cancel_url: `${BASE_URL}`, }, transactions: [{ item_list: { @@ -87,6 +89,7 @@ api.checkout = { description, }], }; + try { let result = await paypal.payment.create(createPayment); let link = _.find(result.links, { rel: 'approval_url' }).href; @@ -98,6 +101,7 @@ api.checkout = { }; /** + * @apiIgnore Payments are considered part of the private API * @api {get} /paypal/checkout/success Paypal checkout success * @apiVersion 3.0.0 * @apiName PaypalCheckoutSuccess @@ -110,7 +114,7 @@ api.checkout = { **/ api.checkoutSuccess = { method: 'GET', - url: '/payments/paypal/checkout/success', + url: '/paypal/checkout/success', middlewares: [authWithSession], async handler (req, res) { let paymentId = req.query.paymentId; @@ -144,6 +148,7 @@ api.checkoutSuccess = { }; /** + * @apiIgnore Payments are considered part of the private API * @api {get} /paypal/subscribe Paypal subscribe * @apiVersion 3.0.0 * @apiName PaypalSubscribe @@ -156,7 +161,7 @@ api.checkoutSuccess = { **/ api.subscribe = { method: 'GET', - url: '/payments/paypal/subscribe', + url: '/paypal/subscribe', middlewares: [authWithUrl], async handler (req, res) { let sub = shared.content.subscriptionBlocks[req.query.sub]; @@ -190,6 +195,7 @@ api.subscribe = { }; /** + * @apiIgnore Payments are considered part of the private API * @api {get} /paypal/subscribe/success Paypal subscribe success * @apiVersion 3.0.0 * @apiName PaypalSubscribeSuccess @@ -201,7 +207,7 @@ api.subscribe = { **/ api.subscribeSuccess = { method: 'GET', - url: '/payments/paypal/subscribe/success', + url: '/paypal/subscribe/success', middlewares: [authWithSession], async handler (req, res) { let user = res.locals.user; @@ -223,6 +229,7 @@ api.subscribeSuccess = { }; /** + * @apiIgnore Payments are considered part of the private API * @api {get} /paypal/subscribe/cancel Paypal subscribe cancel * @apiVersion 3.0.0 * @apiName PaypalSubscribeCancel @@ -234,7 +241,7 @@ api.subscribeSuccess = { **/ api.subscribeCancel = { method: 'GET', - url: '/payments/paypal/subscribe/cancel', + url: '/paypal/subscribe/cancel', middlewares: [authWithUrl], async handler (req, res) { let user = res.locals.user; @@ -261,6 +268,7 @@ api.subscribeCancel = { }; /** + * @apiIgnore Payments are considered part of the private API * @api {post} /paypal/ipn Paypal IPN * @apiVersion 3.0.0 * @apiName PaypalIpn @@ -273,7 +281,7 @@ api.subscribeCancel = { **/ api.ipn = { method: 'POST', - url: '/payments/paypal/ipn', + url: '/paypal/ipn', middlewares: [], async handler (req, res) { res.respond(200); diff --git a/website/src/controllers/top-level/payments/paypalBillingSetup.js b/website/src/controllers/top-level/payments/paypalBillingSetup.js deleted file mode 100644 index 1015e8f89e..0000000000 --- a/website/src/controllers/top-level/payments/paypalBillingSetup.js +++ /dev/null @@ -1,98 +0,0 @@ -// This file is used for creating paypal billing plans. PayPal doesn't have a web interface for setting up recurring -// payment plan definitions, instead you have to create it via their REST SDK and keep it updated the same way. So this -// file will be used once for initing your billing plan (then you get the resultant plan.id to store in config.json), -// and once for any time you need to edit the plan thereafter -import path from 'path'; -import nconf from 'nconf'; -import _ from 'lodash'; -import paypal from 'paypal-rest-sdk'; -import shared from '../../../../../common'; - -let blocks = shared.content.subscriptionBlocks; -const BILLING_PLAN_TITLE = 'Habitica Subscription'; -const LIVE = nconf.get('PAYPAL:mode') === 'live'; -const OP = 'create'; // list create update remove - -nconf.argv().env().file('user', path.join(path.resolve(__dirname, '../../../config.json'))); - -/* eslint-disable camelcase */ -paypal.configure({ - mode: nconf.get('PAYPAL:mode'), // sandbox or live - client_id: nconf.get('PAYPAL:client_id'), - client_secret: nconf.get('PAYPAL:client_secret'), -}); - -// https://developer.paypal.com/docs/api/#billing-plans-and-agreements -let billingPlanAttributes = { - name: BILLING_PLAN_TITLE, - description: BILLING_PLAN_TITLE, - type: 'INFINITE', - merchant_preferences: { - auto_bill_amount: 'yes', - cancel_url: LIVE ? 'https://habitica.com' : 'http://localhost:3000', - return_url: LIVE ? 'https://habitica.com/paypal/subscribe/success' : 'http://localhost:3000/paypal/subscribe/success', - }, - payment_definitions: [{ - type: 'REGULAR', - frequency: 'MONTH', - cycles: '0', - }], -}; - -_.each(blocks, function defineBlock (block) { - block.definition = _.cloneDeep(billingPlanAttributes); - _.merge(block.definition.payment_definitions[0], { - name: `${BILLING_PLAN_TITLE} (\$${block.price} every ${block.months} months, recurring)`, - frequency_interval: `${block.months}`, - amount: { - currency: 'USD', - value: `${block.price}`, - }, - }); -}); - -let update = { - op: 'replace', - path: '/merchant_preferences', - value: { - cancel_url: 'https://habitica.com', - }, -}; - -switch (OP) { - case 'list': - paypal.billingPlan.list({status: 'ACTIVE'}, function listPlans () { - // TODO Was a console.log statement. Need proper response output - }); - break; - case 'get': - paypal.billingPlan.get(nconf.get('PAYPAL:billing_plans:12'), function getPlan () { - // TODO Was a console.log statement. Need proper response output - }); - break; - case 'update': - paypal.billingPlan.update(nconf.get('PAYPAL:billing_plans:12'), update, function updatePlan () { - // TODO Was a console.log statement. Need proper response output - }); - break; - case 'create': - paypal.billingPlan.create(blocks.google_6mo.definition, function createPlan (err, plan) { - if (err) return; // TODO Was a console.log statement. Need proper response output - if (plan.state === 'ACTIVE') - return; // TODO Was a console.log statement. Need proper response output - let billingPlanUpdateAttributes = [{ - op: 'replace', - path: '/', - value: { - state: 'ACTIVE', - }, - }]; - // Activate the plan by changing status to Active - paypal.billingPlan.update(plan.id, billingPlanUpdateAttributes, function activatePlan () { - // TODO Was a console.log statement. Need proper response output - }); - }); - break; - case 'remove': break; -} -/* eslint-enable camelcase */ diff --git a/website/src/controllers/top-level/payments/stripe.js b/website/src/controllers/top-level/payments/stripe.js index d211a60d89..a319d2d4f9 100644 --- a/website/src/controllers/top-level/payments/stripe.js +++ b/website/src/controllers/top-level/payments/stripe.js @@ -2,6 +2,7 @@ import stripeModule from 'stripe'; import shared from '../../../../../common'; import { BadRequest, + NotAuthorized, } from '../../../libs/api-v3/errors'; import { model as Coupon } from '../../../models/coupon'; import payments from '../../../libs/api-v3/payments'; @@ -18,22 +19,23 @@ const stripe = stripeModule(nconf.get('STRIPE_API_KEY')); let api = {}; /** + * @apiIgnore Payments are considered part of the private API * @api {post} /stripe/checkout Stripe checkout * @apiVersion 3.0.0 * @apiName StripeCheckout * @apiGroup Payments * - * @apiParam {string} id The token - * @apiParam {string} gift stringified json object, gift - * @apiParam {string} sub subscription, possible values are: basic_earned, basic_3mo, basic_6mo, google_6mo, basic_12mo - * @apiParam {string} coupon coupon for the matching subscription, required only for certain subscriptions - * @apiParam {string} email the customer email + * @apiParam {string} id Body parameter - The token + * @apiParam {string} email Body parameter - the customer email + * @apiParam {string} gift Query parameter - stringified json object, gift + * @apiParam {string} sub Query parameter - subscription, possible values are: basic_earned, basic_3mo, basic_6mo, google_6mo, basic_12mo + * @apiParam {string} coupon Query parameter - coupon for the matching subscription, required only for certain subscriptions * - * @apiSuccess {} empty object + * @apiSuccess {Object} data Empty object **/ api.checkout = { method: 'POST', - url: '/payments/stripe/checkout', + url: '/stripe/checkout', middlewares: [authWithHeaders()], async handler (req, res) { let token = req.body.id; @@ -49,15 +51,16 @@ api.checkout = { coupon = await Coupon.findOne({_id: cc.validate(req.query.coupon), event: sub.key}); if (!coupon) throw new BadRequest(res.t('invalidCoupon')); } - let customer = { + + response = await stripe.customers.create({ email: req.body.email, metadata: { uuid: user._id }, card: token, plan: sub.key, - }; - response = await stripe.customers.create(customer); + }); } else { let amount = 500; // $5 + if (gift) { if (gift.type === 'subscription') { amount = `${shared.content.subscriptionBlocks[gift.subscription.key].price * 100}`; @@ -65,6 +68,7 @@ api.checkout = { amount = `${gift.gems.amount / 4 * 100}`; } } + response = await stripe.charges.create({ amount, currency: 'usd', @@ -87,80 +91,74 @@ api.checkout = { paymentMethod: 'Stripe', gift, }; + if (gift) { let member = await User.findById(gift.uuid); gift.member = member; if (gift.type === 'subscription') method = 'createSubscription'; data.paymentMethod = 'Gift'; } + await payments[method](data); } + res.respond(200, {}); }, }; /** - * @api {post} /stripe/subscribe/edit Stripe subscribeEdit + * @apiIgnore Payments are considered part of the private API + * @api {post} /stripe/subscribe/edit Edit Stripe subscription * @apiVersion 3.0.0 * @apiName StripeSubscribeEdit * @apiGroup Payments * - * @apiParam {string} id The token + * @apiParam {string} id Body parameter - The token * - * @apiSuccess {} + * @apiSuccess {Object} data Empty object **/ api.subscribeEdit = { method: 'POST', - url: '/payments/stripe/subscribe/edit', + url: '/stripe/subscribe/edit', middlewares: [authWithHeaders()], async handler (req, res) { let token = req.body.id; let user = res.locals.user; let customerId = user.purchased.plan.customerId; - if (!customerId) throw new BadRequest(res.t('missingSubscription')); + if (!customerId) throw new NotAuthorized(res.t('missingSubscription')); - try { - let subscriptions = await stripe.customers.listSubscriptions(customerId); - let subscriptionId = subscriptions.data[0].id; - await stripe.customers.updateSubscription(customerId, subscriptionId, { card: token }); - res.respond(200, {}); - } catch (error) { - throw new BadRequest(error.message); - } + let subscriptions = await stripe.customers.listSubscriptions(customerId); + let subscriptionId = subscriptions.data[0].id; + await stripe.customers.updateSubscription(customerId, subscriptionId, { card: token }); + res.respond(200, {}); }, }; /** - * @api {get} /stripe/subscribe/cancel Stripe subscribeCancel + * @apiIgnore Payments are considered part of the private API + * @api {get} /stripe/subscribe/cancel Cancel Stripe subscription * @apiVersion 3.0.0 * @apiName StripeSubscribeCancel * @apiGroup Payments - * - * @apiParam - * - * @apiSuccess {} **/ api.subscribeCancel = { method: 'GET', - url: '/payments/stripe/subscribe/cancel', + url: '/stripe/subscribe/cancel', middlewares: [authWithUrl], async handler (req, res) { let user = res.locals.user; - if (!user.purchased.plan.customerId) throw new BadRequest(res.t('missingSubscription')); - try { - let customer = await stripe.customers.retrieve(user.purchased.plan.customeerId); - await stripe.customers.del(user.purchased.plan.customerId); - let data = { - user, - nextBill: customer.subscription.current_period_end * 1000, // timestamp in seconds - paymentMethod: 'Stripe', - }; - await payments.cancelSubscriptoin(data); - res.respond(200, {}); - } catch (e) { - throw new BadRequest(e); - } + if (!user.purchased.plan.customerId) throw new NotAuthorized(res.t('missingSubscription')); + + let customer = await stripe.customers.retrieve(user.purchased.plan.customeerId); + await stripe.customers.del(user.purchased.plan.customerId); + await payments.cancelSubscriptoin({ + user, + nextBill: customer.subscription.current_period_end * 1000, // timestamp in seconds + paymentMethod: 'Stripe', + }); + + res.redirect('/'); }, }; diff --git a/website/src/libs/api-v3/amazonPayments.js b/website/src/libs/api-v3/amazonPayments.js index 38403ba4d0..c22b3cfb3b 100644 --- a/website/src/libs/api-v3/amazonPayments.js +++ b/website/src/libs/api-v3/amazonPayments.js @@ -1,9 +1,13 @@ import amazonPayments from 'amazon-payments'; import nconf from 'nconf'; import common from '../../../../common'; -let t = common.i18n.t; -const IS_PROD = nconf.get('NODE_ENV') === 'production'; import Q from 'q'; +import { + BadRequest, +} from './errors'; + +const t = common.i18n.t; +const IS_PROD = nconf.get('NODE_ENV') === 'production'; let amzPayment = amazonPayments.connect({ environment: amazonPayments.Environment[IS_PROD ? 'Production' : 'Sandbox'], @@ -13,10 +17,6 @@ let amzPayment = amazonPayments.connect({ clientId: nconf.get('AMAZON_PAYMENTS:CLIENT_ID'), }); -/** - * From: https://payments.amazon.com/documentation/apireference/201751670#201751670 - */ - let getTokenInfo = Q.nbind(amzPayment.api.getTokenInfo, amzPayment.api); let createOrderReferenceId = Q.nbind(amzPayment.offAmazonPayments.createOrderReferenceForId, amzPayment.offAmazonPayments); let setOrderReferenceDetails = Q.nbind(amzPayment.offAmazonPayments.setOrderReferenceDetails, amzPayment.offAmazonPayments); @@ -30,7 +30,7 @@ let authorizeOnBillingAgreement = (inputSet) => { return new Promise((resolve, reject) => { amzPayment.offAmazonPayments.authorizeOnBillingAgreement(inputSet, (err, response) => { if (err) return reject(err); - if (response.AuthorizationDetails.AuthorizationStatus.State === 'Declined') return reject(t('paymentNotSuccessful')); + if (response.AuthorizationDetails.AuthorizationStatus.State === 'Declined') return reject(new BadRequest(t('paymentNotSuccessful'))); return resolve(response); }); }); @@ -40,7 +40,7 @@ let authorize = (inputSet) => { return new Promise((resolve, reject) => { amzPayment.offAmazonPayments.authorize(inputSet, (err, response) => { if (err) return reject(err); - if (response.AuthorizationDetails.AuthorizationStatus.State === 'Declined') return reject(t('paymentNotSuccessful')); + if (response.AuthorizationDetails.AuthorizationStatus.State === 'Declined') return reject(new BadRequest(t('paymentNotSuccessful'))); return resolve(response); }); }); diff --git a/website/src/libs/api-v3/payments.js b/website/src/libs/api-v3/payments.js index 8a28e7d1a9..5a9c888972 100644 --- a/website/src/libs/api-v3/payments.js +++ b/website/src/libs/api-v3/payments.js @@ -10,10 +10,6 @@ import nconf from 'nconf'; import pushNotify from './pushNotifications'; import shared from '../../../../common' ; -import iap from '../../controllers/top-level/payments/iap'; -import paypal from '../../controllers/top-level/payments/paypal'; -import stripe from '../../controllers/top-level/payments/stripe'; - const IS_PROD = nconf.get('IS_PROD'); let api = {}; @@ -45,6 +41,7 @@ api.createSubscription = async function createSubscription (data) { plan.dateTerminated = moment(plan.dateTerminated).add({months}).toDate(); if (!plan.dateUpdated) plan.dateUpdated = new Date(); } + if (!plan.customerId) plan.customerId = 'Gift'; // don't override existing customer, but all sub need a customerId } else { _(plan).merge({ // override with these values @@ -73,12 +70,13 @@ api.createSubscription = async function createSubscription (data) { if (plan.consecutive.gemCapExtra > 25) plan.consecutive.gemCapExtra = 25; plan.consecutive.trinkets += perks; } + revealMysteryItems(recipient); if (IS_PROD) { if (!data.gift) txnEmail(data.user, 'subscription-begins'); - let analyticsData = { + analytics.trackPurchase({ uuid: data.user._id, itemPurchased: 'Subscription', sku: `${data.paymentMethod.toLowerCase()}-subscription`, @@ -87,8 +85,7 @@ api.createSubscription = async function createSubscription (data) { quantity: 1, gift: Boolean(data.gift), purchaseValue: block.price, - }; - analytics.trackPurchase(analyticsData); + }); } data.user.purchased.txnCount++; @@ -114,9 +111,7 @@ api.createSubscription = async function createSubscription (data) { if (data.gift) await data.gift.member.save(); }; -/** - * Sets their subscription to be cancelled later - */ +// Sets their subscription to be cancelled later api.cancelSubscription = async function cancelSubscription (data) { let plan = data.user.purchased.plan; let now = moment(); @@ -146,12 +141,14 @@ api.cancelSubscription = async function cancelSubscription (data) { api.buyGems = async function buyGems (data) { let amt = data.amount || 5; amt = data.gift ? data.gift.gems.amount / 4 : amt; + (data.gift ? data.gift.member : data.user).balance += amt; data.user.purchased.txnCount++; + if (IS_PROD) { if (!data.gift) txnEmail(data.user, 'donation'); - let analyticsData = { + analytics.trackPurchase({ uuid: data.user._id, itemPurchased: 'Gems', sku: `${data.paymentMethod.toLowerCase()}-checkout`, @@ -160,8 +157,7 @@ api.buyGems = async function buyGems (data) { quantity: 1, gift: Boolean(data.gift), purchaseValue: amt, - }; - analytics.trackPurchase(analyticsData); + }); } if (data.gift) { @@ -179,23 +175,11 @@ api.buyGems = async function buyGems (data) { if (data.gift.member._id !== data.user._id) { // Only send push notifications if sending to a user other than yourself pushNotify.sendNotify(data.gift.member, shared.i18n.t('giftedGems'), `${gemAmount} Gems - by ${byUsername}`); } + await data.gift.member.save(); } + await data.user.save(); }; -api.stripeCheckout = stripe.checkout; -api.stripeSubscribeCancel = stripe.subscribeCancel; -api.stripeSubscribeEdit = stripe.subscribeEdit; - -api.paypalSubscribe = paypal.createBillingAgreement; -api.paypalSubscribeSuccess = paypal.executeBillingAgreement; -api.paypalSubscribeCancel = paypal.cancelSubscription; -api.paypalCheckout = paypal.createPayment; -api.paypalCheckoutSuccess = paypal.executePayment; -api.paypalIPN = paypal.ipn; - -api.iapAndroidVerify = iap.androidVerify; -api.iapIosVerify = iap.iosVerify; - module.exports = api; diff --git a/website/src/middlewares/api-v3/errorHandler.js b/website/src/middlewares/api-v3/errorHandler.js index d775292a5b..027e25301b 100644 --- a/website/src/middlewares/api-v3/errorHandler.js +++ b/website/src/middlewares/api-v3/errorHandler.js @@ -52,6 +52,12 @@ module.exports = function errorHandler (err, req, res, next) { // eslint-disable }); } + // Handle Stripe Card errors errors (can be safely shown to the users) + // https://stripe.com/docs/api/node#errors + if (err.type === 'StripeCardError') { + responseErr = new BadRequest(err.message); + } + if (!responseErr || responseErr.httpCode >= 500) { // Try to identify the error... // ... diff --git a/website/src/middlewares/api-v3/v2.js b/website/src/middlewares/api-v3/v2.js index 4eb2686bdc..ec49e326a4 100644 --- a/website/src/middlewares/api-v3/v2.js +++ b/website/src/middlewares/api-v3/v2.js @@ -19,8 +19,6 @@ v2app.use(responseHandler); // Custom Directives v2app.use('/', require('../../routes/api-v2/auth')); -// v2app.use('/', require('../../routes/api-v2/coupon')); // TODO REMOVE - ONLY v3 -// v2app.use('/', require('../../routes/api-v2/unsubscription')); // TODO REMOVE - ONLY v3 require('../../routes/api-v2/swagger')(swagger, v2app); diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index caa823acc1..ee0f7209a1 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -5,7 +5,14 @@ import baseModel from '../libs/api-v3/baseModel'; import _ from 'lodash'; import * as Tasks from './task'; import { model as User } from './user'; +import { + model as Group, + TAVERN_ID, +} from './group'; import { removeFromArray } from '../libs/api-v3/collectionManipulators'; +import shared from '../../../common'; +import { sendTxn as txnEmail } from '../libs/api-v3/email'; +import sendPushNotification from '../libs/api-v3/pushNotifications'; let Schema = mongoose.Schema; @@ -251,6 +258,65 @@ schema.methods.unlinkTasks = async function challengeUnlinkTasks (user, keep) { } }; +// TODO everything here should be moved to a worker +// actually even for a worker it's probably just too big and will kill mongo +schema.methods.closeChal = async function closeChal (broken = {}) { + let challenge = this; + + let winner = broken.winner; + let brokenReason = broken.broken; + + // Delete the challenge + await this.model('Challenge').remove({_id: challenge._id}).exec(); + + // Refund the leader if the challenge is closed and the group not the tavern + if (challenge.group !== TAVERN_ID && brokenReason === 'CHALLENGE_DELETED') { + await User.update({_id: challenge.leader}, {$inc: {balance: challenge.prize / 4}}).exec(); + } + + // Update the challengeCount on the group + await Group.update({_id: challenge.group}, {$inc: {challengeCount: -1}}).exec(); + + // Award prize to winner and notify + if (winner) { + winner.achievements.challenges.push(challenge.name); + winner.balance += challenge.prize / 4; + let savedWinner = await winner.save(); + if (savedWinner.preferences.emailNotifications.wonChallenge !== false) { + txnEmail(savedWinner, 'won-challenge', [ + {name: 'CHALLENGE_NAME', content: challenge.name}, + ]); + } + + sendPushNotification(savedWinner, shared.i18n.t('wonChallenge'), challenge.name); + } + + // Run some operations in the background withouth blocking the thread + let backgroundTasks = [ + // And it's tasks + Tasks.Task.remove({'challenge.id': challenge._id, userId: {$exists: false}}).exec(), + // Set the challenge tag to non-challenge status and remove the challenge from the user's challenges + User.update({ + challenges: challenge._id, + 'tags._id': challenge._id, + }, { + $set: {'tags.$.challenge': false}, + $pull: {challenges: challenge._id}, + }, {multi: true}).exec(), + // Break users' tasks + Tasks.Task.update({ + 'challenge.id': challenge._id, + }, { + $set: { + 'challenge.broken': brokenReason, + 'challenge.winner': winner && winner.profile.name, + }, + }, {multi: true}).exec(), + ]; + + Q.all(backgroundTasks); +}; + // Methods to adapt the new schema to API v2 responses (mostly tasks inside the challenge model) // These will be removed once API v2 is discontinued From 1a43ab35c079d1ad8a6908a97638dfc45d45ab4d Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 10 May 2016 16:56:01 +0200 Subject: [PATCH 30/34] v3 payments: port amazon payments --- website/public/js/services/paymentServices.js | 12 +- .../controllers/top-level/payments/amazon.js | 259 +++++++++--------- .../controllers/top-level/payments/stripe.js | 4 + website/src/libs/api-v3/amazonPayments.js | 8 +- 4 files changed, 137 insertions(+), 146 deletions(-) diff --git a/website/public/js/services/paymentServices.js b/website/public/js/services/paymentServices.js index a3fc832d24..c5bfeae262 100644 --- a/website/public/js/services/paymentServices.js +++ b/website/public/js/services/paymentServices.js @@ -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/top-level/payments/amazon.js b/website/src/controllers/top-level/payments/amazon.js index 17b8486389..a22618fa23 100644 --- a/website/src/controllers/top-level/payments/amazon.js +++ b/website/src/controllers/top-level/payments/amazon.js @@ -18,13 +18,11 @@ let api = {}; /** * @apiIgnore Payments are considered part of the private API - * @api {post} /amazon/verifyAccessToken verify access token + * @api {post} /amazon/verifyAccessToken Amazon Payments: verify access token * @apiVersion 3.0.0 * @apiName AmazonVerifyAccessToken * @apiGroup Payments * - * @apiParam {string} access_token the access token - * * @apiSuccess {Object} data Empty object **/ api.verifyAccessToken = { @@ -32,56 +30,53 @@ api.verifyAccessToken = { url: '/amazon/verifyAccessToken', middlewares: [authWithHeaders()], async handler (req, res) { - try { - await amzLib.getTokenInfo(req.body.access_token); - res.respond(200, {}); - } catch (error) { - throw new BadRequest(error.body.error_description); - } + 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 create order reference id + * @api {post} /amazon/createOrderReferenceId Amazon Payments: create order reference id * @apiVersion 3.0.0 * @apiName AmazonCreateOrderReferenceId * @apiGroup Payments * - * @apiParam {string} billingAgreementId billing agreement id - * - * @apiSuccess {object} data.orderReferenceId The order reference id. + * @apiSuccess {string} data.orderReferenceId The order reference id. **/ api.createOrderReferenceId = { method: 'POST', url: '/amazon/createOrderReferenceId', middlewares: [authWithHeaders()], async handler (req, res) { - try { - let response = await amzLib.createOrderReferenceId({ - Id: req.body.billingAgreementId, - IdType: 'BillingAgreement', - ConfirmNow: false, - }); - res.respond(200, { - orderReferenceId: response.OrderReferenceDetails.AmazonOrderReferenceId, - }); - } catch (error) { - throw new BadRequest(error); - } + 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 do checkout + * @api {post} /amazon/checkout Amazon Payments: checkout * @apiVersion 3.0.0 * @apiName AmazonCheckout * @apiGroup Payments * - * @apiParam {string} billingAgreementId billing agreement id - * - * @apiSuccess {object} object containing { orderReferenceId } + * @apiSuccess {object} data Empty object **/ api.checkout = { method: 'POST', @@ -93,6 +88,8 @@ api.checkout = { 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; @@ -101,68 +98,62 @@ api.checkout = { } } - try { - 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: { + await amzLib.setOrderReferenceDetails({ + AmazonOrderReferenceId: orderReferenceId, + OrderReferenceAttributes: { + OrderTotal: { CurrencyCode: 'USD', Amount: amount, }, - SellerAuthorizationNote: 'HabitRPG Payment', - TransactionTimeout: 0, - CaptureNow: true, - }); + SellerNote: 'HabitRPG Payment', + SellerOrderAttributes: { + SellerOrderId: shared.uuid(), + StoreName: 'HabitRPG', + }, + }, + }); - await amzLib.closeOrderReference({ AmazonOrderReferenceId: orderReferenceId }); + await amzLib.confirmOrderReference({ 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); + await amzLib.authorize({ + AmazonOrderReferenceId: orderReferenceId, + AuthorizationReferenceId: shared.uuid().substring(0, 32), + AuthorizationAmount: { + CurrencyCode: 'USD', + Amount: amount, + }, + SellerAuthorizationNote: 'HabitRPG Payment', + TransactionTimeout: 0, + CaptureNow: true, + }); - res.respond(200); - } catch (error) { - throw new BadRequest(error); + 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 Subscribe + * @api {post} /amazon/subscribe Amazon Payments: subscribe * @apiVersion 3.0.0 * @apiName AmazonSubscribe * @apiGroup Payments * - * @apiParam {string} billingAgreementId billing agreement id - * @apiParam {string} subscription Subscription plan - * @apiParam {string} coupon Coupon - * - * @apiSuccess {object} data.orderReferenceId The order reference id. + * @apiSuccess {object} data Empty object **/ api.subscribe = { method: 'POST', @@ -174,67 +165,62 @@ api.subscribe = { let coupon = req.body.coupon; let user = res.locals.user; - if (!sub) { - throw new BadRequest(res.t('missingSubscriptionCode')); + 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')); } - try { - 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 BadRequest(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(), + await amzLib.setBillingAgreementDetails({ + AmazonBillingAgreementId: billingAgreementId, + BillingAgreementAttributes: { + SellerNote: 'HabitRPG Subscription', + SellerBillingAgreementAttributes: { + SellerBillingAgreementId: shared.uuid(), StoreName: 'HabitRPG', + CustomInformation: 'HabitRPG Subscription', }, - }); + }, + }); - await payments.createSubscription({ - user, - customerId: billingAgreementId, - paymentMethod: 'Amazon Payments', - sub, - }); + await amzLib.confirmBillingAgreement({ + AmazonBillingAgreementId: billingAgreementId, + }); - res.respond(200); - } catch (error) { - throw new BadRequest(error); - } + 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 SubscribeCancel + * @api {get} /amazon/subscribe/cancel Amazon Payments: subscribe cancel * @apiVersion 3.0.0 * @apiName AmazonSubscribe * @apiGroup Payments @@ -249,21 +235,20 @@ api.subscribeCancel = { if (!billingAgreementId) throw new NotAuthorized(res.t('missingSubscription')); - try { - await amzLib.closeBillingAgreement({ - AmazonBillingAgreementId: billingAgreementId, - }); + await amzLib.closeBillingAgreement({ + AmazonBillingAgreementId: billingAgreementId, + }); - let data = { - user, - nextBill: moment(user.purchased.plan.lastBillingDate).add({ days: 30 }), - paymentMethod: 'Amazon Payments', - }; - await payments.cancelSubscription(data); + 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('/'); - } catch (error) { - throw new BadRequest(error.message); } }, }; diff --git a/website/src/controllers/top-level/payments/stripe.js b/website/src/controllers/top-level/payments/stripe.js index a319d2d4f9..2ac8c863f7 100644 --- a/website/src/controllers/top-level/payments/stripe.js +++ b/website/src/controllers/top-level/payments/stripe.js @@ -45,6 +45,8 @@ api.checkout = { 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')); @@ -127,10 +129,12 @@ api.subscribeEdit = { 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, {}); }, }; diff --git a/website/src/libs/api-v3/amazonPayments.js b/website/src/libs/api-v3/amazonPayments.js index c22b3cfb3b..338a6acd08 100644 --- a/website/src/libs/api-v3/amazonPayments.js +++ b/website/src/libs/api-v3/amazonPayments.js @@ -6,7 +6,9 @@ import { BadRequest, } from './errors'; -const t = common.i18n.t; +// TODO better handling of errors + +const i18n = common.i18n; const IS_PROD = nconf.get('NODE_ENV') === 'production'; let amzPayment = amazonPayments.connect({ @@ -30,7 +32,7 @@ let authorizeOnBillingAgreement = (inputSet) => { return new Promise((resolve, reject) => { amzPayment.offAmazonPayments.authorizeOnBillingAgreement(inputSet, (err, response) => { if (err) return reject(err); - if (response.AuthorizationDetails.AuthorizationStatus.State === 'Declined') return reject(new BadRequest(t('paymentNotSuccessful'))); + if (response.AuthorizationDetails.AuthorizationStatus.State === 'Declined') return reject(new BadRequest(i18n.t('paymentNotSuccessful'))); return resolve(response); }); }); @@ -40,7 +42,7 @@ let authorize = (inputSet) => { return new Promise((resolve, reject) => { amzPayment.offAmazonPayments.authorize(inputSet, (err, response) => { if (err) return reject(err); - if (response.AuthorizationDetails.AuthorizationStatus.State === 'Declined') return reject(new BadRequest(t('paymentNotSuccessful'))); + if (response.AuthorizationDetails.AuthorizationStatus.State === 'Declined') return reject(new BadRequest(i18n.t('paymentNotSuccessful'))); return resolve(response); }); }); From cd84ebd4c5da6f61e959636ac55b4d696508d592 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 10 May 2016 16:57:12 +0200 Subject: [PATCH 31/34] v3 payments: fix client errors for Stripe --- website/public/js/services/paymentServices.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/public/js/services/paymentServices.js b/website/public/js/services/paymentServices.js index c5bfeae262..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); }); } }); From 1c887b18e1aec0ddcbd0175c217f78e4b2882325 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 10 May 2016 17:00:47 +0200 Subject: [PATCH 32/34] v3 payments: fix urls in tests --- .../payments/GET-payments_amazon_subscribe_cancel.test.js | 2 +- .../integration/payments/GET-payments_paypal_checkout.test.js | 2 +- .../payments/GET-payments_paypal_checkout_success.test.js | 2 +- .../integration/payments/GET-payments_paypal_subscribe.test.js | 2 +- .../payments/GET-payments_paypal_subscribe_cancel.test.js | 2 +- .../payments/GET-payments_paypal_subscribe_success.test.js | 2 +- .../payments/GET-payments_stripe_subscribe_cancel.test.js | 2 +- .../integration/payments/POST-payments_amazon_checkout.test.js | 2 +- .../POST-payments_amazon_createOrderReferenceId.test.js | 2 +- .../integration/payments/POST-payments_amazon_subscribe.test.js | 2 +- .../payments/POST-payments_amazon_verifyAccessToken.test.js | 2 +- .../v3/integration/payments/POST-payments_paypal_ipn.test.js | 2 +- .../integration/payments/POST-payments_stripe_checkout.test.js | 2 +- .../payments/POST-payments_stripe_subscribe_edit.test.js | 2 +- 14 files changed, 14 insertions(+), 14 deletions(-) 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 index 1f05739bb9..007c58f4f7 100644 --- 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 @@ -4,7 +4,7 @@ import { } from '../../../../helpers/api-integration/v3'; describe('payments : amazon #subscribeCancel', () => { - let endpoint = '/payments/amazon/subscribe/cancel'; + let endpoint = '/amazon/subscribe/cancel'; let user; beforeEach(async () => { 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 index 12ea7c8ee9..25fc501000 100644 --- a/test/api/v3/integration/payments/GET-payments_paypal_checkout.test.js +++ b/test/api/v3/integration/payments/GET-payments_paypal_checkout.test.js @@ -4,7 +4,7 @@ import { } from '../../../../helpers/api-integration/v3'; describe('payments : paypal #checkout', () => { - let endpoint = '/payments/paypal/checkout'; + let endpoint = '/paypal/checkout'; let user; beforeEach(async () => { 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 index 4dae9d8485..346b8ce847 100644 --- 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 @@ -4,7 +4,7 @@ import { } from '../../../../helpers/api-integration/v3'; describe('payments : paypal #checkoutSuccess', () => { - let endpoint = '/payments/paypal/checkout/success'; + let endpoint = '/paypal/checkout/success'; let user; beforeEach(async () => { 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 index 7640cfdf92..c52309675a 100644 --- a/test/api/v3/integration/payments/GET-payments_paypal_subscribe.test.js +++ b/test/api/v3/integration/payments/GET-payments_paypal_subscribe.test.js @@ -4,7 +4,7 @@ import { } from '../../../../helpers/api-integration/v3'; describe('payments : paypal #subscribe', () => { - let endpoint = '/payments/paypal/subscribe'; + let endpoint = '/paypal/subscribe'; let user; beforeEach(async () => { 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 index 2e4ccedf01..890bc864b6 100644 --- 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 @@ -4,7 +4,7 @@ import { } from '../../../../helpers/api-integration/v3'; describe('payments : paypal #subscribeCancel', () => { - let endpoint = '/payments/paypal/subscribe/cancel'; + let endpoint = '/paypal/subscribe/cancel'; let user; beforeEach(async () => { 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 index 961556ff8b..31bae03e40 100644 --- 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 @@ -4,7 +4,7 @@ import { } from '../../../../helpers/api-integration/v3'; describe('payments : paypal #subscribeSuccess', () => { - let endpoint = '/payments/paypal/subscribe/success'; + let endpoint = '/paypal/subscribe/success'; let user; beforeEach(async () => { 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 index b65d4ea6c2..68747eb535 100644 --- 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 @@ -4,7 +4,7 @@ import { } from '../../../../helpers/api-integration/v3'; describe('payments - stripe - #subscribeCancel', () => { - let endpoint = '/payments/stripe/subscribe/cancel'; + let endpoint = '/stripe/subscribe/cancel'; let user; beforeEach(async () => { 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 index 846416ed5d..6a574eb204 100644 --- a/test/api/v3/integration/payments/POST-payments_amazon_checkout.test.js +++ b/test/api/v3/integration/payments/POST-payments_amazon_checkout.test.js @@ -3,7 +3,7 @@ import { } from '../../../../helpers/api-integration/v3'; describe('payments - amazon - #checkout', () => { - let endpoint = '/payments/amazon/checkout'; + let endpoint = '/amazon/checkout'; let user; beforeEach(async () => { 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 index 3eb00b7c3c..17a50520eb 100644 --- a/test/api/v3/integration/payments/POST-payments_amazon_createOrderReferenceId.test.js +++ b/test/api/v3/integration/payments/POST-payments_amazon_createOrderReferenceId.test.js @@ -3,7 +3,7 @@ import { } from '../../../../helpers/api-integration/v3'; describe('payments - amazon - #createOrderReferenceId', () => { - let endpoint = '/payments/amazon/createOrderReferenceId'; + let endpoint = '/amazon/createOrderReferenceId'; let user; beforeEach(async () => { 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 index 02a30a7ce5..5c3b98ad87 100644 --- a/test/api/v3/integration/payments/POST-payments_amazon_subscribe.test.js +++ b/test/api/v3/integration/payments/POST-payments_amazon_subscribe.test.js @@ -4,7 +4,7 @@ import { } from '../../../../helpers/api-integration/v3'; describe('payments - amazon - #subscribe', () => { - let endpoint = '/payments/amazon/subscribe'; + let endpoint = '/amazon/subscribe'; let user; beforeEach(async () => { 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 index ecc021e25d..db8edbabb0 100644 --- a/test/api/v3/integration/payments/POST-payments_amazon_verifyAccessToken.test.js +++ b/test/api/v3/integration/payments/POST-payments_amazon_verifyAccessToken.test.js @@ -4,7 +4,7 @@ import { } from '../../../../helpers/api-integration/v3'; describe('payments : amazon', () => { - let endpoint = '/payments/amazon/verifyAccessToken'; + let endpoint = '/amazon/verifyAccessToken'; let user; beforeEach(async () => { 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 index dcdbd14c44..f8e6c74f82 100644 --- a/test/api/v3/integration/payments/POST-payments_paypal_ipn.test.js +++ b/test/api/v3/integration/payments/POST-payments_paypal_ipn.test.js @@ -3,7 +3,7 @@ import { } from '../../../../helpers/api-integration/v3'; describe('payments - paypal - #ipn', () => { - let endpoint = '/payments/paypal/ipn'; + let endpoint = '/paypal/ipn'; let user; beforeEach(async () => { 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 index bc4d857a03..3f0cc15eaf 100644 --- a/test/api/v3/integration/payments/POST-payments_stripe_checkout.test.js +++ b/test/api/v3/integration/payments/POST-payments_stripe_checkout.test.js @@ -3,7 +3,7 @@ import { } from '../../../../helpers/api-integration/v3'; describe('payments - stripe - #checkout', () => { - let endpoint = '/payments/stripe/checkout'; + let endpoint = '/stripe/checkout'; let user; beforeEach(async () => { 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 index c456d389a4..4b2f889888 100644 --- 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 @@ -4,7 +4,7 @@ import { } from '../../../../helpers/api-integration/v3'; describe('payments - stripe - #subscribeEdit', () => { - let endpoint = '/payments/stripe/subscribe/edit'; + let endpoint = '/stripe/subscribe/edit'; let user; beforeEach(async () => { From d33564e5d445de8b5e4f093b48f01e347f8c4ed9 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 10 May 2016 17:57:55 +0200 Subject: [PATCH 33/34] v3 payments: port paypal --- test/helpers/api-integration/requester.js | 2 +- .../controllers/top-level/payments/paypal.js | 188 ++++++++---------- 2 files changed, 82 insertions(+), 108 deletions(-) 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/src/controllers/top-level/payments/paypal.js b/website/src/controllers/top-level/payments/paypal.js index 1a3240d82e..a940511c64 100644 --- a/website/src/controllers/top-level/payments/paypal.js +++ b/website/src/controllers/top-level/payments/paypal.js @@ -8,6 +8,7 @@ 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 { @@ -16,8 +17,8 @@ import { } from '../../../middlewares/api-v3/auth'; import { BadRequest, + NotAuthorized, } from '../../../libs/api-v3/errors'; -import * as logger from '../../../libs/api-v3/logger'; const BASE_URL = nconf.get('BASE_URL'); @@ -34,17 +35,24 @@ paypal.configure({ 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 - * @apiDescription Redirects to Paypal + * @api {get} /paypal/checkout Paypal: checkout * @apiVersion 3.0.0 * @apiName PaypalCheckout * @apiGroup Payments - * - * @apiParam {string} gift Query parameter - The stringified object representing the user, the gift recepient. **/ api.checkout = { method: 'GET', @@ -62,7 +70,7 @@ api.checkout = { description = `${description} (Gift)`; } else { amount = Number(shared.content.subscriptionBlocks[gift.subscription.key].price).toFixed(2); - description = 'monthly HabitRPG Subscription (Gift)'; + description = 'mo. HabitRPG Subscription (Gift)'; } } @@ -77,6 +85,7 @@ api.checkout = { item_list: { items: [{ name: description, + // sku: 1, price: amount, currency: 'USD', quality: 1, @@ -90,27 +99,18 @@ api.checkout = { }], }; - 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); - } + 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 + * @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', @@ -119,6 +119,7 @@ api.checkoutSuccess = { async handler (req, res) { let paymentId = req.query.paymentId; let customerId = req.query.payerID; + let method = 'buyGems'; let data = { user: res.locals.user, @@ -126,38 +127,31 @@ api.checkoutSuccess = { 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; + 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'; } - await paypal.payment.execute(paymentId, { payer_id: customerId }); - await payments[method](data); - res.redirect('/'); - } catch (e) { - throw new BadRequest(e); + 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 + * @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', @@ -165,17 +159,18 @@ api.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')); + 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(), + start_date: moment().add({ minutes: 5 }).format(), plan: { id: sub.paypalKey, }, @@ -183,27 +178,20 @@ api.subscribe = { 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); - } + 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 + * @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', @@ -213,31 +201,25 @@ api.subscribeSuccess = { 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); - } + + 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 + * @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', @@ -246,59 +228,51 @@ api.subscribeCancel = { 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); + 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 - * - * @apiParam {string} txn_type txn_type - * @apiParam {string} recurring_payment_id recurring_payment_id - * - * @apiSuccess {} empty object **/ api.ipn = { method: 'POST', url: '/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' }); - } + 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' }); } - } catch (e) { - logger.error(e); } }, }; -/* eslint-disable camelcase */ - module.exports = api; From b3a78fba973dd59cd7d3406769b98a0f9576ae49 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 10 May 2016 18:14:28 +0200 Subject: [PATCH 34/34] v3 payments: fix tests --- common/locales/en/api-v3.json | 2 -- .../payments/POST-payments_amazon_checkout.test.js | 14 ++++++-------- .../POST-payments_amazon_verifyAccessToken.test.js | 3 +-- .../payments/POST-payments_paypal_ipn.test.js | 2 +- .../payments/POST-payments_stripe_checkout.test.js | 2 +- .../POST-payments_stripe_subscribe_edit.test.js | 4 ++-- 6 files changed, 11 insertions(+), 16 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index d41c2a78d6..e0e768adf8 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -175,8 +175,6 @@ "resetComplete": "Reset completed", "lvl10ChangeClass": "To change class you must be at least level 10.", "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", "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/test/api/v3/integration/payments/POST-payments_amazon_checkout.test.js b/test/api/v3/integration/payments/POST-payments_amazon_checkout.test.js index 6a574eb204..8745a74e85 100644 --- a/test/api/v3/integration/payments/POST-payments_amazon_checkout.test.js +++ b/test/api/v3/integration/payments/POST-payments_amazon_checkout.test.js @@ -10,13 +10,11 @@ describe('payments - amazon - #checkout', () => { user = await generateUser(); }); - it('verifies credentials', async (done) => { - try { - await user.post(endpoint); - } catch (e) { - expect(e.error).to.eql('BadRequest'); - expect(e.message.type).to.eql('InvalidParameterValue'); - done(); - } + 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_verifyAccessToken.test.js b/test/api/v3/integration/payments/POST-payments_amazon_verifyAccessToken.test.js index db8edbabb0..51ccf8c41c 100644 --- a/test/api/v3/integration/payments/POST-payments_amazon_verifyAccessToken.test.js +++ b/test/api/v3/integration/payments/POST-payments_amazon_verifyAccessToken.test.js @@ -1,6 +1,5 @@ import { generateUser, - translate as t, } from '../../../../helpers/api-integration/v3'; describe('payments : amazon', () => { @@ -15,7 +14,7 @@ describe('payments : amazon', () => { await expect(user.post(endpoint)).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', - message: t('missingAccessToken'), + 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 index f8e6c74f82..219e9ce35b 100644 --- a/test/api/v3/integration/payments/POST-payments_paypal_ipn.test.js +++ b/test/api/v3/integration/payments/POST-payments_paypal_ipn.test.js @@ -12,6 +12,6 @@ describe('payments - paypal - #ipn', () => { it('verifies credentials', async () => { let result = await user.post(endpoint); - expect(result).to.eql({}); + 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 index 3f0cc15eaf..1443a3af74 100644 --- a/test/api/v3/integration/payments/POST-payments_stripe_checkout.test.js +++ b/test/api/v3/integration/payments/POST-payments_stripe_checkout.test.js @@ -11,7 +11,7 @@ describe('payments - stripe - #checkout', () => { }); it('verifies credentials', async () => { - await expect(user.post(endpoint)).to.eventually.be.rejected.and.eql({ + 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 index 4b2f889888..d6d568ace4 100644 --- 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 @@ -13,8 +13,8 @@ describe('payments - stripe - #subscribeEdit', () => { it('verifies credentials', async () => { await expect(user.post(endpoint)).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', + code: 401, + error: 'NotAuthorized', message: t('missingSubscription'), }); });