From c25b09c7edd7e89885c937851a40a9951e84e8e9 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Fri, 21 Oct 2022 16:57:12 +0200 Subject: [PATCH 001/367] 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 002/367] 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 003/367] 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 004/367] 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 005/367] 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 006/367] 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 007/367] 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 008/367] 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 009/367] 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 010/367] 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 011/367] 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 012/367] 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 013/367] 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 014/367] 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 015/367] 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 016/367] 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 017/367] 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 018/367] 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 019/367] 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 020/367] 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 4ddfdb84acbbdf2eaf3e150810d1fe04317e5cd4 Mon Sep 17 00:00:00 2001 From: CuriousMagpie Date: Thu, 17 Nov 2022 14:57:58 -0500 Subject: [PATCH 021/367] get most of the right parts in the same place --- .../components/shared/number-increment.vue | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 website/client/src/components/shared/number-increment.vue diff --git a/website/client/src/components/shared/number-increment.vue b/website/client/src/components/shared/number-increment.vue new file mode 100644 index 0000000000..8596d204bc --- /dev/null +++ b/website/client/src/components/shared/number-increment.vue @@ -0,0 +1,119 @@ + + + + + From 4f5a720c30ce42d8d6fd7e1a4b239ad396173eb3 Mon Sep 17 00:00:00 2001 From: CuriousMagpie Date: Fri, 18 Nov 2022 15:27:18 -0500 Subject: [PATCH 022/367] how to make component show up? --- ...mber-increment.vue => numberIncrement.vue} | 20 +++++++++---------- .../src/components/shops/market/sellModal.vue | 2 ++ 2 files changed, 12 insertions(+), 10 deletions(-) rename website/client/src/components/shared/{number-increment.vue => numberIncrement.vue} (87%) diff --git a/website/client/src/components/shared/number-increment.vue b/website/client/src/components/shared/numberIncrement.vue similarity index 87% rename from website/client/src/components/shared/number-increment.vue rename to website/client/src/components/shared/numberIncrement.vue index 8596d204bc..91ad8670e0 100644 --- a/website/client/src/components/shared/number-increment.vue +++ b/website/client/src/components/shared/numberIncrement.vue @@ -2,9 +2,9 @@
Date: Tue, 22 Nov 2022 15:28:48 -0500 Subject: [PATCH 023/367] add conditionals, add component to buy & sell modals --- .../src/components/shared/numberIncrement.vue | 115 ++++++++++++++++-- .../client/src/components/shops/buyModal.vue | 5 + .../src/components/shops/market/sellModal.vue | 3 + 3 files changed, 114 insertions(+), 9 deletions(-) diff --git a/website/client/src/components/shared/numberIncrement.vue b/website/client/src/components/shared/numberIncrement.vue index 91ad8670e0..594728f523 100644 --- a/website/client/src/components/shared/numberIncrement.vue +++ b/website/client/src/components/shared/numberIncrement.vue @@ -1,37 +1,91 @@ @@ -129,6 +136,15 @@ border-top: none; padding-top: 0; } + .close-link { + color: $purple-300; + line-height: 1.71; + font-size: 0.875rem; + cursor: pointer; + padding-top:16px; + padding-bottom: 8px; + text-align: center; + } } } From b264e539f4d0fe629cd6235632a533d1c277367d Mon Sep 17 00:00:00 2001 From: CuriousMagpie Date: Tue, 21 Mar 2023 14:49:43 -0400 Subject: [PATCH 218/367] add crtl/cmd instructions to skip modal --- .../src/components/externalLinkModal.vue | 31 ++++++++++++++----- website/common/locales/en/generic.json | 3 +- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/website/client/src/components/externalLinkModal.vue b/website/client/src/components/externalLinkModal.vue index 47fe67af41..2d64dfd536 100644 --- a/website/client/src/components/externalLinkModal.vue +++ b/website/client/src/components/externalLinkModal.vue @@ -33,6 +33,11 @@ v-html="$t('leaveHabiticaText')" >
+
+ {{ $t('skipExternalLinkModal') }} +
@@ -64,13 +69,13 @@ .modal-close { position: absolute; - right: 16px; - top: 16px; + right: 12px; + top: 12px; cursor: pointer; .icon-close { - width: 18px; - height: 18px; + width: 16px; + height: 16px; vertical-align: middle; & svg { @@ -90,7 +95,7 @@ .modal-header { justify-content: center; - padding-top: 24px; + padding-top: 32px; padding-bottom: 0px; background: $yellow-100; border-top-right-radius: 8px; @@ -113,7 +118,7 @@ h2 { color: $yellow-1; - // font-size: 1.25rem; + margin-bottom: 16px; } } @@ -125,6 +130,16 @@ font-size: 0.875rem; line-height: 1.71; text-align: center; + margin-top:24px; + } + + .skip-modal { + color: $gray-100; + font-size: 0.75rem; + text-align: center; + line-height: 1.33; + margin-top: 16px; + // padding-bottom: 24px; } } @@ -141,8 +156,8 @@ line-height: 1.71; font-size: 0.875rem; cursor: pointer; - padding-top:16px; - padding-bottom: 8px; + margin-top:16px; + margin-bottom: 8px; text-align: center; } } diff --git a/website/common/locales/en/generic.json b/website/common/locales/en/generic.json index d360bed360..26a51eaf7b 100644 --- a/website/common/locales/en/generic.json +++ b/website/common/locales/en/generic.json @@ -214,5 +214,6 @@ "askQuestion": "Ask a Question", "emptyReportBugMessage": "Report Bug Message missing", "leaveHabitica": "You are about to leave Habitica.com", - "leaveHabiticaText": "Habitica is not responsible for the content of any linked website that is not owned or operated by HabitRPG.
Please note that these websites' practices may differ from Habitica’s community guidelines." + "leaveHabiticaText": "Habitica is not responsible for the content of any linked website that is not owned or operated by HabitRPG.
Please note that these websites' practices may differ from Habitica’s community guidelines.", + "skipExternalLinkModal": "Hold CTRL (Windows) or Command (Mac) when clicking a link to skip this modal." } From 5de2573521188b8a70971cddf694ab3d739e0ae1 Mon Sep 17 00:00:00 2001 From: SabreCat Date: Tue, 21 Mar 2023 13:54:23 -0500 Subject: [PATCH 219/367] 4.264.2 --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index b522c73c6a..554d6d588d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "habitica", - "version": "4.264.1", + "version": "4.264.2", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 6b7e215efb..89e177f8c0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "habitica", "description": "A habit tracker app which treats your goals like a Role Playing Game.", - "version": "4.264.1", + "version": "4.264.2", "main": "./website/server/index.js", "dependencies": { "@babel/core": "^7.20.12", From 5afb46f23712dd0278924503246c5c4f28fb956a Mon Sep 17 00:00:00 2001 From: CuriousMagpie Date: Tue, 21 Mar 2023 16:08:26 -0400 Subject: [PATCH 220/367] fix close icon on buy & sell and keyboard input into number increment component is now a number, not a string --- website/client/src/components/shops/buyModal.vue | 6 +++--- .../client/src/components/shops/market/sellModal.vue | 11 ++++++----- website/client/src/mixins/numberInvalid.js | 5 +++-- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/website/client/src/components/shops/buyModal.vue b/website/client/src/components/shops/buyModal.vue index 8f6f6ecf24..13ca7f097c 100644 --- a/website/client/src/components/shops/buyModal.vue +++ b/website/client/src/components/shops/buyModal.vue @@ -17,7 +17,7 @@