diff --git a/services/web/app/src/Features/Subscription/Errors.js b/services/web/app/src/Features/Subscription/Errors.js index 0e1c82498d..53ecf7ba12 100644 --- a/services/web/app/src/Features/Subscription/Errors.js +++ b/services/web/app/src/Features/Subscription/Errors.js @@ -22,6 +22,8 @@ class PendingChangeError extends OError {} class InactiveError extends OError {} +class SubtotalLimitExceededError extends OError {} + module.exports = { RecurlyTransactionError, DuplicateAddOnError, @@ -30,4 +32,5 @@ module.exports = { ManuallyCollectedError, PendingChangeError, InactiveError, + SubtotalLimitExceededError, } diff --git a/services/web/app/src/Features/Subscription/RecurlyClient.js b/services/web/app/src/Features/Subscription/RecurlyClient.js index 37234f1f70..7bd7d1f3b2 100644 --- a/services/web/app/src/Features/Subscription/RecurlyClient.js +++ b/services/web/app/src/Features/Subscription/RecurlyClient.js @@ -16,7 +16,10 @@ const { RecurlyPlan, RecurlyImmediateCharge, } = require('./RecurlyEntities') -const { MissingBillingInfoError } = require('./Errors') +const { + MissingBillingInfoError, + SubtotalLimitExceededError, +} = require('./Errors') /** * @import { RecurlySubscriptionChangeRequest } from './RecurlyEntities' @@ -116,14 +119,37 @@ async function getSubscriptionForUser(userId) { */ async function applySubscriptionChangeRequest(changeRequest) { const body = subscriptionChangeRequestToApi(changeRequest) - const change = await client.createSubscriptionChange( - `uuid-${changeRequest.subscription.id}`, - body - ) - logger.debug( - { subscriptionId: changeRequest.subscription.id, changeId: change.id }, - 'created subscription change' - ) + + try { + const change = await client.createSubscriptionChange( + `uuid-${changeRequest.subscription.id}`, + body + ) + logger.debug( + { subscriptionId: changeRequest.subscription.id, changeId: change.id }, + 'created subscription change' + ) + } catch (err) { + if (err instanceof recurly.errors.ValidationError) { + /** + * @type {{params?: { param?: string }[] | null}} + */ + const validationError = err + if ( + validationError.params?.some( + p => p.param === 'subtotal_amount_in_cents' + ) + ) { + throw new SubtotalLimitExceededError( + 'Subtotal amount in cents exceeded error', + { + subscriptionId: changeRequest.subscription.id, + } + ) + } + } + throw err + } } /** @@ -134,14 +160,38 @@ async function applySubscriptionChangeRequest(changeRequest) { */ async function previewSubscriptionChange(changeRequest) { const body = subscriptionChangeRequestToApi(changeRequest) - const subscriptionChange = await client.previewSubscriptionChange( - `uuid-${changeRequest.subscription.id}`, - body - ) - return subscriptionChangeFromApi( - changeRequest.subscription, - subscriptionChange - ) + + try { + const subscriptionChange = await client.previewSubscriptionChange( + `uuid-${changeRequest.subscription.id}`, + body + ) + + return subscriptionChangeFromApi( + changeRequest.subscription, + subscriptionChange + ) + } catch (err) { + if (err instanceof recurly.errors.ValidationError) { + /** + * @type {{params?: { param?: string }[] | null}} + */ + const validationError = err + if ( + validationError.params?.some( + p => p.param === 'subtotal_amount_in_cents' + ) + ) { + throw new SubtotalLimitExceededError( + 'Subtotal amount in cents exceeded error', + { + subscriptionId: changeRequest.subscription.id, + } + ) + } + } + throw err + } } async function removeSubscriptionChange(subscriptionId) { diff --git a/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs b/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs index 637802a665..c9837fb7b2 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs +++ b/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs @@ -18,6 +18,7 @@ import { ManuallyCollectedError, PendingChangeError, InactiveError, + SubtotalLimitExceededError, } from './Errors.js' import RecurlyClient from './RecurlyClient.js' @@ -205,6 +206,13 @@ async function previewAddSeatsSubscriptionChange(req, res) { return res.status(422).end() } + if (error instanceof SubtotalLimitExceededError) { + return res.status(422).json({ + code: 'subtotal_limit_exceeded', + adding: req.body.adding, + }) + } + logger.err( { error }, 'error trying to preview "add seats" subscription change' @@ -239,6 +247,13 @@ async function createAddSeatsSubscriptionChange(req, res) { return res.status(422).end() } + if (error instanceof SubtotalLimitExceededError) { + return res.status(422).json({ + code: 'subtotal_limit_exceeded', + adding: req.body.adding, + }) + } + logger.err( { error }, 'error trying to create "add seats" subscription change' @@ -313,6 +328,10 @@ async function subscriptionUpgradePage(req, res) { ) } + if (error instanceof SubtotalLimitExceededError) { + return res.redirect('/user/subscription/group/subtotal-limit-exceeded') + } + if (error instanceof PendingChangeError || error instanceof InactiveError) { return res.redirect('/user/subscription') } @@ -370,6 +389,21 @@ async function manuallyCollectedSubscription(req, res) { } } +async function subtotalLimitExceeded(req, res) { + try { + const userId = SessionManager.getLoggedInUserId(req.session) + const subscription = + await SubscriptionLocator.promises.getUsersSubscription(userId) + + res.render('subscriptions/subtotal-limit-exceeded', { + groupName: subscription.teamName, + }) + } catch (error) { + logger.err({ error }, 'error trying to render subtotal limit exceeded page') + return res.render('/user/subscription') + } +} + export default { removeUserFromGroup: expressify(removeUserFromGroup), removeSelfFromGroup: expressify(removeSelfFromGroup), @@ -386,4 +420,5 @@ export default { upgradeSubscription: expressify(upgradeSubscription), missingBillingInformation: expressify(missingBillingInformation), manuallyCollectedSubscription: expressify(manuallyCollectedSubscription), + subtotalLimitExceeded: expressify(subtotalLimitExceeded), } diff --git a/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs b/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs index 27032df669..04b44aca1c 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs +++ b/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs @@ -135,6 +135,14 @@ export default { SubscriptionGroupController.manuallyCollectedSubscription ) + webRouter.get( + '/user/subscription/group/subtotal-limit-exceeded', + AuthenticationController.requireLogin(), + RateLimiterMiddleware.rateLimit(subscriptionRateLimiter), + SubscriptionGroupController.flexibleLicensingSplitTest, + SubscriptionGroupController.subtotalLimitExceeded + ) + // Team invites webRouter.get( '/subscription/invites/:token/', diff --git a/services/web/app/views/subscriptions/subtotal-limit-exceeded.pug b/services/web/app/views/subscriptions/subtotal-limit-exceeded.pug new file mode 100644 index 0000000000..bdbad595ee --- /dev/null +++ b/services/web/app/views/subscriptions/subtotal-limit-exceeded.pug @@ -0,0 +1,13 @@ +extends ../layout-marketing + +block vars + - bootstrap5PageStatus = 'enabled' // Enforce BS5 version + +block entrypointVar + - entrypoint = 'pages/user/subscription/group-management/subtotal-limit-exceeded' + +block append meta + meta(name="ol-groupName", data-type="string", content=groupName) + +block content + main.content.content-alt#subtotal-limit-exceeded-root diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 9938bf4b8b..fd64863454 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -1539,6 +1539,8 @@ "somthing_went_wrong_compiling": "", "sorry_it_looks_like_that_didnt_work_this_time": "", "sorry_there_are_no_experiments": "", + "sorry_there_was_an_issue_adding_x_users_to_your_subscription": "", + "sorry_there_was_an_issue_upgrading_your_subscription": "", "sorry_your_table_cant_be_displayed_at_the_moment": "", "sort_by": "", "sort_by_x": "", diff --git a/services/web/frontend/js/features/group-management/components/add-seats/add-seats.tsx b/services/web/frontend/js/features/group-management/components/add-seats/add-seats.tsx index 93ff091427..54a374c506 100644 --- a/services/web/frontend/js/features/group-management/components/add-seats/add-seats.tsx +++ b/services/web/frontend/js/features/group-management/components/add-seats/add-seats.tsx @@ -20,7 +20,7 @@ import CostSummary from '@/features/group-management/components/add-seats/cost-s import RequestStatus from '@/features/group-management/components/request-status' import useAsync from '@/shared/hooks/use-async' import getMeta from '@/utils/meta' -import { postJSON } from '@/infrastructure/fetch-json' +import { FetchError, postJSON } from '@/infrastructure/fetch-json' import { debugConsole } from '@/utils/debugging' import * as yup from 'yup' import { @@ -54,7 +54,8 @@ function AddSeats() { runAsync: runAsyncCostSummary, data: costSummaryData, reset: resetCostSummaryData, - } = useAsync() + error: errorCostSummary, + } = useAsync() const { isLoading: isAddingSeats, isError: isErrorAddingSeats, @@ -326,6 +327,7 @@ function AddSeats() { shouldContactSales: boolean costSummaryData: Nullable totalLicenses: number @@ -387,6 +390,7 @@ type CostSummarySectionProps = { function CostSummarySection({ isLoadingCostSummary, isErrorCostSummary, + errorCostSummary, shouldContactSales, costSummaryData, totalLicenses, @@ -416,6 +420,24 @@ function CostSummarySection({ } if (isErrorCostSummary) { + if (errorCostSummary?.data?.code === 'subtotal_limit_exceeded') { + return ( + ]} + values={{ count: errorCostSummary?.data?.adding }} + shouldUnescape + tOptions={{ interpolation: { escapeValue: true } }} + /> + } + /> + ) + } + return ( ) diff --git a/services/web/frontend/js/features/group-management/components/subtotal-limit-exceeded.tsx b/services/web/frontend/js/features/group-management/components/subtotal-limit-exceeded.tsx new file mode 100644 index 0000000000..b194d7528f --- /dev/null +++ b/services/web/frontend/js/features/group-management/components/subtotal-limit-exceeded.tsx @@ -0,0 +1,25 @@ +import { Trans } from 'react-i18next' +import OLNotification from '@/features/ui/components/ol/ol-notification' +import Card from '@/features/group-management/components/card' + +function SubtotalLimitExceeded() { + return ( + + , + ]} + /> + } + className="m-0" + /> + + ) +} + +export default SubtotalLimitExceeded diff --git a/services/web/frontend/js/pages/user/subscription/group-management/subtotal-limit-exceeded.tsx b/services/web/frontend/js/pages/user/subscription/group-management/subtotal-limit-exceeded.tsx new file mode 100644 index 0000000000..242c04f5cf --- /dev/null +++ b/services/web/frontend/js/pages/user/subscription/group-management/subtotal-limit-exceeded.tsx @@ -0,0 +1,8 @@ +import '../base' +import ReactDOM from 'react-dom' +import SubtotalLimitExceeded from '@/features/group-management/components/subtotal-limit-exceeded' + +const element = document.getElementById('subtotal-limit-exceeded-root') +if (element) { + ReactDOM.render(, element) +} diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 4bfe569c9c..555aae1f86 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -2016,6 +2016,8 @@ "sorry_it_looks_like_that_didnt_work_this_time": "Sorry! It looks like that didn’t work this time. Please try again.", "sorry_something_went_wrong_opening_the_document_please_try_again": "Sorry, an unexpected error occurred when trying to open this content on Overleaf. Please try again.", "sorry_there_are_no_experiments": "Sorry, there are no experiments currently running in Overleaf Labs.", + "sorry_there_was_an_issue_adding_x_users_to_your_subscription": "Sorry, there was an issue adding __count__ users to your subscription. Please <0>contact our Support team for help.", + "sorry_there_was_an_issue_upgrading_your_subscription": "Sorry, there was an issue upgrading your subscription. Please <0>contact our Support team for help.", "sorry_this_account_has_been_suspended": "Sorry, this account has been suspended.", "sorry_your_table_cant_be_displayed_at_the_moment": "Sorry, your table can’t be displayed at the moment.", "sorry_your_token_expired": "Sorry, your token expired", diff --git a/services/web/test/frontend/features/group-management/components/subtotal-limit-exceeded.spec.tsx b/services/web/test/frontend/features/group-management/components/subtotal-limit-exceeded.spec.tsx new file mode 100644 index 0000000000..917282fc92 --- /dev/null +++ b/services/web/test/frontend/features/group-management/components/subtotal-limit-exceeded.spec.tsx @@ -0,0 +1,24 @@ +import '../../../helpers/bootstrap-5' +import SubtotalLimitExceeded from '@/features/group-management/components/subtotal-limit-exceeded' + +describe('', function () { + beforeEach(function () { + cy.window().then(win => { + win.metaAttributesCache.set('ol-groupName', 'My Awesome Team') + }) + + cy.mount() + }) + + it('shows subtotal limit exceeded notification', function () { + cy.findByRole('alert').within(() => { + cy.findByText( + /sorry, there was an issue upgrading your subscription\. Please.*for help/i + ).within(() => { + cy.findByRole('link', { + name: /contact our support team/i, + }).should('have.attr', 'href', '/contact') + }) + }) + }) +}) diff --git a/services/web/test/unit/src/Subscription/RecurlyClientTests.js b/services/web/test/unit/src/Subscription/RecurlyClientTests.js index 8640d8defd..91c16b1ac1 100644 --- a/services/web/test/unit/src/Subscription/RecurlyClientTests.js +++ b/services/web/test/unit/src/Subscription/RecurlyClientTests.js @@ -111,6 +111,7 @@ describe('RecurlyClient', function () { } this.Errors = { MissingBillingInfoError: class extends Error {}, + SubtotalLimitExceededError: class extends Error {}, } return (this.RecurlyClient = SandboxedModule.require(MODULE_PATH, { @@ -308,6 +309,32 @@ describe('RecurlyClient', function () { }) ).to.eventually.be.rejectedWith(Error) }) + + it('should throw SubtotalLimitExceededError', async function () { + class ValidationError extends recurly.errors.ValidationError { + constructor() { + super() + this.params = [{ param: 'subtotal_amount_in_cents' }] + } + } + this.client.createSubscriptionChange = sinon + .stub() + .throws(new ValidationError()) + await expect( + this.RecurlyClient.promises.applySubscriptionChangeRequest({ + subscription: this.subscription, + }) + ).to.be.rejectedWith(this.Errors.SubtotalLimitExceededError) + }) + + it('should rethrow errors different than SubtotalLimitExceededError', async function () { + this.client.createSubscriptionChange = sinon.stub().throws(new Error()) + await expect( + this.RecurlyClient.promises.applySubscriptionChangeRequest({ + subscription: this.subscription, + }) + ).to.be.rejectedWith(Error) + }) }) describe('removeSubscriptionChange', function () { @@ -467,6 +494,40 @@ describe('RecurlyClient', function () { expect(immediateCharge.total).to.be.equal(96.2) }) }) + + it('should throw SubtotalLimitExceededError', async function () { + class ValidationError extends recurly.errors.ValidationError { + constructor() { + super() + this.params = [{ param: 'subtotal_amount_in_cents' }] + } + } + this.client.previewSubscriptionChange = sinon + .stub() + .throws(new ValidationError()) + await expect( + this.RecurlyClient.promises.previewSubscriptionChange( + new RecurlySubscriptionChangeRequest({ + subscription: this.subscription, + timeframe: 'now', + planCode: 'new-plan', + }) + ) + ).to.be.rejectedWith(this.Errors.SubtotalLimitExceededError) + }) + + it('should rethrow errors different than SubtotalLimitExceededError', async function () { + this.client.previewSubscriptionChange = sinon.stub().throws(new Error()) + await expect( + this.RecurlyClient.promises.previewSubscriptionChange( + new RecurlySubscriptionChangeRequest({ + subscription: this.subscription, + timeframe: 'now', + planCode: 'new-plan', + }) + ) + ).to.be.rejectedWith(Error) + }) }) describe('getPaymentMethod', function () { diff --git a/services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs b/services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs index ac6e0b2f72..e7ea218cc1 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs +++ b/services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs @@ -128,6 +128,7 @@ describe('SubscriptionGroupController', function () { ManuallyCollectedError: class extends Error {}, PendingChangeError: class extends Error {}, InactiveError: class extends Error {}, + SubtotalLimitExceededError: class extends Error {}, } this.Controller = await esmock.strict(modulePath, { @@ -495,6 +496,30 @@ describe('SubscriptionGroupController', function () { this.Controller.previewAddSeatsSubscriptionChange(this.req, res) }) + + it('should fail previewing "add seats" change with SubtotalLimitExceededError', function (done) { + this.req.body = { adding: 2 } + this.SubscriptionGroupHandler.promises.previewAddSeatsSubscriptionChange = + sinon.stub().throws(new this.Errors.SubtotalLimitExceededError()) + + const res = { + status: statusCode => { + statusCode.should.equal(422) + + return { + json: data => { + data.should.deep.equal({ + code: 'subtotal_limit_exceeded', + adding: this.req.body.adding, + }) + done() + }, + } + }, + } + + this.Controller.previewAddSeatsSubscriptionChange(this.req, res) + }) }) describe('createAddSeatsSubscriptionChange', function () { @@ -532,6 +557,30 @@ describe('SubscriptionGroupController', function () { this.Controller.createAddSeatsSubscriptionChange(this.req, res) }) + + it('should fail applying "add seats" change with SubtotalLimitExceededError', function (done) { + this.req.body = { adding: 2 } + this.SubscriptionGroupHandler.promises.createAddSeatsSubscriptionChange = + sinon.stub().throws(new this.Errors.SubtotalLimitExceededError()) + + const res = { + status: statusCode => { + statusCode.should.equal(422) + + return { + json: data => { + data.should.deep.equal({ + code: 'subtotal_limit_exceeded', + adding: this.req.body.adding, + }) + done() + }, + } + }, + } + + this.Controller.createAddSeatsSubscriptionChange(this.req, res) + }) }) describe('submitForm', function () { @@ -666,6 +715,21 @@ describe('SubscriptionGroupController', function () { this.Controller.subscriptionUpgradePage(this.req, res) }) + + it('should redirect to subtotal limit exceeded page', function (done) { + this.SubscriptionGroupHandler.promises.getGroupPlanUpgradePreview = sinon + .stub() + .throws(new this.Errors.SubtotalLimitExceededError()) + + const res = { + redirect: url => { + url.should.equal('/user/subscription/group/subtotal-limit-exceeded') + done() + }, + } + + this.Controller.subscriptionUpgradePage(this.req, res) + }) }) describe('upgradeSubscription', function () {