Merge pull request #24412 from overleaf/ii-flexible-licensing-manually-collected-2

[web] Add seats feature for manually collected subscriptions

GitOrigin-RevId: f7cc6f8ce17163f10e175a06bb471de6e3a96e3c
This commit is contained in:
ilkin-overleaf
2025-04-30 08:05:00 +00:00
committed by Copybot
parent 62760a9bf5
commit 1a8c549389
19 changed files with 561 additions and 51 deletions
@@ -30,6 +30,8 @@ class PaymentProviderSubscription {
* @param {Date} props.periodStart
* @param {Date} props.periodEnd
* @param {string} props.collectionMethod
* @param {string} [props.poNumber]
* @param {string} [props.termsAndConditions]
* @param {PaymentProviderSubscriptionChange} [props.pendingChange]
* @param {PaymentProvider['service']} [props.service]
* @param {string} [props.state]
@@ -53,6 +55,8 @@ class PaymentProviderSubscription {
this.periodStart = props.periodStart
this.periodEnd = props.periodEnd
this.collectionMethod = props.collectionMethod
this.poNumber = props.poNumber ?? ''
this.termsAndConditions = props.termsAndConditions ?? ''
this.pendingChange = props.pendingChange ?? null
this.service = props.service ?? 'recurly'
this.state = props.state ?? 'active'
@@ -263,6 +267,39 @@ class PaymentProviderSubscription {
})
}
/**
* Update the "PO number" and "Terms and conditions" in a subscription
*
* @param {string} poNumber
* @param {string} termsAndConditions
* @return {PaymentProviderSubscriptionUpdateRequest} - the update request to send to
* Recurly
*/
getRequestForPoNumberAndTermsAndConditionsUpdate(
poNumber,
termsAndConditions
) {
return new PaymentProviderSubscriptionUpdateRequest({
subscription: this,
poNumber,
termsAndConditions,
})
}
/**
* Update the "Terms and conditions" in a subscription
*
* @param {string} termsAndConditions
* @return {PaymentProviderSubscriptionUpdateRequest} - the update request to send to
* Recurly
*/
getRequestForTermsAndConditionsUpdate(termsAndConditions) {
return new PaymentProviderSubscriptionUpdateRequest({
subscription: this,
termsAndConditions,
})
}
/**
* Returns whether this subscription is manually collected
*
@@ -304,6 +341,20 @@ class PaymentProviderSubscriptionAddOn {
}
}
class PaymentProviderSubscriptionUpdateRequest {
/**
* @param {object} props
* @param {PaymentProviderSubscription} props.subscription
* @param {string} [props.poNumber]
* @param {string} [props.termsAndConditions]
*/
constructor(props) {
this.subscription = props.subscription
this.poNumber = props.poNumber ?? ''
this.termsAndConditions = props.termsAndConditions ?? ''
}
}
class PaymentProviderSubscriptionChangeRequest {
/**
* @param {object} props
@@ -514,6 +565,7 @@ module.exports = {
PaymentProviderSubscriptionAddOn,
PaymentProviderSubscriptionChange,
PaymentProviderSubscriptionChangeRequest,
PaymentProviderSubscriptionUpdateRequest,
PaymentProviderSubscriptionAddOnUpdate,
PaypalPaymentMethod,
CreditCardPaymentMethod,
@@ -25,6 +25,7 @@ const {
/**
* @import { PaymentProviderSubscriptionChangeRequest } from './PaymentProviderEntities'
* @import { PaymentProviderSubscriptionUpdateRequest } from './PaymentProviderEntities'
* @import { PaymentMethod } from './types'
*/
@@ -188,7 +189,29 @@ async function getSubscriptionForUser(userId) {
}
/**
* Request a susbcription change from Recurly
* Request a subscription update from Recurly
*
* @param {PaymentProviderSubscriptionUpdateRequest} updateRequest
*/
async function updateSubscriptionDetails(updateRequest) {
const body = subscriptionUpdateRequestToApi(updateRequest)
const updatedSubscription = await client.updateSubscription(
`uuid-${updateRequest.subscription.id}`,
body
)
logger.debug(
{
subscriptionId: updateRequest.subscription.id,
updateId: updatedSubscription.id,
},
'updated subscription'
)
}
/**
* Request a subscription change from Recurly
*
* @param {PaymentProviderSubscriptionChangeRequest} changeRequest
*/
@@ -361,6 +384,25 @@ async function getPlan(planCode) {
return planFromApi(plan)
}
/**
* Get the country code for given user
*
* @param {string} userId
* @return {Promise<string>}
*/
async function getCountryCode(userId) {
const account = await client.getAccount(`code-${userId}`)
const countryCode = account.address?.country
if (!countryCode) {
throw new OError('Country code not found', {
userId,
})
}
return countryCode
}
function subscriptionIsCanceledOrExpired(subscription) {
const state = subscription?.recurlyStatus?.state
return state === 'canceled' || state === 'expired'
@@ -424,7 +466,10 @@ function subscriptionFromApi(apiSubscription) {
apiSubscription.currency == null ||
apiSubscription.currentPeriodStartedAt == null ||
apiSubscription.currentPeriodEndsAt == null ||
apiSubscription.collectionMethod == null
apiSubscription.collectionMethod == null ||
// The values below could be null initially if the subscription has never updated
!('poNumber' in apiSubscription) ||
!('termsAndConditions' in apiSubscription)
) {
throw new OError('Invalid Recurly subscription', {
subscription: apiSubscription,
@@ -446,6 +491,8 @@ function subscriptionFromApi(apiSubscription) {
periodStart: apiSubscription.currentPeriodStartedAt,
periodEnd: apiSubscription.currentPeriodEndsAt,
collectionMethod: apiSubscription.collectionMethod,
poNumber: apiSubscription.poNumber ?? '',
termsAndConditions: apiSubscription.termsAndConditions ?? '',
service: 'recurly',
state: apiSubscription.state ?? 'active',
trialPeriodStart: apiSubscription.trialStartedAt,
@@ -639,6 +686,22 @@ function subscriptionChangeRequestToApi(changeRequest) {
return requestBody
}
/**
* Build an API request from a PaymentProviderSubscriptionUpdateRequest
*
* @param {PaymentProviderSubscriptionUpdateRequest} updateRequest
*/
function subscriptionUpdateRequestToApi(updateRequest) {
const requestBody = {}
if (updateRequest.poNumber) {
requestBody.poNumber = updateRequest.poNumber
}
if (updateRequest.termsAndConditions) {
requestBody.termsAndConditions = updateRequest.termsAndConditions
}
return requestBody
}
module.exports = {
errors: recurly.errors,
@@ -648,6 +711,7 @@ module.exports = {
getSubscription: callbackify(getSubscription),
getSubscriptionForUser: callbackify(getSubscriptionForUser),
previewSubscriptionChange: callbackify(previewSubscriptionChange),
updateSubscriptionDetails: callbackify(updateSubscriptionDetails),
applySubscriptionChangeRequest: callbackify(applySubscriptionChangeRequest),
removeSubscriptionChange: callbackify(removeSubscriptionChange),
removeSubscriptionChangeByUuid: callbackify(removeSubscriptionChangeByUuid),
@@ -656,6 +720,7 @@ module.exports = {
getPaymentMethod: callbackify(getPaymentMethod),
getAddOn: callbackify(getAddOn),
getPlan: callbackify(getPlan),
getCountryCode: callbackify(getCountryCode),
subscriptionIsCanceledOrExpired,
pauseSubscriptionByUuid: callbackify(pauseSubscriptionByUuid),
resumeSubscriptionByUuid: callbackify(resumeSubscriptionByUuid),
@@ -668,6 +733,7 @@ module.exports = {
getActiveCouponsForUserId,
getCustomerManagementLink,
previewSubscriptionChange,
updateSubscriptionDetails,
applySubscriptionChangeRequest,
removeSubscriptionChange,
removeSubscriptionChangeByUuid,
@@ -678,5 +744,6 @@ module.exports = {
getPaymentMethod,
getAddOn,
getPlan,
getCountryCode,
},
}
@@ -723,7 +723,7 @@ function getPlanNameForDisplay(planName, planCode) {
*
* @param {SubscriptionChangeDescription} subscriptionChangeDescription A description of the change for the frontend
* @param {PaymentProviderSubscriptionChange} subscriptionChange The subscription change object coming from Recurly
* @param {PaymentMethod} paymentMethod The payment method associated to the user
* @param {PaymentMethod} [paymentMethod] The payment method associated to the user
* @return {SubscriptionChangePreview}
*/
function makeChangePreview(
@@ -739,7 +739,7 @@ function makeChangePreview(
change: subscriptionChangeDescription,
currency: subscription.currency,
immediateCharge: { ...subscriptionChange.immediateCharge },
paymentMethod: paymentMethod.toString(),
paymentMethod: paymentMethod?.toString(),
nextPlan: {
annual: nextPlan.annual ?? false,
},
@@ -8,6 +8,7 @@ import SessionManager from '../Authentication/SessionManager.js'
import UserAuditLogHandler from '../User/UserAuditLogHandler.js'
import { expressify } from '@overleaf/promise-utils'
import Modules from '../../infrastructure/Modules.js'
import SplitTestHandler from '../SplitTests/SplitTestHandler.js'
import UserGetter from '../User/UserGetter.js'
import { Subscription } from '../../models/Subscription.js'
import { isProfessionalGroupPlan } from './PlansHelper.mjs'
@@ -135,23 +136,39 @@ async function addSeatsToGroupSubscription(req, res) {
userId
)
await SubscriptionGroupHandler.promises.ensureFlexibleLicensingEnabled(plan)
await SubscriptionGroupHandler.promises.ensureSubscriptionCollectionMethodIsNotManual(
recurlySubscription
)
await SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPendingChanges(
recurlySubscription
)
// Check if the user has missing billing details
await RecurlyClient.promises.getPaymentMethod(userId)
await SubscriptionGroupHandler.promises.ensureSubscriptionIsActive(
subscription
)
const { variant: flexibleLicensingForManuallyBilledSubscriptionsVariant } =
await SplitTestHandler.promises.getAssignment(
req,
res,
'flexible-group-licensing-for-manually-billed-subscriptions'
)
if (flexibleLicensingForManuallyBilledSubscriptionsVariant === 'enabled') {
await SubscriptionGroupHandler.promises.checkBillingInfoExistence(
recurlySubscription,
userId
)
} else {
await SubscriptionGroupHandler.promises.ensureSubscriptionCollectionMethodIsNotManual(
recurlySubscription
)
// Check if the user has missing billing details
await RecurlyClient.promises.getPaymentMethod(userId)
}
res.render('subscriptions/add-seats', {
subscriptionId: subscription._id,
groupName: subscription.teamName,
totalLicenses: subscription.membersLimit,
isProfessional: isProfessionalGroupPlan(subscription),
isCollectionMethodManual: recurlySubscription.isCollectionMethodManual,
})
} catch (error) {
if (error instanceof MissingBillingInfoError) {
@@ -231,7 +248,8 @@ async function createAddSeatsSubscriptionChange(req, res) {
const create =
await SubscriptionGroupHandler.promises.createAddSeatsSubscriptionChange(
userId,
req.body.adding
req.body.adding,
req.body.poNumber
)
res.json(create)
@@ -264,11 +282,27 @@ async function createAddSeatsSubscriptionChange(req, res) {
async function submitForm(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session)
const userEmail = await UserGetter.promises.getUserEmail(userId)
const { adding } = req.body
const { adding, poNumber } = req.body
const { recurlySubscription } =
await SubscriptionGroupHandler.promises.getUsersGroupSubscriptionDetails(
userId
)
if (recurlySubscription.isCollectionMethodManual) {
await SubscriptionGroupHandler.promises.updateSubscriptionPaymentTerms(
userId,
recurlySubscription,
poNumber
)
}
const messageLines = [`\n**Overleaf Sales Contact Form:**`]
messageLines.push('**Subject:** Self-Serve Group User Increase Request')
messageLines.push(`**Estimated Number of Users:** ${adding}`)
if (poNumber) {
messageLines.push(`**PO Number:** ${poNumber}`)
}
messageLines.push(
`**Message:** This email has been generated on behalf of user with email **${userEmail}** ` +
'to request an increase in the total number of users for their subscription.'
@@ -7,6 +7,7 @@ const RecurlyClient = require('./RecurlyClient')
const PlansLocator = require('./PlansLocator')
const SubscriptionHandler = require('./SubscriptionHandler')
const GroupPlansData = require('./GroupPlansData')
const Modules = require('../../infrastructure/Modules')
const { MEMBERS_LIMIT_ADD_ON_CODE } = require('./PaymentProviderEntities')
const {
ManuallyCollectedError,
@@ -122,13 +123,21 @@ async function getUsersGroupSubscriptionDetails(userId) {
}
}
async function checkBillingInfoExistence(recurlySubscription, userId) {
// Verify the billing info only if the collection method is not manual (e.g. automatic)
if (!recurlySubscription.isCollectionMethodManual) {
// Check if the user has missing billing details
await RecurlyClient.promises.getPaymentMethod(userId)
}
}
async function _addSeatsSubscriptionChange(userId, adding) {
const { subscription, recurlySubscription, plan } =
await getUsersGroupSubscriptionDetails(userId)
await ensureFlexibleLicensingEnabled(plan)
await ensureSubscriptionIsActive(subscription)
await ensureSubscriptionCollectionMethodIsNotManual(recurlySubscription)
await ensureSubscriptionHasNoPendingChanges(recurlySubscription)
await checkBillingInfoExistence(recurlySubscription, userId)
const currentAddonQuantity =
recurlySubscription.addOns.find(
@@ -202,7 +211,6 @@ function _shouldUseLegacyPricing(
async function previewAddSeatsSubscriptionChange(userId, adding) {
const { changeRequest, currentAddonQuantity } =
await _addSeatsSubscriptionChange(userId, adding)
const paymentMethod = await RecurlyClient.promises.getPaymentMethod(userId)
const subscriptionChange =
await RecurlyClient.promises.previewSubscriptionChange(changeRequest)
const subscriptionChangePreview =
@@ -217,16 +225,20 @@ async function previewAddSeatsSubscriptionChange(userId, adding) {
prevQuantity: currentAddonQuantity,
},
},
subscriptionChange,
paymentMethod
subscriptionChange
)
return subscriptionChangePreview
}
async function createAddSeatsSubscriptionChange(userId, adding) {
async function createAddSeatsSubscriptionChange(userId, adding, poNumber) {
const { changeRequest, recurlySubscription } =
await _addSeatsSubscriptionChange(userId, adding)
if (recurlySubscription.isCollectionMethodManual) {
await updateSubscriptionPaymentTerms(userId, recurlySubscription, poNumber)
}
await RecurlyClient.promises.applySubscriptionChangeRequest(changeRequest)
await SubscriptionHandler.promises.syncSubscription(
{ uuid: recurlySubscription.id },
@@ -236,6 +248,29 @@ async function createAddSeatsSubscriptionChange(userId, adding) {
return { adding }
}
async function updateSubscriptionPaymentTerms(
userId,
recurlySubscription,
poNumber
) {
const countryCode = await RecurlyClient.promises.getCountryCode(userId)
const [termsAndConditions] = await Modules.promises.hooks.fire(
'generateTermsAndConditions',
{ countryCode, poNumber }
)
const updateRequest = poNumber
? recurlySubscription.getRequestForPoNumberAndTermsAndConditionsUpdate(
poNumber,
termsAndConditions
)
: recurlySubscription.getRequestForTermsAndConditionsUpdate(
termsAndConditions
)
await RecurlyClient.promises.updateSubscriptionDetails(updateRequest)
}
async function _getUpgradeTargetPlanCodeMaybeThrow(subscription) {
if (
subscription.planCode.includes('professional') ||
@@ -308,6 +343,7 @@ module.exports = {
isUserPartOfGroup: callbackify(isUserPartOfGroup),
getGroupPlanUpgradePreview: callbackify(getGroupPlanUpgradePreview),
upgradeGroupPlan: callbackify(upgradeGroupPlan),
checkBillingInfoExistence: callbackify(checkBillingInfoExistence),
promises: {
removeUserFromGroup,
replaceUserReferencesInGroups,
@@ -320,7 +356,9 @@ module.exports = {
getUsersGroupSubscriptionDetails,
previewAddSeatsSubscriptionChange,
createAddSeatsSubscriptionChange,
updateSubscriptionPaymentTerms,
getGroupPlanUpgradePreview,
upgradeGroupPlan,
checkBillingInfoExistence,
},
}
@@ -23,6 +23,7 @@ const MAX_NUMBER_OF_USERS = 20
const addSeatsValidateSchema = {
body: Joi.object({
adding: Joi.number().integer().min(1).max(MAX_NUMBER_OF_USERS).required(),
poNumber: Joi.string(),
}),
}
@@ -90,6 +91,7 @@ export default {
validate({
body: Joi.object({
adding: Joi.number().integer().min(MAX_NUMBER_OF_USERS).required(),
poNumber: Joi.string(),
}),
}),
RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
@@ -4,11 +4,11 @@ block entrypointVar
- entrypoint = 'pages/user/subscription/group-management/add-seats'
block append meta
meta(name="ol-subscriptionData" data-type="json" content=subscriptionData)
meta(name="ol-groupName", data-type="string", content=groupName)
meta(name="ol-subscriptionId", data-type="string", content=subscriptionId)
meta(name="ol-totalLicenses", data-type="number", content=totalLicenses)
meta(name="ol-isProfessional", data-type="boolean", content=isProfessional)
meta(name="ol-isCollectionMethodManual", data-type="boolean", content=isCollectionMethodManual)
block content
main.content.content-alt#main-content
@@ -757,6 +757,7 @@
"how_we_use_your_data": "",
"how_we_use_your_data_explanation": "",
"i_confirm_am_student": "",
"i_want_to_add_a_po_number": "",
"i_want_to_stay": "",
"id": "",
"identify_errors_with_your_compile": "",
@@ -1226,6 +1227,7 @@
"plus_additional_collaborators_document_history_track_changes_and_more": "",
"plus_more": "",
"plus_x_additional_licenses_for_a_total_of_y_licenses": "",
"po_number": "",
"postal_code": "",
"premium": "",
"premium_feature": "",
@@ -16,6 +16,7 @@ import {
} from 'react-bootstrap-5'
import FormText from '@/features/ui/components/bootstrap-5/form/form-text'
import Button from '@/features/ui/components/bootstrap-5/button'
import PoNumber from '@/features/group-management/components/add-seats/po-number'
import CostSummary from '@/features/group-management/components/add-seats/cost-summary'
import RequestStatus from '@/features/group-management/components/request-status'
import useAsync from '@/shared/hooks/use-async'
@@ -29,6 +30,7 @@ import {
} from '../../../../../../types/subscription/subscription-change-preview'
import { MergeAndOverride, Nullable } from '../../../../../../types/utils'
import { sendMB } from '../../../../infrastructure/event-tracking'
import { useFeatureFlag } from '@/shared/context/split-test-context'
export const MAX_NUMBER_OF_USERS = 20
@@ -43,8 +45,12 @@ function AddSeats() {
const subscriptionId = getMeta('ol-subscriptionId')
const totalLicenses = Number(getMeta('ol-totalLicenses'))
const isProfessional = getMeta('ol-isProfessional')
const isCollectionMethodManual = getMeta('ol-isCollectionMethodManual')
const [addSeatsInputError, setAddSeatsInputError] = useState<string>()
const [shouldContactSales, setShouldContactSales] = useState(false)
const isFlexibleGroupLicensingForManuallyBilledSubscriptions = useFeatureFlag(
'flexible-group-licensing-for-manually-billed-subscriptions'
)
const controller = useAbortController()
const { signal: addSeatsSignal } = useAbortController()
const { signal: contactSalesSignal } = useAbortController()
@@ -151,6 +157,9 @@ function AddSeats() {
formData.get('seats') === ''
? undefined
: (formData.get('seats') as string)
const poNumber = !formData.get('po_number')
? undefined
: (formData.get('po_number') as string)
if (!(await validateSeats(rawSeats))) {
return
@@ -166,6 +175,7 @@ function AddSeats() {
signal: contactSalesSignal,
body: {
adding: rawSeats,
poNumber,
},
}
)
@@ -176,7 +186,10 @@ function AddSeats() {
})
const post = postJSON('/user/subscription/group/add-users/create', {
signal: addSeatsSignal,
body: { adding: Number(rawSeats) },
body: {
adding: Number(rawSeats),
poNumber,
},
})
runAsyncAddSeats(post)
.then(() => {
@@ -323,6 +336,8 @@ function AddSeats() {
<FormText type="error">{addSeatsInputError}</FormText>
)}
</FormGroup>
{isFlexibleGroupLicensingForManuallyBilledSubscriptions &&
isCollectionMethodManual && <PoNumber />}
</div>
<CostSummarySection
isLoadingCostSummary={isLoadingCostSummary}
@@ -0,0 +1,30 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { FormControl, FormGroup, FormLabel } from 'react-bootstrap-5'
import OLFormCheckbox from '@/features/ui/components/ol/ol-form-checkbox'
function PoNumber() {
const { t } = useTranslation()
const [isPoNumberChecked, setIsPoNumberChecked] = useState(false)
return (
<>
<FormGroup className="mt-3">
<OLFormCheckbox
label={t('i_want_to_add_a_po_number')}
id="po-number-checkbox"
checked={isPoNumberChecked}
onChange={e => setIsPoNumberChecked(e.target.checked)}
/>
</FormGroup>
{isPoNumberChecked && (
<FormGroup className="mt-2" controlId="po-number">
<FormLabel>{t('po_number')}</FormLabel>
<FormControl type="text" required className="w-25" name="po_number" />
</FormGroup>
)}
</>
)
}
export default PoNumber
@@ -1,5 +1,6 @@
import useWaitForI18n from '../../../../shared/hooks/use-wait-for-i18n'
import AddSeats from '@/features/group-management/components/add-seats/add-seats'
import { SplitTestProvider } from '@/shared/context/split-test-context'
function Root() {
const { isReady } = useWaitForI18n()
@@ -8,7 +9,11 @@ function Root() {
return null
}
return <AddSeats />
return (
<SplitTestProvider>
<AddSeats />
</SplitTestProvider>
)
}
export default Root
+1
View File
@@ -129,6 +129,7 @@ export interface Meta {
'ol-institutionLinked': InstitutionLink | undefined
'ol-inviteToken': string
'ol-inviterName': string
'ol-isCollectionMethodManual': boolean
'ol-isExternalAuthenticationSystemUsed': boolean
'ol-isManagedAccount': boolean
'ol-isPaywallChangeCompileTimeoutEnabled': boolean
+2
View File
@@ -982,6 +982,7 @@
"how_we_use_your_data_explanation": "<0>Please help us continue to improve Overleaf by answering a few quick questions. Your answers will help us and our corporate group understand more about our user base. We may use this information to improve your Overleaf experience, for example by providing personalized onboarding, upgrade prompts, help suggestions, and tailored marketing communications (if youve opted-in to receive them).</0><1>For more details on how we use your personal data, please see our <0>Privacy Notice</0>.</1>",
"hundreds_templates_info": "Produce beautiful documents starting from our gallery of LaTeX templates for journals, conferences, theses, reports, CVs and much more.",
"i_confirm_am_student": "I confirm that I am currently a student.",
"i_want_to_add_a_po_number": "I want to add a PO number",
"i_want_to_stay": "I want to stay",
"id": "ID",
"identify_errors_with_your_compile": "Identify errors with your compile",
@@ -1632,6 +1633,7 @@
"plus_additional_collaborators_document_history_track_changes_and_more": "(plus additional collaborators, document history, track changes, and more).",
"plus_more": "plus more",
"plus_x_additional_licenses_for_a_total_of_y_licenses": "Plus <0>__additionalLicenses__</0> additional license(s) for a total of <1>__count__ licenses</1>",
"po_number": "PO Number",
"popular_tags": "Popular Tags",
"portal_add_affiliation_to_join": "It looks like you are already logged in to __appName__. If you have a __portalTitle__ email you can add it now.",
"position": "Position",
@@ -1,6 +1,7 @@
import AddSeats, {
MAX_NUMBER_OF_USERS,
} from '@/features/group-management/components/add-seats/add-seats'
import { SplitTestProvider } from '@/shared/context/split-test-context'
describe('<AddSeats />', function () {
beforeEach(function () {
@@ -11,9 +12,17 @@ describe('<AddSeats />', function () {
win.metaAttributesCache.set('ol-subscriptionId', '123')
win.metaAttributesCache.set('ol-totalLicenses', this.totalLicenses)
win.metaAttributesCache.set('ol-isProfessional', false)
win.metaAttributesCache.set('ol-isCollectionMethodManual', true)
win.metaAttributesCache.set('ol-splitTestVariants', {
'flexible-group-licensing-for-manually-billed-subscriptions': 'enabled',
})
})
cy.mount(<AddSeats />)
cy.mount(
<SplitTestProvider>
<AddSeats />
</SplitTestProvider>
)
cy.findByRole('button', { name: /buy licenses/i })
cy.findByTestId('add-more-users-group-form')
@@ -68,6 +77,28 @@ describe('<AddSeats />', function () {
)
})
describe('PO number', function () {
it('should not render the PO checkbox and PO input if collection method is not manual', function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-isCollectionMethodManual', false)
})
cy.mount(
<SplitTestProvider>
<AddSeats />
</SplitTestProvider>
)
cy.findByLabelText(/i want to add a po number/i).should('not.exist')
cy.findByLabelText(/^po number$/i).should('not.exist')
})
it('should check the PO checkbox in order to activate the PO input field', function () {
cy.findByLabelText(/^po number$/i).should('not.exist')
cy.findByLabelText(/i want to add a po number/i).check()
cy.findByLabelText(/^po number$/i)
})
})
describe('"Upgrade my plan" link', function () {
it('shows the link', function () {
cy.findByRole('link', { name: /upgrade my plan/i }).should(
@@ -82,7 +113,11 @@ describe('<AddSeats />', function () {
win.metaAttributesCache.set('ol-isProfessional', true)
})
cy.mount(<AddSeats />)
cy.mount(
<SplitTestProvider>
<AddSeats />
</SplitTestProvider>
)
cy.findByRole('link', { name: /upgrade my plan/i }).should('not.exist')
})
@@ -325,16 +360,23 @@ describe('<AddSeats />', function () {
})
function makeRequest(statusCode: number, adding: string) {
const PO_NUMBER = 'PO123456789'
cy.intercept('POST', '/user/subscription/group/add-users/create', {
statusCode,
}).as('addUsersRequest')
cy.get('@input').type(adding)
cy.findByLabelText(/i want to add a po number/i).check()
cy.findByLabelText(/^po number$/i).type(PO_NUMBER)
cy.get('@addUsersBtn').click()
const body = {
adding: Number(adding),
poNumber: PO_NUMBER,
}
cy.get('@addUsersRequest')
.its('request.body')
.should('deep.equal', {
adding: Number(adding),
})
.should('deep.equal', body)
.and('have.keys', Object.keys(body))
cy.findByTestId('add-more-users-group-form').should('not.exist')
}
@@ -6,6 +6,7 @@ const Errors = require('../../../../app/src/Features/Subscription/Errors')
const {
AI_ADD_ON_CODE,
PaymentProviderSubscriptionChangeRequest,
PaymentProviderSubscriptionUpdateRequest,
PaymentProviderSubscriptionChange,
PaymentProviderSubscription,
PaymentProviderSubscriptionAddOnUpdate,
@@ -307,6 +308,36 @@ describe('PaymentProviderEntities', function () {
})
})
describe('getRequestForPoNumberAndTermsAndConditionsUpdate()', function () {
it('returns a correct update request', function () {
const updateRequest =
this.subscription.getRequestForPoNumberAndTermsAndConditionsUpdate(
'O12345',
'T&C copy'
)
expect(updateRequest).to.deep.equal(
new PaymentProviderSubscriptionUpdateRequest({
subscription: this.subscription,
poNumber: 'O12345',
termsAndConditions: 'T&C copy',
})
)
})
})
describe('getRequestForTermsAndConditionsUpdate()', function () {
it('returns a correct update request', function () {
const updateRequest =
this.subscription.getRequestForTermsAndConditionsUpdate('T&C copy')
expect(updateRequest).to.deep.equal(
new PaymentProviderSubscriptionUpdateRequest({
subscription: this.subscription,
termsAndConditions: 'T&C copy',
})
)
})
})
describe('without add-ons', function () {
beforeEach(function () {
const { PaymentProviderSubscription } = this.PaymentProviderEntities
@@ -381,6 +412,8 @@ describe('PaymentProviderEntities', function () {
periodStart: new Date(),
periodEnd: new Date(),
collectionMethod: 'automatic',
poNumber: '012345',
termsAndConditions: 'T&C copy',
})
const change = new PaymentProviderSubscriptionChange({
subscription,
@@ -5,6 +5,7 @@ const SandboxedModule = require('sandboxed-module')
const {
PaymentProviderSubscription,
PaymentProviderSubscriptionChangeRequest,
PaymentProviderSubscriptionUpdateRequest,
PaymentProviderSubscriptionAddOnUpdate,
PaymentProviderAccount,
PaymentProviderCoupon,
@@ -57,6 +58,8 @@ describe('RecurlyClient', function () {
periodStart: new Date(),
periodEnd: new Date(),
collectionMethod: 'automatic',
poNumber: '',
termsAndConditions: '',
})
this.recurlySubscription = {
@@ -87,6 +90,8 @@ describe('RecurlyClient', function () {
currentPeriodStartedAt: this.subscription.periodStart,
currentPeriodEndsAt: this.subscription.periodEnd,
collectionMethod: this.subscription.collectionMethod,
poNumber: this.subscription.poNumber,
termsAndConditions: this.subscription.termsAndConditions,
}
this.recurlySubscriptionChange = new recurly.SubscriptionChange()
@@ -444,6 +449,37 @@ describe('RecurlyClient', function () {
})
})
describe('updateSubscriptionDetails', function () {
beforeEach(function () {
this.client.updateSubscription = sinon
.stub()
.resolves({ id: this.subscription.id })
})
it('handles subscription update', async function () {
await this.RecurlyClient.promises.updateSubscriptionDetails(
new PaymentProviderSubscriptionUpdateRequest({
subscription: this.subscription,
poNumber: '012345',
termsAndConditions: 'T&C',
})
)
expect(this.client.updateSubscription).to.be.calledWith(
'uuid-subscription-id',
{ poNumber: '012345', termsAndConditions: 'T&C' }
)
})
it('should throw any API errors', async function () {
this.client.updateSubscription = sinon.stub().throws()
await expect(
this.RecurlyClient.promises.updateSubscriptionDetails({
subscription: this.subscription,
})
).to.eventually.be.rejectedWith(Error)
})
})
describe('removeSubscriptionChange', function () {
beforeEach(function () {
this.client.removeSubscriptionChange = sinon.stub().resolves()
@@ -654,4 +690,29 @@ describe('RecurlyClient', function () {
).to.be.rejectedWith(Error)
})
})
describe('getCountryCode', function () {
it('should return the country code from the account info', async function () {
this.client.getAccount = sinon.stub().resolves({
address: {
country: 'GB',
},
})
const countryCode = await this.RecurlyClient.promises.getCountryCode(
this.user._id
)
expect(countryCode).to.equal('GB')
})
it('should throw if country code doesnt exist', async function () {
this.client.getAccount = sinon.stub().resolves({
address: {
country: '',
},
})
await expect(
this.RecurlyClient.promises.getCountryCode(this.user._id)
).to.be.rejectedWith(Error, 'Country code not found')
})
})
})
@@ -34,6 +34,12 @@ describe('SubscriptionGroupController', function () {
canUseFlexibleLicensing: true,
}
this.recurlySubscription = {
get isCollectionMethodManual() {
return true
},
}
this.previewSubscriptionChangeData = {
change: {},
currency: 'USD',
@@ -41,13 +47,15 @@ describe('SubscriptionGroupController', function () {
this.createSubscriptionChangeData = { adding: 1 }
this.paymentMethod = { cardType: 'Visa', lastFour: '1111' }
this.SubscriptionGroupHandler = {
promises: {
removeUserFromGroup: sinon.stub().resolves(),
getUsersGroupSubscriptionDetails: sinon.stub().resolves({
subscription: this.subscription,
plan: this.plan,
recurlySubscription: {},
recurlySubscription: this.recurlySubscription,
}),
previewAddSeatsSubscriptionChange: sinon
.stub()
@@ -62,6 +70,8 @@ describe('SubscriptionGroupController', function () {
getGroupPlanUpgradePreview: sinon
.stub()
.resolves(this.previewSubscriptionChangeData),
checkBillingInfoExistence: sinon.stub().resolves(this.paymentMethod),
updateSubscriptionPaymentTerms: sinon.stub().resolves(),
},
}
@@ -96,7 +106,7 @@ describe('SubscriptionGroupController', function () {
this.SplitTestHandler = {
promises: {
getAssignment: sinon.stub().resolves(),
getAssignment: sinon.stub().resolves({ variant: 'enabled' }),
},
}
@@ -111,7 +121,6 @@ describe('SubscriptionGroupController', function () {
this.RecurlyClient = {
promises: {
getPaymentMethod: sinon.stub().resolves(this.paymentMethod),
// getSubscription: sinon.stub().resolves(this.subscription),
},
}
@@ -355,14 +364,21 @@ describe('SubscriptionGroupController', function () {
this.SubscriptionGroupHandler.promises.ensureFlexibleLicensingEnabled
.calledWith(this.plan)
.should.equal(true)
this.SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPendingChanges
.calledWith(this.recurlySubscription)
.should.equal(true)
this.SubscriptionGroupHandler.promises.ensureSubscriptionIsActive
.calledWith(this.subscription)
.should.equal(true)
this.SubscriptionGroupHandler.promises.checkBillingInfoExistence
.calledWith(this.recurlySubscription, this.adminUserId)
.should.equal(true)
page.should.equal('subscriptions/add-seats')
props.subscriptionId.should.equal(this.subscriptionId)
props.groupName.should.equal(this.subscription.teamName)
props.totalLicenses.should.equal(this.subscription.membersLimit)
props.isProfessional.should.equal(false)
props.isCollectionMethodManual.should.equal(true)
done()
},
}
@@ -399,7 +415,7 @@ 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.checkBillingInfoExistence = sinon
.stub()
.throws(new this.Errors.MissingBillingInfoError())
@@ -415,22 +431,6 @@ 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 there is a pending change', function (done) {
this.SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPendingChanges =
sinon.stub().throws(new this.Errors.PendingChangeError())
@@ -586,10 +586,16 @@ describe('SubscriptionGroupController', function () {
describe('submitForm', function () {
it('should build and pass the request body to the sales submit handler', function (done) {
const adding = 100
this.req.body = { adding }
const poNumber = 'PO123456'
this.req.body = { adding, poNumber }
const res = {
sendStatus: code => {
this.SubscriptionGroupHandler.promises.updateSubscriptionPaymentTerms(
this.adminUserId,
this.recurlySubscription,
poNumber
)
this.Modules.promises.hooks.fire
.calledWith('sendSupportRequest', {
email: this.user.email,
@@ -602,6 +608,8 @@ describe('SubscriptionGroupController', function () {
'\n' +
`**Estimated Number of Users:** ${adding}\n` +
'\n' +
`**PO Number:** ${poNumber}\n` +
'\n' +
`**Message:** This email has been generated on behalf of user with email **${this.user.email}** to request an increase in the total number of users for their subscription.`,
inbox: 'sales',
})
@@ -36,6 +36,15 @@ describe('SubscriptionGroupHandler', function () {
},
}
this.termsAndConditionsUpdate = {
termsAndConditions: 'T&C copy',
}
this.poNumberAndTermsAndConditionsUpdate = {
poNumber: '4444',
...this.termsAndConditionsUpdate,
}
this.recurlySubscription = {
id: 123,
addOns: [
@@ -50,10 +59,19 @@ describe('SubscriptionGroupHandler', function () {
getRequestForFlexibleLicensingGroupPlanUpgrade: sinon
.stub()
.returns(this.changeRequest),
getRequestForPoNumberAndTermsAndConditionsUpdate: sinon
.stub()
.returns(this.poNumberAndTermsAndConditionsUpdate),
getRequestForTermsAndConditionsUpdate: sinon
.stub()
.returns(this.termsAndConditionsUpdate),
currency: 'USD',
hasAddOn(code) {
return this.addOns.some(addOn => addOn.code === code)
},
get isCollectionMethodManual() {
return false
},
}
this.SubscriptionLocator = {
@@ -119,6 +137,8 @@ describe('SubscriptionGroupHandler', function () {
applySubscriptionChangeRequest: sinon
.stub()
.resolves(this.applySubscriptionChange),
getCountryCode: sinon.stub().resolves('BG'),
updateSubscriptionDetails: sinon.stub().resolves(),
},
}
@@ -155,6 +175,19 @@ describe('SubscriptionGroupHandler', function () {
},
}
this.Modules = {
promises: {
hooks: {
fire: sinon.stub().callsFake(hookName => {
if (hookName === 'generateTermsAndConditions') {
return Promise.resolve(['T&Cs'])
}
return Promise.resolve()
}),
},
},
}
this.Handler = SandboxedModule.require(modulePath, {
requires: {
'./SubscriptionUpdater': this.SubscriptionUpdater,
@@ -169,6 +202,7 @@ describe('SubscriptionGroupHandler', function () {
'./PaymentProviderEntities': this.PaymentProviderEntities,
'../Authentication/SessionManager': this.SessionManager,
'./GroupPlansData': this.GroupPlansData,
'../../infrastructure/Modules': this.Modules,
},
})
})
@@ -398,8 +432,7 @@ describe('SubscriptionGroupHandler', function () {
prevQuantity: this.prevQuantity,
},
},
this.previewSubscriptionChange,
this.paymentMethod
this.previewSubscriptionChange
)
.should.equal(true)
preview.should.equal(this.changePreview)
@@ -408,12 +441,30 @@ describe('SubscriptionGroupHandler', function () {
describe('createAddSeatsSubscriptionChange', function () {
it('should change the subscription', async function () {
this.recurlySubscription = {
...this.recurlySubscription,
get isCollectionMethodManual() {
return true
},
}
this.RecurlyClient.promises.getSubscription = sinon
.stub()
.resolves(this.recurlySubscription)
const result =
await this.Handler.promises.createAddSeatsSubscriptionChange(
this.adminUser_id,
this.adding
this.adding,
'123'
)
this.RecurlyClient.promises.updateSubscriptionDetails
.calledWith(
sinon.match
.has('poNumber')
.and(sinon.match.has('termsAndConditions'))
)
.should.equal(true)
this.RecurlyClient.promises.applySubscriptionChangeRequest
.calledWith(this.changeRequest)
.should.equal(true)
@@ -430,6 +481,48 @@ describe('SubscriptionGroupHandler', function () {
})
})
describe('updateSubscriptionPaymentTerms', function () {
describe('accounts with PO number', function () {
it('should update the subscription PO number and T&C', async function () {
this.RecurlyClient.promises.getCountryCode = sinon
.stub()
.resolves('GB')
await this.Handler.promises.updateSubscriptionPaymentTerms(
this.adminUser_id,
this.recurlySubscription,
this.poNumberAndTermsAndConditionsUpdate.poNumber
)
this.recurlySubscription.getRequestForPoNumberAndTermsAndConditionsUpdate
.calledWithMatch(
this.poNumberAndTermsAndConditionsUpdate.poNumber,
'T&Cs'
)
.should.equal(true)
this.RecurlyClient.promises.updateSubscriptionDetails
.calledWith(this.poNumberAndTermsAndConditionsUpdate)
.should.equal(true)
})
})
describe('accounts with no PO number', function () {
it('should update the subscription T&C only', async function () {
this.RecurlyClient.promises.getCountryCode = sinon
.stub()
.resolves('GB')
await this.Handler.promises.updateSubscriptionPaymentTerms(
this.adminUser_id,
this.recurlySubscription
)
this.recurlySubscription.getRequestForTermsAndConditionsUpdate
.calledWithMatch('T&Cs')
.should.equal(true)
this.RecurlyClient.promises.updateSubscriptionDetails
.calledWith(this.termsAndConditionsUpdate)
.should.equal(true)
})
})
})
describe('has no "additional-license" add-on', function () {
beforeEach(function () {
this.recurlySubscription.addOns = []
@@ -474,8 +567,7 @@ describe('SubscriptionGroupHandler', function () {
prevQuantity: this.prevQuantity,
},
},
this.previewSubscriptionChange,
this.paymentMethod
this.previewSubscriptionChange
)
.should.equal(true)
preview.should.equal(this.changePreview)
@@ -743,4 +835,30 @@ describe('SubscriptionGroupHandler', function () {
result.should.equal(this.changePreview)
})
})
describe('checkBillingInfoExistence', function () {
it('should invoke the payment method function when collection method is "automatic"', async function () {
await this.Handler.promises.checkBillingInfoExistence(
this.recurlySubscription,
this.adminUser_id
)
this.RecurlyClient.promises.getPaymentMethod
.calledWith(this.adminUser_id)
.should.equal(true)
})
it('shouldnt invoke the payment method function when collection method is "manual"', async function () {
const recurlySubscription = {
...this.recurlySubscription,
get isCollectionMethodManual() {
return true
},
}
await this.Handler.promises.checkBillingInfoExistence(
recurlySubscription,
this.adminUser_id
)
this.RecurlyClient.promises.getPaymentMethod.should.not.have.been.called
})
})
})
@@ -1,7 +1,7 @@
export type SubscriptionChangePreview = {
change: SubscriptionChangeDescription
currency: string
paymentMethod: string
paymentMethod: string | undefined
nextPlan: {
annual: boolean
}