diff --git a/test/api/v3/unit/libs/inAppPurchases.test.js b/test/api/v3/unit/libs/inAppPurchases.test.js new file mode 100644 index 0000000000..5080bdf79f --- /dev/null +++ b/test/api/v3/unit/libs/inAppPurchases.test.js @@ -0,0 +1,41 @@ +import { model as User } from '../../../../../website/server/models/user'; +import requireAgain from 'require-again'; +import iapLibrary from 'in-app-purchase'; + +describe.only('In App Purchases', () => { + let user; + let pathToIAP = '../../../../../website/server/libs/api-v3/inAppPurchases'; + let iap; + let setupSpy; + let validateSpy; + let isValidatedSpy; + + beforeEach(() => { + user = new User(); + setupSpy = sinon.spy(); + validateSpy = sinon.spy(); + isValidatedSpy = sinon.spy(); + + sandbox.stub(iapLibrary, 'setup').returns((err) => setupSpy(err)); + sandbox.stub(iapLibrary, 'validate').returns((err) => validateSpy(err)); + sandbox.stub(iapLibrary, 'isValidated').returns(isValidatedSpy); + + iap = requireAgain(pathToIAP); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('Android', () => { + it('applies new valid receipt', async () => { + await iap.iapAndroidVerify(user, { + receipt: {token: 1}, + }); + + expect(setupSpy).to.have.been.called; + expect(validateSpy).to.have.been.called; + expect(isValidatedSpy).to.have.been.called; + }); + }); +}); diff --git a/website/server/controllers/top-level/payments/iap.js b/website/server/controllers/top-level/payments/iap.js index e99590fa71..884c6b72c9 100644 --- a/website/server/controllers/top-level/payments/iap.js +++ b/website/server/controllers/top-level/payments/iap.js @@ -1,22 +1,13 @@ -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'; +import { + iapAndroidVerify, + iapIOSVerify, +} from '../../../libs/api-v3/inAppPurchases'; -// NOT PORTED TO v3 - -iap.config({ - // this is the path to the directory containing iap-sanbox/iap-live files - googlePublicKeyPath: nconf.get('IAP_GOOGLE_KEYDIR'), -}); - -// Validation ERROR Codes -const INVALID_PAYLOAD = 6778001; -// const CONNECTION_FAILED = 6778002; -// const PURCHASE_EXPIRED = 6778003; +// IMPORTANT: NOT PORTED TO v3 standards (not using res.respond) let api = {}; @@ -32,57 +23,8 @@ api.iapAndroidVerify = { url: '/iap/android/verify', middlewares: [authWithUrl], async handler (req, res) { - let user = res.locals.user; - let iapBody = req.body; - - iap.setup((error) => { - if (error) { - let resObj = { - ok: false, - data: 'IAP Error', - }; - - return res.json(resObj); - } - - // google receipt must be provided as an object - // { - // "data": "{stringified data object}", - // "signature": "signature from google" - // } - let testObj = { - data: iapBody.transaction.receipt, - signature: iapBody.transaction.signature, - }; - - // iap is ready - iap.validate(iap.GOOGLE, testObj, (err, googleRes) => { - if (err) { - let resObj = { - ok: false, - data: { - code: INVALID_PAYLOAD, - message: err.toString(), - }, - }; - - return res.json(resObj); - } - - if (iap.isValidated(googleRes)) { - let resObj = { - ok: true, - data: googleRes, - }; - - payments.buyGems({ - user, - paymentMethod: 'IAP GooglePlay', - amount: 5.25, - }).then(() => res.json(resObj)); - } - }); - }); + let resObject = await iapAndroidVerify(res.locals.user, req.body); + return res.json(resObject); }, }; @@ -98,93 +40,8 @@ api.iapiOSVerify = { url: '/iap/ios/verify', middlewares: [authWithHeaders()], async handler (req, res) { - let iapBody = req.body; - let user = res.locals.user; - - iap.setup(function iosSetupResult (error) { - if (error) { - let resObj = { - ok: false, - data: 'IAP Error', - }; - - return res.json(resObj); - } - - // iap is ready - iap.validate(iap.APPLE, iapBody.transaction.receipt, (err, appleRes) => { - if (err) { - let resObj = { - ok: false, - data: { - code: INVALID_PAYLOAD, - message: err.toString(), - }, - }; - - return res.json(resObj); - } - - if (iap.isValidated(appleRes)) { - let purchaseDataList = iap.getPurchaseData(appleRes); - if (purchaseDataList.length > 0) { - let correctReceipt = true; - - for (let index in 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); - }); - }); + let resObject = await iapIOSVerify(res.locals.user, req.body); + return res.json(resObject); }, }; diff --git a/website/server/libs/api-v3/inAppPurchases.js b/website/server/libs/api-v3/inAppPurchases.js new file mode 100644 index 0000000000..8c5043a029 --- /dev/null +++ b/website/server/libs/api-v3/inAppPurchases.js @@ -0,0 +1,175 @@ +import nconf from 'nconf'; +import iap from 'in-app-purchase'; +import payments from './payments'; +import { model as IapPurchaseReceipt } from '../../models/iapPurchaseReceipt'; +import Bluebird from 'bluebird'; + +// Validation ERROR Codes +const INVALID_PAYLOAD = 6778001; +// const CONNECTION_FAILED = 6778002; +// const PURCHASE_EXPIRED = 6778003; + +iap.config({ + // this is the path to the directory containing iap-sanbox/iap-live files + googlePublicKeyPath: nconf.get('IAP_GOOGLE_KEYDIR'), +}); + +let iapSetup = Bluebird.promisify(iap.setup, { context: iap }); +let iapValidate = Bluebird.promisify(iap.validate, { context: iap }); + +async function iapAndroidVerify (user, iapBody) { + try { + await iapSetup(); + let testObj = { + data: iapBody.transaction.receipt, + signature: iapBody.transaction.signature, + }; + + try { + let googleRes = iapValidate(iap.GOOGLE, testObj); + + if (iap.isValidated(googleRes)) { + let resObj = { + ok: true, + data: googleRes, + }; + + let token = testObj.data.token; + if (!token) token = testObj.data.purchaseToken; + + let existingReceipt = await IapPurchaseReceipt.findOne({ + _id: token, + }).exec(); + + if (!existingReceipt) { + try { + await IapPurchaseReceipt.create({ + token, + consumed: true, + userID: user._id, + }); + + await payments.buyGems({ + user, + paymentMethod: 'IAP GooglePlay', + amount: 5.25, + }); + + return resObj; + } catch (err) { + return resObj; + } + } else { + return resObj; + } + } + } catch (error) { + return { + ok: false, + data: { + code: INVALID_PAYLOAD, + message: error.toString(), + }, + }; + } + } catch (error) { + return { + ok: false, + data: 'IAP Error', + }; + } +} + +async function iapIOSVerify (user, iapBody) { + iap.setup(function iosSetupResult (error) { + if (error) { + let resObj = { + ok: false, + data: 'IAP Error', + }; + + return resObj; + } + + // iap is ready + iap.validate(iap.APPLE, iapBody.transaction.receipt, (err, appleRes) => { + if (err) { + let resObj = { + ok: false, + data: { + code: INVALID_PAYLOAD, + message: err.toString(), + }, + }; + + return resObj; + } + + if (iap.isValidated(appleRes)) { + let purchaseDataList = iap.getPurchaseData(appleRes); + if (purchaseDataList.length > 0) { + let correctReceipt = true; + + for (let index in 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 resObj; + } + } + + // wrong receipt content + let resObj = { + ok: false, + data: { + code: INVALID_PAYLOAD, + message: 'Incorrect receipt content', + }, + }; + + return resObj; + } + + // invalid receipt + let resObj = { + ok: false, + data: { + code: INVALID_PAYLOAD, + message: 'Invalid receipt', + }, + }; + + return resObj; + }); + }); +} + + +module.exports = { + iapAndroidVerify, + iapIOSVerify, + iapSetup, +}; \ No newline at end of file diff --git a/website/server/models/iapPurchaseReceipt.js b/website/server/models/iapPurchaseReceipt.js new file mode 100644 index 0000000000..6450ded11f --- /dev/null +++ b/website/server/models/iapPurchaseReceipt.js @@ -0,0 +1,22 @@ +import mongoose from 'mongoose'; +import baseModel from '../libs/api-v3/baseModel'; +import validator from 'validator'; + +const Schema = mongoose.Schema; + +export let schema = new Schema({ + _id: {type: String, required: true}, // Use a custom string as _id + consumed: {type: Boolean, default: false, required: true}, + userId: {type: String, ref: 'User', required: true, validate: [validator.isUUID, 'Invalid uuid.']}, +}, { + strict: true, + minimize: false, // So empty objects are returned +}); + +schema.plugin(baseModel, { + noSet: ['id', '_id', 'userId', 'consumed'], // Nothing can be set from the client + timestamps: true, + _id: false, // using custom _id +}); + +export let model = mongoose.model('IapPurchaseReceipt', schema);