diff --git a/services/web/app/src/Features/Subscription/PaymentProviderEntities.js b/services/web/app/src/Features/Subscription/PaymentProviderEntities.js index 52896d9bb0..8cc15f6f2e 100644 --- a/services/web/app/src/Features/Subscription/PaymentProviderEntities.js +++ b/services/web/app/src/Features/Subscription/PaymentProviderEntities.js @@ -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, diff --git a/services/web/app/src/Features/Subscription/RecurlyClient.js b/services/web/app/src/Features/Subscription/RecurlyClient.js index e3265c745f..f5f2e5f31f 100644 --- a/services/web/app/src/Features/Subscription/RecurlyClient.js +++ b/services/web/app/src/Features/Subscription/RecurlyClient.js @@ -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} + */ +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, }, } diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js index d3657ca026..885784d10d 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/services/web/app/src/Features/Subscription/SubscriptionController.js @@ -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, }, diff --git a/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs b/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs index 14d73f91de..6ce552ec75 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs +++ b/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs @@ -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.' diff --git a/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js b/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js index b313cb63ad..3d03ce0d2f 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js +++ b/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js @@ -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, }, } diff --git a/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs b/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs index 326dbf875c..54523b0004 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs +++ b/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs @@ -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), diff --git a/services/web/app/views/subscriptions/add-seats.pug b/services/web/app/views/subscriptions/add-seats.pug index fb04ef91fb..697a554c97 100644 --- a/services/web/app/views/subscriptions/add-seats.pug +++ b/services/web/app/views/subscriptions/add-seats.pug @@ -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 diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index dcf37871ef..a336213296 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -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": "", diff --git a/services/web/frontend/js/features/group-management/components/add-seats/add-seats.tsx b/services/web/frontend/js/features/group-management/components/add-seats/add-seats.tsx index 7163fd2cee..988876a4a0 100644 --- a/services/web/frontend/js/features/group-management/components/add-seats/add-seats.tsx +++ b/services/web/frontend/js/features/group-management/components/add-seats/add-seats.tsx @@ -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() 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() { {addSeatsInputError} )} + {isFlexibleGroupLicensingForManuallyBilledSubscriptions && + isCollectionMethodManual && } + + setIsPoNumberChecked(e.target.checked)} + /> + + {isPoNumberChecked && ( + + {t('po_number')} + + + )} + + ) +} + +export default PoNumber diff --git a/services/web/frontend/js/features/group-management/components/add-seats/root.tsx b/services/web/frontend/js/features/group-management/components/add-seats/root.tsx index e6d9ae4700..af6d74928d 100644 --- a/services/web/frontend/js/features/group-management/components/add-seats/root.tsx +++ b/services/web/frontend/js/features/group-management/components/add-seats/root.tsx @@ -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 + return ( + + + + ) } export default Root diff --git a/services/web/frontend/js/utils/meta.ts b/services/web/frontend/js/utils/meta.ts index f502143818..7aab88b050 100644 --- a/services/web/frontend/js/utils/meta.ts +++ b/services/web/frontend/js/utils/meta.ts @@ -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 diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 500319ee82..1117a55fed 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -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 you’ve opted-in to receive them).<1>For more details on how we use your personal data, please see our <0>Privacy Notice.", "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__ additional license(s) for a total of <1>__count__ licenses", + "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", diff --git a/services/web/test/frontend/features/group-management/components/add-seats.spec.tsx b/services/web/test/frontend/features/group-management/components/add-seats.spec.tsx index ab5ddb8355..91f2dd9841 100644 --- a/services/web/test/frontend/features/group-management/components/add-seats.spec.tsx +++ b/services/web/test/frontend/features/group-management/components/add-seats.spec.tsx @@ -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('', function () { beforeEach(function () { @@ -11,9 +12,17 @@ describe('', 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() + cy.mount( + + + + ) cy.findByRole('button', { name: /buy licenses/i }) cy.findByTestId('add-more-users-group-form') @@ -68,6 +77,28 @@ describe('', 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( + + + + ) + + 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('', function () { win.metaAttributesCache.set('ol-isProfessional', true) }) - cy.mount() + cy.mount( + + + + ) cy.findByRole('link', { name: /upgrade my plan/i }).should('not.exist') }) @@ -325,16 +360,23 @@ describe('', 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') } diff --git a/services/web/test/unit/src/Subscription/PaymentProviderEntitiesTest.js b/services/web/test/unit/src/Subscription/PaymentProviderEntitiesTest.js index 3c18c6b04b..1a28130b94 100644 --- a/services/web/test/unit/src/Subscription/PaymentProviderEntitiesTest.js +++ b/services/web/test/unit/src/Subscription/PaymentProviderEntitiesTest.js @@ -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, diff --git a/services/web/test/unit/src/Subscription/RecurlyClientTests.js b/services/web/test/unit/src/Subscription/RecurlyClientTests.js index 8fa7392d0c..4ae415dca5 100644 --- a/services/web/test/unit/src/Subscription/RecurlyClientTests.js +++ b/services/web/test/unit/src/Subscription/RecurlyClientTests.js @@ -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 doesn’t 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') + }) + }) }) diff --git a/services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs b/services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs index b3b6b66dbd..1d86199f9d 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs +++ b/services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs @@ -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', }) diff --git a/services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js b/services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js index 239971560b..ce9a379d00 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js @@ -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('shouldn’t 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 + }) + }) }) diff --git a/services/web/types/subscription/subscription-change-preview.ts b/services/web/types/subscription/subscription-change-preview.ts index 6476ebd7de..096820a2f6 100644 --- a/services/web/types/subscription/subscription-change-preview.ts +++ b/services/web/types/subscription/subscription-change-preview.ts @@ -1,7 +1,7 @@ export type SubscriptionChangePreview = { change: SubscriptionChangeDescription currency: string - paymentMethod: string + paymentMethod: string | undefined nextPlan: { annual: boolean }