From 8421bcc5d2bcff96c160e3f69cd7f6ce62ce49ef Mon Sep 17 00:00:00 2001 From: Liangjun Song <146005915+adai26@users.noreply.github.com> Date: Thu, 6 Feb 2025 12:29:40 +0000 Subject: [PATCH] Merge pull request #23415 from overleaf/ii-flexible-group-licensing-manually-collected [web] Manually collected subscriptions with flexible licensing GitOrigin-RevId: ca7cf2abf5cfa1d873614bf3407fb5a259a78a76 --- .../app/src/Features/Subscription/Errors.js | 3 + .../Features/Subscription/RecurlyClient.js | 4 +- .../Features/Subscription/RecurlyEntities.js | 11 ++++ .../SubscriptionGroupController.mjs | 38 +++++++++++- .../Subscription/SubscriptionGroupHandler.js | 22 +++++++ .../Subscription/SubscriptionRouter.mjs | 8 +++ .../manually-collected-subscription.pug | 13 ++++ .../web/frontend/extracted-translations.json | 2 + .../group-management/components/card.tsx | 43 ++++++++++++++ .../manually-collected-subscription.tsx | 28 +++++++++ .../missing-billing-information.tsx | 59 ++++++------------- .../manually-collected-subscription.tsx | 8 +++ services/web/locales/en.json | 2 + .../src/Subscription/RecurlyClientTests.js | 2 + .../src/Subscription/RecurlyEntitiesTest.js | 1 + .../SubscriptionGroupControllerTests.mjs | 42 ++++++++++++- .../SubscriptionGroupHandlerTests.js | 22 +++++++ 17 files changed, 263 insertions(+), 45 deletions(-) create mode 100644 services/web/app/views/subscriptions/manually-collected-subscription.pug create mode 100644 services/web/frontend/js/features/group-management/components/card.tsx create mode 100644 services/web/frontend/js/features/group-management/components/manually-collected-subscription.tsx create mode 100644 services/web/frontend/js/pages/user/subscription/group-management/manually-collected-subscription.tsx 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 ( +