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:
ilkin-overleaf
2025-05-08 11:54:02 +03:00
committed by Copybot
parent d39d92cce8
commit 2ccdb74d20
21 changed files with 293 additions and 83 deletions
@@ -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": "",
@@ -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}
@@ -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(
@@ -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>
)}
</>
@@ -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"
/>
@@ -354,7 +354,7 @@ function FlexibleGroupLicensingActions({
}) {
const { t } = useTranslation()
if (subscription.pendingPlan) {
if (subscription.pendingPlan || subscription.payment.hasPastDueInvoice) {
return null
}
@@ -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>
)
}
+5
View File
@@ -1105,6 +1105,7 @@
"it": "Italian",
"it_looks_like_that_didnt_work_you_can_try_again_or_get_in_touch": "It looks like that didnt work. You can try again or <0>get in touch</0> with our Support team for more help.",
"it_looks_like_your_account_is_billed_manually": "It looks like your account is being billed manually - adding seats or upgrading your subscription can only be done by the Support team. Please <0>get in touch</0> for help.",
"it_looks_like_your_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": "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_licenses_based_on_remaining_months": "Well 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": "Well 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": "Well 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": "Well 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(
/well charge you now for the cost of your additional licenses based on the remaining months of your current subscription/i
)
cy.findByText(
/after that, well 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(
`well 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(
/well 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 doesnt exist', async function () {
this.client.getAccount = sinon.stub().resolves({
address: {
country: '',
},
})
await expect(
this.RecurlyClient.promises.getCountryCode(this.user._id)
).to.be.rejectedWith(Error, 'Country code not found')
})
})
})
@@ -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
}