diff --git a/test/api/unit/libs/payments/apple.test.js b/test/api/unit/libs/payments/apple.test.js index 1aefe2cb51..32f662d22d 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(); @@ -298,6 +299,7 @@ describe('Apple Payments', () => { expirationDate: moment.utc().add({ day: 1 }).toDate(), productId: option.sku, transactionId: token, + originalTransactionId: token, }]); sub = common.content.subscriptionBlocks[option.subKey]; @@ -321,12 +323,111 @@ 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 () => { - payments.createSubscription.restore(); + it('errors when a user is using the same 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(), + productId: sku, + transactionId: token, + originalTransactionId: token, + }]); await applePayments.subscribe(sku, user, receipt, headers, nextPaymentProcessing); diff --git a/test/api/unit/libs/payments/payments.test.js b/test/api/unit/libs/payments/payments.test.js index 65b42b42f3..84475e4b0e 100644 --- a/test/api/unit/libs/payments/payments.test.js +++ b/test/api/unit/libs/payments/payments.test.js @@ -465,6 +465,89 @@ describe('payments/index', () => { }, }); }); + + context('Upgrades subscription', () => { + it('from basic_earned to basic_6mo', async () => { + data.sub.key = 'basic_earned'; + expect(user.purchased.plan.planId).to.not.exist; + + await api.createSubscription(data); + + expect(user.purchased.plan.planId).to.eql('basic_earned'); + expect(user.purchased.plan.customerId).to.eql('customer-id'); + const created = user.purchased.plan.dateCreated; + const updated = user.purchased.plan.dateUpdated; + + data.sub.key = 'basic_6mo'; + data.updatedFrom = { key: 'basic_earned' }; + await api.createSubscription(data); + expect(user.purchased.plan.planId).to.eql('basic_6mo'); + expect(user.purchased.plan.dateCreated).to.eql(created); + expect(user.purchased.plan.dateUpdated).to.not.eql(updated); + expect(user.purchased.plan.customerId).to.eql('customer-id'); + }); + + it('from basic_3mo to basic_12mo', async () => { + expect(user.purchased.plan.planId).to.not.exist; + + await api.createSubscription(data); + + expect(user.purchased.plan.planId).to.eql('basic_3mo'); + expect(user.purchased.plan.customerId).to.eql('customer-id'); + const created = user.purchased.plan.dateCreated; + const updated = user.purchased.plan.dateUpdated; + + data.sub.key = 'basic_12mo'; + data.updatedFrom = { key: 'basic_3mo' }; + await api.createSubscription(data); + expect(user.purchased.plan.planId).to.eql('basic_12mo'); + expect(user.purchased.plan.dateCreated).to.eql(created); + expect(user.purchased.plan.dateUpdated).to.not.eql(updated); + expect(user.purchased.plan.customerId).to.eql('customer-id'); + }); + }); + + context('Downgrades subscription', () => { + it('from basic_6mo to basic_earned', async () => { + data.sub.key = 'basic_6mo'; + expect(user.purchased.plan.planId).to.not.exist; + + await api.createSubscription(data); + + expect(user.purchased.plan.planId).to.eql('basic_6mo'); + expect(user.purchased.plan.customerId).to.eql('customer-id'); + const created = user.purchased.plan.dateCreated; + const updated = user.purchased.plan.dateUpdated; + + data.sub.key = 'basic_earned'; + data.updatedFrom = { key: 'basic_6mo' }; + await api.createSubscription(data); + expect(user.purchased.plan.planId).to.eql('basic_earned'); + expect(user.purchased.plan.dateCreated).to.eql(created); + expect(user.purchased.plan.dateUpdated).to.not.eql(updated); + expect(user.purchased.plan.customerId).to.eql('customer-id'); + }); + + it('from basic_12mo to basic_3mo', async () => { + expect(user.purchased.plan.planId).to.not.exist; + + data.sub.key = 'basic_12mo'; + await api.createSubscription(data); + + expect(user.purchased.plan.planId).to.eql('basic_12mo'); + expect(user.purchased.plan.customerId).to.eql('customer-id'); + const created = user.purchased.plan.dateCreated; + const updated = user.purchased.plan.dateUpdated; + + data.sub.key = 'basic_3mo'; + data.updatedFrom = { key: 'basic_12mo' }; + await api.createSubscription(data); + expect(user.purchased.plan.planId).to.eql('basic_3mo'); + expect(user.purchased.plan.dateCreated).to.eql(created); + expect(user.purchased.plan.dateUpdated).to.not.eql(updated); + expect(user.purchased.plan.customerId).to.eql('customer-id'); + }); + }); }); context('Block subscription perks', () => { @@ -488,7 +571,6 @@ describe('payments/index', () => { it('adds 10 to plan.consecutive.gemCapExtra for 6 month block', async () => { data.sub.key = 'basic_6mo'; - await api.createSubscription(data); expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(10); @@ -496,7 +578,6 @@ describe('payments/index', () => { it('adds 20 to plan.consecutive.gemCapExtra for 12 month block', async () => { data.sub.key = 'basic_12mo'; - await api.createSubscription(data); expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(20); @@ -532,6 +613,134 @@ describe('payments/index', () => { expect(user.purchased.plan.consecutive.trinkets).to.eql(4); }); + + context('Upgrades subscription', () => { + it('Adds 10 to plan.consecutive.gemCapExtra from basic_earned to basic_6mo', async () => { + data.sub.key = 'basic_earned'; + expect(user.purchased.plan.planId).to.not.exist; + + await api.createSubscription(data); + + expect(user.purchased.plan.planId).to.eql('basic_earned'); + expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(0); + + data.sub.key = 'basic_6mo'; + data.updatedFrom = { key: 'basic_earned' }; + await api.createSubscription(data); + expect(user.purchased.plan.planId).to.eql('basic_6mo'); + expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(10); + }); + + it('Adds 15 to plan.consecutive.gemCapExtra when upgrading from basic_3mo to basic_12mo', async () => { + expect(user.purchased.plan.planId).to.not.exist; + + await api.createSubscription(data); + + expect(user.purchased.plan.planId).to.eql('basic_3mo'); + expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(5); + + data.sub.key = 'basic_12mo'; + data.updatedFrom = { key: 'basic_3mo' }; + await api.createSubscription(data); + expect(user.purchased.plan.planId).to.eql('basic_12mo'); + expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(20); + }); + + it('Adds 2 to plan.consecutive.trinkets from basic_earned to basic_6mo', async () => { + data.sub.key = 'basic_earned'; + expect(user.purchased.plan.planId).to.not.exist; + + await api.createSubscription(data); + + expect(user.purchased.plan.planId).to.eql('basic_earned'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(0); + + data.sub.key = 'basic_6mo'; + data.updatedFrom = { key: 'basic_earned' }; + await api.createSubscription(data); + expect(user.purchased.plan.planId).to.eql('basic_6mo'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(2); + }); + + it('Adds 3 to plan.consecutive.trinkets when upgrading from basic_3mo to basic_12mo', async () => { + expect(user.purchased.plan.planId).to.not.exist; + + await api.createSubscription(data); + + expect(user.purchased.plan.planId).to.eql('basic_3mo'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(1); + + data.sub.key = 'basic_12mo'; + data.updatedFrom = { key: 'basic_3mo' }; + await api.createSubscription(data); + expect(user.purchased.plan.planId).to.eql('basic_12mo'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(4); + }); + }); + + context('Downgrades subscription', () => { + it('does not remove from plan.consecutive.gemCapExtra from basic_6mo to basic_earned', async () => { + data.sub.key = 'basic_6mo'; + expect(user.purchased.plan.planId).to.not.exist; + + await api.createSubscription(data); + + expect(user.purchased.plan.planId).to.eql('basic_6mo'); + expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(10); + + data.sub.key = 'basic_earned'; + data.updatedFrom = { key: 'basic_6mo' }; + await api.createSubscription(data); + expect(user.purchased.plan.planId).to.eql('basic_earned'); + expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(10); + }); + + it('does not remove from plan.consecutive.gemCapExtra from basic_12mo to basic_3mo', async () => { + expect(user.purchased.plan.planId).to.not.exist; + + data.sub.key = 'basic_12mo'; + await api.createSubscription(data); + + expect(user.purchased.plan.planId).to.eql('basic_12mo'); + expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(20); + + data.sub.key = 'basic_3mo'; + data.updatedFrom = { key: 'basic_12mo' }; + await api.createSubscription(data); + expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(20); + }); + + it('does not remove from plan.consecutive.trinkets from basic_6mo to basic_earned', async () => { + data.sub.key = 'basic_6mo'; + expect(user.purchased.plan.planId).to.not.exist; + + await api.createSubscription(data); + + expect(user.purchased.plan.planId).to.eql('basic_6mo'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(2); + + data.sub.key = 'basic_earned'; + data.updatedFrom = { key: 'basic_6mo' }; + await api.createSubscription(data); + expect(user.purchased.plan.planId).to.eql('basic_earned'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(2); + }); + + it('does not remove from plan.consecutive.trinkets from basic_12mo to basic_3mo', async () => { + expect(user.purchased.plan.planId).to.not.exist; + + data.sub.key = 'basic_12mo'; + await api.createSubscription(data); + + expect(user.purchased.plan.planId).to.eql('basic_12mo'); + expect(user.purchased.plan.consecutive.trinkets).to.eql(4); + + data.sub.key = 'basic_3mo'; + data.updatedFrom = { key: 'basic_12mo' }; + await api.createSubscription(data); + expect(user.purchased.plan.consecutive.trinkets).to.eql(4); + }); + }); }); context('Mystery Items', () => { diff --git a/website/server/libs/payments/apple.js b/website/server/libs/payments/apple.js index 8c0fd44184..e91729a7e0 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,33 +136,49 @@ 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) { + 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': 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({ + const data = { user, - customerId: transactionId, + customerId: originalTransactionId, paymentMethod: this.constants.PAYMENT_METHOD_APPLE, sub, 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 e88cf30937..bb5411e9f7 100644 --- a/website/server/libs/payments/subscriptions.js +++ b/website/server/libs/payments/subscriptions.js @@ -75,7 +75,15 @@ async function prepareSubscriptionValues (data) { ? data.gift.subscription.key : data.sub.key]; const autoRenews = data.autoRenews !== undefined ? data.autoRenews : true; - const months = Number(block.months); + const updatedFrom = data.updatedFrom + ? shared.content.subscriptionBlocks[data.updatedFrom.key] + : undefined; + 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;