diff --git a/config.json.example b/config.json.example index 7ca5dfccd0..9e5debf1a2 100644 --- a/config.json.example +++ b/config.json.example @@ -86,5 +86,6 @@ "FLAGGING_URL": "https://hooks.slack.com/services/id/id/id", "FLAGGING_FOOTER_LINK": "https://habitrpg.github.io/flag-o-rama/", "SUBSCRIPTIONS_URL": "https://hooks.slack.com/services/id/id/id" - } + }, + "ITUNES_SHARED_SECRET": "aaaabbbbccccddddeeeeffff00001111" } diff --git a/test/api/v3/integration/payments/apple/GET-payments_apple_cancelSubscribe.js b/test/api/v3/integration/payments/apple/GET-payments_apple_cancelSubscribe.js new file mode 100644 index 0000000000..013c8c20bd --- /dev/null +++ b/test/api/v3/integration/payments/apple/GET-payments_apple_cancelSubscribe.js @@ -0,0 +1,41 @@ +import {generateUser} from '../../../../../helpers/api-integration/v3'; +import applePayments from '../../../../../../website/server/libs/applePayments'; + +describe('payments : apple #cancelSubscribe', () => { + let endpoint = '/iap/ios/subscribe/cancel?noRedirect=true'; + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + describe('success', () => { + let cancelStub; + + beforeEach(async () => { + cancelStub = sinon.stub(applePayments, 'cancelSubscribe').returnsPromise().resolves({}); + }); + + afterEach(() => { + applePayments.cancelSubscribe.restore(); + }); + + it('cancels the subscription', async () => { + user = await generateUser({ + 'profile.name': 'sender', + 'purchased.plan.paymentMethod': 'Apple', + 'purchased.plan.customerId': 'customer-id', + 'purchased.plan.planId': 'basic_3mo', + 'purchased.plan.lastBillingDate': new Date(), + balance: 2, + }); + + await user.get(endpoint); + + expect(cancelStub).to.be.calledOnce; + expect(cancelStub.args[0][0]._id).to.eql(user._id); + expect(cancelStub.args[0][1]['x-api-key']).to.eql(user.apiToken); + expect(cancelStub.args[0][1]['x-api-user']).to.eql(user._id); + }); + }); +}); diff --git a/test/api/v3/integration/payments/apple/POST-payments_apple_verifyiap.js b/test/api/v3/integration/payments/apple/POST-payments_apple_verifyiap.js new file mode 100644 index 0000000000..a5d4c1e769 --- /dev/null +++ b/test/api/v3/integration/payments/apple/POST-payments_apple_verifyiap.js @@ -0,0 +1,40 @@ +import {generateUser} from '../../../../../helpers/api-integration/v3'; +import applePayments from '../../../../../../website/server/libs/applePayments'; + +describe('payments : apple #verify', () => { + let endpoint = '/iap/ios/verify'; + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + describe('success', () => { + let verifyStub; + + beforeEach(async () => { + verifyStub = sinon.stub(applePayments, 'verifyGemPurchase').returnsPromise().resolves({}); + }); + + afterEach(() => { + applePayments.verifyGemPurchase.restore(); + }); + + it('makes a purchase', async () => { + user = await generateUser({ + balance: 2, + }); + + await user.post(endpoint, { + transaction: { + receipt: 'receipt', + }}); + + expect(verifyStub).to.be.calledOnce; + expect(verifyStub.args[0][0]._id).to.eql(user._id); + expect(verifyStub.args[0][1]).to.eql('receipt'); + expect(verifyStub.args[0][2]['x-api-key']).to.eql(user.apiToken); + expect(verifyStub.args[0][2]['x-api-user']).to.eql(user._id); + }); + }); +}); diff --git a/test/api/v3/integration/payments/apple/POST-payments_google_subscribe.test.js b/test/api/v3/integration/payments/apple/POST-payments_google_subscribe.test.js new file mode 100644 index 0000000000..4834844df7 --- /dev/null +++ b/test/api/v3/integration/payments/apple/POST-payments_google_subscribe.test.js @@ -0,0 +1,55 @@ +import {generateUser, translate as t} from '../../../../../helpers/api-integration/v3'; +import applePayments from '../../../../../../website/server/libs/applePayments'; + +describe('payments : apple #subscribe', () => { + let endpoint = '/iap/ios/subscribe'; + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('verifies sub key', async () => { + await expect(user.post(endpoint)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('missingSubscriptionCode'), + }); + }); + + describe('success', () => { + let subscribeStub; + + beforeEach(async () => { + subscribeStub = sinon.stub(applePayments, 'subscribe').returnsPromise().resolves({}); + }); + + afterEach(() => { + applePayments.subscribe.restore(); + }); + + it('makes a purchase', async () => { + user = await generateUser({ + 'profile.name': 'sender', + 'purchased.plan.customerId': 'customer-id', + 'purchased.plan.planId': 'basic_3mo', + 'purchased.plan.lastBillingDate': new Date(), + balance: 2, + }); + + let sku = 'com.habitrpg.ios.habitica.subscription.3month'; + + await user.post(endpoint, { + sku, + receipt: 'receipt', + }); + + expect(subscribeStub).to.be.calledOnce; + expect(subscribeStub.args[0][0]).to.eql(sku); + expect(subscribeStub.args[0][1]._id).to.eql(user._id); + expect(subscribeStub.args[0][2]).to.eql('receipt'); + expect(subscribeStub.args[0][3]['x-api-key']).to.eql(user.apiToken); + expect(subscribeStub.args[0][3]['x-api-user']).to.eql(user._id); + }); + }); +}); diff --git a/test/api/v3/integration/payments/google/GET-payments_google_cancelSubscribe.js b/test/api/v3/integration/payments/google/GET-payments_google_cancelSubscribe.js index 0ee2684320..06f4d0f9f7 100644 --- a/test/api/v3/integration/payments/google/GET-payments_google_cancelSubscribe.js +++ b/test/api/v3/integration/payments/google/GET-payments_google_cancelSubscribe.js @@ -23,6 +23,7 @@ describe('payments : google #cancelSubscribe', () => { it('makes a purchase', async () => { user = await generateUser({ 'profile.name': 'sender', + 'purchased.plan.paymentMethod': 'Google', 'purchased.plan.customerId': 'customer-id', 'purchased.plan.planId': 'basic_3mo', 'purchased.plan.lastBillingDate': new Date(), diff --git a/test/api/v3/unit/libs/applePayments.test.js b/test/api/v3/unit/libs/applePayments.test.js new file mode 100644 index 0000000000..778672f4c8 --- /dev/null +++ b/test/api/v3/unit/libs/applePayments.test.js @@ -0,0 +1,258 @@ +/* eslint-disable camelcase */ +import iapModule from '../../../../../website/server/libs/inAppPurchases'; +import payments from '../../../../../website/server/libs/payments'; +import applePayments from '../../../../../website/server/libs/applePayments'; +import iap from '../../../../../website/server/libs/inAppPurchases'; +import {model as User} from '../../../../../website/server/models/user'; +import common from '../../../../../website/common'; +import moment from 'moment'; + +const i18n = common.i18n; + +describe('Apple Payments', () => { + let subKey = 'basic_3mo'; + + describe('verifyGemPurchase', () => { + let sku, user, token, receipt, headers; + let iapSetupStub, iapValidateStub, iapIsValidatedStub, paymentBuyGemsStub, iapGetPurchaseDataStub; + + beforeEach(() => { + token = 'testToken'; + sku = 'com.habitrpg.ios.habitica.iap.21gems'; + user = new User(); + receipt = `{"token": "${token}", "productId": "${sku}"}`; + headers = {}; + + iapSetupStub = sinon.stub(iapModule, 'setup') + .returnsPromise().resolves(); + iapValidateStub = sinon.stub(iapModule, 'validate') + .returnsPromise().resolves({}); + iapIsValidatedStub = sinon.stub(iapModule, 'isValidated') + .returns(true); + iapGetPurchaseDataStub = sinon.stub(iapModule, 'getPurchaseData') + .returns([{productId: 'com.habitrpg.ios.Habitica.21gems', + transactionId: token, + }]); + paymentBuyGemsStub = sinon.stub(payments, 'buyGems').returnsPromise().resolves({}); + }); + + afterEach(() => { + iapModule.setup.restore(); + iapModule.validate.restore(); + iapModule.isValidated.restore(); + iapModule.getPurchaseData.restore(); + payments.buyGems.restore(); + }); + + it('should throw an error if receipt is invalid', async () => { + iapModule.isValidated.restore(); + iapIsValidatedStub = sinon.stub(iapModule, 'isValidated') + .returns(false); + + await expect(applePayments.verifyGemPurchase(user, receipt, headers)) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 401, + name: 'NotAuthorized', + message: applePayments.constants.RESPONSE_INVALID_RECEIPT, + }); + }); + + it('purchases gems', async () => { + await applePayments.verifyGemPurchase(user, receipt, headers); + + expect(iapSetupStub).to.be.calledOnce; + expect(iapValidateStub).to.be.calledOnce; + expect(iapValidateStub).to.be.calledWith(iap.APPLE, receipt); + expect(iapIsValidatedStub).to.be.calledOnce; + expect(iapIsValidatedStub).to.be.calledWith({}); + expect(iapGetPurchaseDataStub).to.be.calledOnce; + + expect(paymentBuyGemsStub).to.be.calledOnce; + expect(paymentBuyGemsStub).to.be.calledWith({ + user, + paymentMethod: applePayments.constants.PAYMENT_METHOD_APPLE, + amount: 5.25, + headers, + }); + }); + }); + + describe('subscribe', () => { + let sub, sku, user, token, receipt, headers, nextPaymentProcessing; + let iapSetupStub, iapValidateStub, iapIsValidatedStub, paymentsCreateSubscritionStub, iapGetPurchaseDataStub; + + beforeEach(() => { + sub = common.content.subscriptionBlocks[subKey]; + sku = 'com.habitrpg.ios.habitica.subscription.3month'; + + token = 'test-token'; + headers = {}; + receipt = `{"token": "${token}"}`; + nextPaymentProcessing = moment.utc().add({days: 2}); + + iapSetupStub = sinon.stub(iapModule, 'setup') + .returnsPromise().resolves(); + iapValidateStub = sinon.stub(iapModule, 'validate') + .returnsPromise().resolves({}); + iapIsValidatedStub = sinon.stub(iapModule, 'isValidated') + .returns(true); + iapGetPurchaseDataStub = sinon.stub(iapModule, 'getPurchaseData') + .returns([{ + expirationDate: moment.utc().subtract({day: 1}).toDate(), + productId: sku, + transactionId: token, + }, { + expirationDate: moment.utc().add({day: 1}).toDate(), + productId: 'wrongsku', + transactionId: token, + }, { + expirationDate: moment.utc().add({day: 1}).toDate(), + productId: sku, + transactionId: token, + }]); + paymentsCreateSubscritionStub = sinon.stub(payments, 'createSubscription').returnsPromise().resolves({}); + }); + + afterEach(() => { + iapModule.setup.restore(); + iapModule.validate.restore(); + iapModule.isValidated.restore(); + iapModule.getPurchaseData.restore(); + payments.createSubscription.restore(); + }); + + it('should throw an error if receipt is invalid', async () => { + iapModule.isValidated.restore(); + iapIsValidatedStub = sinon.stub(iapModule, 'isValidated') + .returns(false); + + await expect(applePayments.subscribe(sku, user, receipt, headers, nextPaymentProcessing)) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 401, + name: 'NotAuthorized', + message: applePayments.constants.RESPONSE_INVALID_RECEIPT, + }); + }); + + it('creates a user subscription', async () => { + await applePayments.subscribe(sku, user, receipt, headers, nextPaymentProcessing); + + expect(iapSetupStub).to.be.calledOnce; + expect(iapValidateStub).to.be.calledOnce; + expect(iapValidateStub).to.be.calledWith(iap.APPLE, receipt); + expect(iapIsValidatedStub).to.be.calledOnce; + expect(iapIsValidatedStub).to.be.calledWith({}); + expect(iapGetPurchaseDataStub).to.be.calledOnce; + + expect(paymentsCreateSubscritionStub).to.be.calledOnce; + expect(paymentsCreateSubscritionStub).to.be.calledWith({ + user, + customerId: token, + paymentMethod: applePayments.constants.PAYMENT_METHOD_APPLE, + sub, + headers, + additionalData: receipt, + nextPaymentProcessing, + }); + }); + }); + + describe('cancelSubscribe ', () => { + let user, token, receipt, headers, customerId, expirationDate; + let iapSetupStub, iapValidateStub, iapIsValidatedStub, iapGetPurchaseDataStub, paymentCancelSubscriptionSpy; + + beforeEach(async () => { + token = 'test-token'; + headers = {}; + receipt = `{"token": "${token}"}`; + customerId = 'test-customerId'; + expirationDate = moment.utc(); + + iapSetupStub = sinon.stub(iapModule, 'setup') + .returnsPromise().resolves(); + iapValidateStub = sinon.stub(iapModule, 'validate') + .returnsPromise().resolves({ + expirationDate, + }); + iapGetPurchaseDataStub = sinon.stub(iapModule, 'getPurchaseData') + .returns([{expirationDate: expirationDate.toDate()}]); + iapIsValidatedStub = sinon.stub(iapModule, 'isValidated') + .returns(true); + + user = new User(); + user.profile.name = 'sender'; + user.purchased.plan.paymentMethod = applePayments.constants.PAYMENT_METHOD_APPLE; + user.purchased.plan.customerId = customerId; + user.purchased.plan.planId = subKey; + user.purchased.plan.additionalData = receipt; + + paymentCancelSubscriptionSpy = sinon.stub(payments, 'cancelSubscription').returnsPromise().resolves({}); + }); + + afterEach(function () { + iapModule.setup.restore(); + iapModule.validate.restore(); + iapModule.isValidated.restore(); + iapModule.getPurchaseData.restore(); + payments.cancelSubscription.restore(); + }); + + it('should throw an error if we are missing a subscription', async () => { + user.purchased.plan.paymentMethod = undefined; + + await expect(applePayments.cancelSubscribe(user, headers)) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 401, + name: 'NotAuthorized', + message: i18n.t('missingSubscription'), + }); + }); + + it('should throw an error if subscription is still valid', async () => { + iapModule.getPurchaseData.restore(); + iapGetPurchaseDataStub = sinon.stub(iapModule, 'getPurchaseData') + .returns([{expirationDate: expirationDate.add({day: 1}).toDate()}]); + + await expect(applePayments.cancelSubscribe(user, headers)) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 401, + name: 'NotAuthorized', + message: applePayments.constants.RESPONSE_STILL_VALID, + }); + }); + + it('should throw an error if receipt is invalid', async () => { + iapModule.isValidated.restore(); + iapIsValidatedStub = sinon.stub(iapModule, 'isValidated') + .returns(false); + + await expect(applePayments.cancelSubscribe(user, headers)) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 401, + name: 'NotAuthorized', + message: applePayments.constants.RESPONSE_INVALID_RECEIPT, + }); + }); + + it('should cancel a user subscription', async () => { + await applePayments.cancelSubscribe(user, headers); + + expect(iapSetupStub).to.be.calledOnce; + expect(iapValidateStub).to.be.calledOnce; + expect(iapValidateStub).to.be.calledWith(iap.APPLE, receipt); + expect(iapIsValidatedStub).to.be.calledOnce; + expect(iapIsValidatedStub).to.be.calledWith({ + expirationDate, + }); + expect(iapGetPurchaseDataStub).to.be.calledOnce; + + expect(paymentCancelSubscriptionSpy).to.be.calledOnce; + expect(paymentCancelSubscriptionSpy).to.be.calledWith({ + user, + paymentMethod: applePayments.constants.PAYMENT_METHOD_APPLE, + nextBill: expirationDate.toDate(), + headers, + }); + }); + }); +}); diff --git a/test/api/v3/unit/libs/googlePayments.test.js b/test/api/v3/unit/libs/googlePayments.test.js index 054d3a16a9..35b9ff5366 100644 --- a/test/api/v3/unit/libs/googlePayments.test.js +++ b/test/api/v3/unit/libs/googlePayments.test.js @@ -190,6 +190,7 @@ describe('Google Payments', () => { user = new User(); user.profile.name = 'sender'; user.purchased.plan.customerId = customerId; + user.purchased.plan.paymentMethod = googlePayments.constants.PAYMENT_METHOD_GOOGLE; user.purchased.plan.planId = subKey; user.purchased.plan.additionalData = {data: receipt, signature}; @@ -205,7 +206,7 @@ describe('Google Payments', () => { }); it('should throw an error if we are missing a subscription', async () => { - user.purchased.plan.additionalData = undefined; + user.purchased.plan.paymentMethod = undefined; await expect(googlePayments.cancelSubscribe(user, headers)) .to.eventually.be.rejected.and.to.eql({ diff --git a/website/common/locales/en/subscriber.json b/website/common/locales/en/subscriber.json index b902b02005..145017a574 100644 --- a/website/common/locales/en/subscriber.json +++ b/website/common/locales/en/subscriber.json @@ -144,6 +144,7 @@ "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.", + "missingReceipt": "Missing Receipt.", "cannotDeleteActiveAccount": "You have an active subscription, cancel your plan before deleting your account.", "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", diff --git a/website/server/controllers/top-level/payments/iap.js b/website/server/controllers/top-level/payments/iap.js index a22e9e5700..239af7f1d7 100644 --- a/website/server/controllers/top-level/payments/iap.js +++ b/website/server/controllers/top-level/payments/iap.js @@ -2,14 +2,11 @@ import { authWithHeaders, authWithUrl, } from '../../../middlewares/auth'; -import iap from '../../../libs/inAppPurchases'; -import payments from '../../../libs/payments'; import { BadRequest, } from '../../../libs/errors'; -import { model as IapPurchaseReceipt } from '../../../models/iapPurchaseReceipt'; -import logger from '../../../libs/logger'; import googlePayments from '../../../libs/googlePayments'; +import applePayments from '../../../libs/applePayments'; let api = {}; @@ -79,8 +76,6 @@ api.iapCancelSubscriptionAndroid = { }, }; -// IMPORTANT: NOT PORTED TO v3 standards (not using res.respond) - /** * @apiIgnore Payments are considered part of the private API * @api {post} /iap/ios/verify iOS Verify IAP @@ -91,89 +86,54 @@ api.iapiOSVerify = { method: 'POST', url: '/iap/ios/verify', middlewares: [authWithHeaders()], + async handler (req, res) { + if (!req.body.transaction) throw new BadRequest(res.t('missingReceipt')); + + let appleRes = await applePayments.verifyGemPurchase(res.locals.user, req.body.transaction.receipt, req.headers); + + res.respond(200, appleRes); + }, +}; + +/** + * @apiIgnore Payments are considered part of the private API + * @api {post} /iap/android/subscription iOS Subscribe + * @apiName IapiOSSubscribe + * @apiGroup Payments + **/ +api.iapSubscriptioniOS = { + method: 'POST', + url: '/iap/ios/subscribe', + middlewares: [authWithUrl], + async handler (req, res) { + if (!req.body.sku) throw new BadRequest(res.t('missingSubscriptionCode')); + if (!req.body.receipt) throw new BadRequest(res.t('missingReceipt')); + + await applePayments.subscribe(req.body.sku, res.locals.user, req.body.receipt, req.headers); + + res.respond(200); + }, +}; + +/** + * @apiIgnore Payments are considered part of the private API + * @api {get} /iap/android/subscribe/cancel Apple Payments: subscribe cancel + * @apiName iOSSubscribeCancel + * @apiGroup Payments + **/ +api.iapCancelSubscriptioniOS = { + method: 'GET', + url: '/iap/ios/subscribe/cancel', + middlewares: [authWithUrl], async handler (req, res) { let user = res.locals.user; - let iapBody = req.body; - let appleRes; + await applePayments.cancelSubscribe(user, req.headers); - try { - await iap.setup(); - - appleRes = await iap.validate(iap.APPLE, iapBody.transaction.receipt); - let isValidated = iap.isValidated(appleRes); - if (!isValidated) throw new Error('INVALID_RECEIPT'); - - let purchaseDataList = iap.getPurchaseData(appleRes); - if (purchaseDataList.length === 0) throw new Error('NO_ITEM_PURCHASED'); - - let correctReceipt = true; - - // Purchasing one item at a time (processing of await(s) below is sequential not parallel) - for (let index in purchaseDataList) { - let purchaseData = purchaseDataList[index]; - let token = purchaseData.transactionId; - - let existingReceipt = await IapPurchaseReceipt.findOne({ // eslint-disable-line no-await-in-loop - _id: token, - }).exec(); - - if (!existingReceipt) { - await IapPurchaseReceipt.create({ // eslint-disable-line no-await-in-loop - _id: token, - consumed: true, - userId: user._id, - }); - } else { - throw new Error('RECEIPT_ALREADY_USED'); - } - - let amount; - switch (purchaseData.productId) { - case 'com.habitrpg.ios.Habitica.4gems': - amount = 1; - break; - case 'com.habitrpg.ios.Habitica.20gems': - case 'com.habitrpg.ios.Habitica.21gems': - amount = 5.25; - break; - case 'com.habitrpg.ios.Habitica.42gems': - amount = 10.5; - break; - case 'com.habitrpg.ios.Habitica.84gems': - amount = 21; - break; - } - if (!amount) { - correctReceipt = false; - break; - } - - await payments.buyGems({ // eslint-disable-line no-await-in-loop - user, - paymentMethod: 'IAP AppleStore', - amount, - headers: req.headers, - }); - } - - if (!correctReceipt) throw new Error('INVALID_ITEM_PURCHASED'); - - return res.status(200).json({ - ok: true, - data: appleRes, - }); - } catch (err) { - logger.error(err, { - userId: user._id, - iapBody, - appleRes, - }); - - return res.status(500).json({ - ok: false, - data: 'An error occurred while processing the purchase.', - }); + if (req.query.noRedirect) { + res.respond(200); + } else { + res.redirect('/'); } }, }; diff --git a/website/server/libs/applePayments.js b/website/server/libs/applePayments.js new file mode 100644 index 0000000000..881c7ef152 --- /dev/null +++ b/website/server/libs/applePayments.js @@ -0,0 +1,172 @@ +import shared from '../../common'; +import iap from './inAppPurchases'; +import payments from './payments'; +import { + NotAuthorized, + BadRequest, +} from './errors'; +import { model as IapPurchaseReceipt } from '../models/iapPurchaseReceipt'; +import {model as User } from '../models/user'; +import moment from 'moment'; + +let api = {}; + +api.constants = { + PAYMENT_METHOD_APPLE: 'Apple', + RESPONSE_INVALID_RECEIPT: 'INVALID_RECEIPT', + RESPONSE_ALREADY_USED: 'RECEIPT_ALREADY_USED', + RESPONSE_INVALID_ITEM: 'INVALID_ITEM_PURCHASED', + RESPONSE_STILL_VALID: 'SUBSCRIPTION_STILL_VALID', + RESPONSE_NO_ITEM_PURCHASED: 'NO_ITEM_PURCHASED', +}; + +api.verifyGemPurchase = async function verifyGemPurchase (user, receipt, headers) { + await iap.setup(); + let appleRes = await iap.validate(iap.APPLE, receipt); + let isValidated = iap.isValidated(appleRes); + if (!isValidated) throw new NotAuthorized(api.constants.RESPONSE_INVALID_RECEIPT); + let purchaseDataList = iap.getPurchaseData(appleRes); + if (purchaseDataList.length === 0) throw new NotAuthorized(api.constants.RESPONSE_NO_ITEM_PURCHASED); + let correctReceipt = false; + + // Purchasing one item at a time (processing of await(s) below is sequential not parallel) + for (let index in purchaseDataList) { + let purchaseData = purchaseDataList[index]; + let token = purchaseData.transactionId; + + let existingReceipt = await IapPurchaseReceipt.findOne({ // eslint-disable-line no-await-in-loop + _id: token, + }).exec(); + + if (!existingReceipt) { + await IapPurchaseReceipt.create({ // eslint-disable-line no-await-in-loop + _id: token, + consumed: true, + userId: user._id, + }); + + let amount; + switch (purchaseData.productId) { + case 'com.habitrpg.ios.Habitica.4gems': + amount = 1; + break; + case 'com.habitrpg.ios.Habitica.20gems': + case 'com.habitrpg.ios.Habitica.21gems': + amount = 5.25; + break; + case 'com.habitrpg.ios.Habitica.42gems': + amount = 10.5; + break; + case 'com.habitrpg.ios.Habitica.84gems': + amount = 21; + break; + } + if (amount) { + correctReceipt = true; + await payments.buyGems({ // eslint-disable-line no-await-in-loop + user, + paymentMethod: api.constants.PAYMENT_METHOD_APPLE, + amount, + headers, + }); + } + } + } + + if (!correctReceipt) throw new NotAuthorized(api.constants.RESPONSE_INVALID_ITEM); + + return appleRes; +}; + +api.subscribe = async function subscribe (sku, user, receipt, headers, nextPaymentProcessing = undefined) { + if (!sku) throw new BadRequest(shared.i18n.t('missingSubscriptionCode')); + let subCode; + switch (sku) { + case 'subscription1month': + subCode = 'basic_earned'; + break; + case 'com.habitrpg.ios.habitica.subscription.3month': + subCode = 'basic_3mo'; + break; + case 'com.habitrpg.ios.habitica.subscription.6month': + subCode = 'basic_6mo'; + break; + case 'com.habitrpg.ios.habitica.subscription.12month': + subCode = 'basic_12mo'; + break; + } + let sub = subCode ? shared.content.subscriptionBlocks[subCode] : false; + if (!sub) throw new NotAuthorized(this.constants.RESPONSE_INVALID_ITEM); + + await iap.setup(); + + let appleRes = await iap.validate(iap.APPLE, receipt); + let isValidated = iap.isValidated(appleRes); + if (!isValidated) throw new NotAuthorized(api.constants.RESPONSE_INVALID_RECEIPT); + + let purchaseDataList = iap.getPurchaseData(appleRes); + if (purchaseDataList.length === 0) throw new NotAuthorized(api.constants.RESPONSE_NO_ITEM_PURCHASED); + + let transactionId; + + for (let index in purchaseDataList) { + let purchaseData = purchaseDataList[index]; + + let dateTerminated = new Date(Number(purchaseData.expirationDate)); + if (purchaseData.productId === sku && dateTerminated > new Date()) { + transactionId = purchaseData.transactionId; + break; + } + } + if (transactionId) { + let existingUser = await User.findOne({ + 'purchased.plan.customerId': transactionId, + }).exec(); + if (existingUser) throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED); + + nextPaymentProcessing = nextPaymentProcessing ? nextPaymentProcessing : moment.utc().add({days: 2}); + + await payments.createSubscription({ + user, + customerId: transactionId, + paymentMethod: this.constants.PAYMENT_METHOD_APPLE, + sub, + headers, + nextPaymentProcessing, + additionalData: receipt, + }); + } else { + throw new NotAuthorized(api.constants.RESPONSE_INVALID_RECEIPT); + } +}; + + +api.cancelSubscribe = async function cancelSubscribe (user, headers) { + let plan = user.purchased.plan; + + if (plan.paymentMethod !== api.constants.PAYMENT_METHOD_APPLE) throw new NotAuthorized(shared.i18n.t('missingSubscription')); + + await iap.setup(); + + let appleRes = await iap.validate(iap.APPLE, plan.additionalData); + + let isValidated = iap.isValidated(appleRes); + if (!isValidated) throw new NotAuthorized(this.constants.RESPONSE_INVALID_RECEIPT); + + let purchases = iap.getPurchaseData(appleRes); + if (purchases.length === 0) throw new NotAuthorized(this.constants.RESPONSE_INVALID_RECEIPT); + let subscriptionData = purchases[0]; + + let dateTerminated = new Date(Number(subscriptionData.expirationDate)); + if (dateTerminated > new Date()) throw new NotAuthorized(this.constants.RESPONSE_STILL_VALID); + + await payments.cancelSubscription({ + user, + nextBill: dateTerminated, + paymentMethod: this.constants.PAYMENT_METHOD_APPLE, + headers, + }); +}; + + +module.exports = api; diff --git a/website/server/libs/googlePayments.js b/website/server/libs/googlePayments.js index 5c7546cfb6..5b38e9ae27 100644 --- a/website/server/libs/googlePayments.js +++ b/website/server/libs/googlePayments.js @@ -131,13 +131,13 @@ api.subscribe = async function subscribe (sku, user, receipt, signature, headers api.cancelSubscribe = async function cancelSubscribe (user, headers) { - let data = user.purchased.plan.additionalData; + let plan = user.purchased.plan; - if (!data) throw new NotAuthorized(shared.i18n.t('missingSubscription')); + if (plan.paymentMethod !== api.constants.PAYMENT_METHOD_GOOGLE) throw new NotAuthorized(shared.i18n.t('missingSubscription')); await iap.setup(); - let googleRes = await iap.validate(iap.GOOGLE, data); + let googleRes = await iap.validate(iap.GOOGLE, plan.additionalData); let isValidated = iap.isValidated(googleRes); if (!isValidated) throw new NotAuthorized(this.constants.RESPONSE_INVALID_RECEIPT); diff --git a/website/server/libs/inAppPurchases.js b/website/server/libs/inAppPurchases.js index 8d2bebf00b..8d0c676978 100644 --- a/website/server/libs/inAppPurchases.js +++ b/website/server/libs/inAppPurchases.js @@ -14,6 +14,7 @@ iap.config({ googleRefToken: nconf.get('PLAY_API:REFRESH_TOKEN'), googleClientID: nconf.get('PLAY_API:CLIENT_ID'), googleClientSecret: nconf.get('PLAY_API:CLIENT_SECRET'), + applePassword: nconf.get('ITUNES_SHARED_SECRET'), }); module.exports = {