diff --git a/config.json.example b/config.json.example index f76041b85f..7ca5dfccd0 100644 --- a/config.json.example +++ b/config.json.example @@ -8,6 +8,12 @@ "FACEBOOK_SECRET":"aaaabbbbccccddddeeeeffff00001111", "GOOGLE_CLIENT_ID":"123456789012345", "GOOGLE_CLIENT_SECRET":"aaaabbbbccccddddeeeeffff00001111", + "PLAY_API": { + "CLIENT_ID": "aaaabbbbccccddddeeeeffff00001111", + "CLIENT_SECRET": "aaaabbbbccccddddeeeeffff00001111", + "ACCESS_TOKEN":"aaaabbbbccccddddeeeeffff00001111", + "REFRESH_TOKEN":"aaaabbbbccccddddeeeeffff00001111" + }, "NODE_DB_URI":"mongodb://localhost/habitrpg", "TEST_DB_URI":"mongodb://localhost/habitrpg_test", "NODE_ENV":"development", 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 new file mode 100644 index 0000000000..4eb3035447 --- /dev/null +++ b/test/api/v3/integration/payments/google/GET-payments_google_cancelSubscribe.js @@ -0,0 +1,40 @@ +import {generateUser} from '../../../../../helpers/api-integration/v3'; +import googlePayments from '../../../../../../website/server/libs/googlePayments'; + +describe('payments : google #cancelSubscribe', () => { + let endpoint = '/iap/android/subscribe/cancel'; + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + describe('success', () => { + let cancelStub; + + beforeEach(async () => { + cancelStub = sinon.stub(googlePayments, 'cancelSubscribe').returnsPromise().resolves({}); + }); + + afterEach(() => { + googlePayments.cancelSubscribe.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, + }); + + 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/google/POST-payments_google_subscribe.test.js b/test/api/v3/integration/payments/google/POST-payments_google_subscribe.test.js new file mode 100644 index 0000000000..f91ebc8da8 --- /dev/null +++ b/test/api/v3/integration/payments/google/POST-payments_google_subscribe.test.js @@ -0,0 +1,56 @@ +import {generateUser, translate as t} from '../../../../../helpers/api-integration/v3'; +import googlePayments from '../../../../../../website/server/libs/googlePayments'; + +describe('payments : google #subscribe', () => { + let endpoint = '/iap/android/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(googlePayments, 'subscribe').returnsPromise().resolves({}); + }); + + afterEach(() => { + googlePayments.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.android.habitica.subscription.3month'; + + await user.post(endpoint, { + sku, + transaction: {receipt: 'receipt', signature: 'signature'}, + }); + + 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]).to.eql('signature'); + expect(subscribeStub.args[0][4]['x-api-key']).to.eql(user.apiToken); + expect(subscribeStub.args[0][4]['x-api-user']).to.eql(user._id); + }); + }); +}); diff --git a/test/api/v3/integration/payments/google/POST-payments_google_verifyiap.js b/test/api/v3/integration/payments/google/POST-payments_google_verifyiap.js new file mode 100644 index 0000000000..aa0f4d18b2 --- /dev/null +++ b/test/api/v3/integration/payments/google/POST-payments_google_verifyiap.js @@ -0,0 +1,40 @@ +import {generateUser} from '../../../../../helpers/api-integration/v3'; +import googlePayments from '../../../../../../website/server/libs/googlePayments'; + +describe('payments : google #verify', () => { + let endpoint = '/iap/android/verify'; + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + describe('success', () => { + let verifyStub; + + beforeEach(async () => { + verifyStub = sinon.stub(googlePayments, 'verifyGemPurchase').returnsPromise().resolves({}); + }); + + afterEach(() => { + googlePayments.verifyGemPurchase.restore(); + }); + + it('makes a purchase', async () => { + user = await generateUser({ + balance: 2, + }); + + await user.post(endpoint, { + transaction: {receipt: 'receipt', signature: 'signature'}, + }); + + 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]).to.eql('signature'); + expect(verifyStub.args[0][3]['x-api-key']).to.eql(user.apiToken); + expect(verifyStub.args[0][3]['x-api-user']).to.eql(user._id); + }); + }); +}); diff --git a/test/api/v3/unit/libs/googlePayments.test.js b/test/api/v3/unit/libs/googlePayments.test.js new file mode 100644 index 0000000000..054d3a16a9 --- /dev/null +++ b/test/api/v3/unit/libs/googlePayments.test.js @@ -0,0 +1,268 @@ +/* eslint-disable camelcase */ +import iapModule from '../../../../../website/server/libs/inAppPurchases'; +import payments from '../../../../../website/server/libs/payments'; +import googlePayments from '../../../../../website/server/libs/googlePayments'; +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('Google Payments', () => { + let subKey = 'basic_3mo'; + + describe('verifyGemPurchase', () => { + let sku, user, token, receipt, signature, headers; + let iapSetupStub, iapValidateStub, iapIsValidatedStub, paymentBuyGemsStub; + + beforeEach(() => { + sku = 'com.habitrpg.android.habitica.iap.21gems'; + user = new User(); + receipt = `{"token": "${token}", "productId": "${sku}"}`; + signature = ''; + headers = {}; + + iapSetupStub = sinon.stub(iapModule, 'setup') + .returnsPromise().resolves(); + iapValidateStub = sinon.stub(iapModule, 'validate') + .returnsPromise().resolves({}); + iapIsValidatedStub = sinon.stub(iapModule, 'isValidated') + .returns(true); + paymentBuyGemsStub = sinon.stub(payments, 'buyGems').returnsPromise().resolves({}); + }); + + afterEach(() => { + iapModule.setup.restore(); + iapModule.validate.restore(); + iapModule.isValidated.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(googlePayments.verifyGemPurchase(user, receipt, signature, headers)) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 401, + name: 'NotAuthorized', + message: googlePayments.constants.RESPONSE_INVALID_RECEIPT, + }); + }); + + it('should throw an error if productId is invalid', async () => { + receipt = `{"token": "${token}", "productId": "invalid"}`; + + await expect(googlePayments.verifyGemPurchase(user, receipt, signature, headers)) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 401, + name: 'NotAuthorized', + message: googlePayments.constants.RESPONSE_INVALID_ITEM, + }); + }); + + it('purchases gems', async () => { + await googlePayments.verifyGemPurchase(user, receipt, signature, headers); + + expect(iapSetupStub).to.be.calledOnce; + expect(iapValidateStub).to.be.calledOnce; + expect(iapValidateStub).to.be.calledWith(iap.GOOGLE, { + data: receipt, + signature, + }); + expect(iapIsValidatedStub).to.be.calledOnce; + expect(iapIsValidatedStub).to.be.calledWith({}); + + expect(paymentBuyGemsStub).to.be.calledOnce; + expect(paymentBuyGemsStub).to.be.calledWith({ + user, + paymentMethod: googlePayments.constants.PAYMENT_METHOD_GOOGLE, + amount: 5.25, + headers, + }); + }); + }); + + describe('subscribe', () => { + let sub, sku, user, token, receipt, signature, headers, nextPaymentProcessing; + let iapSetupStub, iapValidateStub, iapIsValidatedStub, paymentsCreateSubscritionStub; + + beforeEach(() => { + sub = common.content.subscriptionBlocks[subKey]; + sku = 'com.habitrpg.android.habitica.subscription.3month'; + + token = 'test-token'; + headers = {}; + receipt = `{"token": "${token}"}`; + signature = ''; + 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); + paymentsCreateSubscritionStub = sinon.stub(payments, 'createSubscription').returnsPromise().resolves({}); + }); + + afterEach(() => { + iapModule.setup.restore(); + iapModule.validate.restore(); + iapModule.isValidated.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(googlePayments.subscribe(sku, user, receipt, signature, headers, nextPaymentProcessing)) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 401, + name: 'NotAuthorized', + message: googlePayments.constants.RESPONSE_INVALID_RECEIPT, + }); + }); + + it('should throw an error if sku is invalid', async () => { + sku = 'invalid'; + + await expect(googlePayments.subscribe(sku, user, receipt, signature, headers, nextPaymentProcessing)) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 401, + name: 'NotAuthorized', + message: googlePayments.constants.RESPONSE_INVALID_ITEM, + }); + }); + + it('creates a user subscription', async () => { + await googlePayments.subscribe(sku, user, receipt, signature, headers, nextPaymentProcessing); + + expect(iapSetupStub).to.be.calledOnce; + expect(iapValidateStub).to.be.calledOnce; + expect(iapValidateStub).to.be.calledWith(iap.GOOGLE, { + data: receipt, + signature, + }); + expect(iapIsValidatedStub).to.be.calledOnce; + expect(iapIsValidatedStub).to.be.calledWith({}); + + expect(paymentsCreateSubscritionStub).to.be.calledOnce; + expect(paymentsCreateSubscritionStub).to.be.calledWith({ + user, + customerId: token, + paymentMethod: googlePayments.constants.PAYMENT_METHOD_GOOGLE, + sub, + headers, + additionalData: {data: receipt, signature}, + nextPaymentProcessing, + }); + }); + }); + + describe('cancelSubscribe ', () => { + let user, token, receipt, signature, headers, customerId, expirationDate; + let iapSetupStub, iapValidateStub, iapIsValidatedStub, iapGetPurchaseDataStub, paymentCancelSubscriptionSpy; + + beforeEach(async () => { + token = 'test-token'; + headers = {}; + receipt = `{"token": "${token}"}`; + signature = ''; + 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.customerId = customerId; + user.purchased.plan.planId = subKey; + user.purchased.plan.additionalData = {data: receipt, signature}; + + 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.additionalData = undefined; + + await expect(googlePayments.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(googlePayments.cancelSubscribe(user, headers)) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 401, + name: 'NotAuthorized', + message: googlePayments.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(googlePayments.cancelSubscribe(user, headers)) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 401, + name: 'NotAuthorized', + message: googlePayments.constants.RESPONSE_INVALID_RECEIPT, + }); + }); + + it('should cancel a user subscription', async () => { + await googlePayments.cancelSubscribe(user, headers); + + expect(iapSetupStub).to.be.calledOnce; + expect(iapValidateStub).to.be.calledOnce; + expect(iapValidateStub).to.be.calledWith(iap.GOOGLE, { + data: receipt, + signature, + }); + 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: googlePayments.constants.PAYMENT_METHOD_GOOGLE, + nextBill: expirationDate.toDate(), + headers, + }); + }); + }); +}); diff --git a/website/server/controllers/top-level/payments/iap.js b/website/server/controllers/top-level/payments/iap.js index 3252bc8b15..a22e9e5700 100644 --- a/website/server/controllers/top-level/payments/iap.js +++ b/website/server/controllers/top-level/payments/iap.js @@ -5,10 +5,11 @@ import { import iap from '../../../libs/inAppPurchases'; import payments from '../../../libs/payments'; import { - NotAuthorized, + BadRequest, } from '../../../libs/errors'; import { model as IapPurchaseReceipt } from '../../../models/iapPurchaseReceipt'; import logger from '../../../libs/logger'; +import googlePayments from '../../../libs/googlePayments'; let api = {}; @@ -28,63 +29,56 @@ api.iapAndroidVerify = { let user = res.locals.user; let iapBody = req.body; - await iap.setup(); - - let testObj = { - data: iapBody.transaction.receipt, - signature: iapBody.transaction.signature, - }; - - let googleRes = await iap.validate(iap.GOOGLE, testObj); - - let isValidated = iap.isValidated(googleRes); - if (!isValidated) throw new NotAuthorized('INVALID_RECEIPT'); - - let receiptObj = JSON.parse(testObj.data); // passed as a string - let token = receiptObj.token || receiptObj.purchaseToken; - - let existingReceipt = await IapPurchaseReceipt.findOne({ - _id: token, - }).exec(); - if (existingReceipt) throw new NotAuthorized('RECEIPT_ALREADY_USED'); - - await IapPurchaseReceipt.create({ - _id: token, - consumed: true, - userId: user._id, - }); - - let amount; - - switch (receiptObj.productId) { - case 'com.habitrpg.android.habitica.iap.4gems': - amount = 1; - break; - case 'com.habitrpg.android.habitica.iap.20.gems': - case 'com.habitrpg.android.habitica.iap.21gems': - amount = 5.25; - break; - case 'com.habitrpg.android.habitica.iap.42gems': - amount = 10.5; - break; - case 'com.habitrpg.android.habitica.iap.84gems': - amount = 21; - break; - } - - if (!amount) throw new Error('INVALID_ITEM_PURCHASED'); - - await payments.buyGems({ - user, - paymentMethod: 'IAP GooglePlay', - amount, - headers: req.headers, - }); + let googleRes = await googlePayments.verifyGemPurchase(user, iapBody.transaction.receipt, iapBody.transaction.signature, req.headers); res.respond(200, googleRes); }, }; +/** + * @apiIgnore Payments are considered part of the private API + * @api {post} /iap/android/subscription Android Subscribe + * @apiName IapAndroidSubscribe + * @apiGroup Payments + **/ +api.iapSubscriptionAndroid = { + method: 'POST', + url: '/iap/android/subscribe', + middlewares: [authWithUrl], + async handler (req, res) { + if (!req.body.sku) throw new BadRequest(res.t('missingSubscriptionCode')); + let user = res.locals.user; + let iapBody = req.body; + + await googlePayments.subscribe(req.body.sku, user, iapBody.transaction.receipt, iapBody.transaction.signature, req.headers); + + res.respond(200); + }, +}; + +/** + * @apiIgnore Payments are considered part of the private API + * @api {get} /iap/android/subscribe/cancel Google Payments: subscribe cancel + * @apiName AndroidSubscribeCancel + * @apiGroup Payments + **/ +api.iapCancelSubscriptionAndroid = { + method: 'GET', + url: '/iap/android/subscribe/cancel', + middlewares: [authWithUrl], + async handler (req, res) { + let user = res.locals.user; + + await googlePayments.cancelSubscribe(user, req.headers); + + if (req.query.noRedirect) { + res.respond(200); + } else { + res.redirect('/'); + } + }, +}; + // IMPORTANT: NOT PORTED TO v3 standards (not using res.respond) /** diff --git a/website/server/libs/googlePayments.js b/website/server/libs/googlePayments.js new file mode 100644 index 0000000000..a23d6fe526 --- /dev/null +++ b/website/server/libs/googlePayments.js @@ -0,0 +1,161 @@ +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_GOOGLE: 'Google', + RESPONSE_INVALID_RECEIPT: 'INVALID_RECEIPT', + RESPONSE_ALREADY_USED: 'RECEIPT_ALREADY_USED', + RESPONSE_INVALID_ITEM: 'INVALID_ITEM_PURCHASED', + RESPONSE_STILL_VALID: 'SUBSCRIPTION_STILL_VALID', +}; + +api.verifyGemPurchase = async function verifyGemPurchase (user, receipt, signature, headers) { + await iap.setup(); + + let testObj = { + data: receipt, + signature, + }; + + let googleRes = await iap.validate(iap.GOOGLE, testObj); + + let isValidated = iap.isValidated(googleRes); + if (!isValidated) throw new NotAuthorized(this.constants.RESPONSE_INVALID_RECEIPT); + + let receiptObj = JSON.parse(testObj.data); // passed as a string + let token = receiptObj.token || receiptObj.purchaseToken; + + let existingReceipt = await IapPurchaseReceipt.findOne({ + _id: token, + }).exec(); + if (existingReceipt) throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED); + + await IapPurchaseReceipt.create({ + _id: token, + consumed: true, + userId: user._id, + }); + + let amount; + + switch (receiptObj.productId) { + case 'com.habitrpg.android.habitica.iap.4gems': + amount = 1; + break; + case 'com.habitrpg.android.habitica.iap.20.gems': + case 'com.habitrpg.android.habitica.iap.21gems': + amount = 5.25; + break; + case 'com.habitrpg.android.habitica.iap.42gems': + amount = 10.5; + break; + case 'com.habitrpg.android.habitica.iap.84gems': + amount = 21; + break; + } + + if (!amount) throw new NotAuthorized(this.constants.RESPONSE_INVALID_ITEM); + + await payments.buyGems({ + user, + paymentMethod: this.constants.PAYMENT_METHOD_GOOGLE, + amount, + headers, + }); + + return googleRes; +}; + +api.subscribe = async function subscribe (sku, user, receipt, signature, headers, nextPaymentProcessing = undefined) { + if (!sku) throw new BadRequest(shared.i18n.t('missingSubscriptionCode')); + let subCode; + switch (sku) { + case 'com.habitrpg.android.habitica.subscription.1month': + subCode = 'basic_earned'; + break; + case 'com.habitrpg.android.habitica.subscription.3month': + subCode = 'basic_3mo'; + break; + case 'com.habitrpg.android.habitica.subscription.6month': + subCode = 'basic_6mo'; + break; + case 'com.habitrpg.android.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 testObj = { + data: receipt, + signature, + }; + + let receiptObj = JSON.parse(receipt); // passed as a string + let token = receiptObj.token || receiptObj.purchaseToken; + + let existingUser = await User.findOne({ + 'payments.plan.customerId': token, + }).exec(); + if (existingUser) throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED); + + let googleRes = await iap.validate(iap.GOOGLE, testObj); + + let isValidated = iap.isValidated(googleRes); + if (!isValidated) throw new NotAuthorized(this.constants.RESPONSE_INVALID_RECEIPT); + + nextPaymentProcessing = nextPaymentProcessing ? nextPaymentProcessing : moment.utc().add({days: 2}); + + await payments.createSubscription({ + user, + customerId: token, + paymentMethod: this.constants.PAYMENT_METHOD_GOOGLE, + sub, + headers, + nextPaymentProcessing, + additionalData: testObj, + }); +}; + + +api.cancelSubscribe = async function cancelSubscribe (user, headers) { + let data = user.purchased.plan.additionalData; + + if (!data) throw new NotAuthorized(shared.i18n.t('missingSubscription')); + + await iap.setup(); + + let googleRes = await iap.validate(iap.GOOGLE, data); + + let isValidated = iap.isValidated(googleRes); + if (!isValidated) throw new NotAuthorized(this.constants.RESPONSE_INVALID_RECEIPT); + + let purchases = iap.getPurchaseData(googleRes); + 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_GOOGLE, + headers, + }); +}; + + +module.exports = api; diff --git a/website/server/libs/inAppPurchases.js b/website/server/libs/inAppPurchases.js index 498fc01ae0..8d2bebf00b 100644 --- a/website/server/libs/inAppPurchases.js +++ b/website/server/libs/inAppPurchases.js @@ -10,6 +10,10 @@ import Bluebird from 'bluebird'; iap.config({ // This is the path to the directory containing iap-sanbox/iap-live files googlePublicKeyPath: nconf.get('IAP_GOOGLE_KEYDIR'), + googleAccToken: nconf.get('PLAY_API:ACCESS_TOKEN'), + googleRefToken: nconf.get('PLAY_API:REFRESH_TOKEN'), + googleClientID: nconf.get('PLAY_API:CLIENT_ID'), + googleClientSecret: nconf.get('PLAY_API:CLIENT_SECRET'), }); module.exports = { diff --git a/website/server/libs/payments.js b/website/server/libs/payments.js index 7dfc0c517c..f787c81418 100644 --- a/website/server/libs/payments.js +++ b/website/server/libs/payments.js @@ -106,6 +106,9 @@ api.createSubscription = async function createSubscription (data) { // Specify a lastBillingDate just for Amazon Payments // Resetted every time the subscription restarts lastBillingDate: data.paymentMethod === 'Amazon Payments' ? today : undefined, + nextPaymentProcessing: data.nextPaymentProcessing, + nextBillingDate: data.nextBillingDate, + additionalData: data.additionalData, owner: data.user._id, }).defaults({ // allow non-override if a plan was previously used gemsBought: 0, diff --git a/website/server/models/subscriptionPlan.js b/website/server/models/subscriptionPlan.js index 38c28057aa..e3994c8e06 100644 --- a/website/server/models/subscriptionPlan.js +++ b/website/server/models/subscriptionPlan.js @@ -7,7 +7,7 @@ export let schema = new mongoose.Schema({ subscriptionId: String, owner: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.']}, quantity: {type: Number, default: 1}, - paymentMethod: String, // enum: ['Paypal','Stripe', 'Gift', 'Amazon Payments', '']} + paymentMethod: String, // enum: ['Paypal', 'Stripe', 'Gift', 'Amazon Payments', 'Google', '']} customerId: String, // Billing Agreement Id in case of Amazon Payments dateCreated: Date, dateTerminated: Date, @@ -16,6 +16,9 @@ export let schema = new mongoose.Schema({ gemsBought: {type: Number, default: 0}, mysteryItems: {type: Array, default: () => []}, lastBillingDate: Date, // Used only for Amazon Payments to keep track of billing date + additionalData: mongoose.Schema.Types.Mixed, // Example for Google: {'receipt': 'serialized receipt json', 'signature': 'signature string'} + nextPaymentProcessing: Date, // indicates when the queue server should process this subscription again. + nextBillingDate: Date, // Next time google will bill this user. consecutive: { count: {type: Number, default: 0}, offset: {type: Number, default: 0}, // when gifted subs, offset++ for each month. offset-- each new-month (cron). count doesn't ++ until offset==0 @@ -29,6 +32,7 @@ export let schema = new mongoose.Schema({ }); schema.plugin(baseModel, { + private: ['additionalData'], noSet: ['_id'], timestamps: false, _id: false,