From 1acb0d1bcd127445e1f45e9729e2608c1f001d08 Mon Sep 17 00:00:00 2001 From: Eric Mc Sween <5454374+emcsween@users.noreply.github.com> Date: Wed, 30 Oct 2024 08:51:57 -0400 Subject: [PATCH] Merge pull request #21274 from overleaf/em-recurly-client Separate Recurly business logic GitOrigin-RevId: 9c3b5ce61bdc7a6a5d3f507a31dc8919c882e476 --- .../app/src/Features/Email/EmailHandler.js | 20 +- .../Features/Subscription/RecurlyClient.js | 131 +++++++++-- .../Features/Subscription/RecurlyEntities.js | 194 ++++++++++++++++ .../Subscription/SubscriptionHandler.js | 117 +++------- .../src/Subscription/RecurlyClientTests.js | 164 +++++++------ .../src/Subscription/RecurlyEntitiesTest.js | 206 +++++++++++++++++ .../Subscription/SubscriptionHandlerTests.js | 217 ++++++++---------- 7 files changed, 752 insertions(+), 297 deletions(-) create mode 100644 services/web/app/src/Features/Subscription/RecurlyEntities.js create mode 100644 services/web/test/unit/src/Subscription/RecurlyEntitiesTest.js diff --git a/services/web/app/src/Features/Email/EmailHandler.js b/services/web/app/src/Features/Email/EmailHandler.js index 33b17136f5..f38ce2cc16 100644 --- a/services/web/app/src/Features/Email/EmailHandler.js +++ b/services/web/app/src/Features/Email/EmailHandler.js @@ -7,14 +7,10 @@ const Queues = require('../../infrastructure/Queues') const EMAIL_SETTINGS = Settings.email || {} -module.exports = { - sendEmail: callbackify(sendEmail), - sendDeferredEmail, - promises: { - sendEmail, - }, -} - +/** + * @param {string} emailType + * @param {opts} any + */ async function sendEmail(emailType, opts) { const email = EmailBuilder.buildEmail(emailType, opts) if (email.type === 'lifecycle' && !EMAIL_SETTINGS.lifecycle) { @@ -35,3 +31,11 @@ function sendDeferredEmail(emailType, opts, delay) { logger.warn({ err, emailType, opts }, 'failed to queue deferred email') }) } + +module.exports = { + sendEmail: callbackify(sendEmail), + sendDeferredEmail, + promises: { + sendEmail, + }, +} diff --git a/services/web/app/src/Features/Subscription/RecurlyClient.js b/services/web/app/src/Features/Subscription/RecurlyClient.js index a667366e5a..e04f97748c 100644 --- a/services/web/app/src/Features/Subscription/RecurlyClient.js +++ b/services/web/app/src/Features/Subscription/RecurlyClient.js @@ -1,8 +1,19 @@ +// @ts-check + const recurly = require('recurly') const Settings = require('@overleaf/settings') const logger = require('@overleaf/logger') +const OError = require('@overleaf/o-error') const { callbackify } = require('util') const UserGetter = require('../User/UserGetter') +const { + RecurlySubscription, + RecurlySubscriptionAddOn, +} = require('./RecurlyEntities') + +/** + * @import { RecurlySubscriptionChangeRequest } from './RecurlyEntities' + */ const recurlySettings = Settings.apis.recurly const recurlyApiKey = recurlySettings ? recurlySettings.apiKey : undefined @@ -40,25 +51,51 @@ async function createAccountForUserId(userId) { return account } +/** + * Get a subscription from Recurly + * + * @param {string} subscriptionId + * @return {Promise} + */ async function getSubscription(subscriptionId) { - return await client.getSubscription(subscriptionId) + const subscription = await client.getSubscription(`uuid-${subscriptionId}`) + return makeSubscription(subscription) } -async function getSubscriptionByUuid(subscriptionUuid) { - return await client.getSubscription('uuid-' + subscriptionUuid) -} - -async function changeSubscription(subscriptionId, body) { - const change = await client.createSubscriptionChange(subscriptionId, body) +/** + * Request a susbcription change from Recurly + * + * @param {RecurlySubscriptionChangeRequest} changeRequest + */ +async function applySubscriptionChangeRequest(changeRequest) { + /** @type {recurly.SubscriptionChangeCreate} */ + const body = { + timeframe: changeRequest.timeframe, + } + if (changeRequest.planCode != null) { + body.planCode = changeRequest.planCode + } + if (changeRequest.addOnUpdates != null) { + body.addOns = changeRequest.addOnUpdates.map(addOnUpdate => { + /** @type {recurly.SubscriptionAddOnUpdate} */ + const update = { code: addOnUpdate.code } + if (addOnUpdate.quantity != null) { + update.quantity = addOnUpdate.quantity + } + if (addOnUpdate.unitPrice != null) { + update.unitAmount = addOnUpdate.unitPrice + } + return update + }) + } + const change = await client.createSubscriptionChange( + `uuid-${changeRequest.subscriptionId}`, + body + ) logger.debug( - { subscriptionId, changeId: change.id }, + { subscriptionId: changeRequest.subscriptionId, changeId: change.id }, 'created subscription change' ) - return change -} - -async function changeSubscriptionByUuid(subscriptionUuid, ...args) { - return await changeSubscription('uuid-' + subscriptionUuid, ...args) } async function removeSubscriptionChange(subscriptionId) { @@ -99,15 +136,73 @@ function subscriptionIsCanceledOrExpired(subscription) { return state === 'canceled' || state === 'expired' } +/** + * Build a RecurlySubscription from Recurly API data + * + * @param {recurly.Subscription} subscription + * @return {RecurlySubscription} + */ +function makeSubscription(subscription) { + if ( + subscription.uuid == null || + subscription.plan == null || + subscription.plan.code == null || + subscription.plan.name == null || + subscription.account == null || + subscription.account.code == null || + subscription.unitAmount == null || + subscription.subtotal == null || + subscription.total == null || + subscription.currency == null + ) { + throw new OError('Invalid Recurly subscription', { subscription }) + } + return new RecurlySubscription({ + id: subscription.uuid, + userId: subscription.account.code, + planCode: subscription.plan.code, + planName: subscription.plan.name, + planPrice: subscription.unitAmount, + addOns: (subscription.addOns ?? []).map(makeSubscriptionAddOn), + subtotal: subscription.subtotal, + taxRate: subscription.taxInfo?.rate ?? 0, + taxAmount: subscription.tax ?? 0, + total: subscription.total, + currency: subscription.currency, + }) +} + +/** + * Build a RecurlySubscriptionAddOn from Recurly API data + * + * @param {recurly.SubscriptionAddOn} addOn + * @return {RecurlySubscriptionAddOn} + */ +function makeSubscriptionAddOn(addOn) { + if ( + addOn.addOn == null || + addOn.addOn.code == null || + addOn.addOn.name == null || + addOn.unitAmount == null + ) { + throw new OError('Invalid Recurly add-on', { addOn }) + } + + return new RecurlySubscriptionAddOn({ + code: addOn.addOn.code, + name: addOn.addOn.name, + quantity: addOn.quantity ?? 1, + unitPrice: addOn.unitAmount, + }) +} + module.exports = { errors: recurly.errors, getAccountForUserId: callbackify(getAccountForUserId), createAccountForUserId: callbackify(createAccountForUserId), getSubscription: callbackify(getSubscription), - getSubscriptionByUuid: callbackify(getSubscriptionByUuid), - changeSubscription: callbackify(changeSubscription), - changeSubscriptionByUuid: callbackify(changeSubscriptionByUuid), + applySubscriptionChangeRequest: callbackify(applySubscriptionChangeRequest), removeSubscriptionChange: callbackify(removeSubscriptionChange), removeSubscriptionChangeByUuid: callbackify(removeSubscriptionChangeByUuid), reactivateSubscriptionByUuid: callbackify(reactivateSubscriptionByUuid), @@ -116,11 +211,9 @@ module.exports = { promises: { getSubscription, - getSubscriptionByUuid, getAccountForUserId, createAccountForUserId, - changeSubscription, - changeSubscriptionByUuid, + applySubscriptionChangeRequest, removeSubscriptionChange, removeSubscriptionChangeByUuid, reactivateSubscriptionByUuid, diff --git a/services/web/app/src/Features/Subscription/RecurlyEntities.js b/services/web/app/src/Features/Subscription/RecurlyEntities.js new file mode 100644 index 0000000000..af88324172 --- /dev/null +++ b/services/web/app/src/Features/Subscription/RecurlyEntities.js @@ -0,0 +1,194 @@ +// @ts-check + +const OError = require('@overleaf/o-error') +const { DuplicateAddOnError, AddOnNotPresentError } = require('./Errors') +const PlansLocator = require('./PlansLocator') +const SubscriptionHelper = require('./SubscriptionHelper') + +class RecurlySubscription { + /** + * @param {object} props + * @param {string} props.id + * @param {string} props.userId + * @param {string} props.planCode + * @param {string} props.planName + * @param {number} props.planPrice + * @param {RecurlySubscriptionAddOn[]} [props.addOns] + * @param {number} props.subtotal + * @param {number} [props.taxRate] + * @param {number} [props.taxAmount] + * @param {string} props.currency + * @param {number} props.total + */ + constructor(props) { + this.id = props.id + this.userId = props.userId + this.planCode = props.planCode + this.planName = props.planName + this.planPrice = props.planPrice + this.addOns = props.addOns ?? [] + this.subtotal = props.subtotal + this.taxRate = props.taxRate ?? 0 + this.taxAmount = props.taxAmount ?? 0 + this.currency = props.currency + this.total = props.total + } + + hasAddOn(code) { + return this.addOns.some(addOn => addOn.code === code) + } + + /** + * Change this subscription's plan + * + * @return {RecurlySubscriptionChangeRequest} + */ + getRequestForPlanChange(planCode) { + const currentPlan = PlansLocator.findLocalPlanInSettings(this.planCode) + if (currentPlan == null) { + throw new OError('Unable to find plan in settings', { + planCode: this.planCode, + }) + } + const newPlan = PlansLocator.findLocalPlanInSettings(planCode) + if (newPlan == null) { + throw new OError('Unable to find plan in settings', { planCode }) + } + const changeAtTermEnd = SubscriptionHelper.shouldPlanChangeAtTermEnd( + currentPlan, + newPlan + ) + const timeframe = changeAtTermEnd ? 'term_end' : 'now' + return new RecurlySubscriptionChangeRequest({ + subscriptionId: this.id, + timeframe, + planCode, + }) + } + + /** + * Purchase an add-on on this subscription + * + * @param {string} code + * @param {number} [quantity] + * @return {RecurlySubscriptionChangeRequest} - the change request to send to + * Recurly + * + * @throws {DuplicateAddOnError} if the add-on is already present on the subscription + */ + getRequestForAddOnPurchase(code, quantity = 1) { + if (this.hasAddOn(code)) { + throw new DuplicateAddOnError('Subscription already has add-on', { + subscriptionId: this.id, + addOnCode: code, + }) + } + + const addOnUpdates = this.addOns.map(addOn => addOn.toAddOnUpdate()) + addOnUpdates.push(new RecurlySubscriptionAddOnUpdate({ code, quantity })) + return new RecurlySubscriptionChangeRequest({ + subscriptionId: this.id, + timeframe: 'now', + addOnUpdates, + }) + } + + /** + * Remove an add-on from this subscription + * + * @param {string} code + * @return {RecurlySubscriptionChangeRequest} + * + * @throws {AddOnNotPresentError} if the subscription doesn't have the add-on + */ + getRequestForAddOnRemoval(code) { + if (!this.hasAddOn(code)) { + throw new AddOnNotPresentError( + 'Subscripiton does not have add-on to remove', + { + subscriptionId: this.id, + addOnCode: code, + } + ) + } + const addOnUpdates = this.addOns + .filter(addOn => addOn.code !== code) + .map(addOn => addOn.toAddOnUpdate()) + return new RecurlySubscriptionChangeRequest({ + subscriptionId: this.id, + timeframe: 'term_end', + addOnUpdates, + }) + } +} + +class RecurlySubscriptionAddOn { + /** + * @param {object} props + * @param {string} props.code + * @param {string} props.name + * @param {number} props.quantity + * @param {number} props.unitPrice + */ + constructor(props) { + this.code = props.code + this.name = props.name + this.quantity = props.quantity + this.unitPrice = props.unitPrice + } + + get preTaxTotal() { + return this.quantity * this.unitPrice + } + + /** + * Return an add-on update that doesn't modify the add-on + */ + toAddOnUpdate() { + return new RecurlySubscriptionAddOnUpdate({ + code: this.code, + quantity: this.quantity, + unitPrice: this.unitPrice, + }) + } +} + +class RecurlySubscriptionChangeRequest { + /** + * @param {object} props + * @param {string} props.subscriptionId + * @param {"now" | "term_end"} props.timeframe + * @param {string} [props.planCode] + * @param {RecurlySubscriptionAddOnUpdate[]} [props.addOnUpdates] + */ + constructor(props) { + if (props.planCode == null && props.addOnUpdates == null) { + throw new OError('Invalid RecurlySubscriptionChangeRequest', { props }) + } + this.subscriptionId = props.subscriptionId + this.timeframe = props.timeframe + this.planCode = props.planCode ?? null + this.addOnUpdates = props.addOnUpdates ?? null + } +} + +class RecurlySubscriptionAddOnUpdate { + /** + * @param {object} props + * @param {string} props.code + * @param {number} [props.quantity] + * @param {number} [props.unitPrice] + */ + constructor(props) { + this.code = props.code + this.quantity = props.quantity ?? null + this.unitPrice = props.unitPrice ?? null + } +} + +module.exports = { + RecurlySubscription, + RecurlySubscriptionAddOn, + RecurlySubscriptionChangeRequest, + RecurlySubscriptionAddOnUpdate, +} diff --git a/services/web/app/src/Features/Subscription/SubscriptionHandler.js b/services/web/app/src/Features/Subscription/SubscriptionHandler.js index 766e3272a8..1903cabc27 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionHandler.js +++ b/services/web/app/src/Features/Subscription/SubscriptionHandler.js @@ -1,3 +1,5 @@ +// @ts-check + const RecurlyWrapper = require('./RecurlyWrapper') const RecurlyClient = require('./RecurlyClient') const { User } = require('../../models/User') @@ -5,15 +7,13 @@ const logger = require('@overleaf/logger') const SubscriptionUpdater = require('./SubscriptionUpdater') const LimitationsManager = require('./LimitationsManager') const EmailHandler = require('../Email/EmailHandler') -const PlansLocator = require('./PlansLocator') -const SubscriptionHelper = require('./SubscriptionHelper') const { callbackify } = require('@overleaf/promise-utils') const UserUpdater = require('../User/UserUpdater') -const { - DuplicateAddOnError, - AddOnNotPresentError, - NoRecurlySubscriptionError, -} = require('./Errors') +const { NoRecurlySubscriptionError } = require('./Errors') + +/** + * @import { RecurlySubscription } from './RecurlyEntities' + */ async function validateNoSubscriptionInRecurly(userId) { let subscriptions = @@ -76,13 +76,18 @@ async function updateSubscription(user, planCode, couponCode) { ) } - if (!hasSubscription) { + if ( + !hasSubscription || + subscription == null || + subscription.recurlySubscription_id == null + ) { return } + const recurlySubscriptionId = subscription.recurlySubscription_id if (couponCode) { const usersSubscription = await RecurlyWrapper.promises.getSubscription( - subscription.recurlySubscription_id, + recurlySubscriptionId, { includeAccount: true } ) @@ -91,47 +96,12 @@ async function updateSubscription(user, planCode, couponCode) { couponCode ) } - let changeAtTermEnd - const currentPlan = PlansLocator.findLocalPlanInSettings( - subscription.planCode + const recurlySubscription = await RecurlyClient.promises.getSubscription( + recurlySubscriptionId ) - const newPlan = PlansLocator.findLocalPlanInSettings(planCode) - if (currentPlan && newPlan) { - changeAtTermEnd = SubscriptionHelper.shouldPlanChangeAtTermEnd( - currentPlan, - newPlan - ) - } else { - logger.error( - { currentPlan: subscription.planCode, newPlan: planCode }, - 'unable to locate both plans in settings' - ) - throw new Error('unable to locate both plans in settings') - } - - const timeframe = changeAtTermEnd ? 'term_end' : 'now' - const subscriptionChangeOptions = { planCode, timeframe } - await _updateAndSyncSubscription( - user, - subscription.recurlySubscription_id, - subscriptionChangeOptions - ) -} - -async function _updateAndSyncSubscription( - user, - recurlySubscriptionId, - subscriptionChangeOptions -) { - await RecurlyClient.promises.changeSubscriptionByUuid( - recurlySubscriptionId, - subscriptionChangeOptions - ) - - // v2 recurly API wants a UUID, but UUID isn't included in the subscription change response - // we got the UUID from the DB using userHasV2Subscription() - it is the only property - // we need to be able to build a 'recurlySubscription' object for syncSubscription() + const changeRequest = recurlySubscription.getRequestForPlanChange(planCode) + await RecurlyClient.promises.applySubscriptionChangeRequest(changeRequest) await syncSubscription({ uuid: recurlySubscriptionId }, user._id) } @@ -139,7 +109,7 @@ async function cancelPendingSubscriptionChange(user) { const { hasSubscription, subscription } = await LimitationsManager.promises.userHasV2Subscription(user) - if (hasSubscription) { + if (hasSubscription && subscription != null) { await RecurlyClient.promises.removeSubscriptionChangeByUuid( subscription.recurlySubscription_id ) @@ -150,7 +120,7 @@ async function cancelSubscription(user) { try { const { hasSubscription, subscription } = await LimitationsManager.promises.userHasV2Subscription(user) - if (hasSubscription) { + if (hasSubscription && subscription != null) { await RecurlyClient.promises.cancelSubscriptionByUuid( subscription.recurlySubscription_id ) @@ -178,7 +148,7 @@ async function reactivateSubscription(user) { try { const { hasSubscription, subscription } = await LimitationsManager.promises.userHasV2Subscription(user) - if (hasSubscription) { + if (hasSubscription && subscription != null) { await RecurlyClient.promises.reactivateSubscriptionByUuid( subscription.recurlySubscription_id ) @@ -269,6 +239,9 @@ async function _updateSubscriptionFromRecurly(subscription) { ) } +/** + * @return {Promise} + */ async function _getSubscription(user) { const { hasSubscription = false, subscription } = await LimitationsManager.promises.userHasV2Subscription(user) @@ -281,50 +254,26 @@ async function _getSubscription(user) { } const currentSub = await RecurlyClient.promises.getSubscription( - `uuid-${subscription.recurlySubscription_id}` + subscription.recurlySubscription_id ) return currentSub } async function purchaseAddon(user, addOnCode, quantity) { const subscription = await _getSubscription(user) - const currentAddons = subscription?.addOns || [] - - const hasAddon = currentAddons.some(addOn => addOn.addOn.code === addOnCode) - if (hasAddon) { - throw new DuplicateAddOnError('User already has add-on', { - userId: user._id, - addOnCode, - }) - } - const addOns = [...currentAddons, { code: addOnCode, quantity }] - const subscriptionChangeOptions = { addOns, timeframe: 'now' } - - await _updateAndSyncSubscription( - user, - subscription.uuid, - subscriptionChangeOptions + const changeRequest = subscription.getRequestForAddOnPurchase( + addOnCode, + quantity ) + await RecurlyClient.promises.applySubscriptionChangeRequest(changeRequest) + await syncSubscription({ uuid: subscription.id }, user._id) } async function removeAddon(user, addOnCode) { const subscription = await _getSubscription(user) - const currentAddons = subscription?.addOns || [] - - const hasAddon = currentAddons.some(addOn => addOn.addOn.code === addOnCode) - if (!hasAddon) { - throw new AddOnNotPresentError('User does not have add-on to remove', { - userId: user._id, - addOnCode, - }) - } - const addOns = currentAddons.filter(addOn => addOn.addOn.code !== addOnCode) - const subscriptionChangeOptions = { addOns, timeframe: 'term_end' } - await _updateAndSyncSubscription( - user, - subscription.uuid, - subscriptionChangeOptions - ) + const changeRequest = subscription.getRequestForAddOnRemoval(addOnCode) + await RecurlyClient.promises.applySubscriptionChangeRequest(changeRequest) + await syncSubscription({ uuid: subscription.id }, user._id) } module.exports = { diff --git a/services/web/test/unit/src/Subscription/RecurlyClientTests.js b/services/web/test/unit/src/Subscription/RecurlyClientTests.js index 8fbd426473..43c6bf9651 100644 --- a/services/web/test/unit/src/Subscription/RecurlyClientTests.js +++ b/services/web/test/unit/src/Subscription/RecurlyClientTests.js @@ -1,8 +1,13 @@ const sinon = require('sinon') const { expect } = require('chai') const recurly = require('recurly') -const modulePath = '../../../../app/src/Features/Subscription/RecurlyClient' const SandboxedModule = require('sandboxed-module') +const { + RecurlySubscriptionChangeRequest, + RecurlySubscriptionAddOnUpdate, +} = require('../../../../app/src/Features/Subscription/RecurlyEntities') + +const MODULE_PATH = '../../../../app/src/Features/Subscription/RecurlyClient' describe('RecurlyClient', function () { beforeEach(function () { @@ -13,20 +18,62 @@ describe('RecurlyClient', function () { privateKey: 'private_nonsense', }, }, + plans: [], + features: [], } this.user = { _id: '123456', email: 'joe@example.com', first_name: 'Joe' } - this.subscription = { - id: 'subscription-123', - uuid: 'subscription-uuid-123', - } this.subscriptionChange = { id: 'subscription-change-123' } - this.recurlyAccount = new recurly.Account() Object.assign(this.recurlyAccount, { code: this.user._id }) - this.recurlySubscription = new recurly.Subscription() - Object.assign(this.recurlySubscription, this.subscription) + this.subscriptionAddOn = { + code: 'addon-code', + name: 'My Add-On', + quantity: 1, + unitPrice: 2, + } + + this.subscription = { + id: 'subscription-id', + userId: 'user-id', + currency: 'EUR', + planCode: 'plan-code', + planName: 'plan-name', + planPrice: 13, + addOns: [this.subscriptionAddOn], + subtotal: 15, + taxRate: 0.1, + taxAmount: 1.5, + total: 16.5, + } + + this.recurlySubscription = { + uuid: this.subscription.id, + account: { + code: this.subscription.userId, + }, + plan: { + code: this.subscription.planCode, + name: this.subscription.planName, + }, + addOns: [ + { + addOn: { + code: this.subscriptionAddOn.code, + name: this.subscriptionAddOn.name, + }, + quantity: this.subscriptionAddOn.quantity, + unitAmount: this.subscriptionAddOn.unitPrice, + }, + ], + unitAmount: this.subscription.planPrice, + subtotal: this.subscription.subtotal, + taxInfo: { rate: this.subscription.taxRate }, + tax: this.subscription.taxAmount, + total: this.subscription.total, + currency: this.subscription.currency, + } this.recurlySubscriptionChange = new recurly.SubscriptionChange() Object.assign(this.recurlySubscriptionChange, this.subscriptionChange) @@ -52,7 +99,7 @@ describe('RecurlyClient', function () { }, } - return (this.RecurlyClient = SandboxedModule.require(modulePath, { + return (this.RecurlyClient = SandboxedModule.require(MODULE_PATH, { globals: { console, }, @@ -130,12 +177,12 @@ describe('RecurlyClient', function () { it('should return the subscription found by recurly', async function () { this.client.getSubscription = sinon .stub() + .withArgs('uuid-subscription-id') .resolves(this.recurlySubscription) - await expect( - this.RecurlyClient.promises.getSubscription(this.subscription.id) + const subscription = await this.RecurlyClient.promises.getSubscription( + this.subscription.id ) - .to.eventually.be.an.instanceOf(recurly.Subscription) - .that.has.property('id', this.subscription.id) + expect(subscription).to.deep.equal(this.subscription) }) it('should throw any API errors', async function () { @@ -146,69 +193,58 @@ describe('RecurlyClient', function () { }) }) - describe('changeSubscription', function () { + describe('applySubscriptionChangeRequest', function () { beforeEach(function () { this.client.createSubscriptionChange = sinon .stub() .resolves(this.recurlySubscriptionChange) }) - it('should attempt to create a subscription change', async function () { - this.RecurlyClient.promises.changeSubscription(this.subscription.id, {}) + it('handles plan changes', async function () { + await this.RecurlyClient.promises.applySubscriptionChangeRequest( + new RecurlySubscriptionChangeRequest({ + subscriptionId: this.subscription.id, + timeframe: 'now', + planCode: 'new-plan', + }) + ) expect(this.client.createSubscriptionChange).to.be.calledWith( - this.subscription.id + 'uuid-subscription-id', + { timeframe: 'now', planCode: 'new-plan' } ) }) - it('should return the subscription change event', async function () { - await expect( - this.RecurlyClient.promises.changeSubscription( - this.subscriptionChange.id, - {} - ) + it('handles add-on changes', async function () { + await this.RecurlyClient.promises.applySubscriptionChangeRequest( + new RecurlySubscriptionChangeRequest({ + subscriptionId: this.subscription.id, + timeframe: 'now', + addOnUpdates: [ + new RecurlySubscriptionAddOnUpdate({ + code: 'new-add-on', + quantity: 2, + unitPrice: 8.99, + }), + ], + }) + ) + expect(this.client.createSubscriptionChange).to.be.calledWith( + 'uuid-subscription-id', + { + timeframe: 'now', + addOns: [{ code: 'new-add-on', quantity: 2, unitAmount: 8.99 }], + } ) - .to.eventually.be.an.instanceOf(recurly.SubscriptionChange) - .that.has.property('id', this.subscriptionChange.id) }) it('should throw any API errors', async function () { this.client.createSubscriptionChange = sinon.stub().throws() await expect( - this.RecurlyClient.promises.changeSubscription(this.subscription.id, {}) - ).to.eventually.be.rejectedWith(Error) - }) - - describe('changeSubscriptionByUuid', function () { - it('should attempt to create a subscription change', async function () { - this.RecurlyClient.promises.changeSubscriptionByUuid( - this.subscription.uuid, + this.RecurlyClient.promises.applySubscriptionChangeRequest( + this.subscription.id, {} ) - expect(this.client.createSubscriptionChange).to.be.calledWith( - 'uuid-' + this.subscription.uuid - ) - }) - - it('should return the subscription change event', async function () { - await expect( - this.RecurlyClient.promises.changeSubscriptionByUuid( - this.subscriptionChange.id, - {} - ) - ) - .to.eventually.be.an.instanceOf(recurly.SubscriptionChange) - .that.has.property('id', this.subscriptionChange.id) - }) - - it('should throw any API errors', async function () { - this.client.createSubscriptionChange = sinon.stub().throws() - await expect( - this.RecurlyClient.promises.changeSubscriptionByUuid( - this.subscription.id, - {} - ) - ).to.eventually.be.rejectedWith(Error) - }) + ).to.eventually.be.rejectedWith(Error) }) }) @@ -265,11 +301,11 @@ describe('RecurlyClient', function () { this.client.reactivateSubscription = sinon .stub() .resolves(this.recurlySubscription) - await expect( - this.RecurlyClient.promises.reactivateSubscriptionByUuid( + const subscription = + await this.RecurlyClient.promises.reactivateSubscriptionByUuid( this.subscription.uuid ) - ).to.eventually.be.an.instanceOf(recurly.Subscription) + expect(subscription).to.deep.equal(this.recurlySubscription) expect(this.client.reactivateSubscription).to.be.calledWith( 'uuid-' + this.subscription.uuid ) @@ -281,11 +317,11 @@ describe('RecurlyClient', function () { this.client.cancelSubscription = sinon .stub() .resolves(this.recurlySubscription) - await expect( - this.RecurlyClient.promises.cancelSubscriptionByUuid( + const subscription = + await this.RecurlyClient.promises.cancelSubscriptionByUuid( this.subscription.uuid ) - ).to.eventually.be.an.instanceOf(recurly.Subscription) + expect(subscription).to.deep.equal(this.recurlySubscription) expect(this.client.cancelSubscription).to.be.calledWith( 'uuid-' + this.subscription.uuid ) diff --git a/services/web/test/unit/src/Subscription/RecurlyEntitiesTest.js b/services/web/test/unit/src/Subscription/RecurlyEntitiesTest.js new file mode 100644 index 0000000000..989b71c8c4 --- /dev/null +++ b/services/web/test/unit/src/Subscription/RecurlyEntitiesTest.js @@ -0,0 +1,206 @@ +// @ts-check + +const SandboxedModule = require('sandboxed-module') +const { expect } = require('chai') +const Errors = require('../../../../app/src/Features/Subscription/Errors') +const { + RecurlySubscriptionChangeRequest, +} = require('../../../../app/src/Features/Subscription/RecurlyEntities') + +const MODULE_PATH = '../../../../app/src/Features/Subscription/RecurlyEntities' + +describe('RecurlyEntities', function () { + describe('RecurlySubscription', function () { + beforeEach(function () { + this.Settings = { + plans: [ + { planCode: 'cheap-plan', price_in_cents: 500 }, + { planCode: 'regular-plan', price_in_cents: 1000 }, + { planCode: 'premium-plan', price_in_cents: 2000 }, + ], + features: [], + } + + this.RecurlyEntities = SandboxedModule.require(MODULE_PATH, { + requires: { + '@overleaf/settings': this.Settings, + './Errors': Errors, + }, + }) + }) + + describe('with add-ons', function () { + beforeEach(function () { + const { RecurlySubscription, RecurlySubscriptionAddOn } = + this.RecurlyEntities + this.addOn = new RecurlySubscriptionAddOn({ + code: 'add-on-code', + name: 'My Add-On', + quantity: 1, + unitPrice: 2, + }) + this.subscription = new RecurlySubscription({ + id: 'subscription-id', + userId: 'user-id', + planCode: 'regular-plan', + planName: 'My Plan', + planPrice: 10, + addOns: [this.addOn], + subtotal: 10.99, + taxRate: 0.2, + taxAmount: 2.4, + total: 14.4, + currency: 'USD', + }) + }) + + describe('hasAddOn()', function () { + it('returns true if the subscription has the given add-on', function () { + expect(this.subscription.hasAddOn(this.addOn.code)).to.be.true + }) + + it("returns false if the subscription doesn't have the given add-on", function () { + expect(this.subscription.hasAddOn('another-add-on')).to.be.false + }) + }) + + describe('getRequestForPlanChange()', function () { + it('returns a change request for upgrades', function () { + const { RecurlySubscriptionChangeRequest } = this.RecurlyEntities + const changeRequest = + this.subscription.getRequestForPlanChange('premium-plan') + expect(changeRequest).to.deep.equal( + new RecurlySubscriptionChangeRequest({ + subscriptionId: this.subscription.id, + timeframe: 'now', + planCode: 'premium-plan', + }) + ) + }) + + it('returns a change request for downgrades', function () { + const { RecurlySubscriptionChangeRequest } = this.RecurlyEntities + const changeRequest = + this.subscription.getRequestForPlanChange('cheap-plan') + expect(changeRequest).to.deep.equal( + new RecurlySubscriptionChangeRequest({ + subscriptionId: this.subscription.id, + timeframe: 'term_end', + planCode: 'cheap-plan', + }) + ) + }) + }) + + describe('getRequestForAddOnPurchase()', function () { + it('returns a change request', function () { + const { + RecurlySubscriptionChangeRequest, + RecurlySubscriptionAddOnUpdate, + } = this.RecurlyEntities + const changeRequest = + this.subscription.getRequestForAddOnPurchase('another-add-on') + expect(changeRequest).to.deep.equal( + new RecurlySubscriptionChangeRequest({ + subscriptionId: this.subscription.id, + timeframe: 'now', + addOnUpdates: [ + new RecurlySubscriptionAddOnUpdate({ + code: this.addOn.code, + quantity: this.addOn.quantity, + unitPrice: this.addOn.unitPrice, + }), + new RecurlySubscriptionAddOnUpdate({ + code: 'another-add-on', + quantity: 1, + }), + ], + }) + ) + }) + + it('throws a DuplicateAddOnError if the subscription already has the add-on', function () { + expect(() => + this.subscription.getRequestForAddOnPurchase(this.addOn.code) + ).to.throw(Errors.DuplicateAddOnError) + }) + }) + + describe('getRequestForAddOnRemoval()', function () { + it('returns a change request', function () { + const changeRequest = this.subscription.getRequestForAddOnRemoval( + this.addOn.code + ) + expect(changeRequest).to.deep.equal( + new RecurlySubscriptionChangeRequest({ + subscriptionId: this.subscription.id, + timeframe: 'term_end', + addOnUpdates: [], + }) + ) + }) + + it("throws an AddOnNotPresentError if the subscription doesn't have the add-on", function () { + expect(() => + this.subscription.getRequestForAddOnRemoval('another-add-on') + ).to.throw(Errors.AddOnNotPresentError) + }) + }) + + describe('without add-ons', function () { + beforeEach(function () { + const { RecurlySubscription } = this.RecurlyEntities + this.subscription = new RecurlySubscription({ + id: 'subscription-id', + userId: 'user-id', + planCode: 'regular-plan', + planName: 'My Plan', + planPrice: 10, + subtotal: 10.99, + taxRate: 0.2, + taxAmount: 2.4, + total: 14.4, + currency: 'USD', + }) + }) + + describe('hasAddOn()', function () { + it('returns false for any add-on', function () { + expect(this.subscription.hasAddOn('some-add-on')).to.be.false + }) + }) + + describe('getRequestForAddOnPurchase()', function () { + it('returns a change request', function () { + const { + RecurlySubscriptionChangeRequest, + RecurlySubscriptionAddOnUpdate, + } = this.RecurlyEntities + const changeRequest = + this.subscription.getRequestForAddOnPurchase('some-add-on') + expect(changeRequest).to.deep.equal( + new RecurlySubscriptionChangeRequest({ + subscriptionId: this.subscription.id, + timeframe: 'now', + addOnUpdates: [ + new RecurlySubscriptionAddOnUpdate({ + code: 'some-add-on', + quantity: 1, + }), + ], + }) + ) + }) + }) + + describe('getRequestForAddOnRemoval()', function () { + it('throws an AddOnNotPresentError', function () { + expect(() => + this.subscription.getRequestForAddOnRemoval('some-add-on') + ).to.throw(Errors.AddOnNotPresentError) + }) + }) + }) + }) + }) +}) diff --git a/services/web/test/unit/src/Subscription/SubscriptionHandlerTests.js b/services/web/test/unit/src/Subscription/SubscriptionHandlerTests.js index 32876e5455..699d121c35 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionHandlerTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionHandlerTests.js @@ -2,6 +2,10 @@ const SandboxedModule = require('sandboxed-module') const sinon = require('sinon') const chai = require('chai') const { expect } = chai +const { + RecurlySubscription, + RecurlySubscriptionChangeRequest, +} = require('../../../../app/src/Features/Subscription/RecurlyEntities') const MODULE_PATH = '../../../../app/src/Features/Subscription/SubscriptionHandler' @@ -23,20 +27,16 @@ const mockRecurlySubscriptions = { } const mockRecurlyClientSubscriptions = { - 'subscription-123-active': { - id: 'subscription-123-recurly-id', - uuid: 'subscription-123-active', - plan: { - name: 'Gold', - code: 'gold', - }, - currentPeriodEndsAt: new Date(), - state: 'active', - unitAmount: 10, - account: { - code: 'user-123', - }, - }, + 'subscription-123-active': new RecurlySubscription({ + id: 'subscription-123-active', + userId: 'user-id', + planCode: 'collaborator', + planName: 'Collaborator', + planPrice: 10, + subtotal: 10, + currency: 'USD', + total: 10, + }), } const mockSubscriptionChanges = { @@ -53,11 +53,16 @@ describe('SubscriptionHandler', function () { { planCode: 'collaborator', name: 'Collaborator', + price_in_cents: 1000, features: { collaborators: -1, versioning: true, }, }, + { + planCode: 'professional', + price_in_cents: 1500, + }, ], defaultPlanCode: { collaborators: 0, @@ -94,7 +99,7 @@ describe('SubscriptionHandler', function () { .stub() .resolves(this.activeRecurlyClientSubscription), cancelSubscriptionByUuid: sinon.stub().resolves(), - changeSubscriptionByUuid: sinon + applySubscriptionChangeRequest: sinon .stub() .resolves(this.activeRecurlySubscriptionChange), getSubscription: sinon @@ -122,14 +127,6 @@ describe('SubscriptionHandler', function () { sendDeferredEmail: sinon.stub(), } - this.PlansLocator = { - findLocalPlanInSettings: sinon.stub().returns({ planCode: 'plan' }), - } - - this.SubscriptionHelper = { - shouldPlanChangeAtTermEnd: sinon.stub(), - } - this.UserUpdater = { promises: { updateUser: sinon.stub().resolves(), @@ -148,8 +145,6 @@ describe('SubscriptionHandler', function () { './LimitationsManager': this.LimitationsManager, '../Email/EmailHandler': this.EmailHandler, '../Analytics/AnalyticsManager': this.AnalyticsManager, - './PlansLocator': this.PlansLocator, - './SubscriptionHelper': this.SubscriptionHelper, '../User/UserUpdater': this.UserUpdater, }, }) @@ -245,105 +240,80 @@ describe('SubscriptionHandler', function () { }) }) - function shouldUpdateSubscription() { - it('should update the subscription', function () { - expect( - this.RecurlyClient.promises.changeSubscriptionByUuid - ).to.have.been.calledWith(this.subscription.recurlySubscription_id) - const updateOptions = - this.RecurlyClient.promises.changeSubscriptionByUuid.args[0][1] - updateOptions.planCode.should.equal(this.plan_code) - }) - } - - function shouldSyncSubscription() { - it('should sync the new subscription to the user', function () { - expect(this.SubscriptionUpdater.promises.syncSubscription).to.have.been - .called - - this.SubscriptionUpdater.promises.syncSubscription.args[0][0].should.deep.equal( - this.activeRecurlySubscription - ) - this.SubscriptionUpdater.promises.syncSubscription.args[0][1].should.deep.equal( - this.user._id - ) - }) - } - - function testUserWithASubscription(shouldPlanChangeAtTermEnd, timeframe) { - describe( - 'when change should happen with timeframe ' + timeframe, - function () { - beforeEach(async function () { - this.user.id = this.activeRecurlySubscription.account.account_code - this.User.findById = (userId, projection) => ({ - exec: () => { - userId.should.equal(this.user.id) - return Promise.resolve(this.user) - }, - }) - this.plan_code = 'collaborator' - this.SubscriptionHelper.shouldPlanChangeAtTermEnd.returns( - shouldPlanChangeAtTermEnd - ) - this.LimitationsManager.promises.userHasV2Subscription.resolves({ - hasSubscription: true, - subscription: this.subscription, - }) - await this.SubscriptionHandler.promises.updateSubscription( - this.user, - this.plan_code, - null - ) - }) - - shouldUpdateSubscription() - shouldSyncSubscription() - - it('should update with timeframe ' + timeframe, function () { - const updateOptions = - this.RecurlyClient.promises.changeSubscriptionByUuid.args[0][1] - updateOptions.timeframe.should.equal(timeframe) - }) - } - ) - } - describe('updateSubscription', function () { describe('with a user with a subscription', function () { - testUserWithASubscription(false, 'now') - testUserWithASubscription(true, 'term_end') + beforeEach(async function () { + this.user.id = this.activeRecurlySubscription.account.account_code + this.User.findById = (userId, projection) => ({ + exec: () => { + userId.should.equal(this.user.id) + return Promise.resolve(this.user) + }, + }) + this.plan_code = 'professional' + this.LimitationsManager.promises.userHasV2Subscription.resolves({ + hasSubscription: true, + subscription: this.subscription, + }) + await this.SubscriptionHandler.promises.updateSubscription( + this.user, + this.plan_code, + null + ) + }) - describe('when plan(s) could not be located in settings', function () { - beforeEach(async function () { - this.user.id = this.activeRecurlySubscription.account.account_code - this.User.findById = (userId, projection) => ({ - exec: () => { - userId.should.equal(this.user.id) - return Promise.resolve(this.user) - }, + it('should update the subscription', function () { + expect( + this.RecurlyClient.promises.applySubscriptionChangeRequest + ).to.have.been.calledWith( + new RecurlySubscriptionChangeRequest({ + subscriptionId: this.subscription.recurlySubscription_id, + timeframe: 'now', + planCode: this.plan_code, }) + ) + }) - this.plan_code = 'collaborator' - this.PlansLocator.findLocalPlanInSettings.returns(null) - this.LimitationsManager.promises.userHasV2Subscription.resolves({ - hasSubscription: true, - subscription: this.subscription, - }) + it('should sync the new subscription to the user', function () { + expect(this.SubscriptionUpdater.promises.syncSubscription).to.have.been + .called + + this.SubscriptionUpdater.promises.syncSubscription.args[0][0].should.deep.equal( + this.activeRecurlySubscription + ) + this.SubscriptionUpdater.promises.syncSubscription.args[0][1].should.deep.equal( + this.user._id + ) + }) + }) + + describe('when plan(s) could not be located in settings', function () { + beforeEach(async function () { + this.user.id = this.activeRecurlySubscription.account.account_code + this.User.findById = (userId, projection) => ({ + exec: () => { + userId.should.equal(this.user.id) + return Promise.resolve(this.user) + }, }) - it('should be rejected and should not update the subscription', function () { - expect( - this.SubscriptionHandler.promises.updateSubscription( - this.user, - this.plan_code, - null - ) - ).to.be.rejected - this.RecurlyClient.promises.changeSubscriptionByUuid.called.should.equal( - false + this.LimitationsManager.promises.userHasV2Subscription.resolves({ + hasSubscription: true, + subscription: this.subscription, + }) + }) + + it('should be rejected and should not update the subscription', function () { + expect( + this.SubscriptionHandler.promises.updateSubscription( + this.user, + 'unknown-plan', + null ) - }) + ).to.be.rejected + this.RecurlyClient.promises.applySubscriptionChangeRequest.called.should.equal( + false + ) }) }) @@ -358,7 +328,7 @@ describe('SubscriptionHandler', function () { }) it('should redirect to the subscription dashboard', function () { - this.RecurlyClient.promises.changeSubscriptionByUuid.called.should.equal( + this.RecurlyClient.promises.applySubscriptionChangeRequest.called.should.equal( false ) this.SubscriptionUpdater.promises.syncSubscription.called.should.equal( @@ -407,11 +377,14 @@ describe('SubscriptionHandler', function () { it('should update the subscription', function () { expect( - this.RecurlyClient.promises.changeSubscriptionByUuid - ).to.be.calledWith(this.subscription.recurlySubscription_id) - const updateOptions = - this.RecurlyClient.promises.changeSubscriptionByUuid.args[0][1] - updateOptions.planCode.should.equal(this.plan_code) + this.RecurlyClient.promises.applySubscriptionChangeRequest + ).to.be.calledWith( + new RecurlySubscriptionChangeRequest({ + subscriptionId: this.subscription.recurlySubscription_id, + timeframe: 'now', + planCode: this.plan_code, + }) + ) }) }) })