diff --git a/services/web/app/src/Features/Subscription/Errors.js b/services/web/app/src/Features/Subscription/Errors.js index 53ecf7ba12..cbcd0014f7 100644 --- a/services/web/app/src/Features/Subscription/Errors.js +++ b/services/web/app/src/Features/Subscription/Errors.js @@ -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, } diff --git a/services/web/app/src/Features/Subscription/PaymentProviderEntities.js b/services/web/app/src/Features/Subscription/PaymentProviderEntities.js index 8cc15f6f2e..298480a972 100644 --- a/services/web/app/src/Features/Subscription/PaymentProviderEntities.js +++ b/services/web/app/src/Features/Subscription/PaymentProviderEntities.js @@ -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 diff --git a/services/web/app/src/Features/Subscription/RecurlyClient.js b/services/web/app/src/Features/Subscription/RecurlyClient.js index f5f2e5f31f..fdb3b023e6 100644 --- a/services/web/app/src/Features/Subscription/RecurlyClient.js +++ b/services/web/app/src/Features/Subscription/RecurlyClient.js @@ -384,25 +384,6 @@ async function getPlan(planCode) { return planFromApi(plan) } -/** - * Get the country code for given user - * - * @param {string} userId - * @return {Promise} - */ -async function getCountryCode(userId) { - const account = await client.getAccount(`code-${userId}`) - const countryCode = account.address?.country - - if (!countryCode) { - throw new OError('Country code not found', { - userId, - }) - } - - return countryCode -} - function subscriptionIsCanceledOrExpired(subscription) { const state = subscription?.recurlyStatus?.state return state === 'canceled' || state === 'expired' @@ -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, }, } diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js index 885784d10d..72efe77980 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/services/web/app/src/Features/Subscription/SubscriptionController.js @@ -740,6 +740,7 @@ function makeChangePreview( currency: subscription.currency, immediateCharge: { ...subscriptionChange.immediateCharge }, paymentMethod: paymentMethod?.toString(), + netTerms: subscription.netTerms, nextPlan: { annual: nextPlan.annual ?? false, }, diff --git a/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs b/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs index 6ce552ec75..ce1207cded 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs +++ b/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs @@ -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, }) diff --git a/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js b/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js index b92ce807f6..5772946b8a 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js +++ b/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js @@ -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, diff --git a/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs b/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs index 54523b0004..154a1882b2 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs +++ b/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs @@ -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), }), } diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 828fedb323..eeb661d392 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -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": "", diff --git a/services/web/frontend/js/features/group-management/components/add-seats/add-seats.tsx b/services/web/frontend/js/features/group-management/components/add-seats/add-seats.tsx index 988876a4a0..79f20ff60f 100644 --- a/services/web/frontend/js/features/group-management/components/add-seats/add-seats.tsx +++ b/services/web/frontend/js/features/group-management/components/add-seats/add-seats.tsx @@ -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() + const [poNumberInputError, setPoNumberInputError] = useState() 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) => { 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() { )} {isFlexibleGroupLicensingForManuallyBilledSubscriptions && - isCollectionMethodManual && } + isCollectionMethodManual && ( + + )} - {t('total_due_today')} + + {isCollectionMethodManual + ? t('total_due_in_x_days', { + days: subscriptionChange.netTerms, + }) + : t('total_due_today')} + {formatCurrency( subscriptionChange.immediateCharge.total, @@ -121,9 +129,14 @@ function CostSummary({ subscriptionChange, totalLicenses }: CostSummaryProps) {
- {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' + )}
{t( diff --git a/services/web/frontend/js/features/group-management/components/add-seats/po-number.tsx b/services/web/frontend/js/features/group-management/components/add-seats/po-number.tsx index f72d7857a4..c66f5cd3fd 100644 --- a/services/web/frontend/js/features/group-management/components/add-seats/po-number.tsx +++ b/services/web/frontend/js/features/group-management/components/add-seats/po-number.tsx @@ -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 +} + +function PoNumber({ error, validate }: PoNumberProps) { const { t } = useTranslation() const [isPoNumberChecked, setIsPoNumberChecked] = useState(false) @@ -20,7 +26,15 @@ function PoNumber() { {isPoNumberChecked && ( {t('po_number')} - + await validate(e.target.value)} + isInvalid={Boolean(error)} + /> + {Boolean(error) && {error}} )} diff --git a/services/web/frontend/js/features/group-management/components/manually-collected-subscription.tsx b/services/web/frontend/js/features/group-management/components/manually-collected-subscription.tsx index 2b2c111716..971d4fa791 100644 --- a/services/web/frontend/js/features/group-management/components/manually-collected-subscription.tsx +++ b/services/web/frontend/js/features/group-management/components/manually-collected-subscription.tsx @@ -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 ( @@ -11,13 +22,23 @@ function ManuallyCollectedSubscription() { type="error" title={t('account_billed_manually')} content={ - , - ]} - /> + isFlexibleGroupLicensingForManuallyBilledSubscriptions ? ( + , + ]} + /> + ) : ( + , + ]} + /> + ) } className="m-0" /> diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/active-new.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/active-new.tsx index dfc6448fb0..465fc60ef7 100644 --- a/services/web/frontend/js/features/subscription/components/dashboard/states/active/active-new.tsx +++ b/services/web/frontend/js/features/subscription/components/dashboard/states/active/active-new.tsx @@ -354,7 +354,7 @@ function FlexibleGroupLicensingActions({ }) { const { t } = useTranslation() - if (subscription.pendingPlan) { + if (subscription.pendingPlan || subscription.payment.hasPastDueInvoice) { return null } diff --git a/services/web/frontend/js/pages/user/subscription/group-management/manually-collected-subscription.tsx b/services/web/frontend/js/pages/user/subscription/group-management/manually-collected-subscription.tsx index 49aee93ca0..dedeffe15d 100644 --- a/services/web/frontend/js/pages/user/subscription/group-management/manually-collected-subscription.tsx +++ b/services/web/frontend/js/pages/user/subscription/group-management/manually-collected-subscription.tsx @@ -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() + root.render( + + + + ) } diff --git a/services/web/locales/en.json b/services/web/locales/en.json index b9cf8163b7..f752e1aae3 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -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 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 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 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, or <1>get in touch 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__ additional license(s) for a total of <1>__count__ licenses", "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 __paymentMethod__.", "webinars": "Webinars", "website_status": "Website status", diff --git a/services/web/test/frontend/features/group-management/components/add-seats.spec.tsx b/services/web/test/frontend/features/group-management/components/add-seats.spec.tsx index 91f2dd9841..211714db2b 100644 --- a/services/web/test/frontend/features/group-management/components/add-seats.spec.tsx +++ b/services/web/test/frontend/features/group-management/components/add-seats.spec.tsx @@ -1,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('', 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('', function () { }, }, currency: 'USD', + netTerms: 30, immediateCharge: { subtotal: 100, tax: 20, @@ -276,13 +302,16 @@ describe('', 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('', 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( + + + + ) + 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 diff --git a/services/web/test/unit/src/Subscription/PaymentProviderEntitiesTest.js b/services/web/test/unit/src/Subscription/PaymentProviderEntitiesTest.js index 1a28130b94..c6593da28d 100644 --- a/services/web/test/unit/src/Subscription/PaymentProviderEntitiesTest.js +++ b/services/web/test/unit/src/Subscription/PaymentProviderEntitiesTest.js @@ -412,6 +412,7 @@ describe('PaymentProviderEntities', function () { periodStart: new Date(), periodEnd: new Date(), collectionMethod: 'automatic', + netTerms: 0, poNumber: '012345', termsAndConditions: 'T&C copy', }) diff --git a/services/web/test/unit/src/Subscription/RecurlyClientTests.js b/services/web/test/unit/src/Subscription/RecurlyClientTests.js index 4ae415dca5..97088e9944 100644 --- a/services/web/test/unit/src/Subscription/RecurlyClientTests.js +++ b/services/web/test/unit/src/Subscription/RecurlyClientTests.js @@ -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') - }) - }) }) diff --git a/services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs b/services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs index 1d86199f9d..4376e752e7 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs +++ b/services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs @@ -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 () { diff --git a/services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js b/services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js index d9e42d645c..0c47db3e14 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js @@ -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 diff --git a/services/web/types/subscription/subscription-change-preview.ts b/services/web/types/subscription/subscription-change-preview.ts index 096820a2f6..5152b80e6e 100644 --- a/services/web/types/subscription/subscription-change-preview.ts +++ b/services/web/types/subscription/subscription-change-preview.ts @@ -2,6 +2,7 @@ export type SubscriptionChangePreview = { change: SubscriptionChangeDescription currency: string paymentMethod: string | undefined + netTerms: number nextPlan: { annual: boolean }