diff --git a/test/api/unit/libs/payments/apple.test.js b/test/api/unit/libs/payments/apple.test.js index bdf23bfa33..10029b6c3b 100644 --- a/test/api/unit/libs/payments/apple.test.js +++ b/test/api/unit/libs/payments/apple.test.js @@ -6,6 +6,7 @@ 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'; +import {mockFindById, restoreFindById} from '../../../../helpers/mongoose.helper'; const i18n = common.i18n; @@ -49,7 +50,7 @@ describe('Apple Payments', () => { iapIsValidatedStub = sinon.stub(iapModule, 'isValidated') .returns(false); - await expect(applePayments.verifyGemPurchase(user, receipt, headers)) + await expect(applePayments.verifyGemPurchase({user, receipt, headers})) .to.eventually.be.rejected.and.to.eql({ httpCode: 401, name: 'NotAuthorized', @@ -61,7 +62,7 @@ describe('Apple Payments', () => { iapGetPurchaseDataStub.restore(); iapGetPurchaseDataStub = sinon.stub(iapModule, 'getPurchaseData').returns([]); - await expect(applePayments.verifyGemPurchase(user, receipt, headers)) + await expect(applePayments.verifyGemPurchase({user, receipt, headers})) .to.eventually.be.rejected.and.to.eql({ httpCode: 401, name: 'NotAuthorized', @@ -71,7 +72,7 @@ describe('Apple Payments', () => { it('errors if the user cannot purchase gems', async () => { sinon.stub(user, 'canGetGems').resolves(false); - await expect(applePayments.verifyGemPurchase(user, receipt, headers)) + await expect(applePayments.verifyGemPurchase({user, receipt, headers})) .to.eventually.be.rejected.and.to.eql({ httpCode: 401, name: 'NotAuthorized', @@ -89,7 +90,7 @@ describe('Apple Payments', () => { transactionId: token, }]); - await expect(applePayments.verifyGemPurchase(user, receipt, headers)) + await expect(applePayments.verifyGemPurchase({user, receipt, headers})) .to.eventually.be.rejected.and.to.eql({ httpCode: 401, name: 'NotAuthorized', @@ -131,7 +132,7 @@ describe('Apple Payments', () => { }]); sinon.stub(user, 'canGetGems').resolves(true); - await applePayments.verifyGemPurchase(user, receipt, headers); + await applePayments.verifyGemPurchase({user, receipt, headers}); expect(iapSetupStub).to.be.calledOnce; expect(iapValidateStub).to.be.calledOnce; @@ -151,6 +152,38 @@ describe('Apple Payments', () => { user.canGetGems.restore(); }); }); + + it('gifts gems', async () => { + const receivingUser = new User(); + await receivingUser.save(); + + mockFindById(receivingUser); + + iapGetPurchaseDataStub.restore(); + iapGetPurchaseDataStub = sinon.stub(iapModule, 'getPurchaseData') + .returns([{productId: gemsCanPurchase[0].productId, + transactionId: token, + }]); + + const gift = {uuid: receivingUser._id}; + await applePayments.verifyGemPurchase({user, gift, 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: receivingUser, + paymentMethod: applePayments.constants.PAYMENT_METHOD_APPLE, + amount: gemsCanPurchase[0].amount, + headers, + }); + restoreFindById(); + }); }); describe('subscribe', () => { diff --git a/test/api/unit/libs/payments/google.test.js b/test/api/unit/libs/payments/google.test.js index 8f66f26c1a..c80639957c 100644 --- a/test/api/unit/libs/payments/google.test.js +++ b/test/api/unit/libs/payments/google.test.js @@ -6,6 +6,7 @@ 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'; +import {mockFindById, restoreFindById} from '../../../../helpers/mongoose.helper'; const i18n = common.i18n; @@ -44,7 +45,7 @@ describe('Google Payments', () => { iapIsValidatedStub = sinon.stub(iapModule, 'isValidated') .returns(false); - await expect(googlePayments.verifyGemPurchase(user, receipt, signature, headers)) + await expect(googlePayments.verifyGemPurchase({user, receipt, signature, headers})) .to.eventually.be.rejected.and.to.eql({ httpCode: 401, name: 'NotAuthorized', @@ -55,7 +56,7 @@ describe('Google Payments', () => { it('should throw an error if productId is invalid', async () => { receipt = `{"token": "${token}", "productId": "invalid"}`; - await expect(googlePayments.verifyGemPurchase(user, receipt, signature, headers)) + await expect(googlePayments.verifyGemPurchase({user, receipt, signature, headers})) .to.eventually.be.rejected.and.to.eql({ httpCode: 401, name: 'NotAuthorized', @@ -66,7 +67,7 @@ describe('Google Payments', () => { it('should throw an error if user cannot purchase gems', async () => { sinon.stub(user, 'canGetGems').resolves(false); - await expect(googlePayments.verifyGemPurchase(user, receipt, signature, headers)) + await expect(googlePayments.verifyGemPurchase({user, receipt, signature, headers})) .to.eventually.be.rejected.and.to.eql({ httpCode: 401, name: 'NotAuthorized', @@ -78,7 +79,7 @@ describe('Google Payments', () => { it('purchases gems', async () => { sinon.stub(user, 'canGetGems').resolves(true); - await googlePayments.verifyGemPurchase(user, receipt, signature, headers); + await googlePayments.verifyGemPurchase({user, receipt, signature, headers}); expect(iapSetupStub).to.be.calledOnce; expect(iapValidateStub).to.be.calledOnce; @@ -99,6 +100,34 @@ describe('Google Payments', () => { expect(user.canGetGems).to.be.calledOnce; user.canGetGems.restore(); }); + + it('gifts gems', async () => { + const receivingUser = new User(); + await receivingUser.save(); + + mockFindById(receivingUser); + + const gift = {uuid: receivingUser._id}; + await googlePayments.verifyGemPurchase({user, gift, 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: receivingUser, + paymentMethod: googlePayments.constants.PAYMENT_METHOD_GOOGLE, + amount: 5.25, + headers, + }); + restoreFindById(); + }); }); describe('subscribe', () => { diff --git a/test/api/v3/integration/payments/apple/POST-payments_apple_norenewsubscribe.test.js b/test/api/v3/integration/payments/apple/POST-payments_apple_norenewsubscribe.test.js new file mode 100644 index 0000000000..bd4223d247 --- /dev/null +++ b/test/api/v3/integration/payments/apple/POST-payments_apple_norenewsubscribe.test.js @@ -0,0 +1,67 @@ +import {generateUser, translate as t} from '../../../../../helpers/api-integration/v3'; +import applePayments from '../../../../../../website/server/libs/payments/apple'; + +describe('payments : apple #norenewsubscribe', () => { + let endpoint = '/iap/ios/norenew-subscribe'; + let sku = 'com.habitrpg.ios.habitica.subscription.3month'; + 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'), + }); + }); + + it('verifies receipt existence', async () => { + await expect(user.post(endpoint, { + sku, + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('missingReceipt'), + }); + }); + + describe('success', () => { + let subscribeStub; + + beforeEach(async () => { + subscribeStub = sinon.stub(applePayments, 'noRenewSubscribe').resolves({}); + }); + + afterEach(() => { + applePayments.noRenewSubscribe.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.post(endpoint, { + sku, + transaction: {receipt: 'receipt'}, + gift: { + uuid: '1', + }, + }); + + expect(subscribeStub).to.be.calledOnce; + expect(subscribeStub.args[0][0].user._id).to.eql(user._id); + expect(subscribeStub.args[0][0].sku).to.eql(sku); + expect(subscribeStub.args[0][0].receipt).to.eql('receipt'); + expect(subscribeStub.args[0][0].headers['x-api-key']).to.eql(user.apiToken); + expect(subscribeStub.args[0][0].headers['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_apple_subscribe.test.js similarity index 100% rename from test/api/v3/integration/payments/apple/POST-payments_google_subscribe.test.js rename to test/api/v3/integration/payments/apple/POST-payments_apple_subscribe.test.js 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 index 00233f6ca8..b0b52ffdd8 100644 --- a/test/api/v3/integration/payments/apple/POST-payments_apple_verifyiap.js +++ b/test/api/v3/integration/payments/apple/POST-payments_apple_verifyiap.js @@ -1,4 +1,4 @@ -import {generateUser} from '../../../../../helpers/api-integration/v3'; +import {generateUser, translate as t} from '../../../../../helpers/api-integration/v3'; import applePayments from '../../../../../../website/server/libs/payments/apple'; describe('payments : apple #verify', () => { @@ -9,6 +9,14 @@ describe('payments : apple #verify', () => { user = await generateUser(); }); + it('verifies receipt existence', async () => { + await expect(user.post(endpoint)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('missingReceipt'), + }); + }); + describe('success', () => { let verifyStub; @@ -31,10 +39,31 @@ describe('payments : apple #verify', () => { }}); 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); + expect(verifyStub.args[0][0].user._id).to.eql(user._id); + expect(verifyStub.args[0][0].receipt).to.eql('receipt'); + expect(verifyStub.args[0][0].headers['x-api-key']).to.eql(user.apiToken); + expect(verifyStub.args[0][0].headers['x-api-user']).to.eql(user._id); + }); + + it('gifts a purchase', async () => { + user = await generateUser({ + balance: 2, + }); + + await user.post(endpoint, { + transaction: { + receipt: 'receipt', + }, + gift: { + uuid: '1', + }}); + + expect(verifyStub).to.be.calledOnce; + expect(verifyStub.args[0][0].user._id).to.eql(user._id); + expect(verifyStub.args[0][0].receipt).to.eql('receipt'); + expect(verifyStub.args[0][0].gift.uuid).to.eql('1'); + expect(verifyStub.args[0][0].headers['x-api-key']).to.eql(user.apiToken); + expect(verifyStub.args[0][0].headers['x-api-user']).to.eql(user._id); }); }); }); diff --git a/test/api/v3/integration/payments/google/POST-payments_google_norenewsubscribe.test.js b/test/api/v3/integration/payments/google/POST-payments_google_norenewsubscribe.test.js new file mode 100644 index 0000000000..9d15135537 --- /dev/null +++ b/test/api/v3/integration/payments/google/POST-payments_google_norenewsubscribe.test.js @@ -0,0 +1,97 @@ +import {generateUser, translate as t} from '../../../../../helpers/api-integration/v3'; +import googlePayments from '../../../../../../website/server/libs/payments/google'; + +describe('payments : google #norenewsubscribe', () => { + let endpoint = '/iap/android/norenew-subscribe'; + let sku = 'com.habitrpg.android.habitica.subscription.3month'; + 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'), + }); + }); + + it('verifies receipt existence', async () => { + await expect(user.post(endpoint, { + sku, + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('missingReceipt'), + }); + }); + + describe('success', () => { + let subscribeStub; + + beforeEach(async () => { + subscribeStub = sinon.stub(googlePayments, 'noRenewSubscribe').resolves({}); + }); + + afterEach(() => { + googlePayments.noRenewSubscribe.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.post(endpoint, { + sku, + transaction: { + receipt: 'receipt', + signature: 'signature', + }, + }); + + expect(subscribeStub).to.be.calledOnce; + expect(subscribeStub.args[0][0].user._id).to.eql(user._id); + expect(subscribeStub.args[0][0].sku).to.eql(sku); + expect(subscribeStub.args[0][0].receipt).to.eql('receipt'); + expect(subscribeStub.args[0][0].signature).to.eql('signature'); + expect(subscribeStub.args[0][0].headers['x-api-key']).to.eql(user.apiToken); + expect(subscribeStub.args[0][0].headers['x-api-user']).to.eql(user._id); + }); + + it('gifts 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.post(endpoint, { + sku, + transaction: { + receipt: 'receipt', + signature: 'signature', + }, + gift: { + uuid: '1', + }, + }); + + expect(subscribeStub).to.be.calledOnce; + expect(subscribeStub.args[0][0].user._id).to.eql(user._id); + expect(subscribeStub.args[0][0].sku).to.eql(sku); + expect(subscribeStub.args[0][0].receipt).to.eql('receipt'); + expect(subscribeStub.args[0][0].signature).to.eql('signature'); + expect(subscribeStub.args[0][0].headers['x-api-key']).to.eql(user.apiToken); + expect(subscribeStub.args[0][0].headers['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 index 68ef509e45..0149a44fc3 100644 --- a/test/api/v3/integration/payments/google/POST-payments_google_verifyiap.js +++ b/test/api/v3/integration/payments/google/POST-payments_google_verifyiap.js @@ -1,4 +1,4 @@ -import {generateUser} from '../../../../../helpers/api-integration/v3'; +import {generateUser, translate as t} from '../../../../../helpers/api-integration/v3'; import googlePayments from '../../../../../../website/server/libs/payments/google'; describe('payments : google #verify', () => { @@ -9,6 +9,14 @@ describe('payments : google #verify', () => { user = await generateUser(); }); + it('verifies receipt existence', async () => { + await expect(user.post(endpoint)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('missingReceipt'), + }); + }); + describe('success', () => { let verifyStub; @@ -30,11 +38,30 @@ describe('payments : google #verify', () => { }); 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); + expect(verifyStub.args[0][0].user._id).to.eql(user._id); + expect(verifyStub.args[0][0].receipt).to.eql('receipt'); + expect(verifyStub.args[0][0].signature).to.eql('signature'); + expect(verifyStub.args[0][0].headers['x-api-key']).to.eql(user.apiToken); + expect(verifyStub.args[0][0].headers['x-api-user']).to.eql(user._id); + }); + + it('gifts a purchase', async () => { + user = await generateUser({ + balance: 2, + }); + + await user.post(endpoint, { + transaction: {receipt: 'receipt', signature: 'signature'}, + gift: {uuid: '1'}, + }); + + expect(verifyStub).to.be.calledOnce; + expect(verifyStub.args[0][0].user._id).to.eql(user._id); + expect(verifyStub.args[0][0].receipt).to.eql('receipt'); + expect(verifyStub.args[0][0].signature).to.eql('signature'); + expect(verifyStub.args[0][0].gift.uuid).to.eql('1'); + expect(verifyStub.args[0][0].headers['x-api-key']).to.eql(user.apiToken); + expect(verifyStub.args[0][0].headers['x-api-user']).to.eql(user._id); }); }); }); diff --git a/test/helpers/mongoose.helper.js b/test/helpers/mongoose.helper.js new file mode 100644 index 0000000000..b4d27408ad --- /dev/null +++ b/test/helpers/mongoose.helper.js @@ -0,0 +1,20 @@ +import mongoose from 'mongoose'; + +export async function mockFindById (response) { + const mockFind = { + select () { + return this; + }, + lean () { + return this; + }, + exec () { + return Promise.resolve(response); + }, + }; + sinon.stub(mongoose.Model, 'findById').returns(mockFind); +} + +export function restoreFindById () { + return mongoose.Model.findById.restore(); +} diff --git a/website/server/controllers/top-level/payments/iap.js b/website/server/controllers/top-level/payments/iap.js index f6f6a3653c..c2e8041d0d 100644 --- a/website/server/controllers/top-level/payments/iap.js +++ b/website/server/controllers/top-level/payments/iap.js @@ -22,11 +22,14 @@ api.iapAndroidVerify = { url: '/iap/android/verify', middlewares: [authWithHeaders()], async handler (req, res) { - let user = res.locals.user; - let iapBody = req.body; - - let googleRes = await googlePayments.verifyGemPurchase(user, iapBody.transaction.receipt, iapBody.transaction.signature, req.headers); - + if (!req.body.transaction) throw new BadRequest(res.t('missingReceipt')); + let googleRes = await googlePayments.verifyGemPurchase({ + user: res.locals.user, + receipt: req.body.transaction.receipt, + signature: req.body.transaction.signature, + gift: req.body.gift, + headers: req.headers, + }); res.respond(200, googleRes); }, }; @@ -43,10 +46,34 @@ api.iapSubscriptionAndroid = { middlewares: [authWithHeaders()], 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, res.locals.user, req.body.transaction.receipt, req.body.transaction.signature, req.headers); - 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 {post} /iap/android/norenew-subscribe Android non-renewing subscription IAP + * @apiName iapSubscriptionAndroidNoRenew + * @apiGroup Payments + **/ +api.iapSubscriptionAndroidNoRenew = { + method: 'POST', + url: '/iap/android/norenew-subscribe', + middlewares: [authWithHeaders()], + async handler (req, res) { + if (!req.body.sku) throw new BadRequest(res.t('missingSubscriptionCode')); + if (!req.body.transaction) throw new BadRequest(res.t('missingReceipt')); + + await googlePayments.noRenewSubscribe({ + sku: req.body.sku, + user: res.locals.user, + receipt: req.body.transaction.receipt, + signature: req.body.transaction.signature, + gift: req.body.gift, + headers: req.headers, + }); res.respond(200); }, @@ -87,9 +114,12 @@ api.iapiOSVerify = { 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); - + let appleRes = await applePayments.verifyGemPurchase({ + user: res.locals.user, + receipt: req.body.transaction.receipt, + gift: req.body.gift, + headers: req.headers, + }); res.respond(200, appleRes); }, }; @@ -137,4 +167,29 @@ api.iapCancelSubscriptioniOS = { }, }; +/** + * @apiIgnore Payments are considered part of the private API + * @api {post} /iap/ios/norenew-subscribe iOS Verify IAP + * @apiName IapiOSVerify + * @apiGroup Payments + **/ +api.iapSubscriptioniOSNoRenew = { + method: 'POST', + url: '/iap/ios/norenew-subscribe', + middlewares: [authWithHeaders()], + async handler (req, res) { + if (!req.body.sku) throw new BadRequest(res.t('missingSubscriptionCode')); + if (!req.body.transaction) throw new BadRequest(res.t('missingReceipt')); + + await applePayments.noRenewSubscribe({ + sku: req.body.sku, + user: res.locals.user, + receipt: req.body.transaction.receipt, + gift: req.body.gift, + headers: req.headers}); + + res.respond(200); + }, +}; + module.exports = api; diff --git a/website/server/libs/payments/apple.js b/website/server/libs/payments/apple.js index c235d24c8e..d680c841e0 100644 --- a/website/server/libs/payments/apple.js +++ b/website/server/libs/payments/apple.js @@ -13,6 +13,7 @@ let api = {}; api.constants = { PAYMENT_METHOD_APPLE: 'Apple', + PAYMENT_METHOD_GIFT: 'Apple (Gift)', RESPONSE_INVALID_RECEIPT: 'INVALID_RECEIPT', RESPONSE_ALREADY_USED: 'RECEIPT_ALREADY_USED', RESPONSE_INVALID_ITEM: 'INVALID_ITEM_PURCHASED', @@ -20,9 +21,15 @@ api.constants = { RESPONSE_NO_ITEM_PURCHASED: 'NO_ITEM_PURCHASED', }; -api.verifyGemPurchase = async function verifyGemPurchase (user, receipt, headers) { - const userCanGetGems = await user.canGetGems(); - if (!userCanGetGems) throw new NotAuthorized(shared.i18n.t('groupPolicyCannotGetGems', user.preferences.language)); +api.verifyGemPurchase = async function verifyGemPurchase (options) { + let {gift, user, receipt, headers} = options; + + if (gift) { + gift.member = await User.findById(gift.uuid).exec(); + } + const receiver = gift ? gift.member : user; + const receiverCanGetGems = await receiver.canGetGems(); + if (!receiverCanGetGems) throw new NotAuthorized(shared.i18n.t('groupPolicyCannotGetGems', user.preferences.language)); await iap.setup(); let appleRes = await iap.validate(iap.APPLE, receipt); @@ -45,6 +52,7 @@ api.verifyGemPurchase = async function verifyGemPurchase (user, receipt, headers await IapPurchaseReceipt.create({ // eslint-disable-line no-await-in-loop _id: token, consumed: true, + // This should always be the buying user even for a gift. userId: user._id, }); @@ -67,7 +75,7 @@ api.verifyGemPurchase = async function verifyGemPurchase (user, receipt, headers if (amount) { correctReceipt = true; await payments.buyGems({ // eslint-disable-line no-await-in-loop - user, + user: receiver, paymentMethod: api.constants.PAYMENT_METHOD_APPLE, amount, headers, @@ -148,6 +156,81 @@ api.subscribe = async function subscribe (sku, user, receipt, headers, nextPayme } }; +api.noRenewSubscribe = async function noRenewSubscribe (options) { + let {sku, gift, user, receipt, headers} = options; + + if (!sku) throw new BadRequest(shared.i18n.t('missingSubscriptionCode')); + + let subCode; + switch (sku) { + case 'com.habitrpg.ios.habitica.norenew_subscription.1month': + subCode = 'basic_earned'; + break; + case 'com.habitrpg.ios.habitica.norenew_subscription.3month': + subCode = 'basic_3mo'; + break; + case 'com.habitrpg.ios.habitica.norenew_subscription.6month': + subCode = 'basic_6mo'; + break; + case 'com.habitrpg.ios.habitica.norenew_subscription.12month': + subCode = 'basic_12mo'; + break; + } + const 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); + const 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 existingReceipt = await IapPurchaseReceipt.findOne({ // eslint-disable-line no-await-in-loop + _id: transactionId, + }).exec(); + if (existingReceipt) throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED); + + await IapPurchaseReceipt.create({ // eslint-disable-line no-await-in-loop + _id: transactionId, + consumed: true, + // This should always be the buying user even for a gift. + userId: user._id, + }); + let data = { + user, + paymentMethod: this.constants.PAYMENT_METHOD_APPLE, + headers, + sub, + autoRenews: false, + }; + + if (gift) { + gift.member = await User.findById(gift.uuid).exec(); + gift.subscription = sub; + data.gift = gift; + data.paymentMethod = this.constants.PAYMENT_METHOD_GIFT; + } + + await payments.createSubscription(data); + } else { + throw new NotAuthorized(api.constants.RESPONSE_INVALID_RECEIPT); + } +}; api.cancelSubscribe = async function cancelSubscribe (user, headers) { let plan = user.purchased.plan; diff --git a/website/server/libs/payments/google.js b/website/server/libs/payments/google.js index fb1a253f7c..4ded8d034c 100644 --- a/website/server/libs/payments/google.js +++ b/website/server/libs/payments/google.js @@ -13,15 +13,22 @@ let api = {}; api.constants = { PAYMENT_METHOD_GOOGLE: 'Google', + PAYMENT_METHOD_GIFT: 'Google (Gift)', 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) { - const userCanGetGems = await user.canGetGems(); - if (!userCanGetGems) throw new NotAuthorized(shared.i18n.t('groupPolicyCannotGetGems', user.preferences.language)); +api.verifyGemPurchase = async function verifyGemPurchase (options) { + let {gift, user, receipt, signature, headers} = options; + + if (gift) { + gift.member = await User.findById(gift.uuid).exec(); + } + const receiver = gift ? gift.member : user; + const receiverCanGetGems = await receiver.canGetGems(); + if (!receiverCanGetGems) throw new NotAuthorized(shared.i18n.t('groupPolicyCannotGetGems', user.preferences.language)); await iap.setup(); @@ -46,6 +53,7 @@ api.verifyGemPurchase = async function verifyGemPurchase (user, receipt, signatu await IapPurchaseReceipt.create({ _id: token, consumed: true, + // This should always be the buying user even for a gift. userId: user._id, }); @@ -70,7 +78,7 @@ api.verifyGemPurchase = async function verifyGemPurchase (user, receipt, signatu if (!amount) throw new NotAuthorized(this.constants.RESPONSE_INVALID_ITEM); await payments.buyGems({ - user, + user: receiver, paymentMethod: this.constants.PAYMENT_METHOD_GOOGLE, amount, headers, @@ -132,6 +140,74 @@ api.subscribe = async function subscribe (sku, user, receipt, signature, headers }); }; +api.noRenewSubscribe = async function noRenewSubscribe (options) { + let {sku, gift, user, receipt, signature, headers} = options; + if (!sku) throw new BadRequest(shared.i18n.t('missingSubscriptionCode')); + let subCode; + switch (sku) { + case 'com.habitrpg.android.habitica.norenew_subscription.1month': + subCode = 'basic_earned'; + break; + case 'com.habitrpg.android.habitica.norenew_subscription.3month': + subCode = 'basic_3mo'; + break; + case 'com.habitrpg.android.habitica.norenew_subscription.6month': + subCode = 'basic_6mo'; + break; + case 'com.habitrpg.android.habitica.norenew_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 = typeof receipt === 'string' ? JSON.parse(receipt) : receipt; // passed as a string + let token = receiptObj.token || receiptObj.purchaseToken; + + let existingReceipt = await IapPurchaseReceipt.findOne({ // eslint-disable-line no-await-in-loop + _id: token, + }).exec(); + if (existingReceipt) throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED); + + await IapPurchaseReceipt.create({ // eslint-disable-line no-await-in-loop + _id: token, + consumed: true, + // This should always be the buying user even for a gift. + userId: user._id, + }); + + let googleRes = await iap.validate(iap.GOOGLE, testObj); + + let isValidated = iap.isValidated(googleRes); + if (!isValidated) throw new NotAuthorized(this.constants.RESPONSE_INVALID_RECEIPT); + + let data = { + user, + paymentMethod: this.constants.PAYMENT_METHOD_GOOGLE, + headers, + sub, + autoRenews: false, + }; + + if (gift) { + gift.member = await User.findById(gift.uuid).exec(); + gift.subscription = sub; + data.gift = gift; + data.paymentMethod = this.constants.PAYMENT_METHOD_GIFT; + } + + await payments.createSubscription(data); + + return googleRes; +}; + api.cancelSubscribe = async function cancelSubscribe (user, headers) { let plan = user.purchased.plan; diff --git a/website/server/libs/payments/subscriptions.js b/website/server/libs/payments/subscriptions.js index 345e147e57..228715c666 100644 --- a/website/server/libs/payments/subscriptions.js +++ b/website/server/libs/payments/subscriptions.js @@ -51,6 +51,7 @@ function _dateDiff (earlyDate, lateDate) { async function createSubscription (data) { let recipient = data.gift ? data.gift.member : data.user; let block = shared.content.subscriptionBlocks[data.gift ? data.gift.subscription.key : data.sub.key]; + let autoRenews = data.autoRenews !== undefined ? data.autoRenews : true; let months = Number(block.months); let today = new Date(); let plan; @@ -85,7 +86,7 @@ async function createSubscription (data) { plan = recipient.purchased.plan; - if (data.gift) { + if (data.gift || !autoRenews) { if (plan.customerId && !plan.dateTerminated) { // User has active plan plan.extraMonths += months; } else {