diff --git a/services/web/app/src/Features/Subscription/Errors.js b/services/web/app/src/Features/Subscription/Errors.js index 48fc97d02b..4cc7d6ab7a 100644 --- a/services/web/app/src/Features/Subscription/Errors.js +++ b/services/web/app/src/Features/Subscription/Errors.js @@ -16,9 +16,12 @@ class AddOnNotPresentError extends OError {} class MissingBillingInfoError extends OError {} +class ManuallyCollectedError extends OError {} + module.exports = { RecurlyTransactionError, DuplicateAddOnError, AddOnNotPresentError, MissingBillingInfoError, + ManuallyCollectedError, } diff --git a/services/web/app/src/Features/Subscription/RecurlyClient.js b/services/web/app/src/Features/Subscription/RecurlyClient.js index 7c7d489797..f66343d9f0 100644 --- a/services/web/app/src/Features/Subscription/RecurlyClient.js +++ b/services/web/app/src/Features/Subscription/RecurlyClient.js @@ -261,7 +261,8 @@ function subscriptionFromApi(apiSubscription) { apiSubscription.currency == null || apiSubscription.currentPeriodStartedAt == null || apiSubscription.currentPeriodEndsAt == null || - apiSubscription.createdAt == null + apiSubscription.createdAt == null || + apiSubscription.collectionMethod == null ) { throw new OError('Invalid Recurly subscription', { subscription: apiSubscription, @@ -283,6 +284,7 @@ function subscriptionFromApi(apiSubscription) { periodStart: apiSubscription.currentPeriodStartedAt, periodEnd: apiSubscription.currentPeriodEndsAt, createdAt: apiSubscription.createdAt, + collectionMethod: apiSubscription.collectionMethod, }) if (apiSubscription.pendingChange != null) { diff --git a/services/web/app/src/Features/Subscription/RecurlyEntities.js b/services/web/app/src/Features/Subscription/RecurlyEntities.js index aafa3eb8bb..bdd2446c30 100644 --- a/services/web/app/src/Features/Subscription/RecurlyEntities.js +++ b/services/web/app/src/Features/Subscription/RecurlyEntities.js @@ -26,6 +26,7 @@ class RecurlySubscription { * @param {Date} props.periodStart * @param {Date} props.periodEnd * @param {Date} props.createdAt + * @param {string} props.collectionMethod * @param {RecurlySubscriptionChange} [props.pendingChange] */ constructor(props) { @@ -43,6 +44,7 @@ class RecurlySubscription { this.periodStart = props.periodStart this.periodEnd = props.periodEnd this.createdAt = props.createdAt + this.collectionMethod = props.collectionMethod this.pendingChange = props.pendingChange ?? null } @@ -246,6 +248,15 @@ class RecurlySubscription { planCode: newPlanCode, }) } + + /** + * Returns whether this subscription is manually collected + * + * @return {boolean} + */ + get isCollectionMethodManual() { + return this.collectionMethod === 'manual' + } } /** diff --git a/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs b/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs index 1647212c39..db2b8012a1 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs +++ b/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs @@ -13,7 +13,7 @@ import ErrorController from '../Errors/ErrorController.js' import UserGetter from '../User/UserGetter.js' import { Subscription } from '../../models/Subscription.js' import { isProfessionalGroupPlan } from './PlansHelper.mjs' -import { MissingBillingInfoError } from './Errors.js' +import { MissingBillingInfoError, ManuallyCollectedError } from './Errors.js' import RecurlyClient from './RecurlyClient.js' /** @@ -126,11 +126,14 @@ async function _removeUserFromGroup( async function addSeatsToGroupSubscription(req, res) { try { const userId = SessionManager.getLoggedInUserId(req.session) - const { subscription, plan } = + const { subscription, recurlySubscription, plan } = await SubscriptionGroupHandler.promises.getUsersGroupSubscriptionDetails( userId ) await SubscriptionGroupHandler.promises.ensureFlexibleLicensingEnabled(plan) + await SubscriptionGroupHandler.promises.ensureSubscriptionCollectionMethodIsNotManual( + recurlySubscription + ) // Check if the user has missing billing details await RecurlyClient.promises.getPaymentMethod(userId) await SubscriptionGroupHandler.promises.ensureSubscriptionIsActive( @@ -155,6 +158,12 @@ async function addSeatsToGroupSubscription(req, res) { ) } + if (error instanceof ManuallyCollectedError) { + return res.redirect( + '/user/subscription/group/manually-collected-subscription' + ) + } + return res.redirect('/user/subscription') } } @@ -268,6 +277,12 @@ async function subscriptionUpgradePage(req, res) { ) } + if (error instanceof ManuallyCollectedError) { + return res.redirect( + '/user/subscription/group/manually-collected-subscription' + ) + } + return res.redirect('/user/subscription') } } @@ -301,6 +316,24 @@ async function missingBillingInformation(req, res) { } } +async function manuallyCollectedSubscription(req, res) { + try { + const userId = SessionManager.getLoggedInUserId(req.session) + const subscription = + await SubscriptionLocator.promises.getUsersSubscription(userId) + + res.render('subscriptions/manually-collected-subscription', { + groupName: subscription.teamName, + }) + } catch (error) { + logger.err( + { error }, + 'error trying to render manually collected subscription page' + ) + return res.render('/user/subscription') + } +} + export default { removeUserFromGroup: expressify(removeUserFromGroup), removeSelfFromGroup: expressify(removeSelfFromGroup), @@ -316,4 +349,5 @@ export default { subscriptionUpgradePage: expressify(subscriptionUpgradePage), upgradeSubscription: expressify(upgradeSubscription), missingBillingInformation: expressify(missingBillingInformation), + manuallyCollectedSubscription: expressify(manuallyCollectedSubscription), } diff --git a/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js b/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js index 22c845351e..591c5f3d60 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js +++ b/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js @@ -8,6 +8,7 @@ const PlansLocator = require('./PlansLocator') const SubscriptionHandler = require('./SubscriptionHandler') const GroupPlansData = require('./GroupPlansData') const { MEMBERS_LIMIT_ADD_ON_CODE } = require('./RecurlyEntities') +const { ManuallyCollectedError } = require('./Errors') async function removeUserFromGroup(subscriptionId, userIdToRemove) { await SubscriptionUpdater.promises.removeUserFromGroup( @@ -68,6 +69,19 @@ async function ensureSubscriptionIsActive(subscription) { } } +async function ensureSubscriptionCollectionMethodIsNotManual( + recurlySubscription +) { + if (recurlySubscription.isCollectionMethodManual) { + throw new ManuallyCollectedError( + 'This subscription is being collected manually', + { + recurlySubscription_id: recurlySubscription.id, + } + ) + } +} + async function getUsersGroupSubscriptionDetails(userId) { const subscription = await SubscriptionLocator.promises.getUsersSubscription(userId) @@ -99,6 +113,8 @@ async function _addSeatsSubscriptionChange(userId, adding) { await getUsersGroupSubscriptionDetails(userId) await ensureFlexibleLicensingEnabled(plan) await ensureSubscriptionIsActive(subscription) + await ensureSubscriptionCollectionMethodIsNotManual(recurlySubscription) + const currentAddonQuantity = recurlySubscription.addOns.find( addOn => addOn.code === MEMBERS_LIMIT_ADD_ON_CODE @@ -207,6 +223,8 @@ async function _getGroupPlanUpgradeChangeRequest(ownerId) { olSubscription.recurlySubscription_id ) + await ensureSubscriptionCollectionMethodIsNotManual(recurlySubscription) + return recurlySubscription.getRequestForGroupPlanUpgrade(newPlanCode) } @@ -244,6 +262,9 @@ module.exports = { replaceUserReferencesInGroups: callbackify(replaceUserReferencesInGroups), ensureFlexibleLicensingEnabled: callbackify(ensureFlexibleLicensingEnabled), ensureSubscriptionIsActive: callbackify(ensureSubscriptionIsActive), + ensureSubscriptionCollectionMethodIsNotManual: callbackify( + ensureSubscriptionCollectionMethodIsNotManual + ), getTotalConfirmedUsersInGroup: callbackify(getTotalConfirmedUsersInGroup), isUserPartOfGroup: callbackify(isUserPartOfGroup), getGroupPlanUpgradePreview: callbackify(getGroupPlanUpgradePreview), @@ -253,6 +274,7 @@ module.exports = { replaceUserReferencesInGroups, ensureFlexibleLicensingEnabled, ensureSubscriptionIsActive, + ensureSubscriptionCollectionMethodIsNotManual, getTotalConfirmedUsersInGroup, isUserPartOfGroup, getUsersGroupSubscriptionDetails, diff --git a/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs b/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs index 544f667697..214ff567b8 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs +++ b/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs @@ -127,6 +127,14 @@ export default { SubscriptionGroupController.missingBillingInformation ) + webRouter.get( + '/user/subscription/group/manually-collected-subscription', + AuthenticationController.requireLogin(), + RateLimiterMiddleware.rateLimit(subscriptionRateLimiter), + SubscriptionGroupController.flexibleLicensingSplitTest, + SubscriptionGroupController.manuallyCollectedSubscription + ) + // Team invites webRouter.get( '/subscription/invites/:token/', diff --git a/services/web/app/views/subscriptions/manually-collected-subscription.pug b/services/web/app/views/subscriptions/manually-collected-subscription.pug new file mode 100644 index 0000000000..b0e1db986b --- /dev/null +++ b/services/web/app/views/subscriptions/manually-collected-subscription.pug @@ -0,0 +1,13 @@ +extends ../layout-marketing + +block vars + - bootstrap5PageStatus = 'enabled' // Enforce BS5 version + +block entrypointVar + - entrypoint = 'pages/user/subscription/group-management/manually-collected-subscription' + +block append meta + meta(name="ol-groupName", data-type="string", content=groupName) + +block content + main.content.content-alt#manually-collected-subscription-root diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index e52610b736..f53002342d 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -45,6 +45,7 @@ "access_denied": "", "access_edit_your_projects": "", "access_levels_changed": "", + "account_billed_manually": "", "account_has_been_link_to_institution_account": "", "account_has_past_due_invoice_change_plan_warning": "", "account_managed_by_group_administrator": "", @@ -804,6 +805,7 @@ "is_email_affiliated": "", "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_payment_details_are_missing_please_update_your_billing_information": "", "join_beta_program": "", "join_now": "", diff --git a/services/web/frontend/js/features/group-management/components/card.tsx b/services/web/frontend/js/features/group-management/components/card.tsx new file mode 100644 index 0000000000..04b858ba7a --- /dev/null +++ b/services/web/frontend/js/features/group-management/components/card.tsx @@ -0,0 +1,43 @@ +import { useTranslation } from 'react-i18next' +import getMeta from '@/utils/meta' +import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n' +import { Card as BSCard, CardBody, Col, Row } from 'react-bootstrap-5' +import IconButton from '@/features/ui/components/bootstrap-5/icon-button' + +type CardProps = { + children: React.ReactNode +} + +function Card({ children }: CardProps) { + const { t } = useTranslation() + const groupName = getMeta('ol-groupName') + const { isReady } = useWaitForI18n() + + if (!isReady) { + return null + } + + return ( +
+ + +
+ +

{groupName || t('group_subscription')}

+
+ + {children} + + +
+
+ ) +} + +export default Card 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 new file mode 100644 index 0000000000..2b2c111716 --- /dev/null +++ b/services/web/frontend/js/features/group-management/components/manually-collected-subscription.tsx @@ -0,0 +1,28 @@ +import { Trans, useTranslation } from 'react-i18next' +import OLNotification from '@/features/ui/components/ol/ol-notification' +import Card from '@/features/group-management/components/card' + +function ManuallyCollectedSubscription() { + const { t } = useTranslation() + + return ( + + , + ]} + /> + } + className="m-0" + /> + + ) +} + +export default ManuallyCollectedSubscription diff --git a/services/web/frontend/js/features/group-management/components/missing-billing-information.tsx b/services/web/frontend/js/features/group-management/components/missing-billing-information.tsx index 1110a5b45a..48f8dc28a5 100644 --- a/services/web/frontend/js/features/group-management/components/missing-billing-information.tsx +++ b/services/web/frontend/js/features/group-management/components/missing-billing-information.tsx @@ -1,50 +1,29 @@ import { Trans, useTranslation } from 'react-i18next' -import { Card, CardBody, Row, Col } from 'react-bootstrap-5' -import getMeta from '@/utils/meta' -import IconButton from '@/features/ui/components/bootstrap-5/icon-button' import OLNotification from '@/features/ui/components/ol/ol-notification' +import Card from '@/features/group-management/components/card' function MissingBillingInformation() { const { t } = useTranslation() - const groupName = getMeta('ol-groupName') return ( -
- - -
- -

{groupName || t('group_subscription')}

-
- - - , - // eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key - , - ]} - /> - } - className="m-0" - /> - - - -
-
+ + , + // eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key + , + ]} + /> + } + className="m-0" + /> + ) } diff --git a/services/web/frontend/js/pages/user/subscription/group-management/manually-collected-subscription.tsx b/services/web/frontend/js/pages/user/subscription/group-management/manually-collected-subscription.tsx new file mode 100644 index 0000000000..ce7f0a9a4a --- /dev/null +++ b/services/web/frontend/js/pages/user/subscription/group-management/manually-collected-subscription.tsx @@ -0,0 +1,8 @@ +import '../base' +import ReactDOM from 'react-dom' +import ManuallyCollectedSubscription from '@/features/group-management/components/manually-collected-subscription' + +const element = document.getElementById('manually-collected-subscription-root') +if (element) { + ReactDOM.render(, element) +} diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 92105417ee..d95f262013 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -51,6 +51,7 @@ "access_edit_your_projects": "Access and edit your projects", "access_levels_changed": "Access levels changed", "account": "Account", + "account_billed_manually": "Account billed manually", "account_has_been_link_to_institution_account": "Your __appName__ account on __email__ has been linked to your __institutionName__ institutional account.", "account_has_past_due_invoice_change_plan_warning": "Your account currently has a past due invoice. You will not be able to change your plan until this is resolved.", "account_linking": "Account Linking", @@ -1061,6 +1062,7 @@ "issued_on": "Issued: __date__", "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_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.", "ja": "Japanese", "january": "January", diff --git a/services/web/test/unit/src/Subscription/RecurlyClientTests.js b/services/web/test/unit/src/Subscription/RecurlyClientTests.js index fb2c7aa978..0790d77b82 100644 --- a/services/web/test/unit/src/Subscription/RecurlyClientTests.js +++ b/services/web/test/unit/src/Subscription/RecurlyClientTests.js @@ -51,6 +51,7 @@ describe('RecurlyClient', function () { periodStart: new Date(), periodEnd: new Date(), createdAt: new Date(), + collectionMethod: 'automatic', }) this.recurlySubscription = { @@ -81,6 +82,7 @@ describe('RecurlyClient', function () { currentPeriodStartedAt: this.subscription.periodStart, currentPeriodEndsAt: this.subscription.periodEnd, createdAt: this.subscription.createdAt, + collectionMethod: this.subscription.collectionMethod, } this.recurlySubscriptionChange = new recurly.SubscriptionChange() diff --git a/services/web/test/unit/src/Subscription/RecurlyEntitiesTest.js b/services/web/test/unit/src/Subscription/RecurlyEntitiesTest.js index f264bf58a3..45a4ef9641 100644 --- a/services/web/test/unit/src/Subscription/RecurlyEntitiesTest.js +++ b/services/web/test/unit/src/Subscription/RecurlyEntitiesTest.js @@ -373,6 +373,7 @@ describe('RecurlyEntities', function () { periodStart: new Date(), periodEnd: new Date(), createdAt: new Date(), + collectionMethod: 'automatic', }) const change = new RecurlySubscriptionChange({ subscription, diff --git a/services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs b/services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs index 3511d0bc39..76e44d7ca0 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs +++ b/services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs @@ -57,6 +57,7 @@ describe('SubscriptionGroupController', function () { .resolves(this.createSubscriptionChangeData), ensureFlexibleLicensingEnabled: sinon.stub().resolves(), ensureSubscriptionIsActive: sinon.stub().resolves(), + ensureSubscriptionCollectionMethodIsNotManual: sinon.stub().resolves(), getGroupPlanUpgradePreview: sinon .stub() .resolves(this.previewSubscriptionChangeData), @@ -109,6 +110,7 @@ describe('SubscriptionGroupController', function () { this.RecurlyClient = { promises: { getPaymentMethod: sinon.stub().resolves(this.paymentMethod), + // getSubscription: sinon.stub().resolves(this.subscription), }, } @@ -122,6 +124,7 @@ describe('SubscriptionGroupController', function () { this.Errors = { MissingBillingInfoError: class MissingBillingInfoError extends Error {}, + ManuallyCollectedError: class ManuallyCollectedError extends Error {}, } this.Controller = await esmock.strict(modulePath, { @@ -408,6 +411,22 @@ describe('SubscriptionGroupController', function () { this.Controller.addSeatsToGroupSubscription(this.req, res) }) + it('should redirect to manually collected subscription error page when collection method is manual', function (done) { + this.SubscriptionGroupHandler.promises.ensureSubscriptionCollectionMethodIsNotManual = + sinon.stub().throws(new this.Errors.ManuallyCollectedError()) + + const res = { + redirect: url => { + url.should.equal( + '/user/subscription/group/manually-collected-subscription' + ) + done() + }, + } + + this.Controller.addSeatsToGroupSubscription(this.req, res) + }) + it('should redirect to subscription page when subscription is not active', function (done) { this.SubscriptionGroupHandler.promises.ensureSubscriptionIsActive = sinon .stub() @@ -598,13 +617,32 @@ describe('SubscriptionGroupController', function () { }) it('should redirect to missing billing information page when billing information is missing', function (done) { - this.RecurlyClient.promises.getPaymentMethod = sinon + this.SubscriptionGroupHandler.promises.getGroupPlanUpgradePreview = sinon .stub() .throws(new this.Errors.MissingBillingInfoError()) const res = { redirect: url => { - url.should.equal('/user/subscription') + url.should.equal( + '/user/subscription/group/missing-billing-information' + ) + done() + }, + } + + this.Controller.subscriptionUpgradePage(this.req, res) + }) + + it('should redirect to manually collected subscription error page when collection method is manual', function (done) { + this.SubscriptionGroupHandler.promises.getGroupPlanUpgradePreview = sinon + .stub() + .throws(new this.Errors.ManuallyCollectedError()) + + const res = { + redirect: url => { + url.should.equal( + '/user/subscription/group/manually-collected-subscription' + ) done() }, } diff --git a/services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js b/services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js index c2c1102b8e..707dedcda6 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js @@ -543,6 +543,28 @@ describe('SubscriptionGroupHandler', function () { }) }) + describe('ensureSubscriptionCollectionMethodIsNotManual', function () { + it('should throw if the subscription is manually collected', async function () { + await expect( + this.Handler.promises.ensureSubscriptionCollectionMethodIsNotManual({ + get isCollectionMethodManual() { + return true + }, + }) + ).to.be.rejectedWith('This subscription is being collected manually') + }) + + it('should not throw if the subscription is automatically collected', async function () { + await expect( + this.Handler.promises.ensureSubscriptionCollectionMethodIsNotManual({ + get isCollectionMethodManual() { + return false + }, + }) + ).to.not.be.rejected + }) + }) + describe('upgradeGroupPlan', function () { it('should upgrade the subscription for flexible licensing group plans', async function () { this.SubscriptionLocator.promises.getUsersSubscription = sinon