From c25b09c7edd7e89885c937851a40a9951e84e8e9 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Fri, 21 Oct 2022 16:57:12 +0200 Subject: [PATCH 01/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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; From 9a40674d8d3224736e5737908131aef3c77c8f41 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Fri, 21 Oct 2022 16:57:12 +0200 Subject: [PATCH 27/76] 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 bc99889587..5e688578d4 100644 --- a/website/server/libs/payments/apple.js +++ b/website/server/libs/payments/apple.js @@ -75,10 +75,6 @@ api.verifyPurchase = async function verifyPurchase (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; @@ -109,27 +105,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 b5f2e66025878e8f211d926c94a25f9d1795c0a7 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Fri, 21 Oct 2022 16:59:18 +0200 Subject: [PATCH 28/76] 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 5e688578d4..9f1e2c7c1c 100644 --- a/website/server/libs/payments/apple.js +++ b/website/server/libs/payments/apple.js @@ -118,7 +118,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 b3d5a8d083d96a23526088479cd1ea14e390b11b Mon Sep 17 00:00:00 2001 From: SabreCat Date: Tue, 25 Oct 2022 16:52:16 -0500 Subject: [PATCH 29/76] 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 9f1e2c7c1c..b9686c265f 100644 --- a/website/server/libs/payments/apple.js +++ b/website/server/libs/payments/apple.js @@ -124,7 +124,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 742da1f2c6b3ac592ff883564dd64e31d7e10d50 Mon Sep 17 00:00:00 2001 From: SabreCat Date: Tue, 25 Oct 2022 16:59:59 -0500 Subject: [PATCH 30/76] 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 b9686c265f..e54477a19a 100644 --- a/website/server/libs/payments/apple.js +++ b/website/server/libs/payments/apple.js @@ -118,7 +118,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 98ec1757f92a7fd45ee987f9bb755622a3df33e1 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Wed, 26 Oct 2022 16:30:15 +0200 Subject: [PATCH 31/76] 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 217a307d0b..e7c5c2bf81 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 f07d0f6441c961a7c1fb44637dff4c13a880accd Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Fri, 28 Oct 2022 11:23:48 +0200 Subject: [PATCH 32/76] Implement correct handling for when subs are up/downgraded --- test/api/unit/libs/payments/apple.test.js | 105 ++++++++- test/api/unit/libs/payments/payments.test.js | 213 +++++++++++++++++- website/server/libs/payments/apple.js | 16 +- website/server/libs/payments/subscriptions.js | 3 +- 4 files changed, 327 insertions(+), 10 deletions(-) diff --git a/test/api/unit/libs/payments/apple.test.js b/test/api/unit/libs/payments/apple.test.js index e7c5c2bf81..00ddaa1dae 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,12 +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(); - await user.save(); + 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 65b42b42f3..84475e4b0e 100644 --- a/test/api/unit/libs/payments/payments.test.js +++ b/test/api/unit/libs/payments/payments.test.js @@ -465,6 +465,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', () => { @@ -488,7 +571,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); @@ -496,7 +578,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); @@ -532,6 +613,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 e54477a19a..358870afe1 100644 --- a/website/server/libs/payments/apple.js +++ b/website/server/libs/payments/apple.js @@ -118,8 +118,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, @@ -131,7 +135,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, @@ -139,7 +143,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 b5558e82dc..76e240e82b 100644 --- a/website/server/libs/payments/subscriptions.js +++ b/website/server/libs/payments/subscriptions.js @@ -74,7 +74,8 @@ async function prepareSubscriptionValues (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 777f7887b4bd2e7fe7da84917c73532e7770c8c9 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Fri, 28 Oct 2022 12:41:43 +0200 Subject: [PATCH 33/76] fix lint errors --- website/server/libs/payments/subscriptions.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/website/server/libs/payments/subscriptions.js b/website/server/libs/payments/subscriptions.js index 76e240e82b..75a49312e2 100644 --- a/website/server/libs/payments/subscriptions.js +++ b/website/server/libs/payments/subscriptions.js @@ -74,8 +74,15 @@ async function prepareSubscriptionValues (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 cf75d941faa440292cffb6feae5af944454704c8 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Fri, 28 Oct 2022 15:28:46 +0200 Subject: [PATCH 34/76] 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 00ddaa1dae..c3a49b2b08 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 0dd25b6431d82e1a546f3b29a6fa46a0a7b23495 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Wed, 2 Nov 2022 16:36:09 +0100 Subject: [PATCH 35/76] 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 c3a49b2b08..905b3296c0 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 358870afe1..3108651153 100644 --- a/website/server/libs/payments/apple.js +++ b/website/server/libs/payments/apple.js @@ -124,12 +124,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 5aca5b4be75eb9d3290c110650a7321df738e946 Mon Sep 17 00:00:00 2001 From: SabreCat Date: Wed, 2 Nov 2022 14:44:49 -0500 Subject: [PATCH 36/76] 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 905b3296c0..31bacfa524 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 9b791b4ba0155f41757e11480088ea375078635d Mon Sep 17 00:00:00 2001 From: SabreCat Date: Wed, 2 Nov 2022 15:15:28 -0500 Subject: [PATCH 37/76] 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 31bacfa524..8b154df0bc 100644 --- a/test/api/unit/libs/payments/apple.test.js +++ b/test/api/unit/libs/payments/apple.test.js @@ -438,6 +438,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') @@ -459,6 +461,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 967717a0105cbc0ea1b23831ee871c613c992428 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Thu, 3 Nov 2022 17:48:36 +0100 Subject: [PATCH 38/76] 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 8b154df0bc..22a564e8bb 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 () => { payments.createSubscription.restore(); @@ -422,14 +455,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', @@ -445,14 +479,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', @@ -468,16 +503,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 7214c63ba0..9cf0f99967 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 3108651153..4d8f54d0c6 100644 --- a/website/server/libs/payments/apple.js +++ b/website/server/libs/payments/apple.js @@ -74,8 +74,33 @@ api.verifyPurchase = async function verifyPurchase (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 @@ -93,29 +118,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 ddf1b4060d6bab2dd3385f4210c0630d0fce8391 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Fri, 4 Nov 2022 13:30:50 +0100 Subject: [PATCH 39/76] 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 22a564e8bb..8423e9419d 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 4d8f54d0c6..5e7f9a813b 100644 --- a/website/server/libs/payments/apple.js +++ b/website/server/libs/payments/apple.js @@ -97,8 +97,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; } } @@ -255,9 +255,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 f9a9d4919b0e03f090f2e72c64a45bdb93c2a2d4 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Tue, 8 Nov 2022 12:19:17 +0100 Subject: [PATCH 40/76] 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 5e7f9a813b..69a4a5ef43 100644 --- a/website/server/libs/payments/apple.js +++ b/website/server/libs/payments/apple.js @@ -86,24 +86,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; @@ -119,10 +115,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]; @@ -131,19 +127,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 8dfa21a4b8a6af55da7ff8197d876de33e35fd29 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Tue, 8 Nov 2022 12:38:24 +0100 Subject: [PATCH 41/76] 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 75a49312e2..78d339eb65 100644 --- a/website/server/libs/payments/subscriptions.js +++ b/website/server/libs/payments/subscriptions.js @@ -144,6 +144,7 @@ async function prepareSubscriptionValues (data) { plan.dateTerminated = moment().add({ months }).toDate(); plan.dateCreated = today; } + plan.dateCurrentTypeCreated = today; } if (!plan.customerId) { @@ -160,6 +161,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 64a3515c10da5cc15612b45c877f95a1a682ba0c Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Wed, 9 Nov 2022 19:49:53 +0100 Subject: [PATCH 42/76] 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 8423e9419d..b3e7739412 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 69a4a5ef43..acf4767a13 100644 --- a/website/server/libs/payments/apple.js +++ b/website/server/libs/payments/apple.js @@ -153,6 +153,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 78d339eb65..ca2fd892c9 100644 --- a/website/server/libs/payments/subscriptions.js +++ b/website/server/libs/payments/subscriptions.js @@ -79,8 +79,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 614850e56c001808ae74c0b5fdfb53b3cf3272ef Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Thu, 10 Nov 2022 13:48:58 +0100 Subject: [PATCH 43/76] 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 b3e7739412..2d700beac2 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 de48925341e93e63e8bb66523e349d8d485b492c Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Thu, 10 Nov 2022 13:55:58 +0100 Subject: [PATCH 44/76] 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 87558a325e80e0a56a7bddfa8e88654194af463c Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Fri, 11 Nov 2022 13:54:17 +0100 Subject: [PATCH 45/76] 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 2d700beac2..f595c0d941 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('verifyPurchase', () => { @@ -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.buySkuItem.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 () => { payments.createSubscription.restore(); @@ -513,6 +591,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, )) @@ -522,6 +601,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, + }); + }); }); }); @@ -546,9 +668,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; @@ -563,6 +685,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(); }); @@ -582,6 +706,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({ @@ -604,7 +730,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 acf4767a13..23a4d227be 100644 --- a/website/server/libs/payments/apple.js +++ b/website/server/libs/payments/apple.js @@ -126,13 +126,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 @@ -247,8 +253,6 @@ api.cancelSubscribe = async function cancelSubscribe (user, headers) { await iap.setup(); - let dateTerminated; - try { const appleRes = await iap.validate(iap.APPLE, plan.additionalData); @@ -258,16 +262,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 ( @@ -278,12 +290,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 0f7001b6093f16df4ddca41accbfd6743383a419 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Fri, 11 Nov 2022 13:58:45 +0100 Subject: [PATCH 46/76] 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 f595c0d941..f2eb0af521 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('verifyPurchase', () => { @@ -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, }]); @@ -602,7 +601,6 @@ describe.only('Apple Payments', () => { }); }); - it('errors when a multiple users exist using the subscription', async () => { user = new User(); await user.save(); @@ -621,7 +619,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') @@ -629,7 +627,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 23a4d227be..bdd86907ff 100644 --- a/website/server/libs/payments/apple.js +++ b/website/server/libs/payments/apple.js @@ -133,8 +133,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); } @@ -262,7 +261,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)); @@ -272,7 +271,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, @@ -289,7 +290,6 @@ api.cancelSubscribe = async function cancelSubscribe (user, headers) { throw err; } } - }; export default api; From 82a1d6ff0e857d4a1335bf9f8b9ea4b8c55b0d5c Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Wed, 30 Nov 2022 15:06:50 +0100 Subject: [PATCH 47/76] 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 ca2fd892c9..193b3276c8 100644 --- a/website/server/libs/payments/subscriptions.js +++ b/website/server/libs/payments/subscriptions.js @@ -246,12 +246,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 e81a052f66e7322a23a37d7aea723761519a0b89 Mon Sep 17 00:00:00 2001 From: SabreCat Date: Wed, 30 Nov 2022 15:27:53 -0600 Subject: [PATCH 48/76] 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 c0c66575367cdbd0e2863cb75b5b00b3d2fb3aa8 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Fri, 2 Dec 2022 11:50:46 +0100 Subject: [PATCH 49/76] 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 193b3276c8..f9a160326c 100644 --- a/website/server/libs/payments/subscriptions.js +++ b/website/server/libs/payments/subscriptions.js @@ -114,6 +114,7 @@ async function prepareSubscriptionValues (data) { let purchaseType = 'subscribe'; let emailType = 'subscription-begins'; let recipientIsSubscribed = recipient.isSubscribed(); + let isNewSubscription = !recipientIsSubscribed // If we are buying a group subscription if (data.groupId) { @@ -154,6 +155,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; @@ -228,6 +233,7 @@ async function prepareSubscriptionValues (data) { itemPurchased, purchaseType, emailType, + isNewSubscription }; } @@ -243,13 +249,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 12479edb776eb1e108bcb31766f9a589d108ef95 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Fri, 2 Dec 2022 17:12:17 +0100 Subject: [PATCH 50/76] 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 f9a160326c..859cd1c76e 100644 --- a/website/server/libs/payments/subscriptions.js +++ b/website/server/libs/payments/subscriptions.js @@ -258,7 +258,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 86e33b23644dcbe28fde669d5cabd6c2d2d3fad1 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Mon, 5 Dec 2022 15:38:30 +0100 Subject: [PATCH 51/76] 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 a3bc20f8557ef35f128aef5dda5c31e88bba159d Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Fri, 9 Dec 2022 12:16:33 +0100 Subject: [PATCH 52/76] 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; From aec24092272e6c5b7a381d8c519719060ef12567 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Mon, 6 Feb 2023 12:59:54 +0100 Subject: [PATCH 53/76] lint fixes --- test/api/unit/libs/cron.test.js | 22 +-- test/api/unit/libs/payments/payments.test.js | 4 +- website/client/package-lock.json | 125 ++++++++---------- .../user-support/subscriptionAndPerks.vue | 4 + website/server/libs/payments/subscriptions.js | 11 +- website/server/models/subscriptionPlan.js | 6 +- 6 files changed, 82 insertions(+), 90 deletions(-) diff --git a/test/api/unit/libs/cron.test.js b/test/api/unit/libs/cron.test.js index 773da476ae..0028a49ffc 100644 --- a/test/api/unit/libs/cron.test.js +++ b/test/api/unit/libs/cron.test.js @@ -436,9 +436,9 @@ 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 + 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()); @@ -519,13 +519,13 @@ describe('cron', async () => { // user6 has a 6-month recurring subscription starting today beforeEach(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; - user6.purchased.plan.consecutive.gemCapExtra = 10; + 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; + 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 () => { @@ -568,7 +568,7 @@ describe('cron', async () => { }); it('keeps existing plan.perkMonthCount intact when incrementing consecutive benefits', async () => { - user6.purchased.plan.perkMonthCount = 2 + user6.purchased.plan.perkMonthCount = 2; clock = sinon.useFakeTimers(moment().utcOffset(0).startOf('month').add(7, 'months') .add(2, 'days') .toDate()); diff --git a/test/api/unit/libs/payments/payments.test.js b/test/api/unit/libs/payments/payments.test.js index 22728c21a1..7b75db7877 100644 --- a/test/api/unit/libs/payments/payments.test.js +++ b/test/api/unit/libs/payments/payments.test.js @@ -268,7 +268,7 @@ describe('payments/index', () => { data.sub.key = 'basic_earned'; data.gift.subscription.key = 'basic_earned'; 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); @@ -283,7 +283,7 @@ describe('payments/index', () => { 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); diff --git a/website/client/package-lock.json b/website/client/package-lock.json index 0af1f10a19..49163a0889 100644 --- a/website/client/package-lock.json +++ b/website/client/package-lock.json @@ -13318,11 +13318,34 @@ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, + "emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "optional": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "optional": true + }, "is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" }, + "loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "optional": true, + "requires": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + } + }, "semver": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", @@ -13354,6 +13377,38 @@ "ansi-regex": "^5.0.1" } }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "optional": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "vue-loader-v16": { + "version": "npm:vue-loader@16.8.3", + "resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-16.8.3.tgz", + "integrity": "sha512-7vKN45IxsKxe5GcVCbc2qFU5aWzyiLrYJyUuMz4BQLKctCj/fmCa0w6fGiiQ2cLFetNcek1ppGJQDCup0c1hpA==", + "optional": true, + "requires": { + "chalk": "^4.1.0", + "hash-sum": "^2.0.0", + "loader-utils": "^2.0.0" + }, + "dependencies": { + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "optional": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, "wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", @@ -30574,76 +30629,6 @@ } } }, - "vue-loader-v16": { - "version": "npm:vue-loader@16.8.3", - "resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-16.8.3.tgz", - "integrity": "sha512-7vKN45IxsKxe5GcVCbc2qFU5aWzyiLrYJyUuMz4BQLKctCj/fmCa0w6fGiiQ2cLFetNcek1ppGJQDCup0c1hpA==", - "requires": { - "chalk": "^4.1.0", - "hash-sum": "^2.0.0", - "loader-utils": "^2.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "emojis-list": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", - "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==" - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" - }, - "loader-utils": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", - "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", - "requires": { - "big.js": "^5.2.2", - "emojis-list": "^3.0.0", - "json5": "^2.1.2" - } - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, "vue-mugen-scroll": { "version": "0.2.6", "resolved": "https://registry.npmjs.org/vue-mugen-scroll/-/vue-mugen-scroll-0.2.6.tgz", diff --git a/website/client/src/components/admin-panel/user-support/subscriptionAndPerks.vue b/website/client/src/components/admin-panel/user-support/subscriptionAndPerks.vue index 8fa3cffb0b..54569195ee 100644 --- a/website/client/src/components/admin-panel/user-support/subscriptionAndPerks.vue +++ b/website/client/src/components/admin-panel/user-support/subscriptionAndPerks.vue @@ -46,6 +46,10 @@ Perk offset months: {{ hero.purchased.plan.consecutive.offset }} +
+ Perk month count: + {{ hero.purchased.plan.perkMonthCount }} +
Next Mystic Hourglass: {{ nextHourglassDate }} diff --git a/website/server/libs/payments/subscriptions.js b/website/server/libs/payments/subscriptions.js index 6601d5f5a6..28f44c74c9 100644 --- a/website/server/libs/payments/subscriptions.js +++ b/website/server/libs/payments/subscriptions.js @@ -13,6 +13,9 @@ import { // eslint-disable-line import/no-cycle model as Group, basicFields as basicGroupFields, } from '../../models/group'; +import { + model as User +} from '../../models/user'; import { NotAuthorized, NotFound, @@ -114,7 +117,7 @@ async function prepareSubscriptionValues (data) { let purchaseType = 'subscribe'; let emailType = 'subscription-begins'; let recipientIsSubscribed = recipient.isSubscribed(); - let isNewSubscription = !recipientIsSubscribed + const isNewSubscription = !recipientIsSubscribed; if (data.user && !data.gift && !data.groupId) { const unlockedUser = await User.findOneAndUpdate( @@ -249,7 +252,7 @@ async function prepareSubscriptionValues (data) { itemPurchased, purchaseType, emailType, - isNewSubscription + isNewSubscription, }; } @@ -265,10 +268,10 @@ async function createSubscription (data) { itemPurchased, purchaseType, emailType, - isNewSubscription + isNewSubscription, } = await prepareSubscriptionValues(data); - console.log() + console.log(); // Block sub perks if (months > 0 && (!data.gift || !isNewSubscription)) { if (!data.gift && !groupId) { diff --git a/website/server/models/subscriptionPlan.js b/website/server/models/subscriptionPlan.js index 1470175b1f..8e381eca24 100644 --- a/website/server/models/subscriptionPlan.js +++ b/website/server/models/subscriptionPlan.js @@ -53,11 +53,11 @@ schema.plugin(baseModel, { schema.methods.incrementPerkCounterAndReward = async function incrementPerkCounterAndReward (userID, adding) { if (typeof adding === 'string' || adding instanceof String) { - adding = parseInt(adding) + 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; + if (this.perkMonthCount === undefined && adding === 1) { + this.perkMonthCount = (this.consecutive.count - 1) % SUBSCRIPTION_BASIC_BLOCK_LENGTH; } else { this.perkMonthCount += adding; } From c04e8ea514441c85711f4c0c1ff619cc9c58f678 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Mon, 6 Feb 2023 13:01:06 +0100 Subject: [PATCH 54/76] comma --- website/server/libs/payments/subscriptions.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/server/libs/payments/subscriptions.js b/website/server/libs/payments/subscriptions.js index 28f44c74c9..de0920d2e7 100644 --- a/website/server/libs/payments/subscriptions.js +++ b/website/server/libs/payments/subscriptions.js @@ -14,7 +14,7 @@ import { // eslint-disable-line import/no-cycle basicFields as basicGroupFields, } from '../../models/group'; import { - model as User + model as User, } from '../../models/user'; import { NotAuthorized, From ad6555c92bce239d2574ad84ba774a3493861043 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Mon, 6 Feb 2023 17:16:43 +0100 Subject: [PATCH 55/76] fix import --- website/server/libs/payments/subscriptions.js | 5 +---- website/server/models/subscriptionPlan.js | 7 ++++--- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/website/server/libs/payments/subscriptions.js b/website/server/libs/payments/subscriptions.js index de0920d2e7..48c04b087c 100644 --- a/website/server/libs/payments/subscriptions.js +++ b/website/server/libs/payments/subscriptions.js @@ -13,9 +13,7 @@ import { // eslint-disable-line import/no-cycle model as Group, basicFields as basicGroupFields, } from '../../models/group'; -import { - model as User, -} from '../../models/user'; +import { model as User } from '../../models/user'; // eslint-disable-line import/no-cycle import { NotAuthorized, NotFound, @@ -271,7 +269,6 @@ async function createSubscription (data) { isNewSubscription, } = await prepareSubscriptionValues(data); - console.log(); // Block sub perks if (months > 0 && (!data.gift || !isNewSubscription)) { if (!data.gift && !groupId) { diff --git a/website/server/models/subscriptionPlan.js b/website/server/models/subscriptionPlan.js index 8e381eca24..3c9888b202 100644 --- a/website/server/models/subscriptionPlan.js +++ b/website/server/models/subscriptionPlan.js @@ -52,14 +52,15 @@ schema.plugin(baseModel, { schema.methods.incrementPerkCounterAndReward = async function incrementPerkCounterAndReward (userID, adding) { + let addingNumber = adding; if (typeof adding === 'string' || adding instanceof String) { - adding = parseInt(adding); + addingNumber = parseInt(adding, 10); } // if perkMonthCount wasn't used before, initialize it. - if (this.perkMonthCount === undefined && adding === 1) { + if (this.perkMonthCount === undefined && addingNumber === 1) { this.perkMonthCount = (this.consecutive.count - 1) % SUBSCRIPTION_BASIC_BLOCK_LENGTH; } else { - this.perkMonthCount += adding; + this.perkMonthCount += addingNumber; } const perks = Math.floor(this.perkMonthCount / 3); From 0ce64a019743027f451c6af9c8532132edbe978d Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Thu, 9 Feb 2023 16:20:46 +0100 Subject: [PATCH 56/76] fix next hourglass calculation --- website/common/script/cron.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/common/script/cron.js b/website/common/script/cron.js index bbe115a5c5..04f6bfb6ed 100644 --- a/website/common/script/cron.js +++ b/website/common/script/cron.js @@ -292,7 +292,7 @@ export function getPlanContext (user, now) { if (planMonths > 1) { monthsTillNextHourglass = plan.consecutive.offset + 1; } else { - monthsTillNextHourglass = 3 - (plan.consecutive.count % 3); + monthsTillNextHourglass = 3 - plan.perkMonthsCount; } const possibleNextHourglassDate = moment(plan.dateUpdated) From 58f4dd0c43bb48cb1f9a0a23bf5b4e63b251142a Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Fri, 10 Feb 2023 17:13:04 +0100 Subject: [PATCH 57/76] fix typo --- website/common/script/cron.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/common/script/cron.js b/website/common/script/cron.js index 04f6bfb6ed..5b827240af 100644 --- a/website/common/script/cron.js +++ b/website/common/script/cron.js @@ -292,7 +292,7 @@ export function getPlanContext (user, now) { if (planMonths > 1) { monthsTillNextHourglass = plan.consecutive.offset + 1; } else { - monthsTillNextHourglass = 3 - plan.perkMonthsCount; + monthsTillNextHourglass = 3 - plan.perkMonthCount; } const possibleNextHourglassDate = moment(plan.dateUpdated) From 41cdab167272777f078d0dfc74c54f86e397306e Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Fri, 10 Feb 2023 17:13:10 +0100 Subject: [PATCH 58/76] adjust tests --- test/common/libs/cron.test.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/common/libs/cron.test.js b/test/common/libs/cron.test.js index d082e1e7b0..ada7523bd6 100644 --- a/test/common/libs/cron.test.js +++ b/test/common/libs/cron.test.js @@ -224,6 +224,7 @@ describe('cron utility functions', () => { it('monthly plan, next date in 1 month', () => { const user = baseUserData(62, 0, 'group_plan_auto'); + user.purchased.plan.perkMonthCount = 2; const planContext = getPlanContext(user, now); @@ -248,5 +249,16 @@ describe('cron utility functions', () => { expect(planContext.nextHourglassDate) .to.be.sameMoment('2022-07-10T02:00:00.144Z'); }); + + + it('multi-month plan with perk count', () => { + const user = baseUserData(60, 1, 'basic_3mo'); + user.purchased.plan.perkMonthCount = 2; + + const planContext = getPlanContext(user, now); + + expect(planContext.nextHourglassDate) + .to.be.sameMoment('2022-07-10T02:00:00.144Z'); + }); }); }); From 77229f3e5ed6d832cb661ed4711956daaea88813 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Fri, 10 Feb 2023 17:13:20 +0100 Subject: [PATCH 59/76] show year for next hourglass --- .../admin-panel/user-support/subscriptionAndPerks.vue | 2 +- website/client/src/components/settings/subscription.vue | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/website/client/src/components/admin-panel/user-support/subscriptionAndPerks.vue b/website/client/src/components/admin-panel/user-support/subscriptionAndPerks.vue index 54569195ee..9e7ffff117 100644 --- a/website/client/src/components/admin-panel/user-support/subscriptionAndPerks.vue +++ b/website/client/src/components/admin-panel/user-support/subscriptionAndPerks.vue @@ -153,7 +153,7 @@ export default { nextHourglassDate () { const currentPlanContext = getPlanContext(this.hero, new Date()); - return currentPlanContext.nextHourglassDate.format('MMMM'); + return currentPlanContext.nextHourglassDate.format('MMMM YYYY'); }, }, methods: { diff --git a/website/client/src/components/settings/subscription.vue b/website/client/src/components/settings/subscription.vue index 0fbc02c77c..f8840f2a0a 100644 --- a/website/client/src/components/settings/subscription.vue +++ b/website/client/src/components/settings/subscription.vue @@ -804,7 +804,7 @@ export default { return currentPlanContext.nextHourglassDate; }, nextHourGlass () { - const nextHourglassMonth = this.nextHourGlassDate.format('MMM'); + const nextHourglassMonth = this.nextHourGlassDate.format('MMM YYYY'); return nextHourglassMonth; }, From 3203b09b7a5efab298a5ae9f8ba0cb94f3b10e03 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Wed, 15 Feb 2023 10:05:18 +0100 Subject: [PATCH 60/76] reset perkMonthCount when subscription ends --- website/server/libs/cron.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/website/server/libs/cron.js b/website/server/libs/cron.js index 7b2acf2eaa..1fea7e8cc8 100644 --- a/website/server/libs/cron.js +++ b/website/server/libs/cron.js @@ -279,6 +279,8 @@ export async function cron (options = {}) { if (user.isSubscribed()) { await grantEndOfTheMonthPerks(user, now); + } if (!user.isSubscribed() && user.purchased.plan.perkMonthCount > 0) { + user.purchased.plan.perkMonthCount = 0; } const { plan } = user.purchased; From 2d5dcae406f2f04f95178933bbca6e1564e0c27c Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Thu, 16 Feb 2023 09:37:00 +0100 Subject: [PATCH 61/76] fix tests --- test/common/libs/cron.test.js | 1 + website/server/libs/payments/subscriptions.js | 16 ---------------- 2 files changed, 1 insertion(+), 16 deletions(-) diff --git a/test/common/libs/cron.test.js b/test/common/libs/cron.test.js index ada7523bd6..0c2e9a29b1 100644 --- a/test/common/libs/cron.test.js +++ b/test/common/libs/cron.test.js @@ -215,6 +215,7 @@ describe('cron utility functions', () => { it('monthly plan, next date in 3 months', () => { const user = baseUserData(60, 0, 'group_plan_auto'); + user.purchased.plan.perkMonthCount = 0; const planContext = getPlanContext(user, now); diff --git a/website/server/libs/payments/subscriptions.js b/website/server/libs/payments/subscriptions.js index 48c04b087c..647a705b40 100644 --- a/website/server/libs/payments/subscriptions.js +++ b/website/server/libs/payments/subscriptions.js @@ -117,22 +117,6 @@ async function prepareSubscriptionValues (data) { let recipientIsSubscribed = recipient.isSubscribed(); const isNewSubscription = !recipientIsSubscribed; - if (data.user && !data.gift && !data.groupId) { - const unlockedUser = await User.findOneAndUpdate( - { - _id: data.user._id, - $or: [ - { _subSignature: 'NOT_RUNNING' }, - { _subSignature: { $exists: false } }, - ], - }, - { $set: { _subSignature: 'SUB_IN_PROGRESS' } }, - ); - if (!unlockedUser) { - throw new NotFound('User not found or subscription already processing.'); - } - } - // If we are buying a group subscription if (data.groupId) { const groupFields = basicGroupFields.concat(' purchased'); From d69de2948bdc1793ea05c38f07c9fa417ce365ea Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Thu, 16 Feb 2023 09:58:09 +0100 Subject: [PATCH 62/76] fix import --- test/common/libs/cron.test.js | 1 - website/server/libs/payments/subscriptions.js | 1 - 2 files changed, 2 deletions(-) diff --git a/test/common/libs/cron.test.js b/test/common/libs/cron.test.js index 0c2e9a29b1..e8be92fceb 100644 --- a/test/common/libs/cron.test.js +++ b/test/common/libs/cron.test.js @@ -251,7 +251,6 @@ describe('cron utility functions', () => { .to.be.sameMoment('2022-07-10T02:00:00.144Z'); }); - it('multi-month plan with perk count', () => { const user = baseUserData(60, 1, 'basic_3mo'); user.purchased.plan.perkMonthCount = 2; diff --git a/website/server/libs/payments/subscriptions.js b/website/server/libs/payments/subscriptions.js index 647a705b40..9911ab9ac7 100644 --- a/website/server/libs/payments/subscriptions.js +++ b/website/server/libs/payments/subscriptions.js @@ -13,7 +13,6 @@ import { // eslint-disable-line import/no-cycle model as Group, basicFields as basicGroupFields, } from '../../models/group'; -import { model as User } from '../../models/user'; // eslint-disable-line import/no-cycle import { NotAuthorized, NotFound, From 3f56b7fa3ffef3cb858bd20f28e66819f14d359f Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Fri, 17 Feb 2023 15:42:31 +0100 Subject: [PATCH 63/76] fix offset calculation --- test/api/unit/libs/payments/payments.test.js | 17 ++++++++++++++++- website/server/libs/payments/subscriptions.js | 2 +- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/test/api/unit/libs/payments/payments.test.js b/test/api/unit/libs/payments/payments.test.js index 7b75db7877..eacc5b86d0 100644 --- a/test/api/unit/libs/payments/payments.test.js +++ b/test/api/unit/libs/payments/payments.test.js @@ -505,12 +505,27 @@ describe('payments/index', () => { expect(user.purchased.plan.perkMonthCount).to.eql(2); }); - it('sets plan.perkMonthCount to zero when creating new subscription', async () => { + it('sets plan.perkMonthCount to zero when creating new monthly subscription', async () => { user.purchased.plan.perkMonthCount = 2; await api.createSubscription(data); expect(user.purchased.plan.perkMonthCount).to.eql(0); }); + it('sets plan.perkMonthCount to zero when creating new 3 month subscription', async () => { + user.purchased.plan.perkMonthCount = 2; + await api.createSubscription(data); + expect(user.purchased.plan.perkMonthCount).to.eql(0); + }); + + it('updates plan.consecutive.offset when changing subscription type', async () => { + await api.createSubscription(data); + expect(user.purchased.plan.consecutive.offset).to.eql(3); + data.sub.key = "basic_6mo"; + await api.createSubscription(data); + expect(user.purchased.plan.consecutive.offset).to.eql(6); + }); + + 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 9911ab9ac7..eaf8b76b7e 100644 --- a/website/server/libs/payments/subscriptions.js +++ b/website/server/libs/payments/subscriptions.js @@ -255,7 +255,7 @@ async function createSubscription (data) { // Block sub perks if (months > 0 && (!data.gift || !isNewSubscription)) { if (!data.gift && !groupId) { - plan.consecutive.offset = months; + plan.consecutive.offset = block.months; } await plan.incrementPerkCounterAndReward(recipient._id, months); } From be184762922a82c53943e370dae813554c21c7ce Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Thu, 23 Feb 2023 13:20:47 +0100 Subject: [PATCH 64/76] correctly set perkMonthCount --- website/server/libs/payments/subscriptions.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/website/server/libs/payments/subscriptions.js b/website/server/libs/payments/subscriptions.js index eaf8b76b7e..98d8ae5187 100644 --- a/website/server/libs/payments/subscriptions.js +++ b/website/server/libs/payments/subscriptions.js @@ -257,7 +257,9 @@ async function createSubscription (data) { if (!data.gift && !groupId) { plan.consecutive.offset = block.months; } - await plan.incrementPerkCounterAndReward(recipient._id, months); + if (months > 1) { + await plan.incrementPerkCounterAndReward(recipient._id, months); + } } if (recipient !== group) { From 62b171ffa598ead2236460be2117cf1c1c32b892 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Fri, 24 Feb 2023 10:47:50 +0100 Subject: [PATCH 65/76] fix linting --- test/api/unit/libs/cron.test.js | 1 - test/api/unit/libs/payments/payments.test.js | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/test/api/unit/libs/cron.test.js b/test/api/unit/libs/cron.test.js index 0028a49ffc..6c6a08b985 100644 --- a/test/api/unit/libs/cron.test.js +++ b/test/api/unit/libs/cron.test.js @@ -477,7 +477,6 @@ 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()); diff --git a/test/api/unit/libs/payments/payments.test.js b/test/api/unit/libs/payments/payments.test.js index eacc5b86d0..fc069b6fa9 100644 --- a/test/api/unit/libs/payments/payments.test.js +++ b/test/api/unit/libs/payments/payments.test.js @@ -520,12 +520,11 @@ describe('payments/index', () => { it('updates plan.consecutive.offset when changing subscription type', async () => { await api.createSubscription(data); expect(user.purchased.plan.consecutive.offset).to.eql(3); - data.sub.key = "basic_6mo"; + data.sub.key = 'basic_6mo'; await api.createSubscription(data); expect(user.purchased.plan.consecutive.offset).to.eql(6); }); - it('awards the Royal Purple Jackalope pet', async () => { await api.createSubscription(data); From 57fb7ca6f2c21f77be9e18466b022618d9f70cf1 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Fri, 24 Feb 2023 11:25:53 +0100 Subject: [PATCH 66/76] fix --- website/server/libs/payments/subscriptions.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/server/libs/payments/subscriptions.js b/website/server/libs/payments/subscriptions.js index 98d8ae5187..eb73d44b4d 100644 --- a/website/server/libs/payments/subscriptions.js +++ b/website/server/libs/payments/subscriptions.js @@ -257,7 +257,7 @@ async function createSubscription (data) { if (!data.gift && !groupId) { plan.consecutive.offset = block.months; } - if (months > 1) { + if (months > 1 || data.gift) { await plan.incrementPerkCounterAndReward(recipient._id, months); } } From c29049146def7ed9c4f1a7561e19f4148e215359 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Mon, 27 Feb 2023 12:06:27 +0100 Subject: [PATCH 67/76] prevent sub accidentally also being applied to other account --- website/server/libs/payments/apple.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/website/server/libs/payments/apple.js b/website/server/libs/payments/apple.js index bdd86907ff..50bcee750b 100644 --- a/website/server/libs/payments/apple.js +++ b/website/server/libs/payments/apple.js @@ -127,14 +127,18 @@ api.subscribe = async function subscribe (user, receipt, headers, nextPaymentPro } } const existingUsers = await User.find({ - 'purchased.plan.customerId': purchase.originalTransactionId, + $or: [ + { 'purchased.plan.customerId': purchase.originalTransactionId }, + { 'purchased.plan.customerId': purchase.transactionId }, + ] + }).exec(); if (existingUsers.length > 0) { if (purchase.originalTransactionId === purchase.transactionId) { throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED); } for (const existingUser of existingUsers) { - if (existingUser._id !== user._id && !existingUser.purchased.plan.dateTerminated) { + if (existingUser._id !== user._id) { throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED); } } From def24142ca227eeb358b0bc150a31e51334a888f Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Mon, 27 Feb 2023 13:30:44 +0100 Subject: [PATCH 68/76] remove wrong test --- test/api/unit/libs/payments/apple.test.js | 74 ----------------------- website/server/libs/payments/apple.js | 3 +- 2 files changed, 1 insertion(+), 76 deletions(-) diff --git a/test/api/unit/libs/payments/apple.test.js b/test/api/unit/libs/payments/apple.test.js index f2eb0af521..2f01fef793 100644 --- a/test/api/unit/libs/payments/apple.test.js +++ b/test/api/unit/libs/payments/apple.test.js @@ -452,80 +452,6 @@ 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 () => { payments.createSubscription.restore(); diff --git a/website/server/libs/payments/apple.js b/website/server/libs/payments/apple.js index 50bcee750b..6001f9ed91 100644 --- a/website/server/libs/payments/apple.js +++ b/website/server/libs/payments/apple.js @@ -130,8 +130,7 @@ api.subscribe = async function subscribe (user, receipt, headers, nextPaymentPro $or: [ { 'purchased.plan.customerId': purchase.originalTransactionId }, { 'purchased.plan.customerId': purchase.transactionId }, - ] - + ], }).exec(); if (existingUsers.length > 0) { if (purchase.originalTransactionId === purchase.transactionId) { From 6887fd70c071d1e4688691e477ff44b1e7e7da01 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Tue, 28 Feb 2023 16:17:52 +0100 Subject: [PATCH 69/76] try something --- 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 6001f9ed91..8a8ab56ade 100644 --- a/website/server/libs/payments/apple.js +++ b/website/server/libs/payments/apple.js @@ -137,7 +137,7 @@ api.subscribe = async function subscribe (user, receipt, headers, nextPaymentPro throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED); } for (const existingUser of existingUsers) { - if (existingUser._id !== user._id) { + if (existingUser._id !== user._id && !existingUser.purchased.plan.dateTerminated) { throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED); } } From a2ce0ab099e3c188aa6f4983a2915d0729edfb68 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Wed, 1 Mar 2023 14:48:50 +0100 Subject: [PATCH 70/76] Set request size limit higher, because of iOS receipt sizes --- test/helpers/api-integration/external-server.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/helpers/api-integration/external-server.js b/test/helpers/api-integration/external-server.js index 5b199d058d..6daf6861e8 100644 --- a/test/helpers/api-integration/external-server.js +++ b/test/helpers/api-integration/external-server.js @@ -12,8 +12,9 @@ const webhookData = {}; app.use(bodyParser.urlencoded({ extended: true, + limit: '10mb' })); -app.use(bodyParser.json()); +app.use(bodyParser.json({limit: '10mb'})); app.post('/webhooks/:id', (req, res) => { const { id } = req.params; From 5d1346e65c499b32cb5e5271312f5becf13a49b3 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Wed, 1 Mar 2023 15:06:08 +0100 Subject: [PATCH 71/76] fix lint --- test/helpers/api-integration/external-server.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/helpers/api-integration/external-server.js b/test/helpers/api-integration/external-server.js index 6daf6861e8..5f02eb03c3 100644 --- a/test/helpers/api-integration/external-server.js +++ b/test/helpers/api-integration/external-server.js @@ -12,9 +12,9 @@ const webhookData = {}; app.use(bodyParser.urlencoded({ extended: true, - limit: '10mb' + limit: '10mb', })); -app.use(bodyParser.json({limit: '10mb'})); +app.use(bodyParser.json({ limit: '10mb' })); app.post('/webhooks/:id', (req, res) => { const { id } = req.params; From 0754c0ff05c60aaf30aff4ca22a5765a952bc0b5 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Thu, 2 Mar 2023 18:04:02 +0100 Subject: [PATCH 72/76] correctly set limit --- website/server/middlewares/index.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/website/server/middlewares/index.js b/website/server/middlewares/index.js index caee16610f..b4b9b269e9 100644 --- a/website/server/middlewares/index.js +++ b/website/server/middlewares/index.js @@ -72,6 +72,7 @@ export default function attachMiddlewares (app, server) { app.use(bodyParser.urlencoded({ extended: true, // Uses 'qs' library as old connect middleware + limit: '10mb', })); app.use(function bodyMiddleware (req, res, next) { // eslint-disable-line prefer-arrow-callback if (req.path === '/stripe/webhooks') { @@ -79,7 +80,7 @@ export default function attachMiddlewares (app, server) { // See https://stripe.com/docs/webhooks/signatures#verify-official-libraries bodyParser.raw({ type: 'application/json' })(req, res, next); } else { - bodyParser.json()(req, res, next); + bodyParser.json({ limit: '10mb' })(req, res, next); } }); From f6f1202baf55d6849c10e9fea77ee5ac06f742da Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Tue, 14 Mar 2023 14:37:47 +0100 Subject: [PATCH 73/76] better init for perkMonthCount --- website/server/models/subscriptionPlan.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/server/models/subscriptionPlan.js b/website/server/models/subscriptionPlan.js index 3c9888b202..07feeb33ac 100644 --- a/website/server/models/subscriptionPlan.js +++ b/website/server/models/subscriptionPlan.js @@ -18,7 +18,7 @@ export const schema = new mongoose.Schema({ dateUpdated: Date, dateCurrentTypeCreated: Date, extraMonths: { $type: Number, default: 0 }, - perkMonthCount: { $type: Number, default: 0 }, + perkMonthCount: { $type: Number, default: -1 }, gemsBought: { $type: Number, default: 0 }, mysteryItems: { $type: Array, default: () => [] }, lastReminderDate: Date, // indicates the last time a subscription reminder was sent @@ -57,7 +57,7 @@ schema.methods.incrementPerkCounterAndReward = async function incrementPerkCount addingNumber = parseInt(adding, 10); } // if perkMonthCount wasn't used before, initialize it. - if (this.perkMonthCount === undefined && addingNumber === 1) { + if ((this.perkMonthCount === undefined || this.perkMonthCount === -1) && addingNumber === 1) { this.perkMonthCount = (this.consecutive.count - 1) % SUBSCRIPTION_BASIC_BLOCK_LENGTH; } else { this.perkMonthCount += addingNumber; From 93e922e7741aae427fe815afb7cc5dbf5eea94de Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Tue, 14 Mar 2023 15:55:41 +0100 Subject: [PATCH 74/76] fix logic --- website/server/models/subscriptionPlan.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/website/server/models/subscriptionPlan.js b/website/server/models/subscriptionPlan.js index 07feeb33ac..49a7ca0600 100644 --- a/website/server/models/subscriptionPlan.js +++ b/website/server/models/subscriptionPlan.js @@ -57,11 +57,14 @@ schema.methods.incrementPerkCounterAndReward = async function incrementPerkCount addingNumber = parseInt(adding, 10); } // if perkMonthCount wasn't used before, initialize it. - if ((this.perkMonthCount === undefined || this.perkMonthCount === -1) && addingNumber === 1) { - this.perkMonthCount = (this.consecutive.count - 1) % SUBSCRIPTION_BASIC_BLOCK_LENGTH; - } else { - this.perkMonthCount += addingNumber; + if (this.perkMonthCount === undefined || this.perkMonthCount === -1) { + if (this.planId == 'basic_earned') { + this.perkMonthCount = (this.consecutive.count - 1) % SUBSCRIPTION_BASIC_BLOCK_LENGTH; + } else { + this.perkMonthCount = 0; + } } + this.perkMonthCount += addingNumber; const perks = Math.floor(this.perkMonthCount / 3); if (perks > 0) { From 5359a2bf3d765093128ed77b4b5b3e137ed61389 Mon Sep 17 00:00:00 2001 From: SabreCat Date: Tue, 14 Mar 2023 16:14:48 -0500 Subject: [PATCH 75/76] fix(lint): === --- website/server/models/subscriptionPlan.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/server/models/subscriptionPlan.js b/website/server/models/subscriptionPlan.js index 49a7ca0600..31849ceb94 100644 --- a/website/server/models/subscriptionPlan.js +++ b/website/server/models/subscriptionPlan.js @@ -58,7 +58,7 @@ schema.methods.incrementPerkCounterAndReward = async function incrementPerkCount } // if perkMonthCount wasn't used before, initialize it. if (this.perkMonthCount === undefined || this.perkMonthCount === -1) { - if (this.planId == 'basic_earned') { + if (this.planId === 'basic_earned') { this.perkMonthCount = (this.consecutive.count - 1) % SUBSCRIPTION_BASIC_BLOCK_LENGTH; } else { this.perkMonthCount = 0; From bbea7897006519b331b3fad84a9f96644ecd9b73 Mon Sep 17 00:00:00 2001 From: SabreCat Date: Tue, 14 Mar 2023 16:27:09 -0500 Subject: [PATCH 76/76] fix(test): don't cross two month boundaries when anticipating +1 perk --- test/api/unit/libs/cron.test.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/api/unit/libs/cron.test.js b/test/api/unit/libs/cron.test.js index 6c6a08b985..6757228033 100644 --- a/test/api/unit/libs/cron.test.js +++ b/test/api/unit/libs/cron.test.js @@ -337,9 +337,12 @@ describe('cron', async () => { expect(user1.purchased.plan.consecutive.gemCapExtra).to.equal(15); }); - it('initialized plan.perkMonthCount if necessary', async () => { + it('initializes plan.perkMonthCount if necessary', async () => { user.purchased.plan.perkMonthCount = undefined; - clock = sinon.useFakeTimers(moment().utcOffset(0).startOf('month').add(1, 'months') + clock = sinon.useFakeTimers(moment(user.purchased.plan.dateUpdated) + .utcOffset(0) + .startOf('month') + .add(1, 'months') .add(2, 'days') .toDate()); await cron({