Merge pull request #23415 from overleaf/ii-flexible-group-licensing-manually-collected

[web] Manually collected subscriptions with flexible licensing

GitOrigin-RevId: ca7cf2abf5cfa1d873614bf3407fb5a259a78a76
This commit is contained in:
Liangjun Song 2025-02-06 12:29:40 +00:00 committed by Copybot
parent d66c11e786
commit 8421bcc5d2
17 changed files with 263 additions and 45 deletions

View file

@ -16,9 +16,12 @@ class AddOnNotPresentError extends OError {}
class MissingBillingInfoError extends OError {}
class ManuallyCollectedError extends OError {}
module.exports = {
RecurlyTransactionError,
DuplicateAddOnError,
AddOnNotPresentError,
MissingBillingInfoError,
ManuallyCollectedError,
}

View file

@ -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) {

View file

@ -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'
}
}
/**

View file

@ -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),
}

View file

@ -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,

View file

@ -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/',

View file

@ -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

View file

@ -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": "",

View file

@ -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 (
<div className="container">
<Row>
<Col xl={{ span: 4, offset: 4 }} md={{ span: 6, offset: 3 }}>
<div className="group-heading" data-testid="group-heading">
<IconButton
variant="ghost"
href="/user/subscription"
size="lg"
icon="arrow_back"
accessibilityLabel={t('back_to_subscription')}
/>
<h2>{groupName || t('group_subscription')}</h2>
</div>
<BSCard>
<CardBody>{children}</CardBody>
</BSCard>
</Col>
</Row>
</div>
)
}
export default Card

View file

@ -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 (
<Card>
<OLNotification
type="error"
title={t('account_billed_manually')}
content={
<Trans
i18nKey="it_looks_like_your_account_is_billed_manually"
components={[
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
<a href="/contact" rel="noreferrer noopener" />,
]}
/>
}
className="m-0"
/>
</Card>
)
}
export default ManuallyCollectedSubscription

View file

@ -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 (
<div className="container">
<Row>
<Col xl={{ span: 4, offset: 4 }} md={{ span: 6, offset: 3 }}>
<div className="group-heading" data-testid="group-heading">
<IconButton
variant="ghost"
href="/user/subscription"
size="lg"
icon="arrow_back"
accessibilityLabel={t('back_to_subscription')}
/>
<h2>{groupName || t('group_subscription')}</h2>
</div>
<Card>
<CardBody>
<OLNotification
type="error"
title={t('missing_payment_details')}
content={
<Trans
i18nKey="it_looks_like_your_payment_details_are_missing_please_update_your_billing_information"
components={[
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
<a href="/user/subscription" rel="noreferrer noopener" />,
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
<a href="/contact" rel="noreferrer noopener" />,
]}
/>
}
className="m-0"
/>
</CardBody>
</Card>
</Col>
</Row>
</div>
<Card>
<OLNotification
type="error"
title={t('missing_payment_details')}
content={
<Trans
i18nKey="it_looks_like_your_payment_details_are_missing_please_update_your_billing_information"
components={[
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
<a href="/user/subscription" rel="noreferrer noopener" />,
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
<a href="/contact" rel="noreferrer noopener" />,
]}
/>
}
className="m-0"
/>
</Card>
)
}

View file

@ -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(<ManuallyCollectedSubscription />, element)
}

View file

@ -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 <b>__email__</b> has been linked to your <b>__institutionName__</b> 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 didnt work. You can try again or <0>get in touch</0> 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</0> 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</0>, or <1>get in touch</1> with our Support team for more help.",
"ja": "Japanese",
"january": "January",

View file

@ -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()

View file

@ -373,6 +373,7 @@ describe('RecurlyEntities', function () {
periodStart: new Date(),
periodEnd: new Date(),
createdAt: new Date(),
collectionMethod: 'automatic',
})
const change = new RecurlySubscriptionChange({
subscription,

View file

@ -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()
},
}

View file

@ -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