-
+ {subscription.plan.annual ? (
+
+
+
+
+ {subscription.pendingPlan && (
+ <>
+ {' '}
+
{t('want_change_to_apply_before_plan_end')}
+ )} + {isInFreeTrial(subscription.recurly.trial_ends_at) && + subscription.recurly.trialEndsAtFormatted && ( +
+ {isLegacyPlan && subscription.recurly.additionalLicenses > 0 ? (
+
+ {subscription.plan.annual + ? t('x_price_per_year', { + price: subscription.recurly.planOnlyDisplayPrice, + }) + : t('x_price_per_month', { + price: subscription.recurly.planOnlyDisplayPrice, + })} +
+ )} + {!recurlyLoadError && ( +{t('you_dont_have_any_add_ons_on_your_account')}
+ )} + > + ) +} + +export default AddOns diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/trial-ending.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/trial-ending.tsx index 3e7ce439a4..11be8ce3b9 100644 --- a/services/web/frontend/js/features/subscription/components/dashboard/states/active/trial-ending.tsx +++ b/services/web/frontend/js/features/subscription/components/dashboard/states/active/trial-ending.tsx @@ -1,12 +1,16 @@ import { Trans } from 'react-i18next' +type TrialEndingProps = { + trialEndsAtFormatted: string + className?: string +} + export function TrialEnding({ trialEndsAtFormatted, -}: { - trialEndsAtFormatted: string -}) { + className, +}: TrialEndingProps) { return ( -+
- {t('view_your_invoices')} - +
{t('your_subscription_has_expired')}
-
{t('view_your_invoices')}
-
-
+
+
\\cite{} block)",
"automatic_user_registration_uppercase": "Automatic user registration",
+ "available_with_group_professional": "Available with Group Professional",
"back": "Back",
"back_to_account_settings": "Back to account settings",
"back_to_all_posts": "Back to all posts",
@@ -216,7 +218,10 @@
"beta_program_opt_out_action": "Opt-Out of Beta Program",
"bibliographies": "Bibliographies",
"billed_annually": "billed annually",
+ "billed_annually_at": "Billed annually at <0>__price__0> <1>(includes plan and any add-ons)1>",
+ "billed_monthly_at": "Billed monthly at <0>__price__0> <1>(includes plan and any add-ons)1>",
"billed_yearly": "billed yearly",
+ "billing": "Billing",
"billing_period_sentence_case": "Billing period",
"binary_history_error": "Preview not available for this file type",
"blank_project": "Blank Project",
@@ -782,6 +787,7 @@
"get_involved": "Get involved",
"get_more_compile_time": "Get more compile time",
"get_most_subscription_by_checking_features": "Get the most out of your __appName__ subscription by checking out <0>__appName__’s features0>.",
+ "get_most_subscription_discover_premium_features": "Get the most from your __appName__ subscription. <0>Discover premium features0>.",
"get_symbol_palette": "Get Symbol Palette",
"get_the_best_overleaf_experience": "Get the best Overleaf experience",
"get_the_best_writing_experience": "Get the best writing experience",
@@ -857,11 +863,15 @@
"group_invite_has_been_sent_to_email": "Group invite has been sent to <0>__email__0>",
"group_libraries": "Group Libraries",
"group_managed_by_group_administrator": "User accounts in this group are managed by the group administrator.",
+ "group_management": "Group management",
+ "group_managers": "Group managers",
+ "group_members": "Group members",
"group_plan_admins_can_easily_add_and_remove_users_from_a_group": "Group plan admins can easily add and remove users from a group. For site-wide plans, users are automatically upgraded when they register or add their email address to Overleaf (domain-based enrollment or SSO).",
"group_plan_tooltip": "You are on the __plan__ plan as a member of a group subscription. Click to find out how to make the most of your Overleaf premium features.",
"group_plan_upgrade_description": "You’re on the <0>__currentPlan__0> plan and you’re upgrading to the <0>__nextPlan__0> plan. If you’re interested in a site-wide Overleaf Commons plan, please <1>get in touch1>.",
"group_plan_with_name_tooltip": "You are on the __plan__ plan as a member of a group subscription, __groupName__. Click to find out how to make the most of your Overleaf premium features.",
"group_professional": "Group Professional",
+ "group_settings": "Group settings",
"group_sso_configuration_idp_metadata": "The information you provide here comes from your Identity Provider (IdP). This is often referred to as its <0>SAML metadata0>. You can add this manually or click <1>Import IdP metadata1> to import an XML file.",
"group_sso_configure_service_provider_in_idp": "For some IdPs, you must configure Overleaf as a Service Provider to get the data you need to fill out this form. To do this, you will need to download the Overleaf metadata.",
"group_sso_documentation_links": "Please see our <0>documentation0> and <1>troubleshooting guide1> for more help.",
@@ -1728,6 +1738,7 @@
"rename": "Rename",
"rename_project": "Rename Project",
"renaming": "Renaming",
+ "renews_on": "Renews on <0>__date__0>",
"reopen": "Re-open",
"reopen_comment_error_message": "There was an error reopening your comment. Please try again in a few moments.",
"reopen_comment_error_title": "Reopen Comment Error",
@@ -2060,6 +2071,8 @@
"suggestion_applied": "Suggestion applied",
"support": "Support",
"support_for_your_browser_is_ending_soon": "Support for your browser is ending soon",
+ "supports_up_to_x_users": "Supports up to <0>__count__ users0>",
+ "supports_up_to_x_users_incl_y_additional_licenses": "Supports up to <0>__count__ users0> (Incl. <1>__additionalLicenses__1> additional license(s))",
"sure_you_want_to_cancel_plan_change": "Are you sure you want to revert your scheduled plan change? You will remain subscribed to the <0>__planName__0> plan.",
"sure_you_want_to_change_plan": "Are you sure you want to change plan to <0>__planName__0>?",
"sure_you_want_to_delete": "Are you sure you want to permanently delete the following files?",
@@ -2356,6 +2369,7 @@
"upgrade_for_12x_more_compile_time": "Upgrade to get 12x more compile time",
"upgrade_my_plan": "Upgrade my plan",
"upgrade_now": "Upgrade now",
+ "upgrade_plan": "Upgrade plan",
"upgrade_summary": "Upgrade summary",
"upgrade_to_add_more_editors_and_access_collaboration_features": "Upgrade to add more editors and access collaboration features like track changes and full project history.",
"upgrade_to_get_feature": "Upgrade to get __feature__, plus:",
@@ -2369,6 +2383,7 @@
"url_to_fetch_the_file_from": "URL to fetch the file from",
"us_gov_banner_government_purchasing": "<0>Get __appName__ for US federal government. 0>Move faster through procurement with our tailored purchasing options. Talk to our government team.",
"us_gov_banner_small_business_reseller": "<0>Easy procurement for US federal government. 0>We partner with small business resellers to help you buy Overleaf organizational plans. Talk to our government team.",
+ "usage_metrics": "Usage metrics",
"use_a_different_password": "Please use a different password",
"use_saml_metadata_to_configure_sso_with_idp": "Use the Overleaf SAML metadata to configure SSO with your Identity Provider.",
"use_your_own_machine": "Use your own machine, with your own setup",
@@ -2405,6 +2420,7 @@
"verify_email_address_before_enabling_managed_users": "You need to verify your email address before enabling managed users.",
"view": "View",
"view_all": "View All",
+ "view_billing_details": "View billing details",
"view_code": "View code",
"view_configuration": "View configuration",
"view_group_members": "View group members",
@@ -2487,6 +2503,7 @@
"x_price_for_first_month": "<0>__price__0> for your first month",
"x_price_for_first_year": "<0>__price__0> for your first year",
"x_price_for_y_months": "<0>__price__0> for your first __discountMonths__ months",
+ "x_price_per_month": "__price__ per month",
"x_price_per_user": "__price__ per user",
"x_price_per_year": "__price__ per year",
"year": "year",
@@ -2519,6 +2536,7 @@
"you_cant_add_or_change_password_due_to_sso": "You can’t add or change your password because your group or organization uses <0>single sign-on (SSO)0>.",
"you_cant_join_this_group_subscription": "You can’t join this group subscription",
"you_cant_reset_password_due_to_sso": "You can’t reset your password because your group or organization uses SSO. <0>Log in with SSO0>.",
+ "you_dont_have_any_add_ons_on_your_account": "You don’t have any add-ons on your account.",
"you_dont_have_any_repositories": "You don’t have any repositories",
"you_have_0_free_suggestions_left": "You have 0 free suggestions left",
"you_have_1_free_suggestion_left": "You have 1 free suggestion left",
diff --git a/services/web/test/frontend/features/subscription/fixtures/subscriptions.ts b/services/web/test/frontend/features/subscription/fixtures/subscriptions.ts
index 9bdde3f181..24194ea080 100644
--- a/services/web/test/frontend/features/subscription/fixtures/subscriptions.ts
+++ b/services/web/test/frontend/features/subscription/fixtures/subscriptions.ts
@@ -54,6 +54,9 @@ export const annualActiveSubscription: RecurlySubscription = {
has_past_due_invoice: { _: 'false', $: { type: 'boolean' } },
},
displayPrice: '$199.00',
+ planOnlyDisplayPrice: '',
+ addOns: [],
+ addOnDisplayPricesWithoutAdditionalLicense: {},
},
}
@@ -96,6 +99,9 @@ export const annualActiveSubscriptionEuro: RecurlySubscription = {
has_past_due_invoice: { _: 'false', $: { type: 'boolean' } },
},
displayPrice: '€221.96',
+ planOnlyDisplayPrice: '',
+ addOns: [],
+ addOnDisplayPricesWithoutAdditionalLicense: {},
},
}
@@ -137,6 +143,9 @@ export const annualActiveSubscriptionPro: RecurlySubscription = {
has_past_due_invoice: { _: 'false', $: { type: 'boolean' } },
},
displayPrice: '$42.00',
+ planOnlyDisplayPrice: '',
+ addOns: [],
+ addOnDisplayPricesWithoutAdditionalLicense: {},
},
}
@@ -179,6 +188,9 @@ export const pastDueExpiredSubscription: RecurlySubscription = {
has_past_due_invoice: { _: 'true', $: { type: 'boolean' } },
},
displayPrice: '$199.00',
+ planOnlyDisplayPrice: '',
+ addOns: [],
+ addOnDisplayPricesWithoutAdditionalLicense: {},
},
}
@@ -221,6 +233,9 @@ export const canceledSubscription: RecurlySubscription = {
has_past_due_invoice: { _: 'false', $: { type: 'boolean' } },
},
displayPrice: '$199.00',
+ planOnlyDisplayPrice: '',
+ addOns: [],
+ addOnDisplayPricesWithoutAdditionalLicense: {},
},
}
@@ -263,6 +278,9 @@ export const pendingSubscriptionChange: RecurlySubscription = {
has_past_due_invoice: { _: 'false', $: { type: 'boolean' } },
},
displayPrice: '$199.00',
+ planOnlyDisplayPrice: '',
+ addOns: [],
+ addOnDisplayPricesWithoutAdditionalLicense: {},
},
pendingPlan: {
planCode: 'professional-annual',
@@ -316,6 +334,9 @@ export const groupActiveSubscription: GroupSubscription = {
has_past_due_invoice: { _: 'false', $: { type: 'boolean' } },
},
displayPrice: '$1290.00',
+ planOnlyDisplayPrice: '',
+ addOns: [],
+ addOnDisplayPricesWithoutAdditionalLicense: {},
},
}
@@ -376,6 +397,9 @@ export const groupActiveSubscriptionWithPendingLicenseChange: GroupSubscription
currentPlanDisplayPrice: '$2709.00',
pendingAdditionalLicenses: 13,
pendingTotalLicenses: 23,
+ planOnlyDisplayPrice: '',
+ addOns: [],
+ addOnDisplayPricesWithoutAdditionalLicense: {},
},
pendingPlan: {
planCode: 'group_collaborator_10_enterprise',
@@ -438,6 +462,9 @@ export const trialSubscription: RecurlySubscription = {
},
},
displayPrice: '$14.00',
+ planOnlyDisplayPrice: '',
+ addOns: [],
+ addOnDisplayPricesWithoutAdditionalLicense: {},
},
}
@@ -511,6 +538,9 @@ export const trialCollaboratorSubscription: RecurlySubscription = {
},
},
displayPrice: '$21.00',
+ planOnlyDisplayPrice: '',
+ addOns: [],
+ addOnDisplayPricesWithoutAdditionalLicense: {},
},
}
@@ -552,5 +582,8 @@ export const monthlyActiveCollaborator: RecurlySubscription = {
has_past_due_invoice: { _: 'false', $: { type: 'boolean' } },
},
displayPrice: '$21.00',
+ planOnlyDisplayPrice: '',
+ addOns: [],
+ addOnDisplayPricesWithoutAdditionalLicense: {},
},
}
diff --git a/services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs b/services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs
index 04b961af2b..3511d0bc39 100644
--- a/services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs
+++ b/services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs
@@ -56,6 +56,7 @@ describe('SubscriptionGroupController', function () {
.stub()
.resolves(this.createSubscriptionChangeData),
ensureFlexibleLicensingEnabled: sinon.stub().resolves(),
+ ensureSubscriptionIsActive: sinon.stub().resolves(),
getGroupPlanUpgradePreview: sinon
.stub()
.resolves(this.previewSubscriptionChangeData),
@@ -347,6 +348,9 @@ describe('SubscriptionGroupController', function () {
this.SubscriptionGroupHandler.promises.ensureFlexibleLicensingEnabled
.calledWith(this.plan)
.should.equal(true)
+ this.SubscriptionGroupHandler.promises.ensureSubscriptionIsActive
+ .calledWith(this.subscription)
+ .should.equal(true)
page.should.equal('subscriptions/add-seats')
props.subscriptionId.should.equal(this.subscriptionId)
props.groupName.should.equal(this.subscription.teamName)
@@ -403,6 +407,21 @@ describe('SubscriptionGroupController', function () {
this.Controller.addSeatsToGroupSubscription(this.req, res)
})
+
+ it('should redirect to subscription page when subscription is not active', function (done) {
+ this.SubscriptionGroupHandler.promises.ensureSubscriptionIsActive = 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 9825cadced..c2c1102b8e 100644
--- a/services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js
+++ b/services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js
@@ -59,7 +59,12 @@ describe('SubscriptionGroupHandler', function () {
this.SubscriptionLocator = {
promises: {
- getUsersSubscription: sinon.stub().resolves({ groupPlan: true }),
+ getUsersSubscription: sinon.stub().resolves({
+ groupPlan: true,
+ recurlyStatus: {
+ state: 'active',
+ },
+ }),
getSubscriptionByMemberIdAndId: sinon.stub(),
getSubscription: sinon.stub().resolves(this.subscription),
},
@@ -303,7 +308,12 @@ describe('SubscriptionGroupHandler', function () {
expect(data).to.deep.equal({
userId: this.adminUser_id,
- subscription: { groupPlan: true },
+ subscription: {
+ groupPlan: true,
+ recurlyStatus: {
+ state: 'active',
+ },
+ },
plan: {
membersLimit: 5,
membersLimitAddOn: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
@@ -517,11 +527,33 @@ describe('SubscriptionGroupHandler', function () {
})
})
+ describe('ensureSubscriptionIsActive', function () {
+ it('should throw if the subscription is not active', async function () {
+ await expect(
+ this.Handler.promises.ensureSubscriptionIsActive({})
+ ).to.be.rejectedWith('The subscription is not active')
+ })
+
+ it('should not throw if the subscription is active', async function () {
+ await expect(
+ this.Handler.promises.ensureSubscriptionIsActive({
+ recurlyStatus: { state: 'active' },
+ })
+ ).to.not.be.rejected
+ })
+ })
+
describe('upgradeGroupPlan', function () {
it('should upgrade the subscription for flexible licensing group plans', async function () {
this.SubscriptionLocator.promises.getUsersSubscription = sinon
.stub()
- .resolves({ groupPlan: true, planCode: 'group_collaborator' })
+ .resolves({
+ groupPlan: true,
+ recurlyStatus: {
+ state: 'active',
+ },
+ planCode: 'group_collaborator',
+ })
await this.Handler.promises.upgradeGroupPlan(this.user_id)
this.recurlySubscription.getRequestForGroupPlanUpgrade
.calledWith('group_professional')
@@ -539,6 +571,9 @@ describe('SubscriptionGroupHandler', function () {
.stub()
.resolves({
groupPlan: true,
+ recurlyStatus: {
+ state: 'active',
+ },
planCode: 'group_collaborator_10_educational',
})
await this.Handler.promises.upgradeGroupPlan(this.user_id)
@@ -556,7 +591,13 @@ describe('SubscriptionGroupHandler', function () {
it('should fail the upgrade if is professional already', async function () {
this.SubscriptionLocator.promises.getUsersSubscription = sinon
.stub()
- .resolves({ groupPlan: true, planCode: 'group_professional' })
+ .resolves({
+ groupPlan: true,
+ recurlyStatus: {
+ state: 'active',
+ },
+ planCode: 'group_professional',
+ })
await expect(
this.Handler.promises.upgradeGroupPlan(this.user_id)
).to.be.rejectedWith('Not eligible for group plan upgrade')
@@ -565,7 +606,13 @@ describe('SubscriptionGroupHandler', function () {
it('should fail the upgrade if not group plan', async function () {
this.SubscriptionLocator.promises.getUsersSubscription = sinon
.stub()
- .resolves({ groupPlan: false, planCode: 'test_plan_code' })
+ .resolves({
+ groupPlan: false,
+ recurlyStatus: {
+ state: 'active',
+ },
+ planCode: 'test_plan_code',
+ })
await expect(
this.Handler.promises.upgradeGroupPlan(this.user_id)
).to.be.rejectedWith('Not eligible for group plan upgrade')
@@ -576,7 +623,13 @@ describe('SubscriptionGroupHandler', function () {
it('should generate preview for subscription upgrade', async function () {
this.SubscriptionLocator.promises.getUsersSubscription = sinon
.stub()
- .resolves({ groupPlan: true, planCode: 'group_collaborator' })
+ .resolves({
+ groupPlan: true,
+ recurlyStatus: {
+ state: 'active',
+ },
+ planCode: 'group_collaborator',
+ })
const result = await this.Handler.promises.getGroupPlanUpgradePreview(
this.user_id
)
diff --git a/services/web/types/subscription/dashboard/subscription.ts b/services/web/types/subscription/dashboard/subscription.ts
index c4ba1991de..c67f249c4c 100644
--- a/services/web/types/subscription/dashboard/subscription.ts
+++ b/services/web/types/subscription/dashboard/subscription.ts
@@ -1,6 +1,6 @@
import { CurrencyCode } from '../currency'
import { Nullable } from '../../utils'
-import { Plan, AddOn } from '../plan'
+import { Plan, AddOn, RecurlyAddOn } from '../plan'
import { User } from '../../user'
type SubscriptionState = 'active' | 'canceled' | 'expired' | 'paused'
@@ -16,6 +16,7 @@ type Recurly = {
billingDetailsLink: string
accountManagementLink: string
additionalLicenses: number
+ addOns: RecurlyAddOn[]
totalLicenses: number
nextPaymentDueAt: string
nextPaymentDueDate: string
@@ -42,6 +43,8 @@ type Recurly = {
}
}
displayPrice: string
+ planOnlyDisplayPrice: string
+ addOnDisplayPricesWithoutAdditionalLicense: Record