diff --git a/services/web/app/src/Features/Subscription/PaymentProviderEntities.js b/services/web/app/src/Features/Subscription/PaymentProviderEntities.js index c8c09d5113..5e64f634de 100644 --- a/services/web/app/src/Features/Subscription/PaymentProviderEntities.js +++ b/services/web/app/src/Features/Subscription/PaymentProviderEntities.js @@ -94,7 +94,7 @@ class PaymentProviderSubscription { * @return {boolean} */ isGroupSubscription() { - return isGroupPlanCode(this.planCode) + return PlansLocator.isGroupPlanCode(this.planCode) } /** @@ -118,33 +118,32 @@ class PaymentProviderSubscription { /** * Change this subscription's plan - * + * @param {string} planCode - the new plan code + * @param {number} [quantity] - the quantity of the plan + * @param {boolean} [shouldChangeAtTermEnd] - whether the change should be applied at the end of the term * @return {PaymentProviderSubscriptionChangeRequest} */ - getRequestForPlanChange(planCode) { - const currentPlan = PlansLocator.findLocalPlanInSettings(this.planCode) - if (currentPlan == null) { - throw new OError('Unable to find plan in settings', { - planCode: this.planCode, - }) - } - const newPlan = PlansLocator.findLocalPlanInSettings(planCode) - if (newPlan == null) { - throw new OError('Unable to find plan in settings', { planCode }) - } - const isInTrial = SubscriptionHelper.isInTrial(this.trialPeriodEnd) - const shouldChangeAtTermEnd = SubscriptionHelper.shouldPlanChangeAtTermEnd( - currentPlan, - newPlan, - isInTrial - ) - + getRequestForPlanChange(planCode, quantity, shouldChangeAtTermEnd) { const changeRequest = new PaymentProviderSubscriptionChangeRequest({ subscription: this, timeframe: shouldChangeAtTermEnd ? 'term_end' : 'now', planCode, }) + if (quantity !== 1) { + // Only group plans in Stripe can have larger than 1 quantity + // This is because in Stripe, the group plans are configued with per-seat pricing + // and the quantity is the number of seats + // Setting the members limit add-on quantity accordingly + // so it is compitible with Recurly's group plan model (1 base plan + add-on for each member) + changeRequest.addOnUpdates = [ + new PaymentProviderSubscriptionAddOnUpdate({ + code: MEMBERS_LIMIT_ADD_ON_CODE, + quantity, + }), + ] + } + // Carry the AI add-on to the new plan if applicable if ( this.isStandaloneAiAddOn() || @@ -155,7 +154,9 @@ class PaymentProviderSubscription { code: AI_ADD_ON_CODE, quantity: 1, }) - changeRequest.addOnUpdates = [addOnUpdate] + changeRequest.addOnUpdates = changeRequest.addOnUpdates + ? [...changeRequest.addOnUpdates, addOnUpdate] + : [addOnUpdate] } return changeRequest @@ -360,6 +361,31 @@ class PaymentProviderSubscription { get isCollectionMethodManual() { return this.collectionMethod === 'manual' } + + /** + * Determine if a plan change should be applied at the end of the term + * + * @param {string} newPlanCode + * @returns {boolean} + */ + shouldPlanChangeAtTermEnd(newPlanCode) { + const currentPlan = PlansLocator.findLocalPlanInSettings(this.planCode) + if (currentPlan == null) { + throw new OError('Unable to find plan in settings', { + planCode: this.planCode, + }) + } + const newPlan = PlansLocator.findLocalPlanInSettings(newPlanCode) + if (newPlan == null) { + throw new OError('Unable to find plan in settings', { newPlanCode }) + } + const isInTrial = SubscriptionHelper.isInTrial(this.trialPeriodEnd) + return SubscriptionHelper.shouldPlanChangeAtTermEnd( + currentPlan, + newPlan, + isInTrial + ) + } } /** @@ -584,15 +610,6 @@ class PaymentProviderAccount { } } -/** - * Returns whether the given plan code is a group plan - * - * @param {string} planCode - */ -function isGroupPlanCode(planCode) { - return planCode.includes('group') -} - module.exports = { MEMBERS_LIMIT_ADD_ON_CODE, PaymentProviderSubscription, @@ -607,6 +624,5 @@ module.exports = { PaymentProviderPlan, PaymentProviderCoupon, PaymentProviderAccount, - isGroupPlanCode, PaymentProviderImmediateCharge, } diff --git a/services/web/app/src/Features/Subscription/PlansLocator.js b/services/web/app/src/Features/Subscription/PlansLocator.js index 67d2f31c52..98bb5ddf7a 100644 --- a/services/web/app/src/Features/Subscription/PlansLocator.js +++ b/services/web/app/src/Features/Subscription/PlansLocator.js @@ -137,9 +137,47 @@ function findLocalPlanInSettings(planCode) { return null } +/** + * Returns whether the given plan code is a group plan + * + * @param {string} planCode + */ +function isGroupPlanCode(planCode) { + return planCode.includes('group') +} + +/** + * Adapts a legacy Recurly group plan code (e.g., `group_professional_5_educational`) + * into its corresponding Stripe-compatible plan code (e.g., `group_professional_educational`), + * extracting the license quantity where applicable. + * + * @param {RecurlyPlanCode} planCode + * @returns {{ planCode: RecurlyPlanCode, quantity: number }} + */ +function convertLegacyGroupPlanCodeToConsolidatedGroupPlanCodeIfNeeded( + planCode +) { + const pattern = + /^group_(collaborator|professional)_(2|3|4|5|10|20|50)_(educational|enterprise)$/ + + const match = planCode.match(pattern) + if (match == null) { + return { planCode, quantity: 1 } + } + + const [, tier, size, usage] = match + const newPlanCode = /** @type {RecurlyPlanCode} */ ( + usage === 'enterprise' ? `group_${tier}` : `group_${tier}_${usage}` + ) + + return { planCode: newPlanCode, quantity: Number(size) } +} + module.exports = { ensurePlansAreSetupCorrectly, findLocalPlanInSettings, buildStripeLookupKey, getPlanTypeAndPeriodFromRecurlyPlanCode, + isGroupPlanCode, + convertLegacyGroupPlanCodeToConsolidatedGroupPlanCodeIfNeeded, } diff --git a/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs b/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs index 7aa596836d..4276ebd6e7 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs +++ b/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs @@ -425,6 +425,21 @@ async function subtotalLimitExceeded(req, res) { } } +async function getGroupPlanPerUserPrices(req, res) { + try { + const userId = SessionManager.getLoggedInUserId(req.session) + const prices = await Modules.promises.hooks.fire( + 'getGroupPlanPerUserPrices', + userId, + req.query.currency + ) + return res.json(prices[0]) + } catch (error) { + logger.err({ error }, 'error trying to get websale group product prices') + return res.sendStatus(500) + } +} + export default { removeUserFromGroup: expressify(removeUserFromGroup), removeSelfFromGroup: expressify(removeSelfFromGroup), @@ -441,4 +456,5 @@ export default { missingBillingInformation: expressify(missingBillingInformation), manuallyCollectedSubscription: expressify(manuallyCollectedSubscription), subtotalLimitExceeded: expressify(subtotalLimitExceeded), + getGroupPlanPerUserPrices: expressify(getGroupPlanPerUserPrices), } diff --git a/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs b/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs index 154a1882b2..6cc83bf232 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs +++ b/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs @@ -106,6 +106,13 @@ export default { SubscriptionGroupController.subscriptionUpgradePage ) + webRouter.get( + '/user/subscription/group/group-plan-per-user-prices', + AuthenticationController.requireLogin(), + RateLimiterMiddleware.rateLimit(subscriptionRateLimiter), + SubscriptionGroupController.getGroupPlanPerUserPrices + ) + webRouter.post( '/user/subscription/group/upgrade-subscription', AuthenticationController.requireLogin(), diff --git a/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js b/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js index 5252abc070..9672af6cd5 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js +++ b/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js @@ -34,6 +34,20 @@ function serializeMongooseObject(object) { : object } +async function isEligibleForGroupPlan(service, isInTrial) { + if (isInTrial) { + return false + } + + if (service === 'recurly') { + return true + } + const [result] = await Modules.promises.hooks.fire( + 'canUpgradeFromIndividualToGroup' + ) + return result +} + async function buildUsersSubscriptionViewModel(user, locale = 'en') { let { personalSubscription, @@ -112,9 +126,7 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') { _id: group._id, planCode: group.planCode, teamName: group.teamName, - admin_id: { - email: group.admin_id.email, - }, + admin_id: { email: group.admin_id.email }, userIsGroupManager, } @@ -141,10 +153,7 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') { planCode: group.planCode, groupPlan: group.groupPlan, teamName: group.teamName, - admin_id: { - _id: group.admin_id._id, - email: group.admin_id.email, - }, + admin_id: { _id: group.admin_id._id, email: group.admin_id.email }, features: group.features, userIsGroupMember, } @@ -221,6 +230,8 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') { if (personalSubscription && paymentRecord && paymentRecord.subscription) { // don't return subscription payment information + personalSubscription.service = + personalSubscription.paymentProvider?.service ?? 'recurly' delete personalSubscription.paymentProvider delete personalSubscription.recurly delete personalSubscription.recurlySubscription_id @@ -277,8 +288,10 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') { !isInTrial && !paymentRecord.subscription.planCode.includes('ann') && !paymentRecord.subscription.addOns?.length > 0, - isEligibleForGroupPlan: - paymentRecord.subscription.service === 'recurly' && !isInTrial, + isEligibleForGroupPlan: await isEligibleForGroupPlan( + paymentRecord.subscription.service, + isInTrial + ), } const isMonthlyCollaboratorPlan = @@ -405,9 +418,7 @@ async function getUsersSubscriptionDetails(user) { individualSubscription = await SubscriptionLocator.promises.getUsersSubscription(user) } - let bestSubscription = { - type: 'free', - } + let bestSubscription = { type: 'free' } if (currentInstitutionsWithLicence?.length) { for (const institutionMembership of currentInstitutionsWithLicence) { const plan = PlansLocator.findLocalPlanInSettings( @@ -601,8 +612,5 @@ module.exports = { buildUsersSubscriptionViewModel: callbackify(buildUsersSubscriptionViewModel), buildPlansList, buildPlansListForSubscriptionDash, - promises: { - buildUsersSubscriptionViewModel, - getUsersSubscriptionDetails, - }, + promises: { buildUsersSubscriptionViewModel, getUsersSubscriptionDetails }, } diff --git a/services/web/frontend/js/features/subscription/context/subscription-dashboard-context.tsx b/services/web/frontend/js/features/subscription/context/subscription-dashboard-context.tsx index bb7ff46566..0dda7dce89 100644 --- a/services/web/frontend/js/features/subscription/context/subscription-dashboard-context.tsx +++ b/services/web/frontend/js/features/subscription/context/subscription-dashboard-context.tsx @@ -22,7 +22,8 @@ import { Institution } from '../../../../../types/institution' import getMeta from '../../../utils/meta' import { loadDisplayPriceWithTaxPromise, - loadGroupDisplayPriceWithTaxPromise, + loadGroupDisplayPriceWithTaxForRecurlyPromise, + loadGroupDisplayPriceWithTaxForStripePromise, } from '../util/recurly-pricing' import { isRecurlyLoaded } from '../util/is-recurly-loaded' import { SubscriptionDashModalIds } from '../../../../../types/subscription/dashboard/modal-ids' @@ -199,36 +200,46 @@ export function SubscriptionDashboardProvider({ useEffect(() => { if ( - isRecurlyLoaded() && - groupPlanToChangeToCode && - groupPlanToChangeToSize && - groupPlanToChangeToUsage && - personalSubscription?.payment + !groupPlanToChangeToCode || + !groupPlanToChangeToSize || + !groupPlanToChangeToUsage || + !personalSubscription?.payment ) { - setQueryingGroupPlanToChangeToPrice(true) - - const { currency, taxRate } = personalSubscription.payment - const fetchGroupDisplayPrice = async () => { - setGroupPlanToChangeToPriceError(false) - let priceData - try { - priceData = await loadGroupDisplayPriceWithTaxPromise( - groupPlanToChangeToCode, - currency, - taxRate, - groupPlanToChangeToSize, - groupPlanToChangeToUsage, - i18n.language - ) - } catch (e) { - debugConsole.error(e) - setGroupPlanToChangeToPriceError(true) - } - setQueryingGroupPlanToChangeToPrice(false) - setGroupPlanToChangeToPrice(priceData) - } - fetchGroupDisplayPrice() + return } + + let loadGroupDisplayPrice + if (personalSubscription.service?.includes('stripe')) { + loadGroupDisplayPrice = loadGroupDisplayPriceWithTaxForStripePromise + } else if (isRecurlyLoaded()) { + loadGroupDisplayPrice = loadGroupDisplayPriceWithTaxForRecurlyPromise + } else { + return + } + + setQueryingGroupPlanToChangeToPrice(true) + + const { currency, taxRate } = personalSubscription.payment + const fetchGroupDisplayPrice = async () => { + setGroupPlanToChangeToPriceError(false) + let priceData + try { + priceData = await loadGroupDisplayPrice( + groupPlanToChangeToCode, + currency, + taxRate, + groupPlanToChangeToSize, + groupPlanToChangeToUsage, + i18n.language + ) + } catch (e) { + debugConsole.error(e) + setGroupPlanToChangeToPriceError(true) + } + setQueryingGroupPlanToChangeToPrice(false) + setGroupPlanToChangeToPrice(priceData) + } + fetchGroupDisplayPrice() }, [ groupPlanToChangeToUsage, groupPlanToChangeToSize, diff --git a/services/web/frontend/js/features/subscription/util/recurly-group-plan-code.ts b/services/web/frontend/js/features/subscription/util/recurly-group-plan-code.ts index 3d52d5bcdb..a43a4b3901 100644 --- a/services/web/frontend/js/features/subscription/util/recurly-group-plan-code.ts +++ b/services/web/frontend/js/features/subscription/util/recurly-group-plan-code.ts @@ -5,3 +5,11 @@ export function getRecurlyGroupPlanCode( ) { return `group_${planCode}_${size}_${usage}` } + +export function getConsolidatedGroupPlanCode(planCode: string, usage: string) { + if (usage === 'enterprise') { + return `group_${planCode}` + } + + return `group_${planCode}_${usage}` +} diff --git a/services/web/frontend/js/features/subscription/util/recurly-pricing.ts b/services/web/frontend/js/features/subscription/util/recurly-pricing.ts index 584174da1a..5a1911169a 100644 --- a/services/web/frontend/js/features/subscription/util/recurly-pricing.ts +++ b/services/web/frontend/js/features/subscription/util/recurly-pricing.ts @@ -1,9 +1,15 @@ import { SubscriptionPricingState } from '@recurly/recurly-js' import { PriceForDisplayData } from '../../../../../types/subscription/plan' import { CurrencyCode } from '../../../../../types/subscription/currency' -import { getRecurlyGroupPlanCode } from './recurly-group-plan-code' +import { + getRecurlyGroupPlanCode, + getConsolidatedGroupPlanCode, +} from './recurly-group-plan-code' import { debugConsole } from '@/utils/debugging' import { formatCurrency } from '@/shared/utils/currency' +import { getJSON } from '../../../infrastructure/fetch-json' + +let groupPlanPerUserPrices: Record | undefined function queryRecurlyPlanPrice(planCode: string, currency: CurrencyCode) { return new Promise(resolve => { @@ -73,7 +79,7 @@ export async function loadDisplayPriceWithTaxPromise( ) } -export async function loadGroupDisplayPriceWithTaxPromise( +export async function loadGroupDisplayPriceWithTaxForRecurlyPromise( groupPlanCode: string, currencyCode: CurrencyCode, taxRate: number, @@ -102,3 +108,44 @@ export async function loadGroupDisplayPriceWithTaxPromise( return price } + +export async function loadGroupDisplayPriceWithTaxForStripePromise( + groupPlanCode: string, + currencyCode: CurrencyCode, + taxRate: number, + size: string, + usage: string, + locale: string +) { + if (!groupPlanPerUserPrices) { + groupPlanPerUserPrices = await getJSON>( + `/user/subscription/group/group-plan-per-user-prices?currency=${currencyCode}` + ) + } + + const planCode = getConsolidatedGroupPlanCode(groupPlanCode, usage) + + if (!(planCode in groupPlanPerUserPrices)) { + throw new Error( + `Group plan code ${planCode} not found in groupPlanPerUserPrices` + ) + } + + const subtotalPrice = groupPlanPerUserPrices[planCode] * parseInt(size) + + const result = formatPriceForDisplayData( + subtotalPrice.toString(), + taxRate, + currencyCode, + locale + ) + + result.perUserDisplayPrice = formatCurrency( + groupPlanPerUserPrices[planCode], + currencyCode, + locale, + true + ) + + return result +} diff --git a/services/web/frontend/js/utils/meta.ts b/services/web/frontend/js/utils/meta.ts index 8f6f8a7ec1..26a116a494 100644 --- a/services/web/frontend/js/utils/meta.ts +++ b/services/web/frontend/js/utils/meta.ts @@ -78,7 +78,6 @@ export interface Meta { // dynamic keys based on permissions 'ol-canUseAddSeatsFeature': boolean 'ol-canUseFlexibleLicensing': boolean - 'ol-canUseFlexibleLicensingForConsolidatedPlans': boolean 'ol-cannot-add-secondary-email': boolean 'ol-cannot-change-password': boolean 'ol-cannot-delete-own-account': boolean diff --git a/services/web/test/unit/src/Subscription/PaymentProviderEntitiesTest.js b/services/web/test/unit/src/Subscription/PaymentProviderEntitiesTest.js index 2cb7037e67..7b2b85b4d8 100644 --- a/services/web/test/unit/src/Subscription/PaymentProviderEntitiesTest.js +++ b/services/web/test/unit/src/Subscription/PaymentProviderEntitiesTest.js @@ -27,6 +27,10 @@ describe('PaymentProviderEntities', function () { { planCode: 'cheap-plan', price_in_cents: 500 }, { planCode: 'regular-plan', price_in_cents: 1000 }, { planCode: 'premium-plan', price_in_cents: 2000 }, + { + planCode: 'group_collaborator_10_enterprise', + price_in_cents: 10000, + }, ], features: [], } @@ -81,8 +85,11 @@ describe('PaymentProviderEntities', function () { it('returns a change request for upgrades', function () { const { PaymentProviderSubscriptionChangeRequest } = this.PaymentProviderEntities - const changeRequest = - this.subscription.getRequestForPlanChange('premium-plan') + const changeRequest = this.subscription.getRequestForPlanChange( + 'premium-plan', + 1, + this.subscription.shouldPlanChangeAtTermEnd('premium-plan') + ) expect(changeRequest).to.deep.equal( new PaymentProviderSubscriptionChangeRequest({ subscription: this.subscription, @@ -95,8 +102,11 @@ describe('PaymentProviderEntities', function () { it('returns a change request for downgrades', function () { const { PaymentProviderSubscriptionChangeRequest } = this.PaymentProviderEntities - const changeRequest = - this.subscription.getRequestForPlanChange('cheap-plan') + const changeRequest = this.subscription.getRequestForPlanChange( + 'cheap-plan', + 1, + this.subscription.shouldPlanChangeAtTermEnd('cheap-plan') + ) expect(changeRequest).to.deep.equal( new PaymentProviderSubscriptionChangeRequest({ subscription: this.subscription, @@ -112,8 +122,11 @@ describe('PaymentProviderEntities', function () { this.subscription.trialPeriodEnd = fiveDaysFromNow const { PaymentProviderSubscriptionChangeRequest } = this.PaymentProviderEntities - const changeRequest = - this.subscription.getRequestForPlanChange('cheap-plan') + const changeRequest = this.subscription.getRequestForPlanChange( + 'cheap-plan', + 1, + this.subscription.shouldPlanChangeAtTermEnd('cheap-plan') + ) expect(changeRequest).to.deep.equal( new PaymentProviderSubscriptionChangeRequest({ subscription: this.subscription, @@ -127,8 +140,11 @@ describe('PaymentProviderEntities', function () { const { PaymentProviderSubscriptionChangeRequest } = this.PaymentProviderEntities this.addOn.code = AI_ADD_ON_CODE - const changeRequest = - this.subscription.getRequestForPlanChange('premium-plan') + const changeRequest = this.subscription.getRequestForPlanChange( + 'premium-plan', + 1, + this.subscription.shouldPlanChangeAtTermEnd('premium-plan') + ) expect(changeRequest).to.deep.equal( new PaymentProviderSubscriptionChangeRequest({ subscription: this.subscription, @@ -148,8 +164,11 @@ describe('PaymentProviderEntities', function () { const { PaymentProviderSubscriptionChangeRequest } = this.PaymentProviderEntities this.addOn.code = AI_ADD_ON_CODE - const changeRequest = - this.subscription.getRequestForPlanChange('cheap-plan') + const changeRequest = this.subscription.getRequestForPlanChange( + 'cheap-plan', + 1, + this.subscription.shouldPlanChangeAtTermEnd('cheap-plan') + ) expect(changeRequest).to.deep.equal( new PaymentProviderSubscriptionChangeRequest({ subscription: this.subscription, @@ -170,8 +189,11 @@ describe('PaymentProviderEntities', function () { this.PaymentProviderEntities this.subscription.planCode = 'assistant-annual' this.subscription.addOns = [] - const changeRequest = - this.subscription.getRequestForPlanChange('cheap-plan') + const changeRequest = this.subscription.getRequestForPlanChange( + 'cheap-plan', + 1, + this.subscription.shouldPlanChangeAtTermEnd('cheap-plan') + ) expect(changeRequest).to.deep.equal( new PaymentProviderSubscriptionChangeRequest({ subscription: this.subscription, @@ -186,6 +208,63 @@ describe('PaymentProviderEntities', function () { }) ) }) + + it('upgrade from individual to group plan for Stripe subscription', function () { + this.subscription.service = 'stripe-uk' + const { PaymentProviderSubscriptionChangeRequest } = + this.PaymentProviderEntities + const changeRequest = this.subscription.getRequestForPlanChange( + 'group_collaborator', + 10, + this.subscription.shouldPlanChangeAtTermEnd( + 'group_collaborator_10_enterprise' + ) + ) + expect(changeRequest).to.deep.equal( + new PaymentProviderSubscriptionChangeRequest({ + subscription: this.subscription, + timeframe: 'now', + planCode: 'group_collaborator', + addOnUpdates: [ + new PaymentProviderSubscriptionAddOnUpdate({ + code: 'additional-license', + quantity: 10, + }), + ], + }) + ) + }) + + it('upgrade from individual to group plan and preserves the AI add-on for Stripe subscription', function () { + this.subscription.service = 'stripe-uk' + const { PaymentProviderSubscriptionChangeRequest } = + this.PaymentProviderEntities + this.addOn.code = AI_ADD_ON_CODE + const changeRequest = this.subscription.getRequestForPlanChange( + 'group_collaborator', + 10, + this.subscription.shouldPlanChangeAtTermEnd( + 'group_collaborator_10_enterprise' + ) + ) + expect(changeRequest).to.deep.equal( + new PaymentProviderSubscriptionChangeRequest({ + subscription: this.subscription, + timeframe: 'now', + planCode: 'group_collaborator', + addOnUpdates: [ + new PaymentProviderSubscriptionAddOnUpdate({ + code: 'additional-license', + quantity: 10, + }), + new PaymentProviderSubscriptionAddOnUpdate({ + code: AI_ADD_ON_CODE, + quantity: 1, + }), + ], + }) + ) + }) }) describe('getRequestForAddOnPurchase()', function () { diff --git a/services/web/test/unit/src/Subscription/PlansLocatorTests.js b/services/web/test/unit/src/Subscription/PlansLocatorTests.js index bd15f5cfaa..7e35ccea58 100644 --- a/services/web/test/unit/src/Subscription/PlansLocatorTests.js +++ b/services/web/test/unit/src/Subscription/PlansLocatorTests.js @@ -266,4 +266,39 @@ describe('PlansLocator', function () { expect(period).to.equal('annual') }) }) + + describe('convertLegacyGroupPlanCodeToConsolidatedGroupPlanCodeIfNeeded', function () { + it('returns original plan name for non-group plan codes', function () { + expect( + this.PlansLocator.convertLegacyGroupPlanCodeToConsolidatedGroupPlanCodeIfNeeded( + 'professional' + ) + ).to.deep.equal({ + planCode: 'professional', + quantity: 1, + }) + }) + + it('converts Recurly enterprise group plan codes to Stripe group plan codes', function () { + expect( + this.PlansLocator.convertLegacyGroupPlanCodeToConsolidatedGroupPlanCodeIfNeeded( + 'group_collaborator_10_enterprise' + ) + ).to.deep.equal({ + planCode: 'group_collaborator', + quantity: 10, + }) + }) + + it('converts Recurly educational group plan codes to Stripe group plan codes', function () { + expect( + this.PlansLocator.convertLegacyGroupPlanCodeToConsolidatedGroupPlanCodeIfNeeded( + 'group_professional_10_educational' + ) + ).to.deep.equal({ + planCode: 'group_professional_educational', + quantity: 10, + }) + }) + }) }) diff --git a/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js b/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js index 86eb51070e..235abf600e 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js @@ -150,6 +150,11 @@ describe('SubscriptionViewModelBuilder', function () { this.PlansLocator = { findLocalPlanInSettings: sinon.stub(), } + this.SplitTestHandler = { + promises: { + getAssignmentForUser: sinon.stub().resolves({ variant: 'default' }), + }, + } this.SubscriptionViewModelBuilder = SandboxedModule.require(modulePath, { requires: { '@overleaf/settings': this.Settings, @@ -168,6 +173,7 @@ describe('SubscriptionViewModelBuilder', function () { './V1SubscriptionManager': {}, '../Publishers/PublishersGetter': this.PublishersGetter, './SubscriptionHelper': SubscriptionHelper, + '../SplitTests/SplitTestHandler': this.SplitTestHandler, }, }) @@ -659,6 +665,9 @@ describe('SubscriptionViewModelBuilder', function () { describe('isEligibleForGroupPlan', function () { it('is false for Stripe subscriptions', async function () { this.paymentRecord.service = 'stripe-us' + this.Modules.promises.hooks.fire + .withArgs('canUpgradeFromIndividualToGroup') + .resolves([false]) const result = await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( this.user