From c25b09c7edd7e89885c937851a40a9951e84e8e9 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Fri, 21 Oct 2022 16:57:12 +0200 Subject: [PATCH 01/26] Allow sub upgrades/downgrades on iOS --- website/server/libs/payments/apple.js | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/website/server/libs/payments/apple.js b/website/server/libs/payments/apple.js index 8c0fd44184..7f82c69af8 100644 --- a/website/server/libs/payments/apple.js +++ b/website/server/libs/payments/apple.js @@ -106,10 +106,6 @@ api.verifyGemPurchase = async function verifyGemPurchase (options) { }; api.subscribe = async function subscribe (sku, user, receipt, headers, nextPaymentProcessing) { - if (user && user.isSubscribed()) { - throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED); - } - if (!sku) throw new BadRequest(shared.i18n.t('missingSubscriptionCode')); let subCode; @@ -140,27 +136,34 @@ api.subscribe = async function subscribe (sku, user, receipt, headers, nextPayme throw new NotAuthorized(api.constants.RESPONSE_NO_ITEM_PURCHASED); } - let transactionId; + let originalTransactionId; + let newTransactionId; for (const purchaseData of purchaseDataList) { const dateTerminated = new Date(Number(purchaseData.expirationDate)); if (purchaseData.productId === sku && dateTerminated > new Date()) { - transactionId = purchaseData.transactionId; + originalTransactionId = purchaseData.originalTransactionId; + newTransactionId = purchaseData.transactionId; break; } } - if (transactionId) { + if (originalTransactionId) { + if (user && user.purchased.plan.customId !== originalTransactionId) { + throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED); + } const existingUser = await User.findOne({ - 'purchased.plan.customerId': transactionId, + 'purchased.plan.customerId': originalTransactionId, }).exec(); - if (existingUser) throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED); + if (existingUser && (originalTransactionId === newTransactionId || existingUser._id !== user._id)) { + throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED); + } nextPaymentProcessing = nextPaymentProcessing || moment.utc().add({ days: 2 }); // eslint-disable-line max-len, no-param-reassign await payments.createSubscription({ user, - customerId: transactionId, + customerId: originalTransactionId, paymentMethod: this.constants.PAYMENT_METHOD_APPLE, sub, headers, From 31685c3e9473fa2d765cd699a9cb70e869072a8c Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Fri, 21 Oct 2022 16:59:18 +0200 Subject: [PATCH 02/26] fix check --- website/server/libs/payments/apple.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/server/libs/payments/apple.js b/website/server/libs/payments/apple.js index 7f82c69af8..36498b0e6d 100644 --- a/website/server/libs/payments/apple.js +++ b/website/server/libs/payments/apple.js @@ -149,7 +149,7 @@ api.subscribe = async function subscribe (sku, user, receipt, headers, nextPayme } if (originalTransactionId) { - if (user && user.purchased.plan.customId !== originalTransactionId) { + if (user && user.isSubscribed() && user.purchased.plan.customId !== originalTransactionId) { throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED); } const existingUser = await User.findOne({ From 6a4b08203ff34e814ca51bb255b1d383220c71b7 Mon Sep 17 00:00:00 2001 From: SabreCat Date: Tue, 25 Oct 2022 16:52:16 -0500 Subject: [PATCH 03/26] fix(lint): line length --- website/server/libs/payments/apple.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/website/server/libs/payments/apple.js b/website/server/libs/payments/apple.js index 36498b0e6d..b6d50626a9 100644 --- a/website/server/libs/payments/apple.js +++ b/website/server/libs/payments/apple.js @@ -155,7 +155,8 @@ api.subscribe = async function subscribe (sku, user, receipt, headers, nextPayme const existingUser = await User.findOne({ 'purchased.plan.customerId': originalTransactionId, }).exec(); - if (existingUser && (originalTransactionId === newTransactionId || existingUser._id !== user._id)) { + if (existingUser + && (originalTransactionId === newTransactionId || existingUser._id !== user._id)) { throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED); } From e6a7d156441ee901e88c5cbce9f467926e9b1e65 Mon Sep 17 00:00:00 2001 From: SabreCat Date: Tue, 25 Oct 2022 16:59:59 -0500 Subject: [PATCH 04/26] fix(typo): customER --- website/server/libs/payments/apple.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/server/libs/payments/apple.js b/website/server/libs/payments/apple.js index b6d50626a9..4f22fcca36 100644 --- a/website/server/libs/payments/apple.js +++ b/website/server/libs/payments/apple.js @@ -149,7 +149,7 @@ api.subscribe = async function subscribe (sku, user, receipt, headers, nextPayme } if (originalTransactionId) { - if (user && user.isSubscribed() && user.purchased.plan.customId !== originalTransactionId) { + if (user && user.isSubscribed() && user.purchased.plan.customerId !== originalTransactionId) { throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED); } const existingUser = await User.findOne({ From 8e2e1709307fbe0396a7c1e3f8227248cad07ea5 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Wed, 26 Oct 2022 16:30:15 +0200 Subject: [PATCH 05/26] fix tests --- test/api/unit/libs/payments/apple.test.js | 1 + 1 file changed, 1 insertion(+) diff --git a/test/api/unit/libs/payments/apple.test.js b/test/api/unit/libs/payments/apple.test.js index 08cb34607e..e3b1ab3831 100644 --- a/test/api/unit/libs/payments/apple.test.js +++ b/test/api/unit/libs/payments/apple.test.js @@ -298,6 +298,7 @@ describe('Apple Payments', () => { expirationDate: moment.utc().add({ day: 1 }).toDate(), productId: option.sku, transactionId: token, + originalTransactionId: token, }]); sub = common.content.subscriptionBlocks[option.subKey]; From 13a25ad89ebd9745667e1c612454497326660e60 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Fri, 28 Oct 2022 11:23:48 +0200 Subject: [PATCH 06/26] Implement correct handling for when subs are up/downgraded --- test/api/unit/libs/payments/apple.test.js | 104 ++++++++- test/api/unit/libs/payments/payments.test.js | 215 +++++++++++++++++- website/server/libs/payments/apple.js | 16 +- website/server/libs/payments/subscriptions.js | 3 +- 4 files changed, 328 insertions(+), 10 deletions(-) diff --git a/test/api/unit/libs/payments/apple.test.js b/test/api/unit/libs/payments/apple.test.js index e3b1ab3831..2cd0eb18a9 100644 --- a/test/api/unit/libs/payments/apple.test.js +++ b/test/api/unit/libs/payments/apple.test.js @@ -218,6 +218,7 @@ describe('Apple Payments', () => { headers = {}; receipt = `{"token": "${token}"}`; nextPaymentProcessing = moment.utc().add({ days: 2 }); + user = new User(); iapSetupStub = sinon.stub(iap, 'setup') .resolves(); @@ -322,11 +323,110 @@ describe('Apple Payments', () => { nextPaymentProcessing, }); }); + if (option !== subOptions[3]) { + const newOption = subOptions[3]; + it(`upgrades a subscription from ${option.sku} to ${newOption.sku}`, async () => { + const oldSub = common.content.subscriptionBlocks[option.subKey]; + user.profile.name = 'sender'; + user.purchased.plan.paymentMethod = applePayments.constants.PAYMENT_METHOD_APPLE; + user.purchased.plan.customerId = token; + user.purchased.plan.planId = option.subKey; + user.purchased.plan.additionalData = receipt; + iap.getPurchaseData.restore(); + iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData') + .returns([{ + expirationDate: moment.utc().add({ day: 2 }).toDate(), + productId: newOption.sku, + transactionId: `${token}new`, + originalTransactionId: token, + }]); + sub = common.content.subscriptionBlocks[newOption.subKey]; + + await applePayments.subscribe(newOption.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, + updatedFrom: oldSub, + }); + }); + } + if (option !== subOptions[0]) { + const newOption = subOptions[0]; + it(`downgrades a subscription from ${option.sku} to ${newOption.sku}`, async () => { + const oldSub = common.content.subscriptionBlocks[option.subKey]; + user.profile.name = 'sender'; + user.purchased.plan.paymentMethod = applePayments.constants.PAYMENT_METHOD_APPLE; + user.purchased.plan.customerId = token; + user.purchased.plan.planId = option.subKey; + user.purchased.plan.additionalData = receipt; + iap.getPurchaseData.restore(); + iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData') + .returns([{ + expirationDate: moment.utc().add({ day: 2 }).toDate(), + productId: newOption.sku, + transactionId: `${token}new`, + originalTransactionId: token, + }]); + sub = common.content.subscriptionBlocks[newOption.subKey]; + + await applePayments.subscribe(newOption.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, + updatedFrom: oldSub, + }); + }); + } }); - it('errors when a user is already subscribed', async () => { + it('errors when a user is using the same subscription', async () => { payments.createSubscription.restore(); - user = new User(); + iap.getPurchaseData.restore(); + iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData') + .returns([{ + expirationDate: moment.utc().add({ day: 1 }).toDate(), + productId: sku, + transactionId: token, + originalTransactionId: token, + }]); + + expect(user.isSubscribed()).to.be.true; await applePayments.subscribe(sku, user, receipt, headers, nextPaymentProcessing); diff --git a/test/api/unit/libs/payments/payments.test.js b/test/api/unit/libs/payments/payments.test.js index 48d6325546..e9fdd3b90f 100644 --- a/test/api/unit/libs/payments/payments.test.js +++ b/test/api/unit/libs/payments/payments.test.js @@ -12,7 +12,7 @@ import { } from '../../../../helpers/api-unit.helper'; import * as worldState from '../../../../../website/server/libs/worldState'; -describe('payments/index', () => { +describe.only('payments/index', () => { let user; let group; let data; let plan; @@ -445,6 +445,89 @@ describe('payments/index', () => { }, }); }); + + context('Upgrades subscription', () => { + it('from basic_earned to basic_6mo', async () => { + data.sub.key = 'basic_earned'; + expect(user.purchased.plan.planId).to.not.exist; + + await api.createSubscription(data); + + expect(user.purchased.plan.planId).to.eql('basic_earned'); + expect(user.purchased.plan.customerId).to.eql('customer-id'); + const created = user.purchased.plan.dateCreated; + const updated = user.purchased.plan.dateUpdated; + + data.sub.key = 'basic_6mo'; + data.updatedFrom = { key: 'basic_earned' }; + await api.createSubscription(data); + expect(user.purchased.plan.planId).to.eql('basic_6mo'); + expect(user.purchased.plan.dateCreated).to.eql(created); + expect(user.purchased.plan.dateUpdated).to.not.eql(updated); + expect(user.purchased.plan.customerId).to.eql('customer-id'); + }); + + it('from basic_3mo to basic_12mo', async () => { + expect(user.purchased.plan.planId).to.not.exist; + + await api.createSubscription(data); + + expect(user.purchased.plan.planId).to.eql('basic_3mo'); + expect(user.purchased.plan.customerId).to.eql('customer-id'); + const created = user.purchased.plan.dateCreated; + const updated = user.purchased.plan.dateUpdated; + + data.sub.key = 'basic_12mo'; + data.updatedFrom = { key: 'basic_3mo' }; + await api.createSubscription(data); + expect(user.purchased.plan.planId).to.eql('basic_12mo'); + expect(user.purchased.plan.dateCreated).to.eql(created); + expect(user.purchased.plan.dateUpdated).to.not.eql(updated); + expect(user.purchased.plan.customerId).to.eql('customer-id'); + }); + }); + + context('Downgrades subscription', () => { + it('from basic_6mo to basic_earned', async () => { + data.sub.key = 'basic_6mo'; + expect(user.purchased.plan.planId).to.not.exist; + + await api.createSubscription(data); + + expect(user.purchased.plan.planId).to.eql('basic_6mo'); + expect(user.purchased.plan.customerId).to.eql('customer-id'); + const created = user.purchased.plan.dateCreated; + const updated = user.purchased.plan.dateUpdated; + + data.sub.key = 'basic_earned'; + data.updatedFrom = { key: 'basic_6mo' }; + await api.createSubscription(data); + expect(user.purchased.plan.planId).to.eql('basic_earned'); + expect(user.purchased.plan.dateCreated).to.eql(created); + expect(user.purchased.plan.dateUpdated).to.not.eql(updated); + expect(user.purchased.plan.customerId).to.eql('customer-id'); + }); + + it('from basic_12mo to basic_3mo', async () => { + expect(user.purchased.plan.planId).to.not.exist; + + data.sub.key = 'basic_12mo'; + await api.createSubscription(data); + + expect(user.purchased.plan.planId).to.eql('basic_12mo'); + expect(user.purchased.plan.customerId).to.eql('customer-id'); + const created = user.purchased.plan.dateCreated; + const updated = user.purchased.plan.dateUpdated; + + data.sub.key = 'basic_3mo'; + data.updatedFrom = { key: 'basic_12mo' }; + await api.createSubscription(data); + expect(user.purchased.plan.planId).to.eql('basic_3mo'); + expect(user.purchased.plan.dateCreated).to.eql(created); + expect(user.purchased.plan.dateUpdated).to.not.eql(updated); + expect(user.purchased.plan.customerId).to.eql('customer-id'); + }); + }); }); context('Block subscription perks', () => { @@ -468,7 +551,6 @@ describe('payments/index', () => { it('adds 10 to plan.consecutive.gemCapExtra for 6 month block', async () => { data.sub.key = 'basic_6mo'; - await api.createSubscription(data); expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(10); @@ -476,7 +558,6 @@ describe('payments/index', () => { it('adds 20 to plan.consecutive.gemCapExtra for 12 month block', async () => { data.sub.key = 'basic_12mo'; - await api.createSubscription(data); expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(20); @@ -512,6 +593,134 @@ describe('payments/index', () => { expect(user.purchased.plan.consecutive.trinkets).to.eql(4); }); + + context('Upgrades subscription', () => { + it('Adds 10 to plan.consecutive.gemCapExtra from basic_earned to basic_6mo', async () => { + data.sub.key = 'basic_earned'; + expect(user.purchased.plan.planId).to.not.exist; + + await api.createSubscription(data); + + expect(user.purchased.plan.planId).to.eql('basic_earned'); + expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(0); + + data.sub.key = 'basic_6mo'; + data.updatedFrom = { key: 'basic_earned' }; + await api.createSubscription(data); + expect(user.purchased.plan.planId).to.eql('basic_6mo'); + expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(10); + }); + + it('Adds 15 to plan.consecutive.gemCapExtra when upgrading from basic_3mo to basic_12mo', async () => { + expect(user.purchased.plan.planId).to.not.exist; + + await api.createSubscription(data); + + expect(user.purchased.plan.planId).to.eql('basic_3mo'); + expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(5); + + data.sub.key = 'basic_12mo'; + data.updatedFrom = { key: 'basic_3mo' }; + await api.createSubscription(data); + expect(user.purchased.plan.planId).to.eql('basic_12mo'); + expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(20); + }); + + it('Adds 2 to plan.consecutive.trinkets from basic_earned to basic_6mo', async () => { + data.sub.key = 'basic_earned'; + expect(user.purchased.plan.planId).to.not.exist; + + await api.createSubscription(data); + + expect(user.purchased.plan.planId).to.eql('basic_earned'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(0); + + data.sub.key = 'basic_6mo'; + data.updatedFrom = { key: 'basic_earned' }; + await api.createSubscription(data); + expect(user.purchased.plan.planId).to.eql('basic_6mo'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(2); + }); + + it('Adds 3 to plan.consecutive.trinkets when upgrading from basic_3mo to basic_12mo', async () => { + expect(user.purchased.plan.planId).to.not.exist; + + await api.createSubscription(data); + + expect(user.purchased.plan.planId).to.eql('basic_3mo'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(1); + + data.sub.key = 'basic_12mo'; + data.updatedFrom = { key: 'basic_3mo' }; + await api.createSubscription(data); + expect(user.purchased.plan.planId).to.eql('basic_12mo'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(4); + }); + }); + + context('Downgrades subscription', () => { + it('does not remove from plan.consecutive.gemCapExtra from basic_6mo to basic_earned', async () => { + data.sub.key = 'basic_6mo'; + expect(user.purchased.plan.planId).to.not.exist; + + await api.createSubscription(data); + + expect(user.purchased.plan.planId).to.eql('basic_6mo'); + expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(10); + + data.sub.key = 'basic_earned'; + data.updatedFrom = { key: 'basic_6mo' }; + await api.createSubscription(data); + expect(user.purchased.plan.planId).to.eql('basic_earned'); + expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(10); + }); + + it('does not remove from plan.consecutive.gemCapExtra from basic_12mo to basic_3mo', async () => { + expect(user.purchased.plan.planId).to.not.exist; + + data.sub.key = 'basic_12mo'; + await api.createSubscription(data); + + expect(user.purchased.plan.planId).to.eql('basic_12mo'); + expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(20); + + data.sub.key = 'basic_3mo'; + data.updatedFrom = { key: 'basic_12mo' }; + await api.createSubscription(data); + expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(20); + }); + + it('does not remove from plan.consecutive.trinkets from basic_6mo to basic_earned', async () => { + data.sub.key = 'basic_6mo'; + expect(user.purchased.plan.planId).to.not.exist; + + await api.createSubscription(data); + + expect(user.purchased.plan.planId).to.eql('basic_6mo'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(2); + + data.sub.key = 'basic_earned'; + data.updatedFrom = { key: 'basic_6mo' }; + await api.createSubscription(data); + expect(user.purchased.plan.planId).to.eql('basic_earned'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(2); + }); + + it('does not remove from plan.consecutive.trinkets from basic_12mo to basic_3mo', async () => { + expect(user.purchased.plan.planId).to.not.exist; + + data.sub.key = 'basic_12mo'; + await api.createSubscription(data); + + expect(user.purchased.plan.planId).to.eql('basic_12mo'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(4); + + data.sub.key = 'basic_3mo'; + data.updatedFrom = { key: 'basic_12mo' }; + await api.createSubscription(data); + expect(user.purchased.plan.consecutive.trinkets).to.eql(4); + }); + }); }); context('Mystery Items', () => { diff --git a/website/server/libs/payments/apple.js b/website/server/libs/payments/apple.js index 4f22fcca36..e91729a7e0 100644 --- a/website/server/libs/payments/apple.js +++ b/website/server/libs/payments/apple.js @@ -149,8 +149,12 @@ api.subscribe = async function subscribe (sku, user, receipt, headers, nextPayme } if (originalTransactionId) { - if (user && user.isSubscribed() && user.purchased.plan.customerId !== originalTransactionId) { - throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED); + let existingSub; + if (user && user.isSubscribed()) { + if (user.purchased.plan.customerId !== originalTransactionId) { + throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED); + } + existingSub = shared.content.subscriptionBlocks[user.purchased.plan.planId]; } const existingUser = await User.findOne({ 'purchased.plan.customerId': originalTransactionId, @@ -162,7 +166,7 @@ api.subscribe = async function subscribe (sku, user, receipt, headers, nextPayme nextPaymentProcessing = nextPaymentProcessing || moment.utc().add({ days: 2 }); // eslint-disable-line max-len, no-param-reassign - await payments.createSubscription({ + const data = { user, customerId: originalTransactionId, paymentMethod: this.constants.PAYMENT_METHOD_APPLE, @@ -170,7 +174,11 @@ api.subscribe = async function subscribe (sku, user, receipt, headers, nextPayme headers, nextPaymentProcessing, additionalData: receipt, - }); + }; + if (existingSub) { + data.updatedFrom = existingSub; + } + await payments.createSubscription(data); } else { throw new NotAuthorized(api.constants.RESPONSE_INVALID_RECEIPT); } diff --git a/website/server/libs/payments/subscriptions.js b/website/server/libs/payments/subscriptions.js index 2052dc4c9a..11dd5768c1 100644 --- a/website/server/libs/payments/subscriptions.js +++ b/website/server/libs/payments/subscriptions.js @@ -70,7 +70,8 @@ async function createSubscription (data) { ? data.gift.subscription.key : data.sub.key]; const autoRenews = data.autoRenews !== undefined ? data.autoRenews : true; - const months = Number(block.months); + const updatedFrom = data.updatedFrom ? shared.content.subscriptionBlocks[data.updatedFrom.key] : undefined; + const months = updatedFrom && Number(updatedFrom.months) !== 1 ? Math.max(0, Number(block.months) - Number(updatedFrom.months)) : Number(block.months); const today = new Date(); let group; let groupId; From 08469c556b76c925fc4a5748bd44dad210e0a0f3 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Fri, 28 Oct 2022 12:41:43 +0200 Subject: [PATCH 07/26] fix lint errors --- test/api/unit/libs/payments/payments.test.js | 2 +- website/server/libs/payments/subscriptions.js | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/test/api/unit/libs/payments/payments.test.js b/test/api/unit/libs/payments/payments.test.js index e9fdd3b90f..75500e28f5 100644 --- a/test/api/unit/libs/payments/payments.test.js +++ b/test/api/unit/libs/payments/payments.test.js @@ -12,7 +12,7 @@ import { } from '../../../../helpers/api-unit.helper'; import * as worldState from '../../../../../website/server/libs/worldState'; -describe.only('payments/index', () => { +describe('payments/index', () => { let user; let group; let data; let plan; diff --git a/website/server/libs/payments/subscriptions.js b/website/server/libs/payments/subscriptions.js index 11dd5768c1..f478317e12 100644 --- a/website/server/libs/payments/subscriptions.js +++ b/website/server/libs/payments/subscriptions.js @@ -70,8 +70,15 @@ async function createSubscription (data) { ? data.gift.subscription.key : data.sub.key]; const autoRenews = data.autoRenews !== undefined ? data.autoRenews : true; - const updatedFrom = data.updatedFrom ? shared.content.subscriptionBlocks[data.updatedFrom.key] : undefined; - const months = updatedFrom && Number(updatedFrom.months) !== 1 ? Math.max(0, Number(block.months) - Number(updatedFrom.months)) : Number(block.months); + const updatedFrom = data.updatedFrom + ? shared.content.subscriptionBlocks[data.updatedFrom.key] + : undefined; + let months; + if (updatedFrom && Number(updatedFrom.months) !== 1) { + months = Math.max(0, Number(block.months) - Number(updatedFrom.months)); + } else { + months = Number(block.months); + } const today = new Date(); let group; let groupId; From 1143f690d1519949ad485d98be428a6de7e3f9cd Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Fri, 28 Oct 2022 15:28:46 +0200 Subject: [PATCH 08/26] fix test --- test/api/unit/libs/payments/apple.test.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/api/unit/libs/payments/apple.test.js b/test/api/unit/libs/payments/apple.test.js index 2cd0eb18a9..3667189ff8 100644 --- a/test/api/unit/libs/payments/apple.test.js +++ b/test/api/unit/libs/payments/apple.test.js @@ -426,8 +426,6 @@ describe('Apple Payments', () => { originalTransactionId: token, }]); - expect(user.isSubscribed()).to.be.true; - await applePayments.subscribe(sku, user, receipt, headers, nextPaymentProcessing); await expect(applePayments.subscribe(sku, user, receipt, headers, nextPaymentProcessing)) From ab953440e353340edea13ba09f420f9c36faa9e3 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Wed, 2 Nov 2022 16:36:09 +0100 Subject: [PATCH 09/26] fix issue where subs would be applied multiple times --- test/api/unit/libs/payments/apple.test.js | 80 ++++++++++++++++++----- website/server/libs/payments/apple.js | 6 +- 2 files changed, 68 insertions(+), 18 deletions(-) diff --git a/test/api/unit/libs/payments/apple.test.js b/test/api/unit/libs/payments/apple.test.js index 3667189ff8..a3f427234c 100644 --- a/test/api/unit/libs/payments/apple.test.js +++ b/test/api/unit/libs/payments/apple.test.js @@ -415,26 +415,72 @@ describe('Apple Payments', () => { } }); - it('errors when a user is using the same subscription', async () => { - payments.createSubscription.restore(); - iap.getPurchaseData.restore(); - iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData') - .returns([{ - expirationDate: moment.utc().add({ day: 1 }).toDate(), - productId: sku, - transactionId: token, - originalTransactionId: token, - }]); + describe('does not apply multiple times', async () => { + it('errors when a user is using the same subscription', async () => { + payments.createSubscription.restore(); + iap.getPurchaseData.restore(); + iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData') + .returns([{ + expirationDate: moment.utc().add({ day: 1 }).toDate(), + productId: sku, + transactionId: token, + originalTransactionId: token, + }]); + + await applePayments.subscribe(sku, user, receipt, headers, nextPaymentProcessing); + + await expect(applePayments.subscribe(sku, user, receipt, headers, nextPaymentProcessing)) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 401, + name: 'NotAuthorized', + message: applePayments.constants.RESPONSE_ALREADY_USED, + }); + }); - await applePayments.subscribe(sku, user, receipt, headers, nextPaymentProcessing); + it('errors when a user is using a rebill of the same subscription', async () => { + payments.createSubscription.restore(); + iap.getPurchaseData.restore(); + iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData') + .returns([{ + expirationDate: moment.utc().add({ day: 1 }).toDate(), + productId: sku, + transactionId: token + 'renew', + originalTransactionId: token, + }]); - await expect(applePayments.subscribe(sku, user, receipt, headers, nextPaymentProcessing)) - .to.eventually.be.rejected.and.to.eql({ - httpCode: 401, - name: 'NotAuthorized', - message: applePayments.constants.RESPONSE_ALREADY_USED, - }); + await applePayments.subscribe(sku, user, receipt, headers, nextPaymentProcessing); + + await expect(applePayments.subscribe(sku, user, receipt, headers, nextPaymentProcessing)) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 401, + name: 'NotAuthorized', + message: applePayments.constants.RESPONSE_ALREADY_USED, + }); + }); + + it('errors when a different user is using the subscription', async () => { + payments.createSubscription.restore(); + iap.getPurchaseData.restore(); + iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData') + .returns([{ + expirationDate: moment.utc().add({ day: 1 }).toDate(), + productId: sku, + transactionId: token, + originalTransactionId: token, + }]); + + await applePayments.subscribe(sku, user, receipt, headers, nextPaymentProcessing); + + const secondUser = new User(); + await expect(applePayments.subscribe(sku, secondUser, receipt, headers, nextPaymentProcessing)) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 401, + name: 'NotAuthorized', + message: applePayments.constants.RESPONSE_ALREADY_USED, + }); + }); }); + }); describe('cancelSubscribe ', () => { diff --git a/website/server/libs/payments/apple.js b/website/server/libs/payments/apple.js index e91729a7e0..c995b295bf 100644 --- a/website/server/libs/payments/apple.js +++ b/website/server/libs/payments/apple.js @@ -155,12 +155,16 @@ api.subscribe = async function subscribe (sku, user, receipt, headers, nextPayme throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED); } existingSub = shared.content.subscriptionBlocks[user.purchased.plan.planId]; + if (existingSub === sub) { + throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED); + } } const existingUser = await User.findOne({ 'purchased.plan.customerId': originalTransactionId, }).exec(); if (existingUser - && (originalTransactionId === newTransactionId || existingUser._id !== user._id)) { + && (originalTransactionId === newTransactionId + || existingUser._id !== user._id)) { throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED); } From b65fa941b9868ebae0951a59c71ada340cfb69c8 Mon Sep 17 00:00:00 2001 From: SabreCat Date: Wed, 2 Nov 2022 14:44:49 -0500 Subject: [PATCH 10/26] fix(test): linting --- test/api/unit/libs/payments/apple.test.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/test/api/unit/libs/payments/apple.test.js b/test/api/unit/libs/payments/apple.test.js index a3f427234c..1f140e9639 100644 --- a/test/api/unit/libs/payments/apple.test.js +++ b/test/api/unit/libs/payments/apple.test.js @@ -426,9 +426,9 @@ describe('Apple Payments', () => { transactionId: token, originalTransactionId: token, }]); - + await applePayments.subscribe(sku, user, receipt, headers, nextPaymentProcessing); - + await expect(applePayments.subscribe(sku, user, receipt, headers, nextPaymentProcessing)) .to.eventually.be.rejected.and.to.eql({ httpCode: 401, @@ -444,7 +444,7 @@ describe('Apple Payments', () => { .returns([{ expirationDate: moment.utc().add({ day: 1 }).toDate(), productId: sku, - transactionId: token + 'renew', + transactionId: `${token}renew`, originalTransactionId: token, }]); @@ -472,7 +472,9 @@ describe('Apple Payments', () => { await applePayments.subscribe(sku, user, receipt, headers, nextPaymentProcessing); const secondUser = new User(); - await expect(applePayments.subscribe(sku, secondUser, receipt, headers, nextPaymentProcessing)) + await expect(applePayments.subscribe( + sku, secondUser, receipt, headers, nextPaymentProcessing, + )) .to.eventually.be.rejected.and.to.eql({ httpCode: 401, name: 'NotAuthorized', @@ -480,7 +482,6 @@ describe('Apple Payments', () => { }); }); }); - }); describe('cancelSubscribe ', () => { From 36d2ad6b9bc522f83ce7a71c851558ad7a093a11 Mon Sep 17 00:00:00 2001 From: SabreCat Date: Wed, 2 Nov 2022 15:15:28 -0500 Subject: [PATCH 11/26] fix(test): save user to avoid lock errors --- test/api/unit/libs/payments/apple.test.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/api/unit/libs/payments/apple.test.js b/test/api/unit/libs/payments/apple.test.js index 8065257a02..bf15e44abd 100644 --- a/test/api/unit/libs/payments/apple.test.js +++ b/test/api/unit/libs/payments/apple.test.js @@ -440,6 +440,8 @@ describe('Apple Payments', () => { }); it('errors when a user is using a rebill of the same subscription', async () => { + user = new User(); + await user.save(); payments.createSubscription.restore(); iap.getPurchaseData.restore(); iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData') @@ -461,6 +463,8 @@ describe('Apple Payments', () => { }); it('errors when a different user is using the subscription', async () => { + user = new User(); + await user.save(); payments.createSubscription.restore(); iap.getPurchaseData.restore(); iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData') From 7fd899b6423a7d4d80ed21cc89a80aedf6a54c1a Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Thu, 3 Nov 2022 17:48:36 +0100 Subject: [PATCH 12/26] Fix logic for apple subscriptions --- test/api/unit/libs/payments/apple.test.js | 78 ++++++++++++++----- .../controllers/top-level/payments/iap.js | 2 +- website/server/libs/payments/apple.js | 52 +++++++------ 3 files changed, 85 insertions(+), 47 deletions(-) diff --git a/test/api/unit/libs/payments/apple.test.js b/test/api/unit/libs/payments/apple.test.js index bf15e44abd..f8d8900bfe 100644 --- a/test/api/unit/libs/payments/apple.test.js +++ b/test/api/unit/libs/payments/apple.test.js @@ -229,14 +229,17 @@ describe('Apple Payments', () => { iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData') .returns([{ expirationDate: moment.utc().subtract({ day: 1 }).toDate(), + purchaseDate: moment.utc().valueOf(), productId: sku, transactionId: token, }, { expirationDate: moment.utc().add({ day: 1 }).toDate(), + purchaseDate: moment.utc().valueOf(), productId: 'wrongsku', transactionId: token, }, { expirationDate: moment.utc().add({ day: 1 }).toDate(), + purchaseDate: moment.utc().valueOf(), productId: sku, transactionId: token, }]); @@ -251,21 +254,12 @@ describe('Apple Payments', () => { if (payments.createSubscription.restore) payments.createSubscription.restore(); }); - it('should throw an error if sku is empty', async () => { - await expect(applePayments.subscribe('', user, receipt, headers, nextPaymentProcessing)) - .to.eventually.be.rejected.and.to.eql({ - httpCode: 400, - name: 'BadRequest', - message: i18n.t('missingSubscriptionCode'), - }); - }); - it('should throw an error if receipt is invalid', async () => { iap.isValidated.restore(); iapIsValidatedStub = sinon.stub(iap, 'isValidated') .returns(false); - await expect(applePayments.subscribe(sku, user, receipt, headers, nextPaymentProcessing)) + await expect(applePayments.subscribe(user, receipt, headers, nextPaymentProcessing)) .to.eventually.be.rejected.and.to.eql({ httpCode: 401, name: 'NotAuthorized', @@ -297,13 +291,14 @@ describe('Apple Payments', () => { iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData') .returns([{ expirationDate: moment.utc().add({ day: 1 }).toDate(), + purchaseDate: new Date(), productId: option.sku, transactionId: token, originalTransactionId: token, }]); sub = common.content.subscriptionBlocks[option.subKey]; - await applePayments.subscribe(option.sku, user, receipt, headers, nextPaymentProcessing); + await applePayments.subscribe(user, receipt, headers, nextPaymentProcessing); expect(iapSetupStub).to.be.calledOnce; expect(iapValidateStub).to.be.calledOnce; @@ -336,14 +331,14 @@ describe('Apple Payments', () => { iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData') .returns([{ expirationDate: moment.utc().add({ day: 2 }).toDate(), + purchaseDate: moment.utc().valueOf(), productId: newOption.sku, transactionId: `${token}new`, originalTransactionId: token, }]); sub = common.content.subscriptionBlocks[newOption.subKey]; - await applePayments.subscribe(newOption.sku, - user, + await applePayments.subscribe(user, receipt, headers, nextPaymentProcessing); @@ -381,14 +376,14 @@ describe('Apple Payments', () => { iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData') .returns([{ expirationDate: moment.utc().add({ day: 2 }).toDate(), + purchaseDate: moment.utc().valueOf(), productId: newOption.sku, transactionId: `${token}new`, originalTransactionId: token, }]); sub = common.content.subscriptionBlocks[newOption.subKey]; - await applePayments.subscribe(newOption.sku, - user, + await applePayments.subscribe(user, receipt, headers, nextPaymentProcessing); @@ -415,6 +410,44 @@ describe('Apple Payments', () => { } }); + it('uses the most recent subscription data', async () => { + iap.getPurchaseData.restore(); + iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData') + .returns([{ + expirationDate: moment.utc().add({ day: 4 }).toDate(), + purchaseDate: moment.utc().subtract({ day: 5 }).toDate(), + productId: 'com.habitrpg.ios.habitica.subscription.3month', + transactionId: token + 'oldest', + originalTransactionId: token + 'evenOlder', + }, { + expirationDate: moment.utc().add({ day: 2 }).toDate(), + purchaseDate: moment.utc().subtract({ day: 1 }).toDate(), + productId: 'com.habitrpg.ios.habitica.subscription.12month', + transactionId: token + 'newest', + originalTransactionId: token + 'newest', + }, { + expirationDate: moment.utc().add({ day: 1 }).toDate(), + purchaseDate: moment.utc().subtract({ day: 2 }).toDate(), + productId: 'com.habitrpg.ios.habitica.subscription.6month', + transactionId: token, + originalTransactionId: token, + }]); + sub = common.content.subscriptionBlocks['basic_12mo']; + + await applePayments.subscribe(user, receipt, headers, nextPaymentProcessing); + + expect(paymentsCreateSubscritionStub).to.be.calledOnce; + expect(paymentsCreateSubscritionStub).to.be.calledWith({ + user, + customerId: token + 'newest', + paymentMethod: applePayments.constants.PAYMENT_METHOD_APPLE, + sub, + headers, + additionalData: receipt, + nextPaymentProcessing, + }); + }) + describe('does not apply multiple times', async () => { it('errors when a user is using the same subscription', async () => { user = new User(); @@ -424,14 +457,15 @@ describe('Apple Payments', () => { iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData') .returns([{ expirationDate: moment.utc().add({ day: 1 }).toDate(), + purchaseDate: moment.utc().toDate(), productId: sku, transactionId: token, originalTransactionId: token, }]); - await applePayments.subscribe(sku, user, receipt, headers, nextPaymentProcessing); + await applePayments.subscribe(user, receipt, headers, nextPaymentProcessing); - await expect(applePayments.subscribe(sku, user, receipt, headers, nextPaymentProcessing)) + await expect(applePayments.subscribe(user, receipt, headers, nextPaymentProcessing)) .to.eventually.be.rejected.and.to.eql({ httpCode: 401, name: 'NotAuthorized', @@ -447,14 +481,15 @@ describe('Apple Payments', () => { iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData') .returns([{ expirationDate: moment.utc().add({ day: 1 }).toDate(), + purchaseDate: moment.utc().toDate(), productId: sku, transactionId: `${token}renew`, originalTransactionId: token, }]); - await applePayments.subscribe(sku, user, receipt, headers, nextPaymentProcessing); + await applePayments.subscribe(user, receipt, headers, nextPaymentProcessing); - await expect(applePayments.subscribe(sku, user, receipt, headers, nextPaymentProcessing)) + await expect(applePayments.subscribe(user, receipt, headers, nextPaymentProcessing)) .to.eventually.be.rejected.and.to.eql({ httpCode: 401, name: 'NotAuthorized', @@ -470,16 +505,17 @@ describe('Apple Payments', () => { iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData') .returns([{ expirationDate: moment.utc().add({ day: 1 }).toDate(), + purchaseDate: moment.utc().toDate(), productId: sku, transactionId: token, originalTransactionId: token, }]); - await applePayments.subscribe(sku, user, receipt, headers, nextPaymentProcessing); + await applePayments.subscribe(user, receipt, headers, nextPaymentProcessing); const secondUser = new User(); await expect(applePayments.subscribe( - sku, secondUser, receipt, headers, nextPaymentProcessing, + secondUser, receipt, headers, nextPaymentProcessing, )) .to.eventually.be.rejected.and.to.eql({ httpCode: 401, diff --git a/website/server/controllers/top-level/payments/iap.js b/website/server/controllers/top-level/payments/iap.js index 00ccb31642..c3f7ca5c56 100644 --- a/website/server/controllers/top-level/payments/iap.js +++ b/website/server/controllers/top-level/payments/iap.js @@ -144,7 +144,7 @@ api.iapSubscriptioniOS = { 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); + await applePayments.subscribe(res.locals.user, req.body.receipt, req.headers); res.respond(200); }, diff --git a/website/server/libs/payments/apple.js b/website/server/libs/payments/apple.js index c995b295bf..9997652db2 100644 --- a/website/server/libs/payments/apple.js +++ b/website/server/libs/payments/apple.js @@ -105,8 +105,33 @@ api.verifyGemPurchase = async function verifyGemPurchase (options) { return appleRes; }; -api.subscribe = async function subscribe (sku, user, receipt, headers, nextPaymentProcessing) { - if (!sku) throw new BadRequest(shared.i18n.t('missingSubscriptionCode')); +api.subscribe = async function subscribe (user, receipt, headers, nextPaymentProcessing) { + await iap.setup(); + + const appleRes = await iap.validate(iap.APPLE, receipt); + const isValidated = iap.isValidated(appleRes); + if (!isValidated) throw new NotAuthorized(api.constants.RESPONSE_INVALID_RECEIPT); + + const purchaseDataList = iap.getPurchaseData(appleRes); + if (purchaseDataList.length === 0) { + throw new NotAuthorized(api.constants.RESPONSE_NO_ITEM_PURCHASED); + } + + let originalTransactionId; + let newTransactionId; + let newestDate; + let sku; + + for (const purchaseData of purchaseDataList) { + const datePurchased = new Date(Number(purchaseData.purchaseDate)); + const dateTerminated = new Date(Number(purchaseData.expirationDate)); + if ((!newestDate || datePurchased > newestDate) && dateTerminated > new Date()) { + originalTransactionId = purchaseData.originalTransactionId; + newTransactionId = purchaseData.transactionId; + newestDate = datePurchased + sku = purchaseData.productId + } + } let subCode; switch (sku) { // eslint-disable-line default-case @@ -124,29 +149,6 @@ api.subscribe = async function subscribe (sku, user, receipt, headers, nextPayme break; } const sub = subCode ? shared.content.subscriptionBlocks[subCode] : false; - if (!sub) throw new NotAuthorized(this.constants.RESPONSE_INVALID_ITEM); - await iap.setup(); - - const appleRes = await iap.validate(iap.APPLE, receipt); - const isValidated = iap.isValidated(appleRes); - if (!isValidated) throw new NotAuthorized(api.constants.RESPONSE_INVALID_RECEIPT); - - const purchaseDataList = iap.getPurchaseData(appleRes); - if (purchaseDataList.length === 0) { - throw new NotAuthorized(api.constants.RESPONSE_NO_ITEM_PURCHASED); - } - - let originalTransactionId; - let newTransactionId; - - for (const purchaseData of purchaseDataList) { - const dateTerminated = new Date(Number(purchaseData.expirationDate)); - if (purchaseData.productId === sku && dateTerminated > new Date()) { - originalTransactionId = purchaseData.originalTransactionId; - newTransactionId = purchaseData.transactionId; - break; - } - } if (originalTransactionId) { let existingSub; From e7fc7feddd57468236e0bee2a88fc8b24bdbecad Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Fri, 4 Nov 2022 13:30:50 +0100 Subject: [PATCH 13/26] Better handling for cancellation when user had multiple subs --- test/api/unit/libs/payments/apple.test.js | 66 +++++++++++------------ website/server/libs/payments/apple.js | 15 ++++-- 2 files changed, 44 insertions(+), 37 deletions(-) diff --git a/test/api/unit/libs/payments/apple.test.js b/test/api/unit/libs/payments/apple.test.js index f8d8900bfe..fce71fc634 100644 --- a/test/api/unit/libs/payments/apple.test.js +++ b/test/api/unit/libs/payments/apple.test.js @@ -412,41 +412,41 @@ describe('Apple Payments', () => { it('uses the most recent subscription data', async () => { iap.getPurchaseData.restore(); - iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData') - .returns([{ - expirationDate: moment.utc().add({ day: 4 }).toDate(), - purchaseDate: moment.utc().subtract({ day: 5 }).toDate(), - productId: 'com.habitrpg.ios.habitica.subscription.3month', - transactionId: token + 'oldest', - originalTransactionId: token + 'evenOlder', - }, { - expirationDate: moment.utc().add({ day: 2 }).toDate(), - purchaseDate: moment.utc().subtract({ day: 1 }).toDate(), - productId: 'com.habitrpg.ios.habitica.subscription.12month', - transactionId: token + 'newest', - originalTransactionId: token + 'newest', - }, { - expirationDate: moment.utc().add({ day: 1 }).toDate(), - purchaseDate: moment.utc().subtract({ day: 2 }).toDate(), - productId: 'com.habitrpg.ios.habitica.subscription.6month', - transactionId: token, - originalTransactionId: token, - }]); - sub = common.content.subscriptionBlocks['basic_12mo']; + iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData') + .returns([{ + expirationDate: moment.utc().add({ day: 4 }).toDate(), + purchaseDate: moment.utc().subtract({ day: 5 }).toDate(), + productId: 'com.habitrpg.ios.habitica.subscription.3month', + transactionId: `${token}oldest`, + originalTransactionId: `${token}evenOlder`, + }, { + expirationDate: moment.utc().add({ day: 2 }).toDate(), + purchaseDate: moment.utc().subtract({ day: 1 }).toDate(), + productId: 'com.habitrpg.ios.habitica.subscription.12month', + transactionId: `${token}newest`, + originalTransactionId: `${token}newest`, + }, { + expirationDate: moment.utc().add({ day: 1 }).toDate(), + purchaseDate: moment.utc().subtract({ day: 2 }).toDate(), + productId: 'com.habitrpg.ios.habitica.subscription.6month', + transactionId: token, + originalTransactionId: token, + }]); + sub = common.content.subscriptionBlocks.basic_12mo; - await applePayments.subscribe(user, receipt, headers, nextPaymentProcessing); + await applePayments.subscribe(user, receipt, headers, nextPaymentProcessing); - expect(paymentsCreateSubscritionStub).to.be.calledOnce; - expect(paymentsCreateSubscritionStub).to.be.calledWith({ - user, - customerId: token + 'newest', - paymentMethod: applePayments.constants.PAYMENT_METHOD_APPLE, - sub, - headers, - additionalData: receipt, - nextPaymentProcessing, - }); - }) + expect(paymentsCreateSubscritionStub).to.be.calledOnce; + expect(paymentsCreateSubscritionStub).to.be.calledWith({ + user, + customerId: `${token}newest`, + paymentMethod: applePayments.constants.PAYMENT_METHOD_APPLE, + sub, + headers, + additionalData: receipt, + nextPaymentProcessing, + }); + }); describe('does not apply multiple times', async () => { it('errors when a user is using the same subscription', async () => { diff --git a/website/server/libs/payments/apple.js b/website/server/libs/payments/apple.js index 9997652db2..e56affc4cf 100644 --- a/website/server/libs/payments/apple.js +++ b/website/server/libs/payments/apple.js @@ -128,8 +128,8 @@ api.subscribe = async function subscribe (user, receipt, headers, nextPaymentPro if ((!newestDate || datePurchased > newestDate) && dateTerminated > new Date()) { originalTransactionId = purchaseData.originalTransactionId; newTransactionId = purchaseData.transactionId; - newestDate = datePurchased - sku = purchaseData.productId + newestDate = datePurchased; + sku = purchaseData.productId; } } @@ -286,9 +286,16 @@ api.cancelSubscribe = async function cancelSubscribe (user, headers) { const purchases = iap.getPurchaseData(appleRes); if (purchases.length === 0) throw new NotAuthorized(this.constants.RESPONSE_INVALID_RECEIPT); - const subscriptionData = purchases[0]; + let newestDate; + + for (const purchaseData of purchases) { + const datePurchased = new Date(Number(purchaseData.purchaseDate)); + if (!newestDate || datePurchased > newestDate) { + dateTerminated = new Date(Number(purchaseData.expirationDate)); + newestDate = datePurchased; + } + } - dateTerminated = new Date(Number(subscriptionData.expirationDate)); if (dateTerminated > new Date()) throw new NotAuthorized(this.constants.RESPONSE_STILL_VALID); } catch (err) { // If we have an invalid receipt, cancel anyway From 3a34aa4cc50ba27fe517a10253f513c65c23007c Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Tue, 8 Nov 2022 12:19:17 +0100 Subject: [PATCH 14/26] Improve recheck handling for test subs --- website/server/libs/payments/apple.js | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/website/server/libs/payments/apple.js b/website/server/libs/payments/apple.js index e56affc4cf..035ca2b5b9 100644 --- a/website/server/libs/payments/apple.js +++ b/website/server/libs/payments/apple.js @@ -117,24 +117,20 @@ api.subscribe = async function subscribe (user, receipt, headers, nextPaymentPro throw new NotAuthorized(api.constants.RESPONSE_NO_ITEM_PURCHASED); } - let originalTransactionId; - let newTransactionId; + let purchase; let newestDate; - let sku; for (const purchaseData of purchaseDataList) { const datePurchased = new Date(Number(purchaseData.purchaseDate)); const dateTerminated = new Date(Number(purchaseData.expirationDate)); if ((!newestDate || datePurchased > newestDate) && dateTerminated > new Date()) { - originalTransactionId = purchaseData.originalTransactionId; - newTransactionId = purchaseData.transactionId; + purchase = purchaseData; newestDate = datePurchased; - sku = purchaseData.productId; } } let subCode; - switch (sku) { // eslint-disable-line default-case + switch (purchase.productId) { // eslint-disable-line default-case case 'subscription1month': subCode = 'basic_earned'; break; @@ -150,10 +146,10 @@ api.subscribe = async function subscribe (user, receipt, headers, nextPaymentPro } const sub = subCode ? shared.content.subscriptionBlocks[subCode] : false; - if (originalTransactionId) { + if (purchase.originalTransactionId) { let existingSub; if (user && user.isSubscribed()) { - if (user.purchased.plan.customerId !== originalTransactionId) { + if (user.purchased.plan.customerId !== purchase.originalTransactionId) { throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED); } existingSub = shared.content.subscriptionBlocks[user.purchased.plan.planId]; @@ -162,19 +158,24 @@ api.subscribe = async function subscribe (user, receipt, headers, nextPaymentPro } } const existingUser = await User.findOne({ - 'purchased.plan.customerId': originalTransactionId, + 'purchased.plan.customerId': purchase.originalTransactionId, }).exec(); if (existingUser - && (originalTransactionId === newTransactionId + && (purchase.originalTransactionId === purchase.transactionId || existingUser._id !== user._id)) { throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED); } nextPaymentProcessing = nextPaymentProcessing || moment.utc().add({ days: 2 }); // eslint-disable-line max-len, no-param-reassign + const terminationDate = moment(Number(purchase.expirationDate)); + if (nextPaymentProcessing > terminationDate) { + // For test subscriptions that have a significantly shorter expiration period, this is better + nextPaymentProcessing = terminationDate; // eslint-disable-line no-param-reassign + } const data = { user, - customerId: originalTransactionId, + customerId: purchase.originalTransactionId, paymentMethod: this.constants.PAYMENT_METHOD_APPLE, sub, headers, From 8d732c59c464afc8bc4e858ecb62422a831ecfed Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Tue, 8 Nov 2022 12:38:24 +0100 Subject: [PATCH 15/26] Add field to track when current subscription type started --- test/api/unit/libs/payments/payments.test.js | 54 ++++++++++++++++++- website/server/libs/payments/subscriptions.js | 2 + website/server/models/subscriptionPlan.js | 1 + 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/test/api/unit/libs/payments/payments.test.js b/test/api/unit/libs/payments/payments.test.js index 84475e4b0e..b00733b45b 100644 --- a/test/api/unit/libs/payments/payments.test.js +++ b/test/api/unit/libs/payments/payments.test.js @@ -13,7 +13,7 @@ import { import * as worldState from '../../../../../website/server/libs/worldState'; import { TransactionModel } from '../../../../../website/server/models/transaction'; -describe('payments/index', () => { +describe.only('payments/index', () => { let user; let group; let data; @@ -203,6 +203,28 @@ describe('payments/index', () => { expect(recipient.purchased.plan.dateCreated).to.exist; }); + it('sets plan.dateCurrentTypeCreated if it did not previously exist', async () => { + expect(recipient.purchased.plan.dateCurrentTypeCreated).to.not.exist; + + await api.createSubscription(data); + + expect(recipient.purchased.plan.dateCurrentTypeCreated).to.exist; + }); + + it('keeps plan.dateCreated when changing subscription type', async () => { + await api.createSubscription(data); + const initialDate = recipient.purchased.plan.dateCreated + await api.createSubscription(data); + expect(recipient.purchased.plan.dateCreated).to.eql(initialDate); + }); + + it('sets plan.dateCurrentTypeCreated when changing subscription type', async () => { + await api.createSubscription(data); + const initialDate = recipient.purchased.plan.dateCurrentTypeCreated + await api.createSubscription(data); + expect(recipient.purchased.plan.dateCurrentTypeCreated).to.not.eql(initialDate); + }); + it('does not change plan.customerId if it already exists', async () => { recipient.purchased.plan = plan; data.customerId = 'purchaserCustomerId'; @@ -386,6 +408,36 @@ describe('payments/index', () => { expect(user.purchased.plan.dateCreated).to.exist; }); + it('sets plan.dateCreated if it did not previously exist', async () => { + expect(user.purchased.plan.dateCreated).to.not.exist; + + await api.createSubscription(data); + + expect(user.purchased.plan.dateCreated).to.exist; + }); + + it('sets plan.dateCurrentTypeCreated if it did not previously exist', async () => { + expect(user.purchased.plan.dateCurrentTypeCreated).to.not.exist; + + await api.createSubscription(data); + + expect(user.purchased.plan.dateCurrentTypeCreated).to.exist; + }); + + it('keeps plan.dateCreated when changing subscription type', async () => { + await api.createSubscription(data); + const initialDate = user.purchased.plan.dateCreated + await api.createSubscription(data); + expect(user.purchased.plan.dateCreated).to.eql(initialDate); + }); + + it('sets plan.dateCurrentTypeCreated when changing subscription type', async () => { + await api.createSubscription(data); + const initialDate = user.purchased.plan.dateCurrentTypeCreated + await api.createSubscription(data); + expect(user.purchased.plan.dateCurrentTypeCreated).to.not.eql(initialDate); + }); + it('awards the Royal Purple Jackalope pet', async () => { await api.createSubscription(data); diff --git a/website/server/libs/payments/subscriptions.js b/website/server/libs/payments/subscriptions.js index bb5411e9f7..0f7ceb7acd 100644 --- a/website/server/libs/payments/subscriptions.js +++ b/website/server/libs/payments/subscriptions.js @@ -161,6 +161,7 @@ async function prepareSubscriptionValues (data) { plan.dateTerminated = moment().add({ months }).toDate(); plan.dateCreated = today; } + plan.dateCurrentTypeCreated = today; } if (!plan.customerId) { @@ -177,6 +178,7 @@ async function prepareSubscriptionValues (data) { planId: block.key, customerId: data.customerId, dateUpdated: today, + dateCurrentTypeCreated: today, paymentMethod: data.paymentMethod, extraMonths: Number(plan.extraMonths) + _dateDiff(today, plan.dateTerminated), dateTerminated: null, diff --git a/website/server/models/subscriptionPlan.js b/website/server/models/subscriptionPlan.js index dc04661142..df6d28ecd5 100644 --- a/website/server/models/subscriptionPlan.js +++ b/website/server/models/subscriptionPlan.js @@ -13,6 +13,7 @@ export const schema = new mongoose.Schema({ dateCreated: Date, dateTerminated: Date, dateUpdated: Date, + dateCurrentTypeCreated: Date, extraMonths: { $type: Number, default: 0 }, gemsBought: { $type: Number, default: 0 }, mysteryItems: { $type: Array, default: () => [] }, From 7d2529f5e17165fe55922d5bd25f5cc42607260c Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Wed, 9 Nov 2022 19:49:53 +0100 Subject: [PATCH 16/26] Add logic for different types of sub upgrades --- test/api/unit/libs/payments/apple.test.js | 1 + test/api/unit/libs/payments/payments.test.js | 494 ++++++++++++++++-- website/server/libs/payments/apple.js | 1 + website/server/libs/payments/subscriptions.js | 28 +- 4 files changed, 474 insertions(+), 50 deletions(-) diff --git a/test/api/unit/libs/payments/apple.test.js b/test/api/unit/libs/payments/apple.test.js index fce71fc634..b8ae75b856 100644 --- a/test/api/unit/libs/payments/apple.test.js +++ b/test/api/unit/libs/payments/apple.test.js @@ -322,6 +322,7 @@ describe('Apple Payments', () => { const newOption = subOptions[3]; it(`upgrades a subscription from ${option.sku} to ${newOption.sku}`, async () => { const oldSub = common.content.subscriptionBlocks[option.subKey]; + oldSub.logic = 'refundAndRepay'; user.profile.name = 'sender'; user.purchased.plan.paymentMethod = applePayments.constants.PAYMENT_METHOD_APPLE; user.purchased.plan.customerId = token; diff --git a/test/api/unit/libs/payments/payments.test.js b/test/api/unit/libs/payments/payments.test.js index b00733b45b..0e8d5e34ae 100644 --- a/test/api/unit/libs/payments/payments.test.js +++ b/test/api/unit/libs/payments/payments.test.js @@ -13,7 +13,7 @@ import { import * as worldState from '../../../../../website/server/libs/worldState'; import { TransactionModel } from '../../../../../website/server/models/transaction'; -describe.only('payments/index', () => { +describe('payments/index', () => { let user; let group; let data; @@ -213,14 +213,14 @@ describe.only('payments/index', () => { it('keeps plan.dateCreated when changing subscription type', async () => { await api.createSubscription(data); - const initialDate = recipient.purchased.plan.dateCreated + const initialDate = recipient.purchased.plan.dateCreated; await api.createSubscription(data); expect(recipient.purchased.plan.dateCreated).to.eql(initialDate); }); it('sets plan.dateCurrentTypeCreated when changing subscription type', async () => { await api.createSubscription(data); - const initialDate = recipient.purchased.plan.dateCurrentTypeCreated + const initialDate = recipient.purchased.plan.dateCurrentTypeCreated; await api.createSubscription(data); expect(recipient.purchased.plan.dateCurrentTypeCreated).to.not.eql(initialDate); }); @@ -426,14 +426,14 @@ describe.only('payments/index', () => { it('keeps plan.dateCreated when changing subscription type', async () => { await api.createSubscription(data); - const initialDate = user.purchased.plan.dateCreated + const initialDate = user.purchased.plan.dateCreated; await api.createSubscription(data); expect(user.purchased.plan.dateCreated).to.eql(initialDate); }); it('sets plan.dateCurrentTypeCreated when changing subscription type', async () => { await api.createSubscription(data); - const initialDate = user.purchased.plan.dateCurrentTypeCreated + const initialDate = user.purchased.plan.dateCurrentTypeCreated; await api.createSubscription(data); expect(user.purchased.plan.dateCurrentTypeCreated).to.not.eql(initialDate); }); @@ -667,66 +667,464 @@ describe.only('payments/index', () => { }); context('Upgrades subscription', () => { - it('Adds 10 to plan.consecutive.gemCapExtra from basic_earned to basic_6mo', async () => { - data.sub.key = 'basic_earned'; - expect(user.purchased.plan.planId).to.not.exist; + context('Using payDifference logic', () => { + beforeEach(async () => { + data.updatedFrom = { logic: 'payDifference' }; + }); + it('Adds 10 to plan.consecutive.gemCapExtra from basic_earned to basic_6mo', async () => { + data.sub.key = 'basic_earned'; + expect(user.purchased.plan.planId).to.not.exist; - await api.createSubscription(data); + await api.createSubscription(data); - expect(user.purchased.plan.planId).to.eql('basic_earned'); - expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(0); + expect(user.purchased.plan.planId).to.eql('basic_earned'); + expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(0); - data.sub.key = 'basic_6mo'; - data.updatedFrom = { key: 'basic_earned' }; - await api.createSubscription(data); - expect(user.purchased.plan.planId).to.eql('basic_6mo'); - expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(10); + data.sub.key = 'basic_6mo'; + data.updatedFrom.key = 'basic_earned'; + await api.createSubscription(data); + expect(user.purchased.plan.planId).to.eql('basic_6mo'); + expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(10); + }); + + it('Adds 15 to plan.consecutive.gemCapExtra when upgrading from basic_3mo to basic_12mo', async () => { + expect(user.purchased.plan.planId).to.not.exist; + + await api.createSubscription(data); + + expect(user.purchased.plan.planId).to.eql('basic_3mo'); + expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(5); + + data.sub.key = 'basic_12mo'; + data.updatedFrom.key = 'basic_3mo'; + await api.createSubscription(data); + expect(user.purchased.plan.planId).to.eql('basic_12mo'); + expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(20); + }); + + it('Adds 2 to plan.consecutive.trinkets from basic_earned to basic_6mo', async () => { + data.sub.key = 'basic_earned'; + expect(user.purchased.plan.planId).to.not.exist; + + await api.createSubscription(data); + + expect(user.purchased.plan.planId).to.eql('basic_earned'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(0); + + data.sub.key = 'basic_6mo'; + data.updatedFrom.key = 'basic_earned'; + await api.createSubscription(data); + expect(user.purchased.plan.planId).to.eql('basic_6mo'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(2); + }); + + it('Adds 2 to plan.consecutive.trinkets when upgrading from basic_6mo to basic_12mo', async () => { + data.sub.key = 'basic_6mo'; + expect(user.purchased.plan.planId).to.not.exist; + + await api.createSubscription(data); + + expect(user.purchased.plan.planId).to.eql('basic_6mo'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(2); + + data.sub.key = 'basic_12mo'; + data.updatedFrom.key = 'basic_6mo'; + await api.createSubscription(data); + expect(user.purchased.plan.planId).to.eql('basic_12mo'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(4); + }); + + it('Adds 3 to plan.consecutive.trinkets when upgrading from basic_3mo to basic_12mo', async () => { + expect(user.purchased.plan.planId).to.not.exist; + + await api.createSubscription(data); + + expect(user.purchased.plan.planId).to.eql('basic_3mo'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(1); + + data.sub.key = 'basic_12mo'; + data.updatedFrom.key = 'basic_3mo'; + await api.createSubscription(data); + expect(user.purchased.plan.planId).to.eql('basic_12mo'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(4); + }); }); - it('Adds 15 to plan.consecutive.gemCapExtra when upgrading from basic_3mo to basic_12mo', async () => { - expect(user.purchased.plan.planId).to.not.exist; + context('Using payFull logic', () => { + beforeEach(async () => { + data.updatedFrom = { logic: 'payFull' }; + }); + it('Adds 10 to plan.consecutive.gemCapExtra from basic_earned to basic_6mo', async () => { + data.sub.key = 'basic_earned'; + expect(user.purchased.plan.planId).to.not.exist; - await api.createSubscription(data); + await api.createSubscription(data); - expect(user.purchased.plan.planId).to.eql('basic_3mo'); - expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(5); + expect(user.purchased.plan.planId).to.eql('basic_earned'); + expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(0); - data.sub.key = 'basic_12mo'; - data.updatedFrom = { key: 'basic_3mo' }; - await api.createSubscription(data); - expect(user.purchased.plan.planId).to.eql('basic_12mo'); - expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(20); + data.sub.key = 'basic_6mo'; + data.updatedFrom.key = 'basic_earned'; + await api.createSubscription(data); + expect(user.purchased.plan.planId).to.eql('basic_6mo'); + expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(10); + }); + + it('Adds 20 to plan.consecutive.gemCapExtra when upgrading from basic_3mo to basic_12mo', async () => { + expect(user.purchased.plan.planId).to.not.exist; + + await api.createSubscription(data); + + expect(user.purchased.plan.planId).to.eql('basic_3mo'); + expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(5); + + data.sub.key = 'basic_12mo'; + data.updatedFrom.key = 'basic_3mo'; + await api.createSubscription(data); + expect(user.purchased.plan.planId).to.eql('basic_12mo'); + expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(25); + }); + + it('Adds 2 to plan.consecutive.trinkets from basic_earned to basic_6mo', async () => { + data.sub.key = 'basic_earned'; + expect(user.purchased.plan.planId).to.not.exist; + + await api.createSubscription(data); + + expect(user.purchased.plan.planId).to.eql('basic_earned'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(0); + + data.sub.key = 'basic_6mo'; + data.updatedFrom.key = 'basic_earned'; + await api.createSubscription(data); + expect(user.purchased.plan.planId).to.eql('basic_6mo'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(2); + }); + + it('Adds 4 to plan.consecutive.trinkets when upgrading from basic_6mo to basic_12mo', async () => { + data.sub.key = 'basic_6mo'; + expect(user.purchased.plan.planId).to.not.exist; + + await api.createSubscription(data); + + expect(user.purchased.plan.planId).to.eql('basic_6mo'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(2); + + data.sub.key = 'basic_12mo'; + data.updatedFrom.key = 'basic_6mo'; + await api.createSubscription(data); + expect(user.purchased.plan.planId).to.eql('basic_12mo'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(6); + }); + + it('Adds 4 to plan.consecutive.trinkets when upgrading from basic_3mo to basic_12mo', async () => { + expect(user.purchased.plan.planId).to.not.exist; + + await api.createSubscription(data); + + expect(user.purchased.plan.planId).to.eql('basic_3mo'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(1); + + data.sub.key = 'basic_12mo'; + data.updatedFrom.key = 'basic_3mo'; + await api.createSubscription(data); + expect(user.purchased.plan.planId).to.eql('basic_12mo'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(5); + }); }); - it('Adds 2 to plan.consecutive.trinkets from basic_earned to basic_6mo', async () => { - data.sub.key = 'basic_earned'; - expect(user.purchased.plan.planId).to.not.exist; + context('Using refundAndRepay logic', () => { + let clock; + beforeEach(async () => { + clock = sinon.useFakeTimers(new Date('2022-01-01')); + data.updatedFrom = { logic: 'refundAndRepay' }; + }); + context('Upgrades within first half of subscription', () => { + it('Adds 10 to plan.consecutive.gemCapExtra from basic_earned to basic_6mo', async () => { + data.sub.key = 'basic_earned'; + expect(user.purchased.plan.planId).to.not.exist; + await api.createSubscription(data); - await api.createSubscription(data); + expect(user.purchased.plan.planId).to.eql('basic_earned'); + expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(0); - expect(user.purchased.plan.planId).to.eql('basic_earned'); - expect(user.purchased.plan.consecutive.trinkets).to.eql(0); + data.sub.key = 'basic_6mo'; + data.updatedFrom.key = 'basic_earned'; + clock.restore(); + clock = sinon.useFakeTimers(new Date('2022-01-10')); + await api.createSubscription(data); + expect(user.purchased.plan.planId).to.eql('basic_6mo'); + expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(10); + }); - data.sub.key = 'basic_6mo'; - data.updatedFrom = { key: 'basic_earned' }; - await api.createSubscription(data); - expect(user.purchased.plan.planId).to.eql('basic_6mo'); - expect(user.purchased.plan.consecutive.trinkets).to.eql(2); - }); + it('Adds 15 to plan.consecutive.gemCapExtra when upgrading from basic_3mo to basic_12mo', async () => { + expect(user.purchased.plan.planId).to.not.exist; - it('Adds 3 to plan.consecutive.trinkets when upgrading from basic_3mo to basic_12mo', async () => { - expect(user.purchased.plan.planId).to.not.exist; + await api.createSubscription(data); - await api.createSubscription(data); + expect(user.purchased.plan.planId).to.eql('basic_3mo'); + expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(5); - expect(user.purchased.plan.planId).to.eql('basic_3mo'); - expect(user.purchased.plan.consecutive.trinkets).to.eql(1); + data.sub.key = 'basic_12mo'; + data.updatedFrom.key = 'basic_3mo'; + clock.restore(); + clock = sinon.useFakeTimers(new Date('2022-02-05')); + await api.createSubscription(data); + expect(user.purchased.plan.planId).to.eql('basic_12mo'); + expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(20); + }); - data.sub.key = 'basic_12mo'; - data.updatedFrom = { key: 'basic_3mo' }; - await api.createSubscription(data); - expect(user.purchased.plan.planId).to.eql('basic_12mo'); - expect(user.purchased.plan.consecutive.trinkets).to.eql(4); + it('Adds 2 to plan.consecutive.trinkets from basic_earned to basic_6mo', async () => { + data.sub.key = 'basic_earned'; + expect(user.purchased.plan.planId).to.not.exist; + + await api.createSubscription(data); + + expect(user.purchased.plan.planId).to.eql('basic_earned'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(0); + + data.sub.key = 'basic_6mo'; + data.updatedFrom.key = 'basic_earned'; + clock.restore(); + clock = sinon.useFakeTimers(new Date('2022-01-08')); + await api.createSubscription(data); + expect(user.purchased.plan.planId).to.eql('basic_6mo'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(2); + }); + + it('Adds 3 to plan.consecutive.trinkets when upgrading from basic_3mo to basic_12mo', async () => { + expect(user.purchased.plan.planId).to.not.exist; + + await api.createSubscription(data); + + expect(user.purchased.plan.planId).to.eql('basic_3mo'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(1); + + data.sub.key = 'basic_12mo'; + data.updatedFrom.key = 'basic_3mo'; + clock.restore(); + clock = sinon.useFakeTimers(new Date('2022-01-31')); + await api.createSubscription(data); + expect(user.purchased.plan.planId).to.eql('basic_12mo'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(4); + }); + + it('Adds 2 to plan.consecutive.trinkets when upgrading from basic_6mo to basic_12mo', async () => { + data.sub.key = 'basic_6mo'; + expect(user.purchased.plan.planId).to.not.exist; + + await api.createSubscription(data); + + expect(user.purchased.plan.planId).to.eql('basic_6mo'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(2); + + data.sub.key = 'basic_12mo'; + data.updatedFrom.key = 'basic_6mo'; + clock.restore(); + clock = sinon.useFakeTimers(new Date('2022-01-28')); + await api.createSubscription(data); + expect(user.purchased.plan.planId).to.eql('basic_12mo'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(4); + }); + + it('Adds 2 to plan.consecutive.trinkets from basic_earned to basic_6mo after initial cycle', async () => { + data.sub.key = 'basic_earned'; + expect(user.purchased.plan.planId).to.not.exist; + + await api.createSubscription(data); + + expect(user.purchased.plan.planId).to.eql('basic_earned'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(0); + + data.sub.key = 'basic_6mo'; + data.updatedFrom.key = 'basic_earned'; + clock.restore(); + clock = sinon.useFakeTimers(new Date('2024-01-08')); + await api.createSubscription(data); + expect(user.purchased.plan.planId).to.eql('basic_6mo'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(2); + }); + + it('Adds 2 to plan.consecutive.trinkets when upgrading from basic_6mo to basic_12mo after initial cycle', async () => { + data.sub.key = 'basic_6mo'; + expect(user.purchased.plan.planId).to.not.exist; + + await api.createSubscription(data); + + expect(user.purchased.plan.planId).to.eql('basic_6mo'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(2); + + data.sub.key = 'basic_12mo'; + data.updatedFrom.key = 'basic_6mo'; + clock.restore(); + clock = sinon.useFakeTimers(new Date('2022-08-28')); + await api.createSubscription(data); + expect(user.purchased.plan.planId).to.eql('basic_12mo'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(4); + }); + + it('Adds 3 to plan.consecutive.trinkets when upgrading from basic_3mo to basic_12mo after initial cycle', async () => { + expect(user.purchased.plan.planId).to.not.exist; + + await api.createSubscription(data); + + expect(user.purchased.plan.planId).to.eql('basic_3mo'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(1); + + data.sub.key = 'basic_12mo'; + data.updatedFrom.key = 'basic_3mo'; + clock.restore(); + clock = sinon.useFakeTimers(new Date('2022-07-31')); + await api.createSubscription(data); + expect(user.purchased.plan.planId).to.eql('basic_12mo'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(4); + }); + }); + context('Upgrades within second half of subscription', () => { + it('Adds 10 to plan.consecutive.gemCapExtra from basic_earned to basic_6mo', async () => { + data.sub.key = 'basic_earned'; + expect(user.purchased.plan.planId).to.not.exist; + + await api.createSubscription(data); + + expect(user.purchased.plan.planId).to.eql('basic_earned'); + expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(0); + + data.sub.key = 'basic_6mo'; + data.updatedFrom.key = 'basic_earned'; + clock.restore(); + clock = sinon.useFakeTimers(new Date('2022-01-20')); + await api.createSubscription(data); + expect(user.purchased.plan.planId).to.eql('basic_6mo'); + expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(10); + }); + + it('Adds 20 to plan.consecutive.gemCapExtra when upgrading from basic_3mo to basic_12mo', async () => { + expect(user.purchased.plan.planId).to.not.exist; + + await api.createSubscription(data); + + expect(user.purchased.plan.planId).to.eql('basic_3mo'); + expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(5); + + data.sub.key = 'basic_12mo'; + data.updatedFrom.key = 'basic_3mo'; + clock.restore(); + clock = sinon.useFakeTimers(new Date('2022-02-24')); + await api.createSubscription(data); + expect(user.purchased.plan.planId).to.eql('basic_12mo'); + expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(25); + }); + + it('Adds 2 to plan.consecutive.trinkets from basic_earned to basic_6mo', async () => { + data.sub.key = 'basic_earned'; + expect(user.purchased.plan.planId).to.not.exist; + + await api.createSubscription(data); + + expect(user.purchased.plan.planId).to.eql('basic_earned'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(0); + + data.sub.key = 'basic_6mo'; + data.updatedFrom.key = 'basic_earned'; + clock.restore(); + clock = sinon.useFakeTimers(new Date('2022-01-28')); + await api.createSubscription(data); + expect(user.purchased.plan.planId).to.eql('basic_6mo'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(2); + }); + + it('Adds 4 to plan.consecutive.trinkets when upgrading from basic_6mo to basic_12mo', async () => { + data.sub.key = 'basic_6mo'; + expect(user.purchased.plan.planId).to.not.exist; + + await api.createSubscription(data); + + expect(user.purchased.plan.planId).to.eql('basic_6mo'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(2); + + data.sub.key = 'basic_12mo'; + data.updatedFrom.key = 'basic_6mo'; + clock.restore(); + clock = sinon.useFakeTimers(new Date('2022-05-28')); + await api.createSubscription(data); + expect(user.purchased.plan.planId).to.eql('basic_12mo'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(6); + }); + + it('Adds 4 to plan.consecutive.trinkets when upgrading from basic_3mo to basic_12mo', async () => { + expect(user.purchased.plan.planId).to.not.exist; + + await api.createSubscription(data); + + expect(user.purchased.plan.planId).to.eql('basic_3mo'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(1); + + data.sub.key = 'basic_12mo'; + data.updatedFrom.key = 'basic_3mo'; + clock.restore(); + clock = sinon.useFakeTimers(new Date('2022-03-03')); + await api.createSubscription(data); + expect(user.purchased.plan.planId).to.eql('basic_12mo'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(5); + }); + + it('Adds 2 to plan.consecutive.trinkets from basic_earned to basic_6mo after initial cycle', async () => { + data.sub.key = 'basic_earned'; + expect(user.purchased.plan.planId).to.not.exist; + + await api.createSubscription(data); + + expect(user.purchased.plan.planId).to.eql('basic_earned'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(0); + + data.sub.key = 'basic_6mo'; + data.updatedFrom.key = 'basic_earned'; + clock.restore(); + clock = sinon.useFakeTimers(new Date('2022-05-28')); + await api.createSubscription(data); + expect(user.purchased.plan.planId).to.eql('basic_6mo'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(2); + }); + + it('Adds 4 to plan.consecutive.trinkets when upgrading from basic_6mo to basic_12mo after initial cycle', async () => { + data.sub.key = 'basic_6mo'; + expect(user.purchased.plan.planId).to.not.exist; + + await api.createSubscription(data); + + expect(user.purchased.plan.planId).to.eql('basic_6mo'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(2); + + data.sub.key = 'basic_12mo'; + data.updatedFrom.key = 'basic_6mo'; + clock.restore(); + clock = sinon.useFakeTimers(new Date('2023-05-28')); + await api.createSubscription(data); + expect(user.purchased.plan.planId).to.eql('basic_12mo'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(6); + }); + + it('Adds 4 to plan.consecutive.trinkets when upgrading from basic_3mo to basic_12mo after initial cycle', async () => { + expect(user.purchased.plan.planId).to.not.exist; + + await api.createSubscription(data); + + expect(user.purchased.plan.planId).to.eql('basic_3mo'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(1); + + data.sub.key = 'basic_12mo'; + data.updatedFrom.key = 'basic_3mo'; + clock.restore(); + clock = sinon.useFakeTimers(new Date('2023-09-03')); + await api.createSubscription(data); + expect(user.purchased.plan.planId).to.eql('basic_12mo'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(5); + }); + }); + afterEach(async () => { + if (clock !== null) clock.restore(); + }); }); }); diff --git a/website/server/libs/payments/apple.js b/website/server/libs/payments/apple.js index 035ca2b5b9..01ed5d87ed 100644 --- a/website/server/libs/payments/apple.js +++ b/website/server/libs/payments/apple.js @@ -184,6 +184,7 @@ api.subscribe = async function subscribe (user, receipt, headers, nextPaymentPro }; if (existingSub) { data.updatedFrom = existingSub; + data.updatedFrom.logic = 'refundAndRepay'; } await payments.createSubscription(data); } else { diff --git a/website/server/libs/payments/subscriptions.js b/website/server/libs/payments/subscriptions.js index 0f7ceb7acd..93d66e24eb 100644 --- a/website/server/libs/payments/subscriptions.js +++ b/website/server/libs/payments/subscriptions.js @@ -80,8 +80,32 @@ async function prepareSubscriptionValues (data) { : undefined; let months; if (updatedFrom && Number(updatedFrom.months) !== 1) { - months = Math.max(0, Number(block.months) - Number(updatedFrom.months)); - } else { + if (Number(updatedFrom.months) > Number(block.months)) { + months = 0; + } else if (data.updatedFrom.logic === 'payDifference') { + months = Math.max(0, Number(block.months) - Number(updatedFrom.months)); + } else if (data.updatedFrom.logic === 'payFull') { + months = Number(block.months); + } else if (data.updatedFrom.logic === 'refundAndRepay') { + const originalMonths = Number(updatedFrom.months); + let currentCycleBegin = moment(recipient.purchased.plan.dateCurrentTypeCreated); + const today = moment(); + while (currentCycleBegin.isBefore()) { + currentCycleBegin = currentCycleBegin.add({ months: originalMonths }); + } + // Subtract last iteration again, because we overshot + currentCycleBegin = currentCycleBegin.subtract({ months: originalMonths }); + // For simplicity we round every month to 30 days since moment can not add half months + if (currentCycleBegin.add({ days: (originalMonths * 30) / 2.0 }).isBefore(today)) { + // user is in second half of their subscription cycle. Give them full benefits. + months = Number(block.months); + } else { + // user is in first half of their subscription cycle. Give them the difference. + months = Math.max(0, Number(block.months) - Number(updatedFrom.months)); + } + } + } + if (months === undefined) { months = Number(block.months); } const today = new Date(); From 15deb778fd68bf0a3ee35805435eec4d7d5313b9 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Thu, 10 Nov 2022 13:48:58 +0100 Subject: [PATCH 17/26] fix tests --- test/api/unit/libs/payments/apple.test.js | 2 +- .../apple/POST-payments_apple_subscribe.test.js | 11 +++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/test/api/unit/libs/payments/apple.test.js b/test/api/unit/libs/payments/apple.test.js index b8ae75b856..ca128435dd 100644 --- a/test/api/unit/libs/payments/apple.test.js +++ b/test/api/unit/libs/payments/apple.test.js @@ -290,7 +290,7 @@ describe('Apple Payments', () => { iap.getPurchaseData.restore(); iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData') .returns([{ - expirationDate: moment.utc().add({ day: 1 }).toDate(), + expirationDate: moment.utc().add({ day: 2 }).toDate(), purchaseDate: new Date(), productId: option.sku, transactionId: token, diff --git a/test/api/v3/integration/payments/apple/POST-payments_apple_subscribe.test.js b/test/api/v3/integration/payments/apple/POST-payments_apple_subscribe.test.js index faf5f0e6ea..7f94667ea3 100644 --- a/test/api/v3/integration/payments/apple/POST-payments_apple_subscribe.test.js +++ b/test/api/v3/integration/payments/apple/POST-payments_apple_subscribe.test.js @@ -1,7 +1,7 @@ import { generateUser, translate as t } from '../../../../../helpers/api-integration/v3'; import applePayments from '../../../../../../website/server/libs/payments/apple'; -describe('payments : apple #subscribe', () => { +describe.only('payments : apple #subscribe', () => { const endpoint = '/iap/ios/subscribe'; let user; @@ -45,11 +45,10 @@ describe('payments : apple #subscribe', () => { }); 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); + expect(subscribeStub.args[0][0]._id).to.eql(user._id); + expect(subscribeStub.args[0][1]).to.eql('receipt'); + expect(subscribeStub.args[0][2]['x-api-key']).to.eql(user.apiToken); + expect(subscribeStub.args[0][2]['x-api-user']).to.eql(user._id); }); }); }); From 037882b50a461de4afd43f9294a635bcf939dbfd Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Thu, 10 Nov 2022 13:55:58 +0100 Subject: [PATCH 18/26] remove only --- .../payments/apple/POST-payments_apple_subscribe.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/api/v3/integration/payments/apple/POST-payments_apple_subscribe.test.js b/test/api/v3/integration/payments/apple/POST-payments_apple_subscribe.test.js index 7f94667ea3..5a9aa456e7 100644 --- a/test/api/v3/integration/payments/apple/POST-payments_apple_subscribe.test.js +++ b/test/api/v3/integration/payments/apple/POST-payments_apple_subscribe.test.js @@ -1,7 +1,7 @@ import { generateUser, translate as t } from '../../../../../helpers/api-integration/v3'; import applePayments from '../../../../../../website/server/libs/payments/apple'; -describe.only('payments : apple #subscribe', () => { +describe('payments : apple #subscribe', () => { const endpoint = '/iap/ios/subscribe'; let user; From 6604f38144bc2a1ea5ca864293ba1372559dcec0 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Fri, 11 Nov 2022 13:54:17 +0100 Subject: [PATCH 19/26] Handle subscription cancelation better --- test/api/unit/libs/payments/apple.test.js | 171 +++++++++++++++++++++- website/server/libs/inAppPurchases.js | 2 + website/server/libs/payments/apple.js | 36 +++-- 3 files changed, 187 insertions(+), 22 deletions(-) diff --git a/test/api/unit/libs/payments/apple.test.js b/test/api/unit/libs/payments/apple.test.js index ca128435dd..3a330d9720 100644 --- a/test/api/unit/libs/payments/apple.test.js +++ b/test/api/unit/libs/payments/apple.test.js @@ -9,7 +9,7 @@ import * as gems from '../../../../../website/server/libs/payments/gems'; const { i18n } = common; -describe('Apple Payments', () => { +describe.only('Apple Payments', () => { const subKey = 'basic_3mo'; describe('verifyGemPurchase', () => { @@ -29,8 +29,9 @@ describe('Apple Payments', () => { .resolves(); iapValidateStub = sinon.stub(iap, 'validate') .resolves({}); - iapIsValidatedStub = sinon.stub(iap, 'isValidated') - .returns(true); + iapIsValidatedStub = sinon.stub(iap, 'isValidated').returns(true); + sinon.stub(iap, 'isExpired').returns(false); + sinon.stub(iap, 'isCanceled').returns(false); iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData') .returns([{ productId: 'com.habitrpg.ios.Habitica.21gems', @@ -44,6 +45,8 @@ describe('Apple Payments', () => { iap.setup.restore(); iap.validate.restore(); iap.isValidated.restore(); + iap.isExpired.restore(); + iap.isCanceled.restore(); iap.getPurchaseData.restore(); payments.buyGems.restore(); gems.validateGiftMessage.restore(); @@ -449,6 +452,81 @@ describe('Apple Payments', () => { }); }); + it('allows second user to subscribe if initial subscription is cancelled', async () => { + user.profile.name = 'sender'; + user.purchased.plan.paymentMethod = applePayments.constants.PAYMENT_METHOD_APPLE; + user.purchased.plan.customerId = token; + user.purchased.plan.planId = common.content.subscriptionBlocks.basic_3mo.key; + user.purchased.plan.additionalData = receipt; + user.purchased.plan.dateTerminated = moment.utc().subtract({ day: 1 }).toDate(); + await user.save() + + iap.getPurchaseData.restore(); + iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData') + .returns([{ + expirationDate: moment.utc().add({ day: 3 }).toDate(), + purchaseDate: moment.utc().toDate(), + productId: sku, + transactionId: token + "new", + originalTransactionId: token, + }]); + + const secondUser = new User(); + await secondUser.save(); + await applePayments.subscribe(secondUser, receipt, headers, nextPaymentProcessing); + + expect(paymentsCreateSubscritionStub).to.be.calledOnce; + expect(paymentsCreateSubscritionStub).to.be.calledWith({ + user: secondUser, + customerId: token, + paymentMethod: applePayments.constants.PAYMENT_METHOD_APPLE, + sub, + headers, + additionalData: receipt, + nextPaymentProcessing, + }); + }); + + + it('allows second user to subscribe if multiple initial subscription are cancelled', async () => { + user.profile.name = 'sender'; + user.purchased.plan.paymentMethod = applePayments.constants.PAYMENT_METHOD_APPLE; + user.purchased.plan.customerId = token; + user.purchased.plan.planId = common.content.subscriptionBlocks.basic_3mo.key; + user.purchased.plan.additionalData = receipt; + user.purchased.plan.dateTerminated = moment.utc().subtract({ day: 1 }).toDate(); + await user.save(); + + const secondUser = new User(); + secondUser.purchased.plan = user.purchased.plan; + await secondUser.save() + + iap.getPurchaseData.restore(); + iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData') + .returns([{ + expirationDate: moment.utc().add({ day: 3 }).toDate(), + purchaseDate: moment.utc().toDate(), + productId: sku, + transactionId: token + "new", + originalTransactionId: token, + }]); + + const thirdUser = new User(); + await thirdUser.save(); + await applePayments.subscribe(thirdUser, receipt, headers, nextPaymentProcessing); + + expect(paymentsCreateSubscritionStub).to.be.calledOnce; + expect(paymentsCreateSubscritionStub).to.be.calledWith({ + user: thirdUser, + customerId: token, + paymentMethod: applePayments.constants.PAYMENT_METHOD_APPLE, + sub, + headers, + additionalData: receipt, + nextPaymentProcessing, + }); + }); + describe('does not apply multiple times', async () => { it('errors when a user is using the same subscription', async () => { user = new User(); @@ -515,6 +593,7 @@ describe('Apple Payments', () => { await applePayments.subscribe(user, receipt, headers, nextPaymentProcessing); const secondUser = new User(); + await secondUser.save(); await expect(applePayments.subscribe( secondUser, receipt, headers, nextPaymentProcessing, )) @@ -524,6 +603,49 @@ describe('Apple Payments', () => { message: applePayments.constants.RESPONSE_ALREADY_USED, }); }); + + + it('errors when a multiple users exist using the subscription', async () => { + user = new User(); + await user.save(); + payments.createSubscription.restore(); + iap.getPurchaseData.restore(); + iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData') + .returns([{ + expirationDate: moment.utc().add({ day: 1 }).toDate(), + purchaseDate: moment.utc().toDate(), + productId: sku, + transactionId: token, + originalTransactionId: token, + }]); + + await applePayments.subscribe(user, receipt, headers, nextPaymentProcessing); + const secondUser = new User(); + secondUser.purchased.plan = user.purchased.plan; + secondUser.purchased.plan.dateTerminate = new Date(); + secondUser.save() + + iap.getPurchaseData.restore(); + iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData') + .returns([{ + expirationDate: moment.utc().add({ day: 1 }).toDate(), + purchaseDate: moment.utc().toDate(), + productId: sku, + transactionId: token + "new", + originalTransactionId: token, + }]); + + const thirdUser = new User(); + await thirdUser.save(); + await expect(applePayments.subscribe( + thirdUser, receipt, headers, nextPaymentProcessing, + )) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 401, + name: 'NotAuthorized', + message: applePayments.constants.RESPONSE_ALREADY_USED, + }); + }); }); }); @@ -548,9 +670,9 @@ describe('Apple Payments', () => { }); iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData') .returns([{ expirationDate: expirationDate.toDate() }]); - iapIsValidatedStub = sinon.stub(iap, 'isValidated') - .returns(true); - + iapIsValidatedStub = sinon.stub(iap, 'isValidated').returns(true); + sinon.stub(iap, 'isCanceled').returns(false); + sinon.stub(iap, 'isExpired').returns(true); user = new User(); user.profile.name = 'sender'; user.purchased.plan.paymentMethod = applePayments.constants.PAYMENT_METHOD_APPLE; @@ -565,6 +687,8 @@ describe('Apple Payments', () => { iap.setup.restore(); iap.validate.restore(); iap.isValidated.restore(); + iap.isExpired.restore(); + iap.isCanceled.restore(); iap.getPurchaseData.restore(); payments.cancelSubscription.restore(); }); @@ -584,6 +708,8 @@ describe('Apple Payments', () => { iap.getPurchaseData.restore(); iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData') .returns([{ expirationDate: expirationDate.add({ day: 1 }).toDate() }]); + iap.isExpired.restore(); + sinon.stub(iap, 'isExpired').returns(false); await expect(applePayments.cancelSubscribe(user, headers)) .to.eventually.be.rejected.and.to.eql({ @@ -606,7 +732,38 @@ describe('Apple Payments', () => { }); }); - it('should cancel a user subscription', async () => { + it('should cancel a cancelled subscription with termination date in the future', async () => { + const futureDate = expirationDate.add({ day: 1 }); + iap.getPurchaseData.restore(); + iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData') + .returns([{ expirationDate: futureDate }]); + iap.isExpired.restore(); + sinon.stub(iap, 'isExpired').returns(false); + + iap.isCanceled.restore(); + sinon.stub(iap, 'isCanceled').returns(true); + + 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: futureDate, + }); + expect(iapGetPurchaseDataStub).to.be.calledOnce; + + expect(paymentCancelSubscriptionSpy).to.be.calledOnce; + expect(paymentCancelSubscriptionSpy).to.be.calledWith({ + user, + paymentMethod: applePayments.constants.PAYMENT_METHOD_APPLE, + nextBill: futureDate.toDate(), + headers, + }); + }); + + it('should cancel an expired subscription', async () => { await applePayments.cancelSubscribe(user, headers); expect(iapSetupStub).to.be.calledOnce; diff --git a/website/server/libs/inAppPurchases.js b/website/server/libs/inAppPurchases.js index 228626f56a..74b652e8e9 100644 --- a/website/server/libs/inAppPurchases.js +++ b/website/server/libs/inAppPurchases.js @@ -21,6 +21,8 @@ export default { setup: util.promisify(iap.setup.bind(iap)), validate: util.promisify(iap.validate.bind(iap)), isValidated: iap.isValidated, + isCanceled: iap.isCanceled, + isExpired: iap.isExpired, getPurchaseData: iap.getPurchaseData, GOOGLE: iap.GOOGLE, APPLE: iap.APPLE, diff --git a/website/server/libs/payments/apple.js b/website/server/libs/payments/apple.js index 01ed5d87ed..c6ab6563e6 100644 --- a/website/server/libs/payments/apple.js +++ b/website/server/libs/payments/apple.js @@ -157,13 +157,19 @@ api.subscribe = async function subscribe (user, receipt, headers, nextPaymentPro throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED); } } - const existingUser = await User.findOne({ + const existingUsers = await User.find({ 'purchased.plan.customerId': purchase.originalTransactionId, }).exec(); - if (existingUser - && (purchase.originalTransactionId === purchase.transactionId - || existingUser._id !== user._id)) { - throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED); + if (existingUsers.length > 0) { + if (purchase.originalTransactionId === purchase.transactionId) { + throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED); + } + for (const index in existingUsers) { + const existingUser = existingUsers[index]; + if (existingUser._id !== user._id && !existingUser.purchased.plan.dateTerminated) { + throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED); + } + } } nextPaymentProcessing = nextPaymentProcessing || moment.utc().add({ days: 2 }); // eslint-disable-line max-len, no-param-reassign @@ -278,8 +284,6 @@ api.cancelSubscribe = async function cancelSubscribe (user, headers) { await iap.setup(); - let dateTerminated; - try { const appleRes = await iap.validate(iap.APPLE, plan.additionalData); @@ -289,16 +293,24 @@ api.cancelSubscribe = async function cancelSubscribe (user, headers) { const purchases = iap.getPurchaseData(appleRes); if (purchases.length === 0) throw new NotAuthorized(this.constants.RESPONSE_INVALID_RECEIPT); let newestDate; + let newestPurchase for (const purchaseData of purchases) { const datePurchased = new Date(Number(purchaseData.purchaseDate)); if (!newestDate || datePurchased > newestDate) { - dateTerminated = new Date(Number(purchaseData.expirationDate)); newestDate = datePurchased; + newestPurchase = purchaseData; } } - if (dateTerminated > new Date()) throw new NotAuthorized(this.constants.RESPONSE_STILL_VALID); + if (!iap.isCanceled(newestPurchase) && !iap.isExpired(newestPurchase)) throw new NotAuthorized(this.constants.RESPONSE_STILL_VALID); + + await payments.cancelSubscription({ + user, + nextBill: new Date(Number(newestPurchase.expirationDate)), + paymentMethod: this.constants.PAYMENT_METHOD_APPLE, + headers, + }); } catch (err) { // If we have an invalid receipt, cancel anyway if ( @@ -309,12 +321,6 @@ api.cancelSubscribe = async function cancelSubscribe (user, headers) { } } - await payments.cancelSubscription({ - user, - nextBill: dateTerminated, - paymentMethod: this.constants.PAYMENT_METHOD_APPLE, - headers, - }); }; export default api; From e3c86349b4abe7434317bea9d23e5b180ba68572 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Fri, 11 Nov 2022 13:58:45 +0100 Subject: [PATCH 20/26] fix lint --- test/api/unit/libs/payments/apple.test.js | 16 +++++++--------- website/server/libs/payments/apple.js | 10 +++++----- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/test/api/unit/libs/payments/apple.test.js b/test/api/unit/libs/payments/apple.test.js index 3a330d9720..e1736162d8 100644 --- a/test/api/unit/libs/payments/apple.test.js +++ b/test/api/unit/libs/payments/apple.test.js @@ -9,7 +9,7 @@ import * as gems from '../../../../../website/server/libs/payments/gems'; const { i18n } = common; -describe.only('Apple Payments', () => { +describe('Apple Payments', () => { const subKey = 'basic_3mo'; describe('verifyGemPurchase', () => { @@ -459,7 +459,7 @@ describe.only('Apple Payments', () => { user.purchased.plan.planId = common.content.subscriptionBlocks.basic_3mo.key; user.purchased.plan.additionalData = receipt; user.purchased.plan.dateTerminated = moment.utc().subtract({ day: 1 }).toDate(); - await user.save() + await user.save(); iap.getPurchaseData.restore(); iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData') @@ -467,7 +467,7 @@ describe.only('Apple Payments', () => { expirationDate: moment.utc().add({ day: 3 }).toDate(), purchaseDate: moment.utc().toDate(), productId: sku, - transactionId: token + "new", + transactionId: `${token}new`, originalTransactionId: token, }]); @@ -487,7 +487,6 @@ describe.only('Apple Payments', () => { }); }); - it('allows second user to subscribe if multiple initial subscription are cancelled', async () => { user.profile.name = 'sender'; user.purchased.plan.paymentMethod = applePayments.constants.PAYMENT_METHOD_APPLE; @@ -499,7 +498,7 @@ describe.only('Apple Payments', () => { const secondUser = new User(); secondUser.purchased.plan = user.purchased.plan; - await secondUser.save() + await secondUser.save(); iap.getPurchaseData.restore(); iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData') @@ -507,7 +506,7 @@ describe.only('Apple Payments', () => { expirationDate: moment.utc().add({ day: 3 }).toDate(), purchaseDate: moment.utc().toDate(), productId: sku, - transactionId: token + "new", + transactionId: `${token}new`, originalTransactionId: token, }]); @@ -604,7 +603,6 @@ describe.only('Apple Payments', () => { }); }); - it('errors when a multiple users exist using the subscription', async () => { user = new User(); await user.save(); @@ -623,7 +621,7 @@ describe.only('Apple Payments', () => { const secondUser = new User(); secondUser.purchased.plan = user.purchased.plan; secondUser.purchased.plan.dateTerminate = new Date(); - secondUser.save() + secondUser.save(); iap.getPurchaseData.restore(); iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData') @@ -631,7 +629,7 @@ describe.only('Apple Payments', () => { expirationDate: moment.utc().add({ day: 1 }).toDate(), purchaseDate: moment.utc().toDate(), productId: sku, - transactionId: token + "new", + transactionId: `${token}new`, originalTransactionId: token, }]); diff --git a/website/server/libs/payments/apple.js b/website/server/libs/payments/apple.js index c6ab6563e6..681588c6dd 100644 --- a/website/server/libs/payments/apple.js +++ b/website/server/libs/payments/apple.js @@ -164,8 +164,7 @@ api.subscribe = async function subscribe (user, receipt, headers, nextPaymentPro if (purchase.originalTransactionId === purchase.transactionId) { throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED); } - for (const index in existingUsers) { - const existingUser = existingUsers[index]; + for (const existingUser of existingUsers) { if (existingUser._id !== user._id && !existingUser.purchased.plan.dateTerminated) { throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED); } @@ -293,7 +292,7 @@ api.cancelSubscribe = async function cancelSubscribe (user, headers) { const purchases = iap.getPurchaseData(appleRes); if (purchases.length === 0) throw new NotAuthorized(this.constants.RESPONSE_INVALID_RECEIPT); let newestDate; - let newestPurchase + let newestPurchase; for (const purchaseData of purchases) { const datePurchased = new Date(Number(purchaseData.purchaseDate)); @@ -303,7 +302,9 @@ api.cancelSubscribe = async function cancelSubscribe (user, headers) { } } - if (!iap.isCanceled(newestPurchase) && !iap.isExpired(newestPurchase)) throw new NotAuthorized(this.constants.RESPONSE_STILL_VALID); + if (!iap.isCanceled(newestPurchase) && !iap.isExpired(newestPurchase)) { + throw new NotAuthorized(this.constants.RESPONSE_STILL_VALID); + } await payments.cancelSubscription({ user, @@ -320,7 +321,6 @@ api.cancelSubscribe = async function cancelSubscribe (user, headers) { throw err; } } - }; export default api; From fae26a517d915f40d3948763fa1d86e779e7363b Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Wed, 30 Nov 2022 15:06:50 +0100 Subject: [PATCH 21/26] Improve handling --- website/server/libs/cron.js | 18 +----------------- website/server/libs/payments/subscriptions.js | 11 +++++------ website/server/models/subscriptionPlan.js | 15 +++++++++++++++ 3 files changed, 21 insertions(+), 23 deletions(-) diff --git a/website/server/libs/cron.js b/website/server/libs/cron.js index cfbdbb0dc5..3555f36836 100644 --- a/website/server/libs/cron.js +++ b/website/server/libs/cron.js @@ -106,32 +106,16 @@ async function grantEndOfTheMonthPerks (user, now) { planMonthsLength = getPlanMonths(plan); } - // every 3 months you get one set of perks - this variable records how many sets you need - let perkAmountNeeded = 0; if (planMonthsLength === 1) { - // User has a single-month recurring subscription and are due for perks - // IF they've been subscribed for a multiple of 3 months. - if (plan.consecutive.count % SUBSCRIPTION_BASIC_BLOCK_LENGTH === 0) { // every 3 months - perkAmountNeeded = 1; - } plan.consecutive.offset = 0; // allow the same logic to be run next month } else { // User has a multi-month recurring subscription // and it renewed in the previous calendar month. - - // e.g., for a 6-month subscription, give two sets of perks - perkAmountNeeded = planMonthsLength / SUBSCRIPTION_BASIC_BLOCK_LENGTH; // don't need to check for perks again for this many months // (subtract 1 because we should have run this when the payment was taken last month) plan.consecutive.offset = planMonthsLength - 1; } - if (perkAmountNeeded > 0) { - // one Hourglass every 3 months - await plan.updateHourglasses(user._id, perkAmountNeeded, 'subscription_perks'); // eslint-disable-line no-await-in-loop - plan.consecutive.gemCapExtra += 5 * perkAmountNeeded; // 5 extra Gems every 3 months - // cap it at 50 (hard 25 limit + extra 25) - if (plan.consecutive.gemCapExtra > 25) plan.consecutive.gemCapExtra = 25; - } + await plan.incrementPerkCounterAndReward(user._id, planMonthsLength); } } } diff --git a/website/server/libs/payments/subscriptions.js b/website/server/libs/payments/subscriptions.js index 93d66e24eb..996292897c 100644 --- a/website/server/libs/payments/subscriptions.js +++ b/website/server/libs/payments/subscriptions.js @@ -263,12 +263,11 @@ async function createSubscription (data) { } = await prepareSubscriptionValues(data); // Block sub perks - const perks = Math.floor(months / 3); - if (perks) { - plan.consecutive.offset += months; - plan.consecutive.gemCapExtra += perks * 5; - if (plan.consecutive.gemCapExtra > 25) plan.consecutive.gemCapExtra = 25; - await plan.updateHourglasses(recipient._id, perks, 'subscription_perks'); // one Hourglass every 3 months + if (months > 0) { + if (!data.gift && !groupId) { + plan.consecutive.offset = months; + } + await plan.incrementPerkCounterAndReward(recipient._id, months); } if (recipient !== group) { diff --git a/website/server/models/subscriptionPlan.js b/website/server/models/subscriptionPlan.js index df6d28ecd5..7b93a81b92 100644 --- a/website/server/models/subscriptionPlan.js +++ b/website/server/models/subscriptionPlan.js @@ -15,6 +15,7 @@ export const schema = new mongoose.Schema({ dateUpdated: Date, dateCurrentTypeCreated: Date, extraMonths: { $type: Number, default: 0 }, + perkMonthCount: { $type: Number, default: 0 }, gemsBought: { $type: Number, default: 0 }, mysteryItems: { $type: Array, default: () => [] }, lastReminderDate: Date, // indicates the last time a subscription reminder was sent @@ -46,6 +47,20 @@ schema.plugin(baseModel, { _id: false, }); +schema.methods.incrementPerkCounterAndReward = async function incrementPerkCounterAndReward (userID, adding) { + this.perkMonthCount += adding; + + const perks = Math.floor(this.perkMonthCount / 3); + if (perks > 0) { + this.consecutive.gemCapExtra += 5 * perks; // 5 extra Gems every 3 months + // cap it at 50 (hard 25 limit + extra 25) + if (this.consecutive.gemCapExtra > 25) this.consecutive.gemCapExtra = 25; + this.perkMonthCount -= (perks * 3); + // one Hourglass every 3 months + await this.updateHourglasses(userID, perks, 'subscription_perks'); // eslint-disable-line no-await-in-loop + } +}; + schema.methods.updateHourglasses = async function updateHourglasses (userId, amount, transactionType, From 67988da33c9c5c2e76594db4f674d3a8fae07af5 Mon Sep 17 00:00:00 2001 From: SabreCat Date: Wed, 30 Nov 2022 15:27:53 -0600 Subject: [PATCH 22/26] fix(lint): line lengths and so on --- website/server/libs/cron.js | 4 +--- website/server/models/subscriptionPlan.js | 3 ++- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/website/server/libs/cron.js b/website/server/libs/cron.js index 3555f36836..7b2acf2eaa 100644 --- a/website/server/libs/cron.js +++ b/website/server/libs/cron.js @@ -63,9 +63,6 @@ const CLEAR_BUFFS = { }; async function grantEndOfTheMonthPerks (user, now) { - // multi-month subscriptions are for multiples of 3 months - const SUBSCRIPTION_BASIC_BLOCK_LENGTH = 3; - const { plan, elapsedMonths } = getPlanContext(user, now); if (elapsedMonths > 0) { @@ -115,6 +112,7 @@ async function grantEndOfTheMonthPerks (user, now) { // (subtract 1 because we should have run this when the payment was taken last month) plan.consecutive.offset = planMonthsLength - 1; } + // eslint-disable-next-line no-await-in-loop await plan.incrementPerkCounterAndReward(user._id, planMonthsLength); } } diff --git a/website/server/models/subscriptionPlan.js b/website/server/models/subscriptionPlan.js index 7b93a81b92..2095c4b6d9 100644 --- a/website/server/models/subscriptionPlan.js +++ b/website/server/models/subscriptionPlan.js @@ -47,7 +47,8 @@ schema.plugin(baseModel, { _id: false, }); -schema.methods.incrementPerkCounterAndReward = async function incrementPerkCounterAndReward (userID, adding) { +schema.methods.incrementPerkCounterAndReward = async function incrementPerkCounterAndReward +(userID, adding) { this.perkMonthCount += adding; const perks = Math.floor(this.perkMonthCount / 3); From d1ee679810794f9684aaa7083d22f4a4e139636d Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Fri, 2 Dec 2022 11:50:46 +0100 Subject: [PATCH 23/26] handle upgrades and creations better --- test/api/unit/libs/payments/payments.test.js | 57 ++++++++++++++++++- website/server/libs/payments/subscriptions.js | 11 +++- website/server/models/subscriptionPlan.js | 5 ++ 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/test/api/unit/libs/payments/payments.test.js b/test/api/unit/libs/payments/payments.test.js index 0e8d5e34ae..3ffd1230e4 100644 --- a/test/api/unit/libs/payments/payments.test.js +++ b/test/api/unit/libs/payments/payments.test.js @@ -13,7 +13,7 @@ import { import * as worldState from '../../../../../website/server/libs/worldState'; import { TransactionModel } from '../../../../../website/server/models/transaction'; -describe('payments/index', () => { +describe.only('payments/index', () => { let user; let group; let data; @@ -235,6 +235,48 @@ describe('payments/index', () => { expect(recipient.purchased.plan.customerId).to.eql('customer-id'); }); + it('sets plan.perkMonthCount to zero if user is not subscribed', async () => { + recipient.purchased.plan = plan; + recipient.purchased.plan.perkMonthCount = 1; + recipient.purchased.plan.customerId = undefined; + data.sub.key = 'basic_earned'; + data.gift.subscription.key = 'basic_earned'; + data.gift.subscription.months = 1; + + expect(recipient.purchased.plan.perkMonthCount).to.eql(1); + await api.createSubscription(data); + + expect(recipient.purchased.plan.perkMonthCount).to.eql(0); + }); + + it('adds to plan.perkMonthCount if user is already subscribed', async () => { + recipient.purchased.plan = plan; + recipient.purchased.plan.perkMonthCount = 1; + data.sub.key = 'basic_earned'; + data.gift.subscription.key = 'basic_earned'; + data.gift.subscription.months = 1; + + expect(recipient.purchased.plan.perkMonthCount).to.eql(1); + await api.createSubscription(data); + + expect(recipient.purchased.plan.perkMonthCount).to.eql(2); + }); + + it('awards perks if plan.perkMonthCount reaches 3', async () => { + recipient.purchased.plan = plan; + recipient.purchased.plan.perkMonthCount = 2; + data.sub.key = 'basic_earned'; + data.gift.subscription.key = 'basic_earned'; + data.gift.subscription.months = 1; + + expect(recipient.purchased.plan.perkMonthCount).to.eql(2); + await api.createSubscription(data); + + expect(recipient.purchased.plan.perkMonthCount).to.eql(0); + expect(recipient.purchased.plan.consecutive.trinkets).to.eql(1); + expect(recipient.purchased.plan.consecutive.gemCapExtra).to.eql(5); + }); + it('sets plan.customerId to "Gift" if it does not already exist', async () => { expect(recipient.purchased.plan.customerId).to.not.exist; @@ -438,6 +480,19 @@ describe('payments/index', () => { expect(user.purchased.plan.dateCurrentTypeCreated).to.not.eql(initialDate); }); + it('keeps plan.perkMonthCount when changing subscription type', async () => { + await api.createSubscription(data); + user.purchased.plan.perkMonthCount = 2; + await api.createSubscription(data); + expect(user.purchased.plan.perkMonthCount).to.eql(2); + }); + + it('sets plan.perkMonthCount to zero when creating new subscription', async () => { + user.purchased.plan.perkMonthCount = 2; + await api.createSubscription(data); + expect(user.purchased.plan.perkMonthCount).to.eql(0); + }); + it('awards the Royal Purple Jackalope pet', async () => { await api.createSubscription(data); diff --git a/website/server/libs/payments/subscriptions.js b/website/server/libs/payments/subscriptions.js index 996292897c..fd92a023c7 100644 --- a/website/server/libs/payments/subscriptions.js +++ b/website/server/libs/payments/subscriptions.js @@ -115,6 +115,7 @@ async function prepareSubscriptionValues (data) { let purchaseType = 'subscribe'; let emailType = 'subscription-begins'; let recipientIsSubscribed = recipient.isSubscribed(); + let isNewSubscription = !recipientIsSubscribed if (data.user && !data.gift && !data.groupId) { const unlockedUser = await User.findOneAndUpdate( @@ -171,6 +172,10 @@ async function prepareSubscriptionValues (data) { const { plan } = recipient.purchased; + if (isNewSubscription) { + plan.perkMonthCount = 0; + } + if (data.gift || !autoRenews) { if (plan.customerId && !plan.dateTerminated) { // User has active plan plan.extraMonths += months; @@ -245,6 +250,7 @@ async function prepareSubscriptionValues (data) { itemPurchased, purchaseType, emailType, + isNewSubscription }; } @@ -260,13 +266,16 @@ async function createSubscription (data) { itemPurchased, purchaseType, emailType, + isNewSubscription } = await prepareSubscriptionValues(data); + console.log() // Block sub perks - if (months > 0) { + if (months > 0 && (!data.gift || !isNewSubscription)) { if (!data.gift && !groupId) { plan.consecutive.offset = months; } + console.log("giving benes"); await plan.incrementPerkCounterAndReward(recipient._id, months); } diff --git a/website/server/models/subscriptionPlan.js b/website/server/models/subscriptionPlan.js index 2095c4b6d9..db4c39725b 100644 --- a/website/server/models/subscriptionPlan.js +++ b/website/server/models/subscriptionPlan.js @@ -3,6 +3,9 @@ import validator from 'validator'; import baseModel from '../libs/baseModel'; import { TransactionModel as Transaction } from './transaction'; +// multi-month subscriptions are for multiples of 3 months +const SUBSCRIPTION_BASIC_BLOCK_LENGTH = 3; + export const schema = new mongoose.Schema({ planId: String, subscriptionId: String, @@ -49,6 +52,8 @@ schema.plugin(baseModel, { schema.methods.incrementPerkCounterAndReward = async function incrementPerkCounterAndReward (userID, adding) { + // if perkMonthCount wasn't used before, initialize it. + if (!this.perkMonthCount) this.perkMonthCount = this.consecutive.count % SUBSCRIPTION_BASIC_BLOCK_LENGTH; this.perkMonthCount += adding; const perks = Math.floor(this.perkMonthCount / 3); From 21652c2670385f547c837083610ecb7752d939a2 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Fri, 2 Dec 2022 17:12:17 +0100 Subject: [PATCH 24/26] fix tests and some cases --- test/api/unit/libs/cron.test.js | 88 +++++++++++++++---- test/api/unit/libs/payments/payments.test.js | 20 ++++- website/server/libs/payments/subscriptions.js | 1 - website/server/models/subscriptionPlan.js | 10 ++- 4 files changed, 100 insertions(+), 19 deletions(-) diff --git a/test/api/unit/libs/cron.test.js b/test/api/unit/libs/cron.test.js index eb15119ee1..2b177b6f2e 100644 --- a/test/api/unit/libs/cron.test.js +++ b/test/api/unit/libs/cron.test.js @@ -231,13 +231,16 @@ describe('cron', async () => { }, }); // user1 has a 1-month recurring subscription starting today - user1.purchased.plan.customerId = 'subscribedId'; - user1.purchased.plan.dateUpdated = moment().toDate(); - user1.purchased.plan.planId = 'basic'; - user1.purchased.plan.consecutive.count = 0; - user1.purchased.plan.consecutive.offset = 0; - user1.purchased.plan.consecutive.trinkets = 0; - user1.purchased.plan.consecutive.gemCapExtra = 0; + beforeEach(async () => { + user1.purchased.plan.customerId = 'subscribedId'; + user1.purchased.plan.dateUpdated = moment().toDate(); + user1.purchased.plan.planId = 'basic'; + user1.purchased.plan.consecutive.count = 0; + user1.purchased.plan.perkMonthCount = 0; + user1.purchased.plan.consecutive.offset = 0; + user1.purchased.plan.consecutive.trinkets = 0; + user1.purchased.plan.consecutive.gemCapExtra = 0; + }); it('does not increment consecutive benefits after the first month', async () => { clock = sinon.useFakeTimers(moment().utcOffset(0).startOf('month').add(1, 'months') @@ -271,6 +274,24 @@ describe('cron', async () => { expect(user1.purchased.plan.consecutive.gemCapExtra).to.equal(0); }); + it('increments consecutive benefits after the second month if they also received a 1 month gift subscription', async () => { + user1.purchased.plan.perkMonthCount = 1; + clock = sinon.useFakeTimers(moment().utcOffset(0).startOf('month').add(2, 'months') + .add(2, 'days') + .toDate()); + // Add 1 month to simulate what happens a month after the subscription was created. + // Add 2 days so that we're sure we're not affected by any start-of-month effects + // e.g., from time zone oddness. + await cron({ + user: user1, tasksByType, daysMissed, analytics, + }); + expect(user1.purchased.plan.perkMonthCount).to.equal(0); + expect(user1.purchased.plan.consecutive.count).to.equal(2); + expect(user1.purchased.plan.consecutive.offset).to.equal(0); + expect(user1.purchased.plan.consecutive.trinkets).to.equal(1); + expect(user1.purchased.plan.consecutive.gemCapExtra).to.equal(5); + }); + it('increments consecutive benefits after the third month', async () => { clock = sinon.useFakeTimers(moment().utcOffset(0).startOf('month').add(3, 'months') .add(2, 'days') @@ -315,6 +336,26 @@ describe('cron', async () => { expect(user1.purchased.plan.consecutive.trinkets).to.equal(3); expect(user1.purchased.plan.consecutive.gemCapExtra).to.equal(15); }); + + it('initialized plan.perkMonthCount if necessary', async () => { + user.purchased.plan.perkMonthCount = undefined; + clock = sinon.useFakeTimers(moment().utcOffset(0).startOf('month').add(1, 'months') + .add(2, 'days') + .toDate()); + await cron({ + user, tasksByType, daysMissed, analytics, + }); + expect(user.purchased.plan.perkMonthCount).to.equal(1); + user.purchased.plan.perkMonthCount = undefined; + clock.restore(); + clock = sinon.useFakeTimers(moment().utcOffset(0).startOf('month').add(2, 'months') + .add(2, 'days') + .toDate()); + await cron({ + user, tasksByType, daysMissed, analytics, + }); + expect(user.purchased.plan.perkMonthCount).to.equal(2); + }); }); describe('for a 3-month recurring subscription', async () => { @@ -330,13 +371,16 @@ describe('cron', async () => { }, }); // user3 has a 3-month recurring subscription starting today - user3.purchased.plan.customerId = 'subscribedId'; - user3.purchased.plan.dateUpdated = moment().toDate(); - user3.purchased.plan.planId = 'basic_3mo'; - user3.purchased.plan.consecutive.count = 0; - user3.purchased.plan.consecutive.offset = 3; - user3.purchased.plan.consecutive.trinkets = 1; - user3.purchased.plan.consecutive.gemCapExtra = 5; + beforeEach(async () => { + user3.purchased.plan.customerId = 'subscribedId'; + user3.purchased.plan.dateUpdated = moment().toDate(); + user3.purchased.plan.planId = 'basic_3mo'; + user3.purchased.plan.perkMonthCount = 0; + user3.purchased.plan.consecutive.count = 0; + user3.purchased.plan.consecutive.offset = 3; + user3.purchased.plan.consecutive.trinkets = 1; + user3.purchased.plan.consecutive.gemCapExtra = 5; + }); it('does not increment consecutive benefits in the first month of the first paid period that they already have benefits for', async () => { clock = sinon.useFakeTimers(moment().utcOffset(0).startOf('month').add(1, 'months') @@ -390,6 +434,17 @@ describe('cron', async () => { expect(user3.purchased.plan.consecutive.gemCapExtra).to.equal(10); }); + it('keeps existing plan.perkMonthCount intact when incrementing consecutive benefits', async () => { + user3.purchased.plan.perkMonthCount = 2 + clock = sinon.useFakeTimers(moment().utcOffset(0).startOf('month').add(4, 'months') + .add(2, 'days') + .toDate()); + await cron({ + user: user3, tasksByType, daysMissed, analytics, + }); + expect(user3.purchased.plan.perkMonthCount).to.equal(2); + }); + it('does not increment consecutive benefits in the second month of the second period that they already have benefits for', async () => { clock = sinon.useFakeTimers(moment().utcOffset(0).startOf('month').add(5, 'months') .add(2, 'days') @@ -417,6 +472,7 @@ describe('cron', async () => { }); it('increments consecutive benefits the month after the third paid period has started', async () => { + console.log(user3.purchased.plan); clock = sinon.useFakeTimers(moment().utcOffset(0).startOf('month').add(7, 'months') .add(2, 'days') .toDate()); @@ -456,13 +512,15 @@ describe('cron', async () => { }, }); // user6 has a 6-month recurring subscription starting today - user6.purchased.plan.customerId = 'subscribedId'; + beforeEach(async () => { + user6.purchased.plan.customerId = 'subscribedId'; user6.purchased.plan.dateUpdated = moment().toDate(); user6.purchased.plan.planId = 'google_6mo'; user6.purchased.plan.consecutive.count = 0; user6.purchased.plan.consecutive.offset = 6; user6.purchased.plan.consecutive.trinkets = 2; user6.purchased.plan.consecutive.gemCapExtra = 10; + }); it('does not increment consecutive benefits in the first month of the first paid period that they already have benefits for', async () => { clock = sinon.useFakeTimers(moment().utcOffset(0).startOf('month').add(1, 'months') diff --git a/test/api/unit/libs/payments/payments.test.js b/test/api/unit/libs/payments/payments.test.js index 3ffd1230e4..22728c21a1 100644 --- a/test/api/unit/libs/payments/payments.test.js +++ b/test/api/unit/libs/payments/payments.test.js @@ -13,7 +13,7 @@ import { import * as worldState from '../../../../../website/server/libs/worldState'; import { TransactionModel } from '../../../../../website/server/models/transaction'; -describe.only('payments/index', () => { +describe('payments/index', () => { let user; let group; let data; @@ -270,6 +270,8 @@ describe.only('payments/index', () => { data.gift.subscription.months = 1; expect(recipient.purchased.plan.perkMonthCount).to.eql(2); + expect(recipient.purchased.plan.consecutive.trinkets).to.eql(0); + expect(recipient.purchased.plan.consecutive.gemCapExtra).to.eql(0); await api.createSubscription(data); expect(recipient.purchased.plan.perkMonthCount).to.eql(0); @@ -277,6 +279,21 @@ describe.only('payments/index', () => { expect(recipient.purchased.plan.consecutive.gemCapExtra).to.eql(5); }); + it('awards perks if plan.perkMonthCount goes over 3', async () => { + recipient.purchased.plan = plan; + recipient.purchased.plan.perkMonthCount = 2; + data.sub.key = 'basic_earned'; + + expect(recipient.purchased.plan.perkMonthCount).to.eql(2); + expect(recipient.purchased.plan.consecutive.trinkets).to.eql(0); + expect(recipient.purchased.plan.consecutive.gemCapExtra).to.eql(0); + await api.createSubscription(data); + + expect(recipient.purchased.plan.perkMonthCount).to.eql(2); + expect(recipient.purchased.plan.consecutive.trinkets).to.eql(1); + expect(recipient.purchased.plan.consecutive.gemCapExtra).to.eql(5); + }); + it('sets plan.customerId to "Gift" if it does not already exist', async () => { expect(recipient.purchased.plan.customerId).to.not.exist; @@ -443,6 +460,7 @@ describe.only('payments/index', () => { expect(user.purchased.plan.customerId).to.eql('customer-id'); expect(user.purchased.plan.dateUpdated).to.exist; expect(user.purchased.plan.gemsBought).to.eql(0); + expect(user.purchased.plan.perkMonthCount).to.eql(0); expect(user.purchased.plan.paymentMethod).to.eql('Payment Method'); expect(user.purchased.plan.extraMonths).to.eql(0); expect(user.purchased.plan.dateTerminated).to.eql(null); diff --git a/website/server/libs/payments/subscriptions.js b/website/server/libs/payments/subscriptions.js index fd92a023c7..c9d9e2b2ba 100644 --- a/website/server/libs/payments/subscriptions.js +++ b/website/server/libs/payments/subscriptions.js @@ -275,7 +275,6 @@ async function createSubscription (data) { if (!data.gift && !groupId) { plan.consecutive.offset = months; } - console.log("giving benes"); await plan.incrementPerkCounterAndReward(recipient._id, months); } diff --git a/website/server/models/subscriptionPlan.js b/website/server/models/subscriptionPlan.js index db4c39725b..a4f6b7ec81 100644 --- a/website/server/models/subscriptionPlan.js +++ b/website/server/models/subscriptionPlan.js @@ -53,11 +53,17 @@ schema.plugin(baseModel, { schema.methods.incrementPerkCounterAndReward = async function incrementPerkCounterAndReward (userID, adding) { // if perkMonthCount wasn't used before, initialize it. - if (!this.perkMonthCount) this.perkMonthCount = this.consecutive.count % SUBSCRIPTION_BASIC_BLOCK_LENGTH; - this.perkMonthCount += adding; + if (this.perkMonthCount == undefined && adding == 1) { + this.perkMonthCount = (this.consecutive.count-1) % SUBSCRIPTION_BASIC_BLOCK_LENGTH; + console.log(`initializing perk count with ${this.perkMonthCount}`); + } else { + this.perkMonthCount += adding; + } + console.log(this.perkMonthCount); const perks = Math.floor(this.perkMonthCount / 3); if (perks > 0) { + console.log(`giving benefits for ${perks} from ${this.perkMonthCount}, ${adding}`); this.consecutive.gemCapExtra += 5 * perks; // 5 extra Gems every 3 months // cap it at 50 (hard 25 limit + extra 25) if (this.consecutive.gemCapExtra > 25) this.consecutive.gemCapExtra = 25; From 8b2af1ef56aed198465b2ca062771ffb9af483d4 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Mon, 5 Dec 2022 15:38:30 +0100 Subject: [PATCH 25/26] remove log --- website/server/models/subscriptionPlan.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/website/server/models/subscriptionPlan.js b/website/server/models/subscriptionPlan.js index a4f6b7ec81..21e4999f99 100644 --- a/website/server/models/subscriptionPlan.js +++ b/website/server/models/subscriptionPlan.js @@ -55,15 +55,12 @@ schema.methods.incrementPerkCounterAndReward = async function incrementPerkCount // if perkMonthCount wasn't used before, initialize it. if (this.perkMonthCount == undefined && adding == 1) { this.perkMonthCount = (this.consecutive.count-1) % SUBSCRIPTION_BASIC_BLOCK_LENGTH; - console.log(`initializing perk count with ${this.perkMonthCount}`); } else { this.perkMonthCount += adding; } - console.log(this.perkMonthCount); const perks = Math.floor(this.perkMonthCount / 3); if (perks > 0) { - console.log(`giving benefits for ${perks} from ${this.perkMonthCount}, ${adding}`); this.consecutive.gemCapExtra += 5 * perks; // 5 extra Gems every 3 months // cap it at 50 (hard 25 limit + extra 25) if (this.consecutive.gemCapExtra > 25) this.consecutive.gemCapExtra = 25; From cde5fbef856d3fd738e6601c1773b7f663f2c3cf Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Fri, 9 Dec 2022 12:16:33 +0100 Subject: [PATCH 26/26] Fix case where a number was sometimes a string --- test/api/unit/libs/cron.test.js | 19 +++++++++++++++++++ website/server/models/subscriptionPlan.js | 3 +++ 2 files changed, 22 insertions(+) diff --git a/test/api/unit/libs/cron.test.js b/test/api/unit/libs/cron.test.js index 2b177b6f2e..773da476ae 100644 --- a/test/api/unit/libs/cron.test.js +++ b/test/api/unit/libs/cron.test.js @@ -347,6 +347,7 @@ describe('cron', async () => { }); expect(user.purchased.plan.perkMonthCount).to.equal(1); user.purchased.plan.perkMonthCount = undefined; + user.purchased.plan.consecutive.count = 8; clock.restore(); clock = sinon.useFakeTimers(moment().utcOffset(0).startOf('month').add(2, 'months') .add(2, 'days') @@ -436,6 +437,8 @@ describe('cron', async () => { it('keeps existing plan.perkMonthCount intact when incrementing consecutive benefits', async () => { user3.purchased.plan.perkMonthCount = 2 + user3.purchased.plan.consecutive.trinkets = 1 + user3.purchased.plan.consecutive.gemCapExtra = 5 clock = sinon.useFakeTimers(moment().utcOffset(0).startOf('month').add(4, 'months') .add(2, 'days') .toDate()); @@ -443,6 +446,8 @@ describe('cron', async () => { user: user3, tasksByType, daysMissed, analytics, }); expect(user3.purchased.plan.perkMonthCount).to.equal(2); + expect(user3.purchased.plan.consecutive.trinkets).to.equal(2); + expect(user3.purchased.plan.consecutive.gemCapExtra).to.equal(10); }); it('does not increment consecutive benefits in the second month of the second period that they already have benefits for', async () => { @@ -516,6 +521,7 @@ describe('cron', async () => { user6.purchased.plan.customerId = 'subscribedId'; user6.purchased.plan.dateUpdated = moment().toDate(); user6.purchased.plan.planId = 'google_6mo'; + user6.purchased.plan.perkMonthCount = 0; user6.purchased.plan.consecutive.count = 0; user6.purchased.plan.consecutive.offset = 6; user6.purchased.plan.consecutive.trinkets = 2; @@ -561,6 +567,19 @@ describe('cron', async () => { expect(user6.purchased.plan.consecutive.gemCapExtra).to.equal(20); }); + it('keeps existing plan.perkMonthCount intact when incrementing consecutive benefits', async () => { + user6.purchased.plan.perkMonthCount = 2 + clock = sinon.useFakeTimers(moment().utcOffset(0).startOf('month').add(7, 'months') + .add(2, 'days') + .toDate()); + await cron({ + user: user6, tasksByType, daysMissed, analytics, + }); + expect(user6.purchased.plan.perkMonthCount).to.equal(2); + expect(user6.purchased.plan.consecutive.trinkets).to.equal(4); + expect(user6.purchased.plan.consecutive.gemCapExtra).to.equal(20); + }); + it('increments consecutive benefits the month after the third paid period has started', async () => { clock = sinon.useFakeTimers(moment().utcOffset(0).startOf('month').add(13, 'months') .add(2, 'days') diff --git a/website/server/models/subscriptionPlan.js b/website/server/models/subscriptionPlan.js index 21e4999f99..1470175b1f 100644 --- a/website/server/models/subscriptionPlan.js +++ b/website/server/models/subscriptionPlan.js @@ -52,6 +52,9 @@ schema.plugin(baseModel, { schema.methods.incrementPerkCounterAndReward = async function incrementPerkCounterAndReward (userID, adding) { + if (typeof adding === 'string' || adding instanceof String) { + adding = parseInt(adding) + } // if perkMonthCount wasn't used before, initialize it. if (this.perkMonthCount == undefined && adding == 1) { this.perkMonthCount = (this.consecutive.count-1) % SUBSCRIPTION_BASIC_BLOCK_LENGTH;