mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-06-02 21:59:00 +02:00
Merge pull request #25318 from overleaf/ii-flexible-licensing-manually-collected-3
[web] Add seats feature for manually collected subscriptions improvements GitOrigin-RevId: 4fbd93097590d97ad6464d1988471a78bf7cb9e2
This commit is contained in:
@@ -24,6 +24,8 @@ class InactiveError extends OError {}
|
||||
|
||||
class SubtotalLimitExceededError extends OError {}
|
||||
|
||||
class HasPastDueInvoiceError extends OError {}
|
||||
|
||||
module.exports = {
|
||||
RecurlyTransactionError,
|
||||
DuplicateAddOnError,
|
||||
@@ -33,4 +35,5 @@ module.exports = {
|
||||
PendingChangeError,
|
||||
InactiveError,
|
||||
SubtotalLimitExceededError,
|
||||
HasPastDueInvoiceError,
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ class PaymentProviderSubscription {
|
||||
* @param {Date} props.periodStart
|
||||
* @param {Date} props.periodEnd
|
||||
* @param {string} props.collectionMethod
|
||||
* @param {number} [props.netTerms]
|
||||
* @param {string} [props.poNumber]
|
||||
* @param {string} [props.termsAndConditions]
|
||||
* @param {PaymentProviderSubscriptionChange} [props.pendingChange]
|
||||
@@ -55,6 +56,7 @@ class PaymentProviderSubscription {
|
||||
this.periodStart = props.periodStart
|
||||
this.periodEnd = props.periodEnd
|
||||
this.collectionMethod = props.collectionMethod
|
||||
this.netTerms = props.netTerms ?? 0
|
||||
this.poNumber = props.poNumber ?? ''
|
||||
this.termsAndConditions = props.termsAndConditions ?? ''
|
||||
this.pendingChange = props.pendingChange ?? null
|
||||
|
||||
@@ -384,25 +384,6 @@ 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'
|
||||
@@ -467,6 +448,7 @@ function subscriptionFromApi(apiSubscription) {
|
||||
apiSubscription.currentPeriodStartedAt == null ||
|
||||
apiSubscription.currentPeriodEndsAt == null ||
|
||||
apiSubscription.collectionMethod == null ||
|
||||
apiSubscription.netTerms == null ||
|
||||
// The values below could be null initially if the subscription has never updated
|
||||
!('poNumber' in apiSubscription) ||
|
||||
!('termsAndConditions' in apiSubscription)
|
||||
@@ -491,6 +473,7 @@ function subscriptionFromApi(apiSubscription) {
|
||||
periodStart: apiSubscription.currentPeriodStartedAt,
|
||||
periodEnd: apiSubscription.currentPeriodEndsAt,
|
||||
collectionMethod: apiSubscription.collectionMethod,
|
||||
netTerms: apiSubscription.netTerms ?? 0,
|
||||
poNumber: apiSubscription.poNumber ?? '',
|
||||
termsAndConditions: apiSubscription.termsAndConditions ?? '',
|
||||
service: 'recurly',
|
||||
@@ -720,7 +703,6 @@ module.exports = {
|
||||
getPaymentMethod: callbackify(getPaymentMethod),
|
||||
getAddOn: callbackify(getAddOn),
|
||||
getPlan: callbackify(getPlan),
|
||||
getCountryCode: callbackify(getCountryCode),
|
||||
subscriptionIsCanceledOrExpired,
|
||||
pauseSubscriptionByUuid: callbackify(pauseSubscriptionByUuid),
|
||||
resumeSubscriptionByUuid: callbackify(resumeSubscriptionByUuid),
|
||||
@@ -744,6 +726,5 @@ module.exports = {
|
||||
getPaymentMethod,
|
||||
getAddOn,
|
||||
getPlan,
|
||||
getCountryCode,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -740,6 +740,7 @@ function makeChangePreview(
|
||||
currency: subscription.currency,
|
||||
immediateCharge: { ...subscriptionChange.immediateCharge },
|
||||
paymentMethod: paymentMethod?.toString(),
|
||||
netTerms: subscription.netTerms,
|
||||
nextPlan: {
|
||||
annual: nextPlan.annual ?? false,
|
||||
},
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
PendingChangeError,
|
||||
InactiveError,
|
||||
SubtotalLimitExceededError,
|
||||
HasPastDueInvoiceError,
|
||||
} from './Errors.js'
|
||||
import RecurlyClient from './RecurlyClient.js'
|
||||
|
||||
@@ -142,6 +143,9 @@ async function addSeatsToGroupSubscription(req, res) {
|
||||
await SubscriptionGroupHandler.promises.ensureSubscriptionIsActive(
|
||||
subscription
|
||||
)
|
||||
await SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPastDueInvoice(
|
||||
subscription
|
||||
)
|
||||
|
||||
const { variant: flexibleLicensingForManuallyBilledSubscriptionsVariant } =
|
||||
await SplitTestHandler.promises.getAssignment(
|
||||
@@ -183,7 +187,11 @@ async function addSeatsToGroupSubscription(req, res) {
|
||||
)
|
||||
}
|
||||
|
||||
if (error instanceof PendingChangeError || error instanceof InactiveError) {
|
||||
if (
|
||||
error instanceof PendingChangeError ||
|
||||
error instanceof InactiveError ||
|
||||
error instanceof HasPastDueInvoiceError
|
||||
) {
|
||||
return res.redirect('/user/subscription')
|
||||
}
|
||||
|
||||
@@ -216,7 +224,8 @@ async function previewAddSeatsSubscriptionChange(req, res) {
|
||||
error instanceof MissingBillingInfoError ||
|
||||
error instanceof ManuallyCollectedError ||
|
||||
error instanceof PendingChangeError ||
|
||||
error instanceof InactiveError
|
||||
error instanceof InactiveError ||
|
||||
error instanceof HasPastDueInvoiceError
|
||||
) {
|
||||
return res.status(422).end()
|
||||
}
|
||||
@@ -258,7 +267,8 @@ async function createAddSeatsSubscriptionChange(req, res) {
|
||||
error instanceof MissingBillingInfoError ||
|
||||
error instanceof ManuallyCollectedError ||
|
||||
error instanceof PendingChangeError ||
|
||||
error instanceof InactiveError
|
||||
error instanceof InactiveError ||
|
||||
error instanceof HasPastDueInvoiceError
|
||||
) {
|
||||
return res.status(422).end()
|
||||
}
|
||||
@@ -395,6 +405,12 @@ async function manuallyCollectedSubscription(req, res) {
|
||||
const subscription =
|
||||
await SubscriptionLocator.promises.getUsersSubscription(userId)
|
||||
|
||||
await SplitTestHandler.promises.getAssignment(
|
||||
req,
|
||||
res,
|
||||
'flexible-group-licensing-for-manually-billed-subscriptions'
|
||||
)
|
||||
|
||||
res.render('subscriptions/manually-collected-subscription', {
|
||||
groupName: subscription.teamName,
|
||||
})
|
||||
|
||||
@@ -17,6 +17,7 @@ const {
|
||||
ManuallyCollectedError,
|
||||
PendingChangeError,
|
||||
InactiveError,
|
||||
HasPastDueInvoiceError,
|
||||
} = require('./Errors')
|
||||
const EmailHelper = require('../Helpers/EmailHelper')
|
||||
const { InvalidEmailError } = require('../Errors/Errors')
|
||||
@@ -103,6 +104,22 @@ async function ensureSubscriptionHasNoPendingChanges(recurlySubscription) {
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureSubscriptionHasNoPastDueInvoice(subscription) {
|
||||
const [paymentRecord] = await Modules.promises.hooks.fire(
|
||||
'getPaymentFromRecord',
|
||||
subscription
|
||||
)
|
||||
|
||||
if (paymentRecord.account.hasPastDueInvoice) {
|
||||
throw new HasPastDueInvoiceError(
|
||||
'This subscription has a past due invoice',
|
||||
{
|
||||
subscriptionId: subscription._id.toString(),
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function getUsersGroupSubscriptionDetails(userId) {
|
||||
const subscription =
|
||||
await SubscriptionLocator.promises.getUsersSubscription(userId)
|
||||
@@ -144,6 +161,7 @@ async function _addSeatsSubscriptionChange(userId, adding) {
|
||||
await ensureSubscriptionIsActive(subscription)
|
||||
await ensureSubscriptionHasNoPendingChanges(recurlySubscription)
|
||||
await checkBillingInfoExistence(recurlySubscription, userId)
|
||||
await ensureSubscriptionHasNoPastDueInvoice(subscription)
|
||||
|
||||
const currentAddonQuantity =
|
||||
recurlySubscription.addOns.find(
|
||||
@@ -259,10 +277,9 @@ async function updateSubscriptionPaymentTerms(
|
||||
recurlySubscription,
|
||||
poNumber
|
||||
) {
|
||||
const countryCode = await RecurlyClient.promises.getCountryCode(userId)
|
||||
const [termsAndConditions] = await Modules.promises.hooks.fire(
|
||||
'generateTermsAndConditions',
|
||||
{ countryCode, poNumber }
|
||||
{ currency: recurlySubscription.currency, poNumber }
|
||||
)
|
||||
|
||||
const updateRequest = poNumber
|
||||
@@ -464,6 +481,9 @@ module.exports = {
|
||||
ensureSubscriptionHasNoPendingChanges: callbackify(
|
||||
ensureSubscriptionHasNoPendingChanges
|
||||
),
|
||||
ensureSubscriptionHasNoPastDueInvoice: callbackify(
|
||||
ensureSubscriptionHasNoPastDueInvoice
|
||||
),
|
||||
getTotalConfirmedUsersInGroup: callbackify(getTotalConfirmedUsersInGroup),
|
||||
isUserPartOfGroup: callbackify(isUserPartOfGroup),
|
||||
getGroupPlanUpgradePreview: callbackify(getGroupPlanUpgradePreview),
|
||||
@@ -477,6 +497,7 @@ module.exports = {
|
||||
ensureSubscriptionIsActive,
|
||||
ensureSubscriptionCollectionMethodIsNotManual,
|
||||
ensureSubscriptionHasNoPendingChanges,
|
||||
ensureSubscriptionHasNoPastDueInvoice,
|
||||
getTotalConfirmedUsersInGroup,
|
||||
isUserPartOfGroup,
|
||||
getUsersGroupSubscriptionDetails,
|
||||
|
||||
@@ -19,11 +19,12 @@ const subscriptionRateLimiter = new RateLimiter('subscription', {
|
||||
})
|
||||
|
||||
const MAX_NUMBER_OF_USERS = 20
|
||||
const MAX_NUMBER_OF_PO_NUMBER_CHARACTERS = 50
|
||||
|
||||
const addSeatsValidateSchema = {
|
||||
body: Joi.object({
|
||||
adding: Joi.number().integer().min(1).max(MAX_NUMBER_OF_USERS).required(),
|
||||
poNumber: Joi.string(),
|
||||
poNumber: Joi.string().max(MAX_NUMBER_OF_PO_NUMBER_CHARACTERS),
|
||||
}),
|
||||
}
|
||||
|
||||
|
||||
@@ -849,6 +849,7 @@
|
||||
"issued_on": "",
|
||||
"it_looks_like_that_didnt_work_you_can_try_again_or_get_in_touch": "",
|
||||
"it_looks_like_your_account_is_billed_manually": "",
|
||||
"it_looks_like_your_account_is_billed_manually_upgrading_subscription": "",
|
||||
"it_looks_like_your_payment_details_are_missing_please_update_your_billing_information": "",
|
||||
"italics": "",
|
||||
"join_beta_program": "",
|
||||
@@ -1237,6 +1238,9 @@
|
||||
"plus_more": "",
|
||||
"plus_x_additional_licenses_for_a_total_of_y_licenses": "",
|
||||
"po_number": "",
|
||||
"po_number_can_include_digits_and_letters_only": "",
|
||||
"po_number_must_not_exceed_x_characters": "",
|
||||
"po_number_must_not_exceed_x_characters_plural": "",
|
||||
"postal_code": "",
|
||||
"premium": "",
|
||||
"premium_feature": "",
|
||||
@@ -1850,6 +1854,7 @@
|
||||
"tooltip_show_filetree": "",
|
||||
"tooltip_show_panel": "",
|
||||
"tooltip_show_pdf": "",
|
||||
"total_due_in_x_days": "",
|
||||
"total_due_today": "",
|
||||
"total_per_month": "",
|
||||
"total_per_year": "",
|
||||
@@ -2034,6 +2039,7 @@
|
||||
"we_sent_new_code": "",
|
||||
"we_will_charge_you_now_for_the_cost_of_your_additional_licenses_based_on_remaining_months": "",
|
||||
"we_will_charge_you_now_for_your_new_plan_based_on_the_remaining_months_of_your_current_subscription": "",
|
||||
"we_will_invoice_you_now_for_the_additional_licenses_based_on_remaining_months": "",
|
||||
"we_will_use_your_existing_payment_method": "",
|
||||
"webinars": "",
|
||||
"website_status": "",
|
||||
|
||||
+44
-2
@@ -33,6 +33,7 @@ import { sendMB } from '../../../../infrastructure/event-tracking'
|
||||
import { useFeatureFlag } from '@/shared/context/split-test-context'
|
||||
|
||||
export const MAX_NUMBER_OF_USERS = 20
|
||||
export const MAX_NUMBER_OF_PO_NUMBER_CHARACTERS = 50
|
||||
|
||||
type CostSummaryData = MergeAndOverride<
|
||||
SubscriptionChangePreview,
|
||||
@@ -47,6 +48,7 @@ function AddSeats() {
|
||||
const isProfessional = getMeta('ol-isProfessional')
|
||||
const isCollectionMethodManual = getMeta('ol-isCollectionMethodManual')
|
||||
const [addSeatsInputError, setAddSeatsInputError] = useState<string>()
|
||||
const [poNumberInputError, setPoNumberInputError] = useState<string>()
|
||||
const [shouldContactSales, setShouldContactSales] = useState(false)
|
||||
const isFlexibleGroupLicensingForManuallyBilledSubscriptions = useFeatureFlag(
|
||||
'flexible-group-licensing-for-manually-billed-subscriptions'
|
||||
@@ -125,6 +127,38 @@ function AddSeats() {
|
||||
}
|
||||
}
|
||||
|
||||
const poNumberValidationSchema = useMemo(() => {
|
||||
return yup
|
||||
.string()
|
||||
.matches(
|
||||
/^[\p{L}\p{N}]*$/u,
|
||||
t('po_number_can_include_digits_and_letters_only')
|
||||
)
|
||||
.max(
|
||||
MAX_NUMBER_OF_PO_NUMBER_CHARACTERS,
|
||||
t('po_number_must_not_exceed_x_characters', {
|
||||
count: MAX_NUMBER_OF_PO_NUMBER_CHARACTERS,
|
||||
})
|
||||
)
|
||||
}, [t])
|
||||
|
||||
const validatePoNumber = async (value: string | undefined) => {
|
||||
try {
|
||||
await poNumberValidationSchema.validate(value)
|
||||
setPoNumberInputError(undefined)
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
if (error instanceof yup.ValidationError) {
|
||||
setPoNumberInputError(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)
|
||||
@@ -161,7 +195,10 @@ function AddSeats() {
|
||||
? undefined
|
||||
: (formData.get('po_number') as string)
|
||||
|
||||
if (!(await validateSeats(rawSeats))) {
|
||||
if (
|
||||
!(await validateSeats(rawSeats)) ||
|
||||
!(await validatePoNumber(poNumber))
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -337,7 +374,12 @@ function AddSeats() {
|
||||
)}
|
||||
</FormGroup>
|
||||
{isFlexibleGroupLicensingForManuallyBilledSubscriptions &&
|
||||
isCollectionMethodManual && <PoNumber />}
|
||||
isCollectionMethodManual && (
|
||||
<PoNumber
|
||||
error={poNumberInputError}
|
||||
validate={validatePoNumber}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<CostSummarySection
|
||||
isLoadingCostSummary={isLoadingCostSummary}
|
||||
|
||||
+17
-4
@@ -7,6 +7,7 @@ import {
|
||||
SubscriptionChangePreview,
|
||||
} from '../../../../../../types/subscription/subscription-change-preview'
|
||||
import { MergeAndOverride } from '../../../../../../types/utils'
|
||||
import getMeta from '@/utils/meta'
|
||||
|
||||
type CostSummaryProps = {
|
||||
subscriptionChange: MergeAndOverride<
|
||||
@@ -18,6 +19,7 @@ type CostSummaryProps = {
|
||||
|
||||
function CostSummary({ subscriptionChange, totalLicenses }: CostSummaryProps) {
|
||||
const { t } = useTranslation()
|
||||
const isCollectionMethodManual = getMeta('ol-isCollectionMethodManual')
|
||||
const factor = 100
|
||||
|
||||
return (
|
||||
@@ -109,7 +111,13 @@ function CostSummary({ subscriptionChange, totalLicenses }: CostSummaryProps) {
|
||||
className="bg-transparent border-0 px-0 gap-3 card-description-secondary"
|
||||
data-testid="total"
|
||||
>
|
||||
<strong className="me-auto">{t('total_due_today')}</strong>
|
||||
<strong className="me-auto">
|
||||
{isCollectionMethodManual
|
||||
? t('total_due_in_x_days', {
|
||||
days: subscriptionChange.netTerms,
|
||||
})
|
||||
: t('total_due_today')}
|
||||
</strong>
|
||||
<strong data-testid="price">
|
||||
{formatCurrency(
|
||||
subscriptionChange.immediateCharge.total,
|
||||
@@ -121,9 +129,14 @@ function CostSummary({ subscriptionChange, totalLicenses }: CostSummaryProps) {
|
||||
<hr className="m-0" />
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
'we_will_charge_you_now_for_the_cost_of_your_additional_licenses_based_on_remaining_months'
|
||||
)}
|
||||
{isCollectionMethodManual
|
||||
? t(
|
||||
'we_will_invoice_you_now_for_the_additional_licenses_based_on_remaining_months',
|
||||
{ days: subscriptionChange.netTerms }
|
||||
)
|
||||
: t(
|
||||
'we_will_charge_you_now_for_the_cost_of_your_additional_licenses_based_on_remaining_months'
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
|
||||
+16
-2
@@ -1,9 +1,15 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FormControl, FormGroup, FormLabel } from 'react-bootstrap-5'
|
||||
import FormText from '@/features/ui/components/bootstrap-5/form/form-text'
|
||||
import OLFormCheckbox from '@/features/ui/components/ol/ol-form-checkbox'
|
||||
|
||||
function PoNumber() {
|
||||
type PoNumberProps = {
|
||||
error: string | undefined
|
||||
validate: (value: string | undefined) => Promise<boolean>
|
||||
}
|
||||
|
||||
function PoNumber({ error, validate }: PoNumberProps) {
|
||||
const { t } = useTranslation()
|
||||
const [isPoNumberChecked, setIsPoNumberChecked] = useState(false)
|
||||
|
||||
@@ -20,7 +26,15 @@ function PoNumber() {
|
||||
{isPoNumberChecked && (
|
||||
<FormGroup className="mt-2" controlId="po-number">
|
||||
<FormLabel>{t('po_number')}</FormLabel>
|
||||
<FormControl type="text" required className="w-25" name="po_number" />
|
||||
<FormControl
|
||||
type="text"
|
||||
required
|
||||
className="w-25"
|
||||
name="po_number"
|
||||
onChange={async e => await validate(e.target.value)}
|
||||
isInvalid={Boolean(error)}
|
||||
/>
|
||||
{Boolean(error) && <FormText type="error">{error}</FormText>}
|
||||
</FormGroup>
|
||||
)}
|
||||
</>
|
||||
|
||||
+28
-7
@@ -1,9 +1,20 @@
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import OLNotification from '@/features/ui/components/ol/ol-notification'
|
||||
import Card from '@/features/group-management/components/card'
|
||||
import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n'
|
||||
import { useFeatureFlag } from '@/shared/context/split-test-context'
|
||||
|
||||
function ManuallyCollectedSubscription() {
|
||||
const { t } = useTranslation()
|
||||
const isFlexibleGroupLicensingForManuallyBilledSubscriptions = useFeatureFlag(
|
||||
'flexible-group-licensing-for-manually-billed-subscriptions'
|
||||
)
|
||||
|
||||
const { isReady } = useWaitForI18n()
|
||||
|
||||
if (!isReady) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -11,13 +22,23 @@ function ManuallyCollectedSubscription() {
|
||||
type="error"
|
||||
title={t('account_billed_manually')}
|
||||
content={
|
||||
<Trans
|
||||
i18nKey="it_looks_like_your_account_is_billed_manually"
|
||||
components={[
|
||||
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
|
||||
<a href="/contact" rel="noreferrer noopener" />,
|
||||
]}
|
||||
/>
|
||||
isFlexibleGroupLicensingForManuallyBilledSubscriptions ? (
|
||||
<Trans
|
||||
i18nKey="it_looks_like_your_account_is_billed_manually_upgrading_subscription"
|
||||
components={[
|
||||
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
|
||||
<a href="/contact" rel="noreferrer noopener" />,
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<Trans
|
||||
i18nKey="it_looks_like_your_account_is_billed_manually"
|
||||
components={[
|
||||
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
|
||||
<a href="/contact" rel="noreferrer noopener" />,
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
className="m-0"
|
||||
/>
|
||||
|
||||
+1
-1
@@ -354,7 +354,7 @@ function FlexibleGroupLicensingActions({
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (subscription.pendingPlan) {
|
||||
if (subscription.pendingPlan || subscription.payment.hasPastDueInvoice) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
+6
-1
@@ -1,9 +1,14 @@
|
||||
import '../base'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import ManuallyCollectedSubscription from '@/features/group-management/components/manually-collected-subscription'
|
||||
import { SplitTestProvider } from '@/shared/context/split-test-context'
|
||||
|
||||
const element = document.getElementById('manually-collected-subscription-root')
|
||||
if (element) {
|
||||
const root = createRoot(element)
|
||||
root.render(<ManuallyCollectedSubscription />)
|
||||
root.render(
|
||||
<SplitTestProvider>
|
||||
<ManuallyCollectedSubscription />
|
||||
</SplitTestProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1105,6 +1105,7 @@
|
||||
"it": "Italian",
|
||||
"it_looks_like_that_didnt_work_you_can_try_again_or_get_in_touch": "It looks like that didn’t work. You can try again or <0>get in touch</0> with our Support team for more help.",
|
||||
"it_looks_like_your_account_is_billed_manually": "It looks like your account is being billed manually - adding seats or upgrading your subscription can only be done by the Support team. Please <0>get in touch</0> for help.",
|
||||
"it_looks_like_your_account_is_billed_manually_upgrading_subscription": "It looks like your account is being billed manually - upgrading your subscription can only be done by the Support team. Please <0>get in touch</0> for help.",
|
||||
"it_looks_like_your_payment_details_are_missing_please_update_your_billing_information": "It looks like your payment details are missing. Please <0>update your billing information</0>, or <1>get in touch</1> with our Support team for more help.",
|
||||
"italics": "Italics",
|
||||
"ja": "Japanese",
|
||||
@@ -1642,6 +1643,8 @@
|
||||
"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",
|
||||
"po_number_can_include_digits_and_letters_only": "PO number can include digits and letters only",
|
||||
"po_number_must_not_exceed_x_characters": "PO number must not exceed __count__ characters",
|
||||
"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",
|
||||
@@ -2377,6 +2380,7 @@
|
||||
"tooltip_show_pdf": "Click to show the PDF",
|
||||
"top_pick": "Top pick",
|
||||
"total": "Total",
|
||||
"total_due_in_x_days": "Total due in __days__ days",
|
||||
"total_due_today": "Total due today",
|
||||
"total_per_month": "Total per month",
|
||||
"total_per_year": "Total per year",
|
||||
@@ -2581,6 +2585,7 @@
|
||||
"we_sent_new_code": "We’ve sent a new code. If it doesn’t arrive, make sure to check your spam and any promotions folders.",
|
||||
"we_will_charge_you_now_for_the_cost_of_your_additional_licenses_based_on_remaining_months": "We’ll charge you now for the cost of your additional licenses based on the remaining months of your current subscription.",
|
||||
"we_will_charge_you_now_for_your_new_plan_based_on_the_remaining_months_of_your_current_subscription": "We’ll charge you now for your new plan based on the remaining months of your current subscription.",
|
||||
"we_will_invoice_you_now_for_the_additional_licenses_based_on_remaining_months": "We’ll invoice you now for the additional licences based on the remaining months of your current subscription, and payment will be due in __days__ days.",
|
||||
"we_will_use_your_existing_payment_method": "We’ll use your existing payment method <strong>__paymentMethod__</strong>.",
|
||||
"webinars": "Webinars",
|
||||
"website_status": "Website status",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import AddSeats, {
|
||||
MAX_NUMBER_OF_USERS,
|
||||
MAX_NUMBER_OF_PO_NUMBER_CHARACTERS,
|
||||
} from '@/features/group-management/components/add-seats/add-seats'
|
||||
import { SplitTestProvider } from '@/shared/context/split-test-context'
|
||||
|
||||
@@ -97,6 +98,30 @@ describe('<AddSeats />', function () {
|
||||
cy.findByLabelText(/i want to add a po number/i).check()
|
||||
cy.findByLabelText(/^po number$/i)
|
||||
})
|
||||
|
||||
describe('validation', function () {
|
||||
beforeEach(function () {
|
||||
cy.findByLabelText(/i want to add a po number/i).check()
|
||||
})
|
||||
|
||||
it('should show max characters error', function () {
|
||||
const totalCharacters = 'a'.repeat(
|
||||
MAX_NUMBER_OF_PO_NUMBER_CHARACTERS + 1
|
||||
)
|
||||
cy.findByLabelText(/^po number$/i).type(totalCharacters)
|
||||
cy.findByText(
|
||||
new RegExp(
|
||||
`po number must not exceed ${MAX_NUMBER_OF_PO_NUMBER_CHARACTERS} characters`,
|
||||
'i'
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('should show letters and numbers only error', function () {
|
||||
cy.findByLabelText(/^po number$/i).type('🚧')
|
||||
cy.findByText(/po number can include digits and letters only/i)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('"Upgrade my plan" link', function () {
|
||||
@@ -251,6 +276,7 @@ describe('<AddSeats />', function () {
|
||||
},
|
||||
},
|
||||
currency: 'USD',
|
||||
netTerms: 30,
|
||||
immediateCharge: {
|
||||
subtotal: 100,
|
||||
tax: 20,
|
||||
@@ -276,13 +302,16 @@ describe('<AddSeats />', function () {
|
||||
cy.findByRole('button', { name: /send request/i }).should('not.exist')
|
||||
})
|
||||
|
||||
it('renders the preview data', function () {
|
||||
function makeRequest(body: object, inputValue: string) {
|
||||
cy.intercept('POST', '/user/subscription/group/add-users/preview', {
|
||||
statusCode: 200,
|
||||
body: this.body,
|
||||
body,
|
||||
}).as('addUsersRequest')
|
||||
cy.get('@input').type(this.adding.toString())
|
||||
cy.get('@input').type(inputValue)
|
||||
}
|
||||
|
||||
it('renders common preview data content', function () {
|
||||
makeRequest(this.body, this.adding.toString())
|
||||
cy.findByTestId('cost-summary').within(() => {
|
||||
cy.contains(
|
||||
new RegExp(
|
||||
@@ -314,22 +343,55 @@ describe('<AddSeats />', function () {
|
||||
cy.findByTestId('discount').should('not.exist')
|
||||
|
||||
cy.findByTestId('total').within(() => {
|
||||
cy.findByText(/total due today/i)
|
||||
cy.findByTestId('price').should(
|
||||
'have.text',
|
||||
`$${this.body.immediateCharge.total}.00`
|
||||
)
|
||||
})
|
||||
|
||||
cy.findByText(
|
||||
/we’ll charge you now for the cost of your additional licenses based on the remaining months of your current subscription/i
|
||||
)
|
||||
cy.findByText(
|
||||
/after that, we’ll bill you \$1,000\.00 \(\$895\.00 \+ \$105\.00 tax\) annually on December 1, unless you cancel/i
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the preview data with manually billed subscription', function () {
|
||||
makeRequest(this.body, this.adding.toString())
|
||||
cy.findByTestId('cost-summary').within(() => {
|
||||
cy.findByTestId('total').within(() => {
|
||||
cy.findByText(
|
||||
new RegExp(`total due in ${this.body.netTerms} days`, 'i')
|
||||
)
|
||||
})
|
||||
})
|
||||
cy.findByText(
|
||||
new RegExp(
|
||||
`we’ll invoice you now for the additional licences based on the remaining months of your current subscription, and payment will be due in ${this.body.netTerms} days`,
|
||||
'i'
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('renders the preview data with automatically billed subscription', function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-isCollectionMethodManual', false)
|
||||
})
|
||||
cy.mount(
|
||||
<SplitTestProvider>
|
||||
<AddSeats />
|
||||
</SplitTestProvider>
|
||||
)
|
||||
makeRequest(this.body, this.adding.toString())
|
||||
cy.findByTestId('cost-summary').within(() => {
|
||||
cy.findByTestId('total').within(() => {
|
||||
cy.findByText(/total due today/i)
|
||||
})
|
||||
})
|
||||
cy.findByText(
|
||||
/we’ll charge you now for the cost of your additional licenses based on the remaining months of your current subscription/i
|
||||
)
|
||||
})
|
||||
|
||||
it('renders the preview data with discount', function () {
|
||||
this.body.immediateCharge.discount = 50
|
||||
|
||||
|
||||
@@ -412,6 +412,7 @@ describe('PaymentProviderEntities', function () {
|
||||
periodStart: new Date(),
|
||||
periodEnd: new Date(),
|
||||
collectionMethod: 'automatic',
|
||||
netTerms: 0,
|
||||
poNumber: '012345',
|
||||
termsAndConditions: 'T&C copy',
|
||||
})
|
||||
|
||||
@@ -58,6 +58,7 @@ describe('RecurlyClient', function () {
|
||||
periodStart: new Date(),
|
||||
periodEnd: new Date(),
|
||||
collectionMethod: 'automatic',
|
||||
netTerms: 0,
|
||||
poNumber: '',
|
||||
termsAndConditions: '',
|
||||
})
|
||||
@@ -90,6 +91,7 @@ describe('RecurlyClient', function () {
|
||||
currentPeriodStartedAt: this.subscription.periodStart,
|
||||
currentPeriodEndsAt: this.subscription.periodEnd,
|
||||
collectionMethod: this.subscription.collectionMethod,
|
||||
netTerms: this.subscription.netTerms,
|
||||
poNumber: this.subscription.poNumber,
|
||||
termsAndConditions: this.subscription.termsAndConditions,
|
||||
}
|
||||
@@ -690,29 +692,4 @@ 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -67,6 +67,7 @@ describe('SubscriptionGroupController', function () {
|
||||
ensureSubscriptionIsActive: sinon.stub().resolves(),
|
||||
ensureSubscriptionCollectionMethodIsNotManual: sinon.stub().resolves(),
|
||||
ensureSubscriptionHasNoPendingChanges: sinon.stub().resolves(),
|
||||
ensureSubscriptionHasNoPastDueInvoice: sinon.stub().resolves(),
|
||||
getGroupPlanUpgradePreview: sinon
|
||||
.stub()
|
||||
.resolves(this.previewSubscriptionChangeData),
|
||||
@@ -138,6 +139,7 @@ describe('SubscriptionGroupController', function () {
|
||||
PendingChangeError: class extends Error {},
|
||||
InactiveError: class extends Error {},
|
||||
SubtotalLimitExceededError: class extends Error {},
|
||||
HasPastDueInvoiceError: class extends Error {},
|
||||
}
|
||||
|
||||
this.Controller = await esmock.strict(modulePath, {
|
||||
@@ -370,6 +372,9 @@ describe('SubscriptionGroupController', function () {
|
||||
this.SubscriptionGroupHandler.promises.ensureSubscriptionIsActive
|
||||
.calledWith(this.subscription)
|
||||
.should.equal(true)
|
||||
this.SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPastDueInvoice
|
||||
.calledWith(this.subscription)
|
||||
.should.equal(true)
|
||||
this.SubscriptionGroupHandler.promises.checkBillingInfoExistence
|
||||
.calledWith(this.recurlySubscription, this.adminUserId)
|
||||
.should.equal(true)
|
||||
@@ -459,6 +464,20 @@ describe('SubscriptionGroupController', function () {
|
||||
|
||||
this.Controller.addSeatsToGroupSubscription(this.req, res)
|
||||
})
|
||||
|
||||
it('should redirect to subscription page when subscription has pending invoice', function (done) {
|
||||
this.SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPastDueInvoice =
|
||||
sinon.stub().rejects()
|
||||
|
||||
const res = {
|
||||
redirect: url => {
|
||||
url.should.equal('/user/subscription')
|
||||
done()
|
||||
},
|
||||
}
|
||||
|
||||
this.Controller.addSeatsToGroupSubscription(this.req, res)
|
||||
})
|
||||
})
|
||||
|
||||
describe('previewAddSeatsSubscriptionChange', function () {
|
||||
|
||||
@@ -146,7 +146,6 @@ describe('SubscriptionGroupHandler', function () {
|
||||
applySubscriptionChangeRequest: sinon
|
||||
.stub()
|
||||
.resolves(this.applySubscriptionChange),
|
||||
getCountryCode: sinon.stub().resolves('BG'),
|
||||
updateSubscriptionDetails: sinon.stub().resolves(),
|
||||
},
|
||||
}
|
||||
@@ -198,6 +197,11 @@ describe('SubscriptionGroupHandler', function () {
|
||||
if (hookName === 'generateTermsAndConditions') {
|
||||
return Promise.resolve(['T&Cs'])
|
||||
}
|
||||
if (hookName === 'getPaymentFromRecord') {
|
||||
return Promise.resolve([
|
||||
{ account: { hasPastDueInvoice: false } },
|
||||
])
|
||||
}
|
||||
return Promise.resolve()
|
||||
}),
|
||||
},
|
||||
@@ -504,9 +508,6 @@ 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,
|
||||
@@ -526,9 +527,6 @@ describe('SubscriptionGroupHandler', function () {
|
||||
|
||||
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
|
||||
@@ -758,6 +756,27 @@ describe('SubscriptionGroupHandler', function () {
|
||||
})
|
||||
})
|
||||
|
||||
describe('ensureSubscriptionHasNoPastDueInvoice', function () {
|
||||
it('should throw if the subscription has past due invoice', async function () {
|
||||
this.Modules.promises.hooks.fire
|
||||
.withArgs('getPaymentFromRecord')
|
||||
.resolves([{ account: { hasPastDueInvoice: true } }])
|
||||
await expect(
|
||||
this.Handler.promises.ensureSubscriptionHasNoPastDueInvoice(
|
||||
this.subscription
|
||||
)
|
||||
).to.be.rejectedWith('This subscription has a past due invoice')
|
||||
})
|
||||
|
||||
it('should not throw if the subscription has no past due invoice', async function () {
|
||||
await expect(
|
||||
this.Handler.promises.ensureSubscriptionHasNoPastDueInvoice(
|
||||
this.subscription
|
||||
)
|
||||
).to.not.be.rejected
|
||||
})
|
||||
})
|
||||
|
||||
describe('upgradeGroupPlan', function () {
|
||||
it('should upgrade the subscription for flexible licensing group plans', async function () {
|
||||
this.SubscriptionLocator.promises.getUsersSubscription = sinon
|
||||
|
||||
@@ -2,6 +2,7 @@ export type SubscriptionChangePreview = {
|
||||
change: SubscriptionChangeDescription
|
||||
currency: string
|
||||
paymentMethod: string | undefined
|
||||
netTerms: number
|
||||
nextPlan: {
|
||||
annual: boolean
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user