Merge pull request #21841 from overleaf/ii-flexible-group-licensing-add-seats

[web] Add seats to a group plan

GitOrigin-RevId: 53497d2cb7aa7d1e7dc8291e391b24f7a32eeece
This commit is contained in:
ilkin-overleaf 2024-12-05 11:48:03 +02:00 committed by Copybot
parent f856ddce87
commit ab4d8fe168
24 changed files with 936 additions and 93 deletions

View file

@ -151,6 +151,44 @@ class RecurlySubscription {
})
}
/**
* Update an add-on on this subscription
*
* @param {string} code
* @param {number} quantity
* @return {RecurlySubscriptionChangeRequest} - the change request to send to
* Recurly
*
* @throws {AddOnNotPresentError} if the subscription doesn't have the add-on
*/
getRequestForAddOnUpdate(code, quantity) {
if (!this.hasAddOn(code)) {
throw new AddOnNotPresentError(
'Subscription does not have add-on to update',
{
subscriptionId: this.id,
addOnCode: code,
}
)
}
const addOnUpdates = this.addOns.map(addOn => {
const update = addOn.toAddOnUpdate()
if (update.code === code) {
update.quantity = quantity
}
return update
})
return new RecurlySubscriptionChangeRequest({
subscription: this,
timeframe: 'now',
addOnUpdates,
})
}
/**
* Remove an add-on from this subscription
*
@ -162,7 +200,7 @@ class RecurlySubscription {
getRequestForAddOnRemoval(code) {
if (!this.hasAddOn(code)) {
throw new AddOnNotPresentError(
'Subscripiton does not have add-on to remove',
'Subscription does not have add-on to remove',
{
subscriptionId: this.id,
addOnCode: code,

View file

@ -10,6 +10,8 @@ import { expressify } from '@overleaf/promise-utils'
import Modules from '../../infrastructure/Modules.js'
import SplitTestHandler from '../SplitTests/SplitTestHandler.js'
import ErrorController from '../Errors/ErrorController.js'
import SalesContactFormController from '../../../../modules/cms/app/src/controllers/SalesContactFormController.mjs'
import UserGetter from '../User/UserGetter.js'
/**
* @import { Subscription } from "../../../../types/subscription/dashboard/subscription"
@ -113,7 +115,98 @@ async function _removeUserFromGroup(
res.sendStatus(200)
}
async function requestConfirmation(req, res) {
/**
* @param {import("express").Request} req
* @param {import("express").Response} res
* @returns {Promise<void>}
*/
async function addSeatsToGroupSubscription(req, res) {
try {
const { subscription, plan } =
await SubscriptionGroupHandler.promises.getUsersGroupSubscriptionDetails(
req
)
await SubscriptionGroupHandler.promises.ensureFlexibleLicensingEnabled(plan)
res.render('subscriptions/add-seats', {
subscriptionId: subscription._id,
groupName: subscription.teamName,
totalLicenses: subscription.membersLimit,
})
} catch (error) {
logger.err(
{ error },
'error while getting users group subscription details'
)
return res.redirect('/user/subscription')
}
}
/**
* @param {import("express").Request} req
* @param {import("express").Response} res
* @returns {Promise<void>}
*/
async function previewAddSeatsSubscriptionChange(req, res) {
try {
const preview =
await SubscriptionGroupHandler.promises.previewAddSeatsSubscriptionChange(
req
)
res.json(preview)
} catch (error) {
logger.err(
{ error },
'error trying to preview "add seats" subscription change'
)
return res.status(400).end()
}
}
/**
* @param {import("express").Request} req
* @param {import("express").Response} res
* @returns {Promise<void>}
*/
async function createAddSeatsSubscriptionChange(req, res) {
try {
const preview =
await SubscriptionGroupHandler.promises.createAddSeatsSubscriptionChange(
req
)
res.json(preview)
} catch (error) {
logger.err(
{ error },
'error trying to create "add seats" subscription change'
)
return res.status(400).end()
}
}
async function submitForm(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session)
const userEmail = await UserGetter.promises.getUserEmail(userId)
const { adding } = req.body
req.body = {
email: userEmail,
subject: 'Self-Serve Group User Increase Request',
estimatedNumberOfUsers: adding,
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.\n\n` +
`The requested number of users to add is: ${adding}`,
inbox: 'sales',
}
return SalesContactFormController.submitForm(req, res)
}
async function flexibleLicensingSplitTest(req, res, next) {
const { variant } = await SplitTestHandler.promises.getAssignment(
req,
res,
@ -124,22 +217,19 @@ async function requestConfirmation(req, res) {
return ErrorController.notFound(req, res)
}
try {
const userId = SessionManager.getLoggedInUserId(req.session)
const subscription =
await SubscriptionLocator.promises.getUsersSubscription(userId)
res.render('subscriptions/request-confirmation-react', {
groupName: subscription.teamName,
})
} catch (error) {
logger.err({ error }, 'error trying to request seats to subscription')
return res.render('/user/subscription')
}
next()
}
export default {
removeUserFromGroup: expressify(removeUserFromGroup),
removeSelfFromGroup: expressify(removeSelfFromGroup),
requestConfirmation: expressify(requestConfirmation),
addSeatsToGroupSubscription: expressify(addSeatsToGroupSubscription),
submitForm: expressify(submitForm),
flexibleLicensingSplitTest: expressify(flexibleLicensingSplitTest),
previewAddSeatsSubscriptionChange: expressify(
previewAddSeatsSubscriptionChange
),
createAddSeatsSubscriptionChange: expressify(
createAddSeatsSubscriptionChange
),
}

View file

@ -1,7 +1,11 @@
const { callbackify } = require('util')
const SubscriptionUpdater = require('./SubscriptionUpdater')
const SubscriptionLocator = require('./SubscriptionLocator')
const SubscriptionController = require('./SubscriptionController')
const { Subscription } = require('../../models/Subscription')
const SessionManager = require('../Authentication/SessionManager')
const RecurlyClient = require('./RecurlyClient')
const PlansLocator = require('./PlansLocator')
async function removeUserFromGroup(subscriptionId, userIdToRemove) {
await SubscriptionUpdater.promises.removeUserFromGroup(
@ -50,15 +54,106 @@ async function _replaceInArray(model, property, oldValue, newValue) {
await model.updateMany(query, { $pull: setOldValue })
}
async function ensureFlexibleLicensingEnabled(plan) {
if (!plan?.canUseFlexibleLicensing) {
throw new Error('The group plan does not support flexible licencing')
}
}
async function getUsersGroupSubscriptionDetails(req) {
const userId = SessionManager.getLoggedInUserId(req.session)
const subscription =
await SubscriptionLocator.promises.getUsersSubscription(userId)
if (!subscription.groupPlan) {
throw new Error('User subscription is not a group plan')
}
const plan = PlansLocator.findLocalPlanInSettings(subscription.planCode)
const recurlySubscription = await RecurlyClient.promises.getSubscription(
subscription.recurlySubscription_id
)
return {
subscription,
recurlySubscription,
plan,
}
}
async function _addSeatsSubscriptionChange(req) {
const adding = req.body.adding
const { recurlySubscription, plan } =
await getUsersGroupSubscriptionDetails(req)
await ensureFlexibleLicensingEnabled(plan)
const userId = SessionManager.getLoggedInUserId(req.session)
const currentAddonQuantity =
recurlySubscription.addOns.find(
addOn => addOn.code === plan.membersLimitAddOn
)?.quantity ?? 0
// Keeps only the new total quantity of addon
const nextAddonQuantity = currentAddonQuantity + adding
const changeRequest = recurlySubscription.getRequestForAddOnUpdate(
plan.membersLimitAddOn,
nextAddonQuantity
)
return {
changeRequest,
userId,
currentAddonQuantity,
recurlySubscription,
plan,
}
}
async function previewAddSeatsSubscriptionChange(req) {
const { changeRequest, userId, currentAddonQuantity, plan } =
await _addSeatsSubscriptionChange(req)
const paymentMethod = await RecurlyClient.promises.getPaymentMethod(userId)
const subscriptionChange =
await RecurlyClient.promises.previewSubscriptionChange(changeRequest)
const subscriptionChangePreview =
await SubscriptionController.makeChangePreview(
{
type: 'add-on-update',
addOn: {
code: plan.membersLimitAddOn,
quantity: subscriptionChange.nextAddOns.find(
addon => addon.code === plan.membersLimitAddOn
).quantity,
prevQuantity: currentAddonQuantity,
},
},
subscriptionChange,
paymentMethod
)
return subscriptionChangePreview
}
async function createAddSeatsSubscriptionChange(req) {
const { changeRequest } = await _addSeatsSubscriptionChange(req)
await RecurlyClient.promises.applySubscriptionChangeRequest(changeRequest)
return { adding: req.body.adding }
}
module.exports = {
removeUserFromGroup: callbackify(removeUserFromGroup),
replaceUserReferencesInGroups: callbackify(replaceUserReferencesInGroups),
ensureFlexibleLicensingEnabled: callbackify(ensureFlexibleLicensingEnabled),
getTotalConfirmedUsersInGroup: callbackify(getTotalConfirmedUsersInGroup),
isUserPartOfGroup: callbackify(isUserPartOfGroup),
promises: {
removeUserFromGroup,
replaceUserReferencesInGroups,
ensureFlexibleLicensingEnabled,
getTotalConfirmedUsersInGroup,
isUserPartOfGroup,
getUsersGroupSubscriptionDetails,
previewAddSeatsSubscriptionChange,
createAddSeatsSubscriptionChange,
},
}

View file

@ -7,6 +7,7 @@ import { RateLimiter } from '../../infrastructure/RateLimiter.js'
import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.js'
import Settings from '@overleaf/settings'
import { Joi, validate } from '../../infrastructure/Validation.js'
import SupportRouter from '../../../../modules/support/app/src/SupportRouter.mjs'
const teamInviteRateLimiter = new RateLimiter('team-invite', {
points: 10,
@ -18,6 +19,14 @@ const subscriptionRateLimiter = new RateLimiter('subscription', {
duration: 60,
})
const MAX_NUMBER_OF_USERS = 50
const addSeatsValidateSchema = {
body: Joi.object({
adding: Joi.number().integer().min(1).max(MAX_NUMBER_OF_USERS).required(),
}),
}
export default {
apply(webRouter, privateApiRouter, publicApiRouter) {
if (!Settings.enableSubscriptions) {
@ -62,10 +71,41 @@ export default {
)
webRouter.get(
'/user/subscription/group/request-confirmation',
'/user/subscription/group/add-users',
AuthenticationController.requireLogin(),
RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
SubscriptionGroupController.requestConfirmation
SubscriptionGroupController.flexibleLicensingSplitTest,
SubscriptionGroupController.addSeatsToGroupSubscription
)
webRouter.post(
'/user/subscription/group/add-users/preview',
AuthenticationController.requireLogin(),
validate(addSeatsValidateSchema),
RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
SubscriptionGroupController.previewAddSeatsSubscriptionChange
)
webRouter.post(
'/user/subscription/group/add-users/create',
AuthenticationController.requireLogin(),
validate(addSeatsValidateSchema),
RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
SubscriptionGroupController.createAddSeatsSubscriptionChange
)
webRouter.post(
'/user/subscription/group/add-users/sales-contact-form',
validate({
body: Joi.object({
adding: Joi.number().integer().min(MAX_NUMBER_OF_USERS).required(),
}),
}),
RateLimiterMiddleware.rateLimit(
SupportRouter.rateLimiters.supportRequests
),
RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
SubscriptionGroupController.submitForm
)
// Team invites

View file

@ -0,0 +1,17 @@
extends ../layout-marketing
block vars
- bootstrap5PageStatus = 'enabled' // Enforce BS5 version
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)
block content
main.content.content-alt#main-content
#add-seats-root

View file

@ -1,13 +0,0 @@
extends ../layout-marketing
block vars
- bootstrap5PageStatus = 'enabled' // Enforce BS5 version
block entrypointVar
- entrypoint = 'pages/user/subscription/group-management/request-confirmation'
block append meta
meta(name="ol-groupName", data-type="string", content=groupName)
block content
main.content.content-alt#subscription-manage-group-root

View file

@ -82,6 +82,7 @@
"add_role_and_department": "",
"add_to_dictionary": "",
"add_to_tag": "",
"add_users": "",
"add_your_comment_here": "",
"add_your_first_group_member_now": "",
"added_by_on": "",
@ -93,6 +94,7 @@
"address_second_line_optional": "",
"adjust_column_width": "",
"advanced_reference_search_mode": "",
"after_that_well_bill_you_x_annually_on_date_unless_you_cancel": "",
"aggregate_changed": "",
"aggregate_to": "",
"agree_with_the_terms": "",
@ -291,6 +293,7 @@
"copy_project": "",
"copy_response": "",
"copying": "",
"cost_summary": "",
"country": "",
"country_flag": "",
"coupon_code": "",
@ -456,6 +459,7 @@
"enter_any_size_including_units_or_valid_latex_command": "",
"enter_image_url": "",
"enter_the_confirmation_code": "",
"enter_the_number_of_users_youd_like_to_add_to_see_the_cost_breakdown": "",
"equation_preview": "",
"error": "",
"error_opening_document": "",
@ -682,6 +686,7 @@
"hotkey_undo": "",
"hotkeys": "",
"how_it_works": "",
"how_many_users_do_you_want_to_add": "",
"how_to_create_tables": "",
"how_to_insert_images": "",
"how_we_use_your_data": "",
@ -689,6 +694,8 @@
"i_want_to_stay": "",
"id": "",
"if_you_need_to_customize_your_table_further_you_can": "",
"if_you_want_more_than_x_users_on_your_plan_we_need_to_add_them_for_you": "",
"if_you_want_to_reduce_the_number_of_users_please_contact_support": "",
"if_your_occupation_not_listed_type_full_name": "",
"ignore_validation_errors": "",
"ill_take_it": "",
@ -760,6 +767,7 @@
"ip_address": "",
"is_email_affiliated": "",
"issued_on": "",
"it_looks_like_that_didnt_work_you_can_try_again_or_get_in_touch": "",
"join_beta_program": "",
"join_now": "",
"join_overleaf_labs": "",
@ -1281,6 +1289,7 @@
"revoke_invite": "",
"right": "",
"role": "",
"sales_tax": "",
"saml_auth_error": "",
"saml_identity_exists_error": "",
"saml_invalid_signature_error": "",
@ -1324,6 +1333,7 @@
"search_whole_word": "",
"search_within_selection": "",
"searched_path_for_lines_containing": "",
"seats": "",
"security": "",
"see_changes_in_your_documents_live": "",
"select_a_column_or_a_merged_cell_to_align": "",
@ -1361,6 +1371,7 @@
"selection_deleted": "",
"send_first_message": "",
"send_message": "",
"send_request": "",
"sending": "",
"sent": "",
"server_error": "",
@ -1658,6 +1669,7 @@
"tooltip_hide_pdf": "",
"tooltip_show_filetree": "",
"tooltip_show_pdf": "",
"total_due_today": "",
"total_per_month": "",
"total_per_year": "",
"total_with_subtotal_and_tax": "",
@ -1746,6 +1758,7 @@
"upgrade": "",
"upgrade_cc_btn": "",
"upgrade_for_12x_more_compile_time": "",
"upgrade_my_plan": "",
"upgrade_now": "",
"upgrade_to_add_more_editors": "",
"upgrade_to_add_more_editors_and_access_collaboration_features": "",
@ -1777,6 +1790,9 @@
"valid": "",
"valid_sso_configuration": "",
"validation_issue_entry_description": "",
"value_must_be_a_number": "",
"value_must_be_a_whole_number": "",
"value_must_be_at_least_x": "",
"vat": "",
"vat_number": "",
"verify_email_address_before_enabling_managed_users": "",
@ -1813,6 +1829,7 @@
"we_got_your_request": "",
"we_logged_you_in": "",
"we_sent_new_code": "",
"we_will_charge_you_now_for_the_cost_of_your_additional_users_based_on_remaining_months": "",
"webinars": "",
"website_status": "",
"wed_love_you_to_stay": "",
@ -1894,6 +1911,7 @@
"your_affiliation_is_confirmed": "",
"your_browser_does_not_support_this_feature": "",
"your_compile_timed_out": "",
"your_current_plan_supports_up_to_x_users": "",
"your_current_project_will_revert_to_the_version_from_time": "",
"your_git_access_info": "",
"your_git_access_info_bullet_1": "",
@ -1922,10 +1940,13 @@
"youre_about_to_disable_single_sign_on": "",
"youre_about_to_enable_single_sign_on": "",
"youre_about_to_enable_single_sign_on_sso_only": "",
"youre_adding_x_users_to_your_plan_giving_you_a_total_of_y_users": "",
"youre_already_setup_for_sso": "",
"youre_joining": "",
"youre_on_free_trial_which_ends_on": "",
"youre_signed_in_as_logout": "",
"youve_added_more_users": "",
"youve_added_x_more_users_to_your_subscription_invite_people": "",
"youve_lost_edit_access": "",
"youve_unlinked_all_users": "",
"zoom_in": "",

View file

@ -0,0 +1,341 @@
import { useState, useEffect, useMemo, useRef } from 'react'
import { debounce } from 'lodash'
import { Trans, useTranslation } from 'react-i18next'
import withErrorBoundary from '@/infrastructure/error-boundary'
import useAbortController from '@/shared/hooks/use-abort-controller'
import LoadingSpinner from '@/shared/components/loading-spinner'
import Notification from '@/shared/components/notification'
import IconButton from '@/features/ui/components/bootstrap-5/icon-button'
import {
Card,
Row,
Col,
FormGroup,
FormLabel,
FormControl,
} 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 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'
import getMeta from '@/utils/meta'
import { postJSON } from '@/infrastructure/fetch-json'
import { debugConsole } from '@/utils/debugging'
import * as yup from 'yup'
import {
AddOnUpdate,
SubscriptionChangePreview,
} from '../../../../../../types/subscription/subscription-change-preview'
import { MergeAndOverride } from '../../../../../../types/utils'
const MAX_NUMBER_OF_USERS = 50
function AddSeats() {
const { t } = useTranslation()
const groupName = getMeta('ol-groupName')
const subscriptionId = getMeta('ol-subscriptionId')
const totalLicenses = Number(getMeta('ol-totalLicenses'))
const [addSeatsInputError, setAddSeatsInputError] = useState<string>()
const [shouldContactSales, setShouldContactSales] = useState(false)
const controller = useAbortController()
const { signal: addSeatsSignal } = useAbortController()
const { signal: contactSalesSignal } = useAbortController()
const {
isLoading: isLoadingCostSummary,
runAsync: runAsyncCostSummary,
data: costSummaryData,
reset: resetCostSummaryData,
} = useAsync<
MergeAndOverride<SubscriptionChangePreview, { change: AddOnUpdate }>
>()
const {
isLoading: isAddingSeats,
isError: isErrorAddingSeats,
isSuccess: isSuccessAddingSeats,
runAsync: runAsyncAddSeats,
data: addedSeatsData,
} = useAsync<{ adding: number }>()
const {
isLoading: isSendingMailToSales,
isError: isErrorSendingMailToSales,
isSuccess: isSuccessSendingMailToSales,
runAsync: runAsyncSendMailToSales,
} = useAsync()
const addSeatsValidationSchema = useMemo(() => {
return yup
.number()
.typeError(t('value_must_be_a_number'))
.integer(t('value_must_be_a_whole_number'))
.min(1, t('value_must_be_at_least_x', { value: 1 }))
.required(t('this_field_is_required'))
}, [t])
const debouncedCostSummaryRequest = useMemo(
() =>
debounce((value: number, signal: AbortSignal) => {
const post = postJSON('/user/subscription/group/add-users/preview', {
signal,
body: { adding: value },
})
runAsyncCostSummary(post).catch(debugConsole.error)
}, 500),
[runAsyncCostSummary]
)
const validateSeats = async (value: string | undefined) => {
try {
await addSeatsValidationSchema.validate(value)
setAddSeatsInputError(undefined)
return true
} catch (error) {
if (error instanceof yup.ValidationError) {
setAddSeatsInputError(error.errors[0])
} else {
debugConsole.error(error)
}
return false
}
}
const handleSeatsChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value === '' ? undefined : e.target.value
const isValidSeatsNumber = await validateSeats(value)
let shouldContactSales = false
if (isValidSeatsNumber) {
const seats = Number(value)
if (seats > MAX_NUMBER_OF_USERS) {
debouncedCostSummaryRequest.cancel()
shouldContactSales = true
} else {
debouncedCostSummaryRequest(seats, controller.signal)
}
} else {
debouncedCostSummaryRequest.cancel()
}
resetCostSummaryData()
setShouldContactSales(shouldContactSales)
}
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const rawSeats =
formData.get('seats') === ''
? undefined
: (formData.get('seats') as string)
if (!(await validateSeats(rawSeats))) {
return
}
if (shouldContactSales) {
const post = postJSON(
'/user/subscription/group/add-users/sales-contact-form',
{
signal: contactSalesSignal,
body: {
adding: rawSeats,
},
}
)
runAsyncSendMailToSales(post).catch(debugConsole.error)
} else {
const post = postJSON('/user/subscription/group/add-users/create', {
signal: addSeatsSignal,
body: { adding: Number(rawSeats) },
})
runAsyncAddSeats(post).catch(debugConsole.error)
}
}
useEffect(() => {
return () => {
debouncedCostSummaryRequest.cancel()
}
}, [debouncedCostSummaryRequest])
const formRef = useRef<HTMLFormElement>(null)
useEffect(() => {
const handleUnload = () => formRef.current?.reset()
window.addEventListener('beforeunload', handleUnload)
return () => window.removeEventListener('beforeunload', handleUnload)
}, [])
if (isErrorAddingSeats || isErrorSendingMailToSales) {
return (
<RequestStatus
variant="danger"
icon="error"
title={t('something_went_wrong')}
content={
<Trans
i18nKey="it_looks_like_that_didnt_work_you_can_try_again_or_get_in_touch"
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
components={[<a href="/contact" rel="noreferrer noopener" />]}
/>
}
/>
)
}
if (isSuccessAddingSeats) {
return (
<RequestStatus
variant="primary"
icon="check_circle"
title={t('youve_added_more_users')}
content={
<Trans
i18nKey="youve_added_x_more_users_to_your_subscription_invite_people"
components={[
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
<a
href={`/manage/groups/${subscriptionId}/members`}
rel="noreferrer noopener"
/>,
]}
values={{ users: addedSeatsData?.adding }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
}
/>
)
}
if (isSuccessSendingMailToSales) {
return (
<RequestStatus
icon="email"
title={t('we_got_your_request')}
content={t('our_team_will_get_back_to_you_shortly')}
/>
)
}
return (
<div className="container">
<Row>
<Col xxl={5} xl={6} lg={7} md={9} className="mx-auto">
<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 className="card-description-secondary">
<Card.Body>
<form
noValidate
className="d-grid gap-4"
onSubmit={handleSubmit}
ref={formRef}
>
<div className="d-grid gap-1">
<h4 className="fw-bold m-0 card-description-secondary">
{t('add_more_users')}
</h4>
<div>
{t('your_current_plan_supports_up_to_x_users', {
users: totalLicenses,
})}
</div>
<div>
<Trans
i18nKey="if_you_want_to_reduce_the_number_of_users_please_contact_support"
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
components={[<a href="/contact" />]}
/>
</div>
</div>
<div>
<FormGroup controlId="number-of-users-input">
<FormLabel>
{t('how_many_users_do_you_want_to_add')}
</FormLabel>
<FormControl
type="text"
required
className="w-25"
name="seats"
disabled={isLoadingCostSummary}
onChange={handleSeatsChange}
isInvalid={Boolean(addSeatsInputError)}
/>
{Boolean(addSeatsInputError) && (
<FormText type="error">{addSeatsInputError}</FormText>
)}
</FormGroup>
</div>
{isLoadingCostSummary ? (
<LoadingSpinner className="ms-auto me-auto" />
) : shouldContactSales ? (
<div>
<Notification
content={
<Trans
i18nKey="if_you_want_more_than_x_users_on_your_plan_we_need_to_add_them_for_you"
// eslint-disable-next-line react/jsx-key
components={[<b />]}
values={{ count: 50 }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
}
type="info"
/>
</div>
) : (
<CostSummary
subscriptionChange={costSummaryData}
totalLicenses={totalLicenses}
/>
)}
<div className="d-flex align-items-center justify-content-end gap-2">
<a
href="user/subscription/group/upgrade-subscription"
rel="noreferrer noopener"
className="me-auto"
>
{t('upgrade_my_plan')}
</a>
<Button variant="secondary" href="/user/subscription">
{t('cancel')}
</Button>
<Button
variant="primary"
type="submit"
disabled={
isAddingSeats ||
isLoadingCostSummary ||
isSendingMailToSales
}
isLoading={isAddingSeats || isSendingMailToSales}
>
{shouldContactSales ? t('send_request') : t('add_users')}
</Button>
</div>
</form>
</Card.Body>
</Card>
</Col>
</Row>
</div>
)
}
export default withErrorBoundary(AddSeats)

View file

@ -0,0 +1,121 @@
import { Trans, useTranslation } from 'react-i18next'
import { Card, ListGroup } from 'react-bootstrap-5'
import { formatCurrencyLocalized } from '@/shared/utils/currency'
import { formatTime } from '@/features/utils/format-date'
import {
AddOnUpdate,
SubscriptionChangePreview,
} from '../../../../../../types/subscription/subscription-change-preview'
import { MergeAndOverride } from '../../../../../../types/utils'
type CostSummaryProps = {
subscriptionChange: MergeAndOverride<
SubscriptionChangePreview,
{ change: AddOnUpdate }
> | null
totalLicenses: number
}
function CostSummary({ subscriptionChange, totalLicenses }: CostSummaryProps) {
const { t } = useTranslation()
return (
<Card className="card-gray card-description-secondary">
<Card.Body className="d-grid gap-2 p-3">
<div>
<div className="fw-bold">{t('cost_summary')}</div>
{subscriptionChange ? (
<Trans
i18nKey="youre_adding_x_users_to_your_plan_giving_you_a_total_of_y_users"
components={[
<b />, // eslint-disable-line react/jsx-key
<b />, // eslint-disable-line react/jsx-key
]}
values={{
adding:
subscriptionChange.change.addOn.quantity -
subscriptionChange.change.addOn.prevQuantity,
total:
totalLicenses +
subscriptionChange.change.addOn.quantity -
subscriptionChange.change.addOn.prevQuantity,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
) : (
t(
'enter_the_number_of_users_youd_like_to_add_to_see_the_cost_breakdown'
)
)}
</div>
{subscriptionChange && (
<>
<div>
<ListGroup>
<ListGroup.Item className="bg-transparent border-0 px-0 gap-3 card-description-secondary">
<span className="me-auto">
{subscriptionChange.nextInvoice.plan.name} x{' '}
{subscriptionChange.change.addOn.quantity -
subscriptionChange.change.addOn.prevQuantity}{' '}
{t('seats')}
</span>
<span>
{formatCurrencyLocalized(
subscriptionChange.immediateCharge.subtotal,
subscriptionChange.currency
)}
</span>
</ListGroup.Item>
<ListGroup.Item className="bg-transparent border-0 px-0 gap-3 card-description-secondary">
<span className="me-auto">
{t('sales_tax')} &middot;{' '}
{subscriptionChange.nextInvoice.tax.rate * 100}%
</span>
<span>
{formatCurrencyLocalized(
subscriptionChange.immediateCharge.tax,
subscriptionChange.currency
)}
</span>
</ListGroup.Item>
<ListGroup.Item className="bg-transparent border-0 px-0 gap-3 card-description-secondary">
<strong className="me-auto">{t('total_due_today')}</strong>
<strong>
{formatCurrencyLocalized(
subscriptionChange.immediateCharge.total,
subscriptionChange.currency
)}
</strong>
</ListGroup.Item>
</ListGroup>
<hr className="m-0" />
</div>
<div>
{t(
'we_will_charge_you_now_for_the_cost_of_your_additional_users_based_on_remaining_months'
)}
</div>
<div>
{t(
'after_that_well_bill_you_x_annually_on_date_unless_you_cancel',
{
subtotal: formatCurrencyLocalized(
subscriptionChange.nextInvoice.total,
subscriptionChange.currency
),
date: formatTime(
subscriptionChange.nextInvoice.date,
'MMMM D'
),
}
)}
</div>
</>
)}
</Card.Body>
</Card>
)
}
export default CostSummary

View file

@ -0,0 +1,19 @@
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()
if (!isReady) {
return null
}
return (
<SplitTestProvider>
<AddSeats />
</SplitTestProvider>
)
}
export default Root

View file

@ -66,7 +66,7 @@ export default function GroupMembers() {
href="/user/subscription/group/add-users"
rel="noreferrer noopener"
>
{t('add_more_users')}
{t('add_more_users')}.
</a>
</small>
)

View file

@ -4,15 +4,23 @@ import Button from '@/features/ui/components/bootstrap-5/button'
import MaterialIcon from '@/shared/components/material-icon'
import getMeta from '@/utils/meta'
import IconButton from '@/features/ui/components/bootstrap-5/icon-button'
import classnames from 'classnames'
function RequestConfirmation() {
type RequestStatusProps = {
icon: string
title: string
content: React.ReactNode
variant?: 'primary' | 'danger'
}
function RequestStatus({ icon, title, content, variant }: RequestStatusProps) {
const { t } = useTranslation()
const groupName = getMeta('ol-groupName')
return (
<div className="container">
<Row>
<Col xl={{ span: 4, offset: 4 }} md={{ span: 6, offset: 3 }}>
<Col xxl={5} xl={6} lg={7} md={9} className="mx-auto">
<div className="group-heading" data-testid="group-heading">
<IconButton
variant="ghost"
@ -25,14 +33,18 @@ function RequestConfirmation() {
</div>
<Card>
<CardBody className="d-grid gap-3">
<div className="card-icon">
<MaterialIcon type="email" />
<div
className={classnames('card-icon', {
[`text-${variant}`]: variant,
})}
>
<MaterialIcon type={icon} />
</div>
<div className="d-grid gap-2 text-center">
<h3 className="mb-0 fw-bold">{t('we_got_your_request')}</h3>
<div className="card-description-secondary">
{t('our_team_will_get_back_to_you_shortly')}
</div>
<h3 className="mb-0 fw-bold" data-testid="title">
{title}
</h3>
<div className="card-description-secondary">{content}</div>
</div>
<div className="text-center">
<Button variant="secondary" href="/user/subscription">
@ -47,4 +59,4 @@ function RequestConfirmation() {
)
}
export default RequestConfirmation
export default RequestStatus

View file

@ -0,0 +1,8 @@
import '../base'
import ReactDOM from 'react-dom'
import Root from '@/features/group-management/components/add-seats/root'
const element = document.getElementById('add-seats-root')
if (element) {
ReactDOM.render(<Root />, element)
}

View file

@ -1,8 +0,0 @@
import '../base'
import ReactDOM from 'react-dom'
import RequestConfirmation from '@/features/group-management/components/request-confirmation'
const element = document.getElementById('subscription-manage-group-root')
if (element) {
ReactDOM.render(<RequestConfirmation />, element)
}

View file

@ -201,6 +201,7 @@ export interface Meta {
'ol-tags': Tag[]
'ol-teamInvites': TeamInvite[]
'ol-thirdPartyIds': ThirdPartyIds
'ol-totalLicenses': number
'ol-translationIoNotLoaded': string
'ol-translationLoadErrorMessage': string
'ol-translationMaintenance': string

View file

@ -196,6 +196,14 @@
.material-symbols {
font-size: 2rem;
}
&.text-primary {
background-color: var(--bg-accent-03);
}
&.text-danger {
background-color: var(--bg-danger-03);
}
}
.card-description-secondary {

View file

@ -1,3 +1,5 @@
.list-group-item {
display: flex;
align-items: center;
min-height: 48px;
}

View file

@ -86,7 +86,7 @@
"add_more_editors": "Add more editors",
"add_more_managers": "Add more managers",
"add_more_members": "Add more members",
"add_more_users": "Add more users.",
"add_more_users": "Add more users",
"add_new_email": "Add new email",
"add_ons_are": "<strong>Add-ons:</strong> __addOnName__",
"add_or_remove_project_from_tag": "Add or remove project from tag __tagName__",
@ -94,6 +94,7 @@
"add_role_and_department": "Add role and department",
"add_to_dictionary": "Add to Dictionary",
"add_to_tag": "Add to tag",
"add_users": "Add users",
"add_your_comment_here": "Add your comment here",
"add_your_first_group_member_now": "Add your first group members now",
"added": "added",
@ -112,6 +113,7 @@
"administration_and_security": "Administration and security",
"advanced_reference_search": "Advanced <0>reference search</0>",
"advanced_reference_search_mode": "Advanced reference search",
"after_that_well_bill_you_x_annually_on_date_unless_you_cancel": "After that, well bill you __subtotal__ + applicable taxes annually on __date__, unless you cancel.",
"aggregate_changed": "Changed",
"aggregate_to": "to",
"agree_with_the_terms": "I agree with the Overleaf terms",
@ -393,6 +395,7 @@
"copy_project": "Copy Project",
"copy_response": "Copy response",
"copying": "Copying",
"cost_summary": "Cost summary",
"could_not_connect_to_collaboration_server": "Could not connect to collaboration server",
"could_not_connect_to_websocket_server": "Could not connect to WebSocket server",
"could_not_load_translations": "Could not load translations",
@ -616,6 +619,7 @@
"enter_any_size_including_units_or_valid_latex_command": "Enter any size (including units) or valid LaTeX command",
"enter_image_url": "Enter image URL",
"enter_the_confirmation_code": "Enter the 6-digit confirmation code sent to __email__.",
"enter_the_number_of_users_youd_like_to_add_to_see_the_cost_breakdown": "Enter the number of users youd like to add to see the cost breakdown.",
"enter_your_email_address": "Enter your email address",
"enter_your_email_address_below_and_we_will_send_you_a_link_to_reset_your_password": "Enter your email address below, and we will send you a link to reset your password",
"enter_your_new_password": "Enter your new password",
@ -956,6 +960,7 @@
"hotkeys": "Hotkeys",
"how_it_works": "How it works",
"how_many_users_do_you_need": "How many users do you need?",
"how_many_users_do_you_want_to_add": "How many users do you want to add?",
"how_to_create_tables": "How to create tables",
"how_to_insert_images": "How to insert images",
"how_we_use_your_data": "How we use your data",
@ -966,6 +971,8 @@
"if_have_existing_can_link": "If you have an existing <b>__appName__</b> account on another email, you can link it to your <b>__institutionName__</b> account by clicking <b>__clickText__</b>.",
"if_owner_can_link": "If you own the <b>__appName__</b> account with <b>__email__</b>, you will be allowed to link it to your <b>__institutionName__</b> institutional account.",
"if_you_need_to_customize_your_table_further_you_can": "If you need to customize your table further, you can. Using LaTeX code, you can change anything from table styles and border styles to colors and column widths. <0>Read our guide</0> to using tables in LaTeX to help you get started.",
"if_you_want_more_than_x_users_on_your_plan_we_need_to_add_them_for_you": "If you want more than __count__ users on your plan, we need to add them for you. Just click <0>Send request</0> below and well be happy to help.",
"if_you_want_to_reduce_the_number_of_users_please_contact_support": "If you want to reduce the number of users on your plan, please <0>contact customer support</0>.",
"if_your_occupation_not_listed_type_full_name": "If your __occupation__ isnt listed, you can type the full name.",
"ignore_and_continue_institution_linking": "You can also ignore this and <a href=\"__link__\">continue to __appName__ with your <b>__email__</b> account</a>.",
"ignore_validation_errors": "Dont check syntax",
@ -1070,6 +1077,7 @@
"is_not_used_on_any_other_website": "is not used on any other website",
"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.",
"ja": "Japanese",
"january": "January",
"join_beta_program": "Join beta program",
@ -1808,6 +1816,7 @@
"ro": "Romanian",
"role": "Role",
"ru": "Russian",
"sales_tax": "Sales tax",
"saml": "SAML",
"saml_auth_error": "Sorry, your identity provider responded with an error. Please contact your administrator for more information.",
"saml_authentication_required_error": "Other login methods have been disabled by your group administrator. Please use your group SSO login.",
@ -1862,6 +1871,7 @@
"search_whole_word": "Whole word",
"search_within_selection": "Within selection",
"searched_path_for_lines_containing": "Searched __path__ for lines containing \"__query__\"",
"seats": "Seats",
"secondary_email_password_reset": "That email is registered as a secondary email. Please enter the primary email for your account.",
"security": "Security",
"see_changes_in_your_documents_live": "See changes in your documents, live",
@ -1904,6 +1914,7 @@
"send": "Send",
"send_first_message": "Send your first message to your collaborators",
"send_message": "Send message",
"send_request": "Send request",
"send_test_email": "Send a test email",
"sending": "Sending",
"sent": "Sent",
@ -2274,6 +2285,7 @@
"tooltip_show_pdf": "Click to show the PDF",
"top_pick": "Top pick",
"total": "Total",
"total_due_today": "Total due today",
"total_per_month": "Total per month",
"total_per_year": "Total per year",
"total_per_year_for_x_users": "total per year for __licenseSize__ users",
@ -2378,6 +2390,7 @@
"upgrade": "Upgrade",
"upgrade_cc_btn": "Upgrade now, pay after 7 days",
"upgrade_for_12x_more_compile_time": "Upgrade to get 12x more compile time",
"upgrade_my_plan": "Upgrade my plan",
"upgrade_now": "Upgrade now",
"upgrade_to_add_more_editors": "Upgrade to add more editors to your project",
"upgrade_to_add_more_editors_and_access_collaboration_features": "Upgrade to add more editors and access collaboration features like track changes and full project history.",
@ -2420,6 +2433,9 @@
"valid": "Valid",
"valid_sso_configuration": "Valid SSO configuration",
"validation_issue_entry_description": "A validation issue which prevented this project from compiling",
"value_must_be_a_number": "Value must be a number",
"value_must_be_a_whole_number": "Value must be a whole number",
"value_must_be_at_least_x": "Value must be at least __value__",
"vat": "VAT",
"vat_number": "VAT Number",
"verify_email_address_before_enabling_managed_users": "You need to verify your email address before enabling managed users.",
@ -2460,6 +2476,7 @@
"we_logged_you_in": "We have logged you in.",
"we_may_also_contact_you_from_time_to_time_by_email_with_a_survey": "<0>We may also contact you</0> from time to time by email with a survey, or to see if you would like to participate in other user research initiatives",
"we_sent_new_code": "Weve sent a new code. If it doesnt arrive, make sure to check your spam and any promotions folders.",
"we_will_charge_you_now_for_the_cost_of_your_additional_users_based_on_remaining_months": "Well charge you now for the cost of your additional users based on the remaining months of your current subscription.",
"webinars": "Webinars",
"website_status": "Website status",
"wed_love_you_to_stay": "Wed love you to stay",
@ -2563,6 +2580,7 @@
"your_affiliation_is_confirmed": "Your <0>__institutionName__</0> affiliation is confirmed.",
"your_browser_does_not_support_this_feature": "Sorry, your browser doesnt support this feature. Please update your browser to its latest version.",
"your_compile_timed_out": "Your compile timed out",
"your_current_plan_supports_up_to_x_users": "Your current plan supports up to __users__ users.",
"your_current_project_will_revert_to_the_version_from_time": "Your current project will revert to the version from __timestamp__",
"your_git_access_info": "Your Git authentication tokens should be entered whenever youre prompted for a password.",
"your_git_access_info_bullet_1": "You can have up to 10 tokens.",
@ -2594,11 +2612,14 @@
"youre_about_to_disable_single_sign_on": "Youre about to disable single sign-on for all group members.",
"youre_about_to_enable_single_sign_on": "Youre about to enable single sign-on (SSO). Before you do this, you should ensure youre confident the SSO configuration is correct and all your group members have managed user accounts.",
"youre_about_to_enable_single_sign_on_sso_only": "Youre about to enable single sign-on (SSO). Before you do this, you should ensure youre confident the SSO configuration is correct.",
"youre_adding_x_users_to_your_plan_giving_you_a_total_of_y_users": "Youre adding <0>__adding__</0> users to your plan giving you a total of <1>__total__</1> users.",
"youre_already_setup_for_sso": "Youre already set up for SSO",
"youre_joining": "Youre joining",
"youre_on_free_trial_which_ends_on": "Youre on a free trial which ends on <0>__date__</0>.",
"youre_signed_in_as_logout": "Youre signed in as <0>__email__</0>. <1>Log out.</1>",
"youre_signed_up": "Youre signed up",
"youve_added_more_users": "Youve added more users!",
"youve_added_x_more_users_to_your_subscription_invite_people": "Youve added __users__ more users to your subscription. <0>Invite people</0>.",
"youve_lost_edit_access": "Youve lost edit access",
"youve_unlinked_all_users": "Youve unlinked all users",
"zh-CN": "Chinese",

View file

@ -1,12 +1,14 @@
import '../../../helpers/bootstrap-5'
import RequestConfirmation from '@/features/group-management/components/request-confirmation'
import RequestStatus from '@/features/group-management/components/request-status'
describe('request confirmation page', function () {
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
})
cy.mount(<RequestConfirmation />)
cy.mount(
<RequestStatus icon="email" title="Test title" content="Test content" />
)
})
it('renders the back button', function () {
@ -25,9 +27,12 @@ describe('request confirmation page', function () {
})
})
it('indicates the message was received', function () {
cy.findByRole('heading', { name: /weve got your request/i })
cy.findByText(/our team will get back to you shortly/i)
it('shows the title', function () {
cy.findByTestId('title').should('contain.text', 'Test title')
})
it('shows the content', function () {
cy.findByText('Test content')
})
it('renders the link to subscriptions', function () {

View file

@ -192,6 +192,39 @@ describe('RecurlyEntities', function () {
})
})
describe('getRequestForAddOnUpdate()', function () {
it('returns a change request', function () {
const {
RecurlySubscriptionChangeRequest,
RecurlySubscriptionAddOnUpdate,
} = this.RecurlyEntities
const newQuantity = 2
const changeRequest = this.subscription.getRequestForAddOnUpdate(
'add-on-code',
newQuantity
)
expect(changeRequest).to.deep.equal(
new RecurlySubscriptionChangeRequest({
subscription: this.subscription,
timeframe: 'now',
addOnUpdates: [
new RecurlySubscriptionAddOnUpdate({
code: this.addOn.code,
quantity: newQuantity,
unitPrice: this.addOn.unitPrice,
}),
],
})
)
})
it("throws a AddOnNotPresentError if the subscription doesn't have the add-on", function () {
expect(() =>
this.subscription.getRequestForAddOnUpdate('another-add-on', 2)
).to.throw(Errors.AddOnNotPresentError)
})
})
describe('getRequestForAddOnRemoval()', function () {
it('returns a change request', function () {
const changeRequest = this.subscription.getRequestForAddOnRemoval(

View file

@ -1,6 +1,6 @@
import esmock from 'esmock'
import sinon from 'sinon'
import { expect } from 'chai'
const modulePath =
'../../../../app/src/Features/Subscription/SubscriptionGroupController'
@ -26,6 +26,7 @@ describe('SubscriptionGroupController', function () {
this.subscription = {
_id: this.subscriptionId,
teamName: 'Cool group',
groupPlan: true,
}
this.SubscriptionGroupHandler = {
@ -37,7 +38,6 @@ describe('SubscriptionGroupController', function () {
this.SubscriptionLocator = {
promises: {
getSubscription: sinon.stub().resolves(this.subscription),
getUsersSubscription: sinon.stub().resolves(this.subscription),
},
}
@ -71,6 +71,12 @@ describe('SubscriptionGroupController', function () {
getAssignment: sinon.stub().yields(null, { variant: 'default' }),
}
this.UserGetter = {
promises: {
getUserEmail: sinon.stub().resolves(this.user),
},
}
this.Controller = await esmock.strict(modulePath, {
'../../../../app/src/Features/Subscription/SubscriptionGroupHandler':
this.SubscriptionGroupHandler,
@ -83,6 +89,7 @@ describe('SubscriptionGroupController', function () {
'../../../../app/src/infrastructure/Modules': this.Modules,
'../../../../app/src/Features/SplitTests/SplitTestHandler':
this.SplitTestHandler,
'../../../../app/src/Features/User/UserGetter': this.UserGetter,
'../../../../app/src/Features/Errors/ErrorController':
(this.ErrorController = {
notFound: sinon.stub(),
@ -269,19 +276,4 @@ describe('SubscriptionGroupController', function () {
this.Controller.removeSelfFromGroup(this.req, res, done)
})
})
describe('add seats', function () {
it('render the request confirmation view', async function () {
this.SplitTestHandler.promises.getAssignment.resolves({
variant: 'enabled',
})
await this.Controller.requestConfirmation(this.req, {
render: (viewPath, viewParams) => {
expect(viewPath).to.equal('subscriptions/request-confirmation-react')
expect(viewParams.groupName).to.equal('Cool group')
},
})
expect(this.ErrorController.notFound).to.not.have.been.called
})
})
})

View file

@ -27,6 +27,10 @@ describe('SubscriptionGroupHandler', function () {
},
}
this.SubscriptionController = {
makeChangePreview: sinon.stub().resolves(),
}
this.SubscriptionUpdater = {
promises: {
removeUserFromGroup: sinon.stub().resolves(),
@ -40,13 +44,19 @@ describe('SubscriptionGroupHandler', function () {
findOne: sinon.stub().returns({ exec: sinon.stub().resolves }),
}
this.RecurlyClient = {
promises: {},
}
this.Handler = SandboxedModule.require(modulePath, {
requires: {
'./SubscriptionUpdater': this.SubscriptionUpdater,
'./SubscriptionLocator': this.SubscriptionLocator,
'./SubscriptionController': this.SubscriptionController,
'../../models/Subscription': {
Subscription: this.Subscription,
},
'./RecurlyClient': this.RecurlyClient,
},
})
})

View file

@ -1,18 +1 @@
export type CurrencyCode =
| 'AUD'
| 'BRL'
| 'CAD'
| 'CHF'
| 'CLP'
| 'COP'
| 'DKK'
| 'EUR'
| 'GBP'
| 'INR'
| 'MXN'
| 'NOK'
| 'NZD'
| 'PEN'
| 'SEK'
| 'SGD'
| 'USD'
export type { CurrencyCode } from './subscription/currency'

View file

@ -34,13 +34,20 @@ type AddOn = {
amount: number
}
export type SubscriptionChangeDescription = AddOnPurchase | PremiumSubscription
export type SubscriptionChangeDescription =
| AddOnPurchase
| AddOnUpdate
| PremiumSubscription
export type AddOnPurchase = {
type: 'add-on-purchase'
addOn: {
code: string
name: string
addOn: Pick<AddOn, 'code' | 'name'>
}
export type AddOnUpdate = {
type: 'add-on-update'
addOn: Pick<AddOn, 'code' | 'quantity'> & {
prevQuantity: AddOn['quantity']
}
}