From d898582b2ff2d6a56d258cf9aa9ccfeb47df5615 Mon Sep 17 00:00:00 2001 From: ilkin-overleaf <100852799+ilkin-overleaf@users.noreply.github.com> Date: Tue, 8 Jul 2025 15:22:57 +0300 Subject: [PATCH] Merge pull request #26829 from overleaf/ii-flexible-licensing-manually-billed-users-add-seats [web] FL manually billed subscriptions with no upsell GitOrigin-RevId: b5f2083c7eabd0a1a5d024d5699d2c5e5556671a --- .../app/src/Features/Subscription/Errors.js | 3 ++ .../SubscriptionGroupController.mjs | 15 +++++-- .../Subscription/SubscriptionGroupHandler.js | 22 ++++++++++ .../web/frontend/extracted-translations.json | 2 +- .../manually-collected-subscription.tsx | 2 +- services/web/locales/en.json | 2 +- .../SubscriptionGroupController.test.mjs | 25 +++++++++++ .../SubscriptionGroupHandlerTests.js | 43 +++++++++++++++++++ 8 files changed, 108 insertions(+), 6 deletions(-) diff --git a/services/web/app/src/Features/Subscription/Errors.js b/services/web/app/src/Features/Subscription/Errors.js index 9ebb08c6db..609ec15a73 100644 --- a/services/web/app/src/Features/Subscription/Errors.js +++ b/services/web/app/src/Features/Subscription/Errors.js @@ -26,6 +26,8 @@ class SubtotalLimitExceededError extends OError {} class HasPastDueInvoiceError extends OError {} +class HasNoAdditionalLicenseWhenManuallyCollectedError extends OError {} + class PaymentActionRequiredError extends OError { constructor(info) { super('Payment action required', info) @@ -43,4 +45,5 @@ module.exports = { InactiveError, SubtotalLimitExceededError, HasPastDueInvoiceError, + HasNoAdditionalLicenseWhenManuallyCollectedError, } diff --git a/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs b/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs index 792850a1c1..82064c173a 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs +++ b/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs @@ -19,6 +19,7 @@ import { InactiveError, SubtotalLimitExceededError, HasPastDueInvoiceError, + HasNoAdditionalLicenseWhenManuallyCollectedError, } from './Errors.js' /** @@ -164,6 +165,9 @@ async function addSeatsToGroupSubscription(req, res) { paymentProviderSubscription, userId ) + await SubscriptionGroupHandler.promises.ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual( + paymentProviderSubscription + ) } else { await SubscriptionGroupHandler.promises.ensureSubscriptionCollectionMethodIsNotManual( paymentProviderSubscription @@ -187,7 +191,10 @@ async function addSeatsToGroupSubscription(req, res) { ) } - if (error instanceof ManuallyCollectedError) { + if ( + error instanceof ManuallyCollectedError || + error instanceof HasNoAdditionalLicenseWhenManuallyCollectedError + ) { return res.redirect( '/user/subscription/group/manually-collected-subscription' ) @@ -231,7 +238,8 @@ async function previewAddSeatsSubscriptionChange(req, res) { error instanceof ManuallyCollectedError || error instanceof PendingChangeError || error instanceof InactiveError || - error instanceof HasPastDueInvoiceError + error instanceof HasPastDueInvoiceError || + error instanceof HasNoAdditionalLicenseWhenManuallyCollectedError ) { return res.status(422).end() } @@ -274,7 +282,8 @@ async function createAddSeatsSubscriptionChange(req, res) { error instanceof ManuallyCollectedError || error instanceof PendingChangeError || error instanceof InactiveError || - error instanceof HasPastDueInvoiceError + error instanceof HasPastDueInvoiceError || + error instanceof HasNoAdditionalLicenseWhenManuallyCollectedError ) { return res.status(422).end() } diff --git a/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js b/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js index bc179f8d23..977218fdd2 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js +++ b/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js @@ -18,6 +18,7 @@ const { PendingChangeError, InactiveError, HasPastDueInvoiceError, + HasNoAdditionalLicenseWhenManuallyCollectedError, } = require('./Errors') const EmailHelper = require('../Helpers/EmailHelper') const { InvalidEmailError } = require('../Errors/Errors') @@ -123,6 +124,22 @@ async function ensureSubscriptionHasNoPastDueInvoice(subscription) { } } +async function ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual( + paymentProviderSubscription +) { + if ( + paymentProviderSubscription.isCollectionMethodManual && + !paymentProviderSubscription.hasAddOn(MEMBERS_LIMIT_ADD_ON_CODE) + ) { + throw new HasNoAdditionalLicenseWhenManuallyCollectedError( + 'This subscription is being collected manually has no "additional-license" add-on', + { + subscription_id: paymentProviderSubscription.id, + } + ) + } +} + async function getUsersGroupSubscriptionDetails(userId) { const subscription = await SubscriptionLocator.promises.getUsersSubscription(userId) @@ -470,6 +487,10 @@ module.exports = { ensureSubscriptionHasNoPastDueInvoice: callbackify( ensureSubscriptionHasNoPastDueInvoice ), + ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual: + callbackify( + ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual + ), getTotalConfirmedUsersInGroup: callbackify(getTotalConfirmedUsersInGroup), isUserPartOfGroup: callbackify(isUserPartOfGroup), getGroupPlanUpgradePreview: callbackify(getGroupPlanUpgradePreview), @@ -484,6 +505,7 @@ module.exports = { ensureSubscriptionCollectionMethodIsNotManual, ensureSubscriptionHasNoPendingChanges, ensureSubscriptionHasNoPastDueInvoice, + ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual, getTotalConfirmedUsersInGroup, isUserPartOfGroup, getUsersGroupSubscriptionDetails, diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 264b6098e3..0bf4c09de8 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -873,7 +873,7 @@ "issued_on": "", "it_looks_like_that_didnt_work_you_can_try_again_or_get_in_touch": "", "it_looks_like_your_account_is_billed_manually": "", - "it_looks_like_your_account_is_billed_manually_upgrading_subscription": "", + "it_looks_like_your_account_is_billed_manually_purchasing_additional_license_or_upgrading_subscription": "", "it_looks_like_your_payment_details_are_missing_please_update_your_billing_information": "", "italics": "", "join_beta_program": "", diff --git a/services/web/frontend/js/features/group-management/components/manually-collected-subscription.tsx b/services/web/frontend/js/features/group-management/components/manually-collected-subscription.tsx index 971d4fa791..a1d984b4bb 100644 --- a/services/web/frontend/js/features/group-management/components/manually-collected-subscription.tsx +++ b/services/web/frontend/js/features/group-management/components/manually-collected-subscription.tsx @@ -24,7 +24,7 @@ function ManuallyCollectedSubscription() { content={ isFlexibleGroupLicensingForManuallyBilledSubscriptions ? ( , diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 6d6774dd05..cc1be42f0a 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -1131,7 +1131,7 @@ "it": "Italian", "it_looks_like_that_didnt_work_you_can_try_again_or_get_in_touch": "It looks like that didn’t work. You can try again or <0>get in touch with our Support team for more help.", "it_looks_like_your_account_is_billed_manually": "It looks like your account is being billed manually - adding seats or upgrading your subscription can only be done by the Support team. Please <0>get in touch for help.", - "it_looks_like_your_account_is_billed_manually_upgrading_subscription": "It looks like your account is being billed manually - upgrading your subscription can only be done by the Support team. Please <0>get in touch for help.", + "it_looks_like_your_account_is_billed_manually_purchasing_additional_license_or_upgrading_subscription": "It looks like your account is being billed manually - purchasing additional licenses or upgrading your subscription can only be done by the Support team. Please <0>get in touch for help.", "it_looks_like_your_payment_details_are_missing_please_update_your_billing_information": "It looks like your payment details are missing. Please <0>update your billing information, or <1>get in touch with our Support team for more help.", "italics": "Italics", "ja": "Japanese", diff --git a/services/web/test/unit/src/Subscription/SubscriptionGroupController.test.mjs b/services/web/test/unit/src/Subscription/SubscriptionGroupController.test.mjs index 2b52f2be37..a64ef71b60 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionGroupController.test.mjs +++ b/services/web/test/unit/src/Subscription/SubscriptionGroupController.test.mjs @@ -73,6 +73,8 @@ describe('SubscriptionGroupController', function () { .resolves(ctx.previewSubscriptionChangeData), checkBillingInfoExistence: sinon.stub().resolves(ctx.paymentMethod), updateSubscriptionPaymentTerms: sinon.stub().resolves(), + ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual: + sinon.stub().resolves(), }, } @@ -140,6 +142,7 @@ describe('SubscriptionGroupController', function () { InactiveError: class extends Error {}, SubtotalLimitExceededError: class extends Error {}, HasPastDueInvoiceError: class extends Error {}, + HasNoAdditionalLicenseWhenManuallyCollectedError: class extends Error {}, } vi.doMock( @@ -521,6 +524,28 @@ describe('SubscriptionGroupController', function () { }) }) + it('should redirect to manually collected subscription error page when collection method is manual and has no additional license add-on', async function (ctx) { + await new Promise(resolve => { + ctx.SubscriptionGroupHandler.promises.ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual = + sinon + .stub() + .throws( + new ctx.Errors.HasNoAdditionalLicenseWhenManuallyCollectedError() + ) + + const res = { + redirect: url => { + url.should.equal( + '/user/subscription/group/manually-collected-subscription' + ) + resolve() + }, + } + + ctx.Controller.addSeatsToGroupSubscription(ctx.req, res) + }) + }) + it('should redirect to subscription page when there is a pending change', async function (ctx) { await new Promise(resolve => { ctx.SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPendingChanges = diff --git a/services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js b/services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js index 63a6e25fde..87793fe440 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js @@ -806,6 +806,49 @@ describe('SubscriptionGroupHandler', function () { }) }) + describe('ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual', function () { + it('should throw if the subscription is manually collected and has no additional license add-on', async function () { + await expect( + this.Handler.promises.ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual( + { + isCollectionMethodManual: true, + hasAddOn: sinon + .stub() + .withArgs('additional-license') + .returns(false), + } + ) + ).to.be.rejectedWith( + 'This subscription is being collected manually has no "additional-license" add-on' + ) + }) + + it('should not throw if the subscription is not manually collected and has no additional license add-on and ', async function () { + await expect( + this.Handler.promises.ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual( + { + isCollectionMethodManual: false, + hasAddOn: sinon + .stub() + .withArgs('additional-license') + .returns(false), + } + ) + ).to.not.be.rejected + }) + + it('should not throw if the subscription is not manually collected and has additional license add-on', async function () { + await expect( + this.Handler.promises.ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual( + { + isCollectionMethodManual: true, + hasAddOn: sinon.stub().withArgs('additional-license').returns(true), + } + ) + ).to.not.be.rejected + }) + }) + describe('getGroupPlanUpgradePreview', function () { it('should generate preview for subscription upgrade', async function () { const result = await this.Handler.promises.getGroupPlanUpgradePreview(