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;