Просмотр исходного кода

Merge pull request #20968 from overleaf/jdt-subscription-page-addons

feat: add all add-ons to price calculation in subs view model
GitOrigin-RevId: d03374192d735278c6459fc6341a72d0b0c7c3aa
Jimmy Domagala-Tang 1 год назад
Родитель
Сommit
9818912cb7
19 измененных файлов с 493 добавлено и 88 удалено
  1. 9 1
      services/web/app/src/Features/Subscription/SubscriptionFormatters.js
  2. 22 29
      services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js
  3. 10 0
      services/web/frontend/extracted-translations.json
  4. 2 1
      services/web/frontend/js/features/subscription/components/dashboard/free-plan.tsx
  5. 20 1
      services/web/frontend/js/features/subscription/components/dashboard/personal-subscription.tsx
  6. 188 0
      services/web/frontend/js/features/subscription/components/dashboard/states/active/active-ai-addon.tsx
  7. 3 1
      services/web/frontend/js/features/subscription/components/dashboard/states/active/active.tsx
  8. 12 0
      services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/buy-ai-add-on-button.tsx
  9. 0 37
      services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/buy-ai-add-on-modal.tsx
  10. 120 0
      services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/cancel-ai-add-on-modal.tsx
  11. 33 3
      services/web/frontend/js/features/subscription/components/dashboard/states/active/pending-plan-change.tsx
  12. 10 3
      services/web/frontend/js/features/subscription/components/dashboard/states/active/subscription-remainder.tsx
  13. 2 1
      services/web/frontend/js/features/subscription/components/dashboard/states/expired.tsx
  14. 6 0
      services/web/frontend/js/features/subscription/data/add-on-codes.ts
  15. 10 0
      services/web/locales/en.json
  16. 12 0
      services/web/test/frontend/features/subscription/fixtures/subscriptions.ts
  17. 1 0
      services/web/types/subscription/dashboard/modal-ids.ts
  18. 3 11
      services/web/types/subscription/dashboard/subscription.ts
  19. 30 0
      services/web/types/subscription/plan.ts

+ 9 - 1
services/web/app/src/Features/Subscription/SubscriptionFormatters.js

@@ -68,15 +68,23 @@ function formatPriceLocalized(priceInCents, currency = 'USD', locale) {
   return formatCurrencyLocalized(priceInCurrencyUnit, currency, locale)
   return formatCurrencyLocalized(priceInCurrencyUnit, currency, locale)
 }
 }
 
 
-function formatDate(date) {
+function formatDateTime(date) {
   if (!date) {
   if (!date) {
     return null
     return null
   }
   }
   return dateformat(date, 'mmmm dS, yyyy h:MM TT Z', true)
   return dateformat(date, 'mmmm dS, yyyy h:MM TT Z', true)
 }
 }
 
 
+function formatDate(date) {
+  if (!date) {
+    return null
+  }
+  return dateformat(date, 'mmmm dS, yyyy', true)
+}
+
 module.exports = {
 module.exports = {
   formatPriceDefault,
   formatPriceDefault,
   formatPriceLocalized,
   formatPriceLocalized,
+  formatDateTime,
   formatDate,
   formatDate,
 }
 }

+ 22 - 29
services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js

@@ -250,17 +250,13 @@ async function buildUsersSubscriptionViewModel(
     // Note: tax_in_cents already includes the tax for any addon.
     // Note: tax_in_cents already includes the tax for any addon.
     let addOnPrice = 0
     let addOnPrice = 0
     let additionalLicenses = 0
     let additionalLicenses = 0
-    if (
-      plan.membersLimitAddOn &&
-      Array.isArray(recurlySubscription.subscription_add_ons)
-    ) {
-      recurlySubscription.subscription_add_ons.forEach(addOn => {
-        if (addOn.add_on_code === plan.membersLimitAddOn) {
-          addOnPrice += addOn.quantity * addOn.unit_amount_in_cents
-          additionalLicenses += addOn.quantity
-        }
-      })
-    }
+    const addOns = recurlySubscription.subscription_add_ons || []
+    addOns.forEach(addOn => {
+      addOnPrice += addOn.quantity * addOn.unit_amount_in_cents
+      if (addOn.add_on_code === plan.membersLimitAddOn) {
+        additionalLicenses += addOn.quantity
+      }
+    })
     const totalLicenses = (plan.membersLimit || 0) + additionalLicenses
     const totalLicenses = (plan.membersLimit || 0) + additionalLicenses
     personalSubscription.recurly = {
     personalSubscription.recurly = {
       tax,
       tax,
@@ -270,14 +266,17 @@ async function buildUsersSubscriptionViewModel(
       billingDetailsLink: buildHostedLink('billing-details'),
       billingDetailsLink: buildHostedLink('billing-details'),
       accountManagementLink: buildHostedLink('account-management'),
       accountManagementLink: buildHostedLink('account-management'),
       additionalLicenses,
       additionalLicenses,
-      addOns: recurlySubscription.subscription_add_ons || [],
+      addOns,
       totalLicenses,
       totalLicenses,
-      nextPaymentDueAt: SubscriptionFormatters.formatDate(
+      nextPaymentDueAt: SubscriptionFormatters.formatDateTime(
+        recurlySubscription.current_period_ends_at
+      ),
+      nextPaymentDueDate: SubscriptionFormatters.formatDate(
         recurlySubscription.current_period_ends_at
         recurlySubscription.current_period_ends_at
       ),
       ),
       currency: recurlySubscription.currency,
       currency: recurlySubscription.currency,
       state: recurlySubscription.state,
       state: recurlySubscription.state,
-      trialEndsAtFormatted: SubscriptionFormatters.formatDate(
+      trialEndsAtFormatted: SubscriptionFormatters.formatDateTime(
         recurlySubscription.trial_ends_at
         recurlySubscription.trial_ends_at
       ),
       ),
       trial_ends_at: recurlySubscription.trial_ends_at,
       trial_ends_at: recurlySubscription.trial_ends_at,
@@ -297,24 +296,18 @@ async function buildUsersSubscriptionViewModel(
       let pendingAddOnTax = 0
       let pendingAddOnTax = 0
       let pendingAddOnPrice = 0
       let pendingAddOnPrice = 0
       if (recurlySubscription.pending_subscription.subscription_add_ons) {
       if (recurlySubscription.pending_subscription.subscription_add_ons) {
-        if (
-          pendingPlan.membersLimitAddOn &&
-          Array.isArray(
-            recurlySubscription.pending_subscription.subscription_add_ons
-          )
-        ) {
-          recurlySubscription.pending_subscription.subscription_add_ons.forEach(
-            addOn => {
-              if (addOn.add_on_code === pendingPlan.membersLimitAddOn) {
-                pendingAddOnPrice += addOn.quantity * addOn.unit_amount_in_cents
-                pendingAdditionalLicenses += addOn.quantity
-              }
-            }
-          )
-        }
+        const pendingRecurlyAddons =
+          recurlySubscription.pending_subscription.subscription_add_ons
+        pendingRecurlyAddons.forEach(addOn => {
+          pendingAddOnPrice += addOn.quantity * addOn.unit_amount_in_cents
+          if (addOn.add_on_code === pendingPlan.membersLimitAddOn) {
+            pendingAdditionalLicenses += addOn.quantity
+          }
+        })
         // Need to calculate tax ourselves as we don't get tax amounts for pending subs
         // Need to calculate tax ourselves as we don't get tax amounts for pending subs
         pendingAddOnTax =
         pendingAddOnTax =
           personalSubscription.recurly.taxRate * pendingAddOnPrice
           personalSubscription.recurly.taxRate * pendingAddOnPrice
+        pendingPlan.addOns = pendingRecurlyAddons
       }
       }
       const pendingSubscriptionTax =
       const pendingSubscriptionTax =
         personalSubscription.recurly.taxRate *
         personalSubscription.recurly.taxRate *

+ 10 - 0
services/web/frontend/extracted-translations.json

@@ -66,6 +66,7 @@
   "add_more_managers": "",
   "add_more_managers": "",
   "add_more_members": "",
   "add_more_members": "",
   "add_new_email": "",
   "add_new_email": "",
+  "add_ons_are": "",
   "add_or_remove_project_from_tag": "",
   "add_or_remove_project_from_tag": "",
   "add_people": "",
   "add_people": "",
   "add_role_and_department": "",
   "add_role_and_department": "",
@@ -120,6 +121,7 @@
   "are_you_getting_an_undefined_control_sequence_error": "",
   "are_you_getting_an_undefined_control_sequence_error": "",
   "are_you_still_at": "",
   "are_you_still_at": "",
   "are_you_sure": "",
   "are_you_sure": "",
+  "are_you_sure_you_want_to_cancel_add_on": "",
   "as_email": "",
   "as_email": "",
   "ask_proj_owner_to_unlink_from_current_github": "",
   "ask_proj_owner_to_unlink_from_current_github": "",
   "ask_proj_owner_to_upgrade_for_full_history": "",
   "ask_proj_owner_to_upgrade_for_full_history": "",
@@ -162,6 +164,7 @@
   "can_now_relink_dropbox": "",
   "can_now_relink_dropbox": "",
   "can_view": "",
   "can_view": "",
   "cancel": "",
   "cancel": "",
+  "cancel_add_on": "",
   "cancel_anytime": "",
   "cancel_anytime": "",
   "cancel_my_account": "",
   "cancel_my_account": "",
   "cancel_my_subscription": "",
   "cancel_my_subscription": "",
@@ -1037,6 +1040,7 @@
   "pdf_viewer": "",
   "pdf_viewer": "",
   "pdf_viewer_error": "",
   "pdf_viewer_error": "",
   "pending_additional_licenses": "",
   "pending_additional_licenses": "",
+  "pending_addon_cancellation": "",
   "pending_invite": "",
   "pending_invite": "",
   "percent_discount_for_groups": "",
   "percent_discount_for_groups": "",
   "percent_is_the_percentage_of_the_line_width": "",
   "percent_is_the_percentage_of_the_line_width": "",
@@ -1187,6 +1191,7 @@
   "remote_service_error": "",
   "remote_service_error": "",
   "remove": "",
   "remove": "",
   "remove_access": "",
   "remove_access": "",
+  "remove_add_on": "",
   "remove_collaborator": "",
   "remove_collaborator": "",
   "remove_from_group": "",
   "remove_from_group": "",
   "remove_link": "",
   "remove_link": "",
@@ -1470,6 +1475,7 @@
   "sure_you_want_to_change_plan": "",
   "sure_you_want_to_change_plan": "",
   "sure_you_want_to_delete": "",
   "sure_you_want_to_delete": "",
   "sure_you_want_to_leave_group": "",
   "sure_you_want_to_leave_group": "",
+  "switch_plan": "",
   "switch_to_editor": "",
   "switch_to_editor": "",
   "switch_to_pdf": "",
   "switch_to_pdf": "",
   "symbol_palette": "",
   "symbol_palette": "",
@@ -1511,6 +1517,7 @@
   "thanks_for_subscribing": "",
   "thanks_for_subscribing": "",
   "thanks_for_subscribing_you_help_sl": "",
   "thanks_for_subscribing_you_help_sl": "",
   "thanks_settings_updated": "",
   "thanks_settings_updated": "",
+  "the_add_on_will_remain_active_until": "",
   "the_following_files_already_exist_in_this_project": "",
   "the_following_files_already_exist_in_this_project": "",
   "the_following_files_and_folders_already_exist_in_this_project": "",
   "the_following_files_and_folders_already_exist_in_this_project": "",
   "the_following_folder_already_exists_in_this_project": "",
   "the_following_folder_already_exists_in_this_project": "",
@@ -1694,6 +1701,7 @@
   "untrash": "",
   "untrash": "",
   "update": "",
   "update": "",
   "update_account_info": "",
   "update_account_info": "",
+  "update_billing_details": "",
   "update_dropbox_settings": "",
   "update_dropbox_settings": "",
   "update_your_billing_details": "",
   "update_your_billing_details": "",
   "updates_to_project_sharing": "",
   "updates_to_project_sharing": "",
@@ -1743,6 +1751,7 @@
   "view_hub_subtext": "",
   "view_hub_subtext": "",
   "view_in_template_gallery": "",
   "view_in_template_gallery": "",
   "view_invitation": "",
   "view_invitation": "",
+  "view_invoices": "",
   "view_labs_experiments": "",
   "view_labs_experiments": "",
   "view_less": "",
   "view_less": "",
   "view_logs": "",
   "view_logs": "",
@@ -1856,6 +1865,7 @@
   "your_new_plan": "",
   "your_new_plan": "",
   "your_password_was_detected": "",
   "your_password_was_detected": "",
   "your_plan": "",
   "your_plan": "",
+  "your_plan_is": "",
   "your_plan_is_changing_at_term_end": "",
   "your_plan_is_changing_at_term_end": "",
   "your_plan_is_limited_to_n_editors": "",
   "your_plan_is_limited_to_n_editors": "",
   "your_plan_is_limited_to_n_editors_plural": "",
   "your_plan_is_limited_to_n_editors_plural": "",

+ 2 - 1
services/web/frontend/js/features/subscription/components/dashboard/free-plan.tsx

@@ -1,5 +1,6 @@
 import { useTranslation, Trans } from 'react-i18next'
 import { useTranslation, Trans } from 'react-i18next'
 import { isSplitTestEnabled } from '@/utils/splitTestUtils'
 import { isSplitTestEnabled } from '@/utils/splitTestUtils'
+import { AI_STANDALONE_PLAN_CODE } from '../../data/add-on-codes'
 
 
 function FreePlan() {
 function FreePlan() {
   const { t } = useTranslation()
   const { t } = useTranslation()
@@ -30,7 +31,7 @@ function FreePlan() {
       {isSplitTestEnabled('ai-add-on') && (
       {isSplitTestEnabled('ai-add-on') && (
         <a
         <a
           className="btn btn-secondary"
           className="btn btn-secondary"
-          href="/user/subscription/new?planCode=assistant"
+          href={`/user/subscription/new?planCode=${AI_STANDALONE_PLAN_CODE}`}
         >
         >
           {t('buy_overleaf_assist')}
           {t('buy_overleaf_assist')}
         </a>
         </a>

+ 20 - 1
services/web/frontend/js/features/subscription/components/dashboard/personal-subscription.tsx

@@ -1,11 +1,17 @@
 import { Trans, useTranslation } from 'react-i18next'
 import { Trans, useTranslation } from 'react-i18next'
 import { RecurlySubscription } from '../../../../../../types/subscription/dashboard/subscription'
 import { RecurlySubscription } from '../../../../../../types/subscription/dashboard/subscription'
 import { ActiveSubscription } from './states/active/active'
 import { ActiveSubscription } from './states/active/active'
+import { ActiveAiAddonSubscription } from './states/active/active-ai-addon'
 import { CanceledSubscription } from './states/canceled'
 import { CanceledSubscription } from './states/canceled'
 import { ExpiredSubscription } from './states/expired'
 import { ExpiredSubscription } from './states/expired'
 import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
 import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
 import PersonalSubscriptionRecurlySyncEmail from './personal-subscription-recurly-sync-email'
 import PersonalSubscriptionRecurlySyncEmail from './personal-subscription-recurly-sync-email'
 import OLNotification from '@/features/ui/components/ol/ol-notification'
 import OLNotification from '@/features/ui/components/ol/ol-notification'
+import {
+  AI_STANDALONE_PLAN_CODE,
+  AI_STANDALONE_ANNUAL_PLAN_CODE,
+  AI_ADD_ON_CODE,
+} from '../../data/add-on-codes'
 
 
 function PastDueSubscriptionAlert({
 function PastDueSubscriptionAlert({
   subscription,
   subscription,
@@ -40,7 +46,20 @@ function PersonalSubscriptionStates({
   const { t } = useTranslation()
   const { t } = useTranslation()
   const state = subscription?.recurly.state
   const state = subscription?.recurly.state
 
 
-  if (state === 'active') {
+  const hasAiAddon = subscription?.addOns?.some(
+    addOn => addOn.addOnCode === AI_ADD_ON_CODE
+  )
+
+  const onAiStandalonePlan = [
+    AI_STANDALONE_PLAN_CODE,
+    AI_STANDALONE_ANNUAL_PLAN_CODE,
+  ].includes(subscription.planCode)
+
+  const planHasAi = onAiStandalonePlan || hasAiAddon
+
+  if (state === 'active' && planHasAi) {
+    return <ActiveAiAddonSubscription subscription={subscription} />
+  } else if (state === 'active') {
     return <ActiveSubscription subscription={subscription} />
     return <ActiveSubscription subscription={subscription} />
   } else if (state === 'canceled') {
   } else if (state === 'canceled') {
     return <CanceledSubscription subscription={subscription} />
     return <CanceledSubscription subscription={subscription} />

+ 188 - 0
services/web/frontend/js/features/subscription/components/dashboard/states/active/active-ai-addon.tsx

@@ -0,0 +1,188 @@
+import { useTranslation, Trans } from 'react-i18next'
+import { PriceExceptions } from '../../../shared/price-exceptions'
+import { useSubscriptionDashboardContext } from '../../../../context/subscription-dashboard-context'
+import { RecurlySubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
+import { CancelSubscription } from './cancel-plan/cancel-subscription'
+import { PendingPlanChange } from './pending-plan-change'
+import SubscriptionRemainder from './subscription-remainder'
+import { ChangePlanModal } from './change-plan/modals/change-plan-modal'
+import { ConfirmChangePlanModal } from './change-plan/modals/confirm-change-plan-modal'
+import { KeepCurrentPlanModal } from './change-plan/modals/keep-current-plan-modal'
+import { ChangeToGroupModal } from './change-plan/modals/change-to-group-modal'
+import { CancelAiAddOnModal } from './change-plan/modals/cancel-ai-add-on-modal'
+import {
+  AI_STANDALONE_PLAN_CODE,
+  ADD_ON_NAME,
+  AI_STANDALONE_PLAN_NAME,
+  AI_STANDALONE_ANNUAL_PLAN_CODE,
+} from '../../../../data/add-on-codes'
+import { CancelSubscriptionButton } from './cancel-subscription-button'
+
+import OLButton from '@/features/ui/components/ol/ol-button'
+
+export function ActiveAiAddonSubscription({
+  subscription,
+}: {
+  subscription: RecurlySubscription
+}) {
+  const { t } = useTranslation()
+  const { recurlyLoadError, showCancellation, setModalIdShown } =
+    useSubscriptionDashboardContext()
+  if (showCancellation) return <CancelSubscription />
+
+  const onStandalonePlan = [
+    AI_STANDALONE_PLAN_CODE,
+    AI_STANDALONE_ANNUAL_PLAN_CODE,
+  ].includes(subscription.planCode)
+
+  const handlePlanChange = () => setModalIdShown('change-plan')
+
+  const handleCancelClick = () => setModalIdShown('cancel-ai-add-on')
+
+  return (
+    <>
+      <p className="mb-0">
+        <Trans
+          i18nKey="your_plan_is"
+          values={{
+            planName: onStandalonePlan
+              ? AI_STANDALONE_PLAN_NAME
+              : subscription.plan.name,
+          }}
+          shouldUnescape
+          tOptions={{ interpolation: { escapeValue: true } }}
+          components={{ strong: <strong /> }}
+        />
+      </p>
+      <p>
+        <Trans
+          i18nKey="add_ons_are"
+          shouldUnescape
+          tOptions={{ interpolation: { escapeValue: true } }}
+          values={{
+            addOnName: ADD_ON_NAME,
+          }}
+          components={{ strong: <strong /> }}
+        />
+      </p>
+      <p>
+        {subscription.pendingPlan && (
+          <PendingPlanChange subscription={subscription} />
+        )}
+      </p>
+      {subscription.pendingPlan &&
+        subscription.pendingPlan.name !== subscription.plan.name && (
+          <p>{t('want_change_to_apply_before_plan_end')}</p>
+        )}
+      <p>
+        <Trans
+          i18nKey="next_payment_of_x_collectected_on_y"
+          values={{
+            paymentAmmount: subscription.recurly.displayPrice,
+            collectionDate: subscription.recurly.nextPaymentDueDate,
+          }}
+          shouldUnescape
+          tOptions={{ interpolation: { escapeValue: true } }}
+          components={[
+            // eslint-disable-next-line react/jsx-key
+            <strong />,
+            // eslint-disable-next-line react/jsx-key
+            <strong />,
+          ]}
+        />
+      </p>
+      <PriceExceptions subscription={subscription} />
+      {!recurlyLoadError && (
+        <p>
+          <i>
+            <SubscriptionRemainder subscription={subscription} hideTime />
+          </i>
+        </p>
+      )}
+      {!recurlyLoadError && (
+        <p className="d-inline-flex flex-wrap gap-1">
+          {onStandalonePlan ? (
+            <StandaloneAiPlanActions
+              handlePlanChange={handlePlanChange}
+              handleCancelClick={handleCancelClick}
+            />
+          ) : (
+            <PlanWithAddonsActions
+              handlePlanChange={handlePlanChange}
+              handleCancelClick={handleCancelClick}
+            />
+          )}
+        </p>
+      )}
+      <p>
+        <a
+          href={subscription.recurly.accountManagementLink}
+          target="_blank"
+          rel="noreferrer noopener"
+        >
+          {t('view_invoices')}
+        </a>
+      </p>
+      <p>
+        <a
+          href={subscription.recurly.billingDetailsLink}
+          target="_blank"
+          rel="noreferrer noopener"
+        >
+          {t('update_billing_details')}
+        </a>
+      </p>
+
+      <ChangePlanModal />
+      <ConfirmChangePlanModal />
+      <KeepCurrentPlanModal />
+      <ChangeToGroupModal />
+      <CancelAiAddOnModal />
+    </>
+  )
+}
+
+function StandaloneAiPlanActions({
+  handlePlanChange,
+  handleCancelClick,
+}: {
+  handlePlanChange(): void
+  handleCancelClick(): void
+}) {
+  const { t } = useTranslation()
+  return (
+    <>
+      <OLButton variant="secondary" onClick={handlePlanChange}>
+        {t('upgrade')}
+      </OLButton>
+
+      <OLButton variant="danger-ghost" onClick={handleCancelClick}>
+        {t('cancel_add_on')}
+      </OLButton>
+    </>
+  )
+}
+
+function PlanWithAddonsActions({
+  handlePlanChange,
+  handleCancelClick,
+}: {
+  handlePlanChange(): void
+  handleCancelClick(): void
+}) {
+  const { t } = useTranslation()
+  return (
+    <>
+      <OLButton variant="secondary" onClick={handlePlanChange}>
+        {t('switch_plan')}
+      </OLButton>
+
+      <>
+        <OLButton variant="danger-ghost" onClick={handleCancelClick}>
+          {t('remove_add_on')}
+        </OLButton>{' '}
+        <CancelSubscriptionButton />
+      </>
+    </>
+  )
+}

+ 3 - 1
services/web/frontend/js/features/subscription/components/dashboard/states/active/active.tsx

@@ -15,7 +15,7 @@ import { ConfirmChangePlanModal } from './change-plan/modals/confirm-change-plan
 import { KeepCurrentPlanModal } from './change-plan/modals/keep-current-plan-modal'
 import { KeepCurrentPlanModal } from './change-plan/modals/keep-current-plan-modal'
 import { ChangeToGroupModal } from './change-plan/modals/change-to-group-modal'
 import { ChangeToGroupModal } from './change-plan/modals/change-to-group-modal'
 import OLButton from '@/features/ui/components/ol/ol-button'
 import OLButton from '@/features/ui/components/ol/ol-button'
-import { BuyAiAddOnButton } from './change-plan/modals/buy-ai-add-on-modal'
+import { BuyAiAddOnButton } from './change-plan/buy-ai-add-on-button'
 import { isSplitTestEnabled } from '@/utils/splitTestUtils'
 import { isSplitTestEnabled } from '@/utils/splitTestUtils'
 
 
 export function ActiveSubscription({
 export function ActiveSubscription({
@@ -30,6 +30,7 @@ export function ActiveSubscription({
   if (showCancellation) return <CancelSubscription />
   if (showCancellation) return <CancelSubscription />
 
 
   const aiAddOnAvailable = isSplitTestEnabled('ai-add-on')
   const aiAddOnAvailable = isSplitTestEnabled('ai-add-on')
+
   return (
   return (
     <>
     <>
       <p>
       <p>
@@ -148,6 +149,7 @@ export function ActiveSubscription({
       <ConfirmChangePlanModal />
       <ConfirmChangePlanModal />
       <KeepCurrentPlanModal />
       <KeepCurrentPlanModal />
       <ChangeToGroupModal />
       <ChangeToGroupModal />
+      {/* to be removed once we have purchasing options ready  */}
       {aiAddOnAvailable && <BuyAiAddOnButton />}
       {aiAddOnAvailable && <BuyAiAddOnButton />}
     </>
     </>
   )
   )

+ 12 - 0
services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/buy-ai-add-on-button.tsx

@@ -0,0 +1,12 @@
+import { Button } from 'react-bootstrap'
+import { postJSON } from '@/infrastructure/fetch-json'
+import { AI_ADD_ON_CODE } from '../../../../../data/add-on-codes'
+
+export function BuyAiAddOnButton() {
+  const addAiAddon = async () => {
+    await postJSON(`/user/subscription/addon/${AI_ADD_ON_CODE}/add`)
+    location.reload()
+  }
+
+  return <Button onClick={addAiAddon}>Purchase AI subscription </Button>
+}

+ 0 - 37
services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/buy-ai-add-on-modal.tsx

@@ -1,37 +0,0 @@
-import { Button } from 'react-bootstrap'
-import { postJSON } from '@/infrastructure/fetch-json'
-import { useSubscriptionDashboardContext } from '@/features/subscription/context/subscription-dashboard-context'
-import { RecurlySubscription } from '../../../../../../../../../../types/subscription/dashboard/subscription'
-
-const AI_ADDON_CODE = 'assistant'
-
-export function BuyAiAddOnButton() {
-  const { personalSubscription } = useSubscriptionDashboardContext()
-  const recurlySub = personalSubscription as RecurlySubscription
-
-  const hasSub = recurlySub?.recurly?.addOns?.some(
-    addOn => addOn.add_on_code === AI_ADDON_CODE
-  )
-
-  const paths = {
-    purchaseAddon: `/user/subscription/addon/${AI_ADDON_CODE}/add`,
-    removeAddon: `/user/subscription/addon/${AI_ADDON_CODE}/remove`,
-  }
-
-  const addAiAddon = async () => {
-    await postJSON(paths.purchaseAddon)
-    location.reload()
-  }
-
-  // gotta change here to change the time to next billing cycle
-  const removeAiAddon = async () => {
-    await postJSON(paths.removeAddon)
-    location.reload()
-  }
-
-  return hasSub ? (
-    <Button onClick={removeAiAddon}>Cancel AI subscription</Button>
-  ) : (
-    <Button onClick={addAiAddon}>Purchase AI subscription </Button>
-  )
-}

+ 120 - 0
services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/cancel-ai-add-on-modal.tsx

@@ -0,0 +1,120 @@
+import { useState } from 'react'
+import { useTranslation, Trans } from 'react-i18next'
+import { SubscriptionDashModalIds } from '../../../../../../../../../../types/subscription/dashboard/modal-ids'
+import { postJSON } from '../../../../../../../../infrastructure/fetch-json'
+import { useSubscriptionDashboardContext } from '../../../../../../context/subscription-dashboard-context'
+import { useLocation } from '../../../../../../../../shared/hooks/use-location'
+import OLModal, {
+  OLModalBody,
+  OLModalFooter,
+  OLModalHeader,
+  OLModalTitle,
+} from '@/features/ui/components/ol/ol-modal'
+import OLButton from '@/features/ui/components/ol/ol-button'
+import OLNotification from '@/features/ui/components/ol/ol-notification'
+import {
+  AI_ADD_ON_CODE,
+  AI_STANDALONE_PLAN_CODE,
+  ADD_ON_NAME,
+} from '../../../../../../data/add-on-codes'
+import {
+  cancelSubscriptionUrl,
+  redirectAfterCancelSubscriptionUrl,
+} from '../../../../../../data/subscription-url'
+
+export function CancelAiAddOnModal() {
+  const modalId: SubscriptionDashModalIds = 'cancel-ai-add-on'
+  const [error, setError] = useState(false)
+  const [inflight, setInflight] = useState(false)
+  const { t } = useTranslation()
+  const { handleCloseModal, modalIdShown, personalSubscription } =
+    useSubscriptionDashboardContext()
+  const location = useLocation()
+
+  if (!personalSubscription) return null
+
+  const onStandalone = personalSubscription.planCode === AI_STANDALONE_PLAN_CODE
+
+  const cancellationEndpoint = onStandalone
+    ? cancelSubscriptionUrl
+    : `/user/subscription/addon/${AI_ADD_ON_CODE}/remove`
+
+  async function handleConfirmChange() {
+    setError(false)
+    setInflight(true)
+
+    try {
+      await postJSON(cancellationEndpoint)
+      location.assign(redirectAfterCancelSubscriptionUrl)
+    } catch (e) {
+      setError(true)
+      setInflight(false)
+    }
+  }
+
+  if (modalIdShown !== modalId) return null
+
+  return (
+    <OLModal
+      id={modalId}
+      show
+      animation
+      onHide={handleCloseModal}
+      backdrop="static"
+    >
+      <OLModalHeader>
+        <OLModalTitle>{t('cancel_add_on')}</OLModalTitle>
+      </OLModalHeader>
+
+      <OLModalBody>
+        {error && (
+          <OLNotification
+            type="error"
+            aria-live="polite"
+            content={
+              <>
+                {t('generic_something_went_wrong')}. {t('try_again')}.{' '}
+                {t('generic_if_problem_continues_contact_us')}.
+              </>
+            }
+          />
+        )}
+        <p>
+          <Trans
+            i18nKey="are_you_sure_you_want_to_cancel_add_on"
+            values={{
+              addOnName: ADD_ON_NAME,
+            }}
+            shouldUnescape
+            tOptions={{ interpolation: { escapeValue: true } }}
+            components={{ strong: <strong /> }}
+          />
+        </p>
+        <p>{t('the_add_on_will_remain_active_until')}</p>
+      </OLModalBody>
+
+      <OLModalFooter>
+        <OLButton
+          variant="secondary"
+          disabled={inflight}
+          onClick={handleCloseModal}
+        >
+          {t('back')}
+        </OLButton>
+        <OLButton
+          variant="danger"
+          disabled={inflight}
+          isLoading={inflight}
+          onClick={handleConfirmChange}
+          bs3Props={{
+            loading: inflight
+              ? t('processing_uppercase') + '…'
+              : t('cancel_add_on'),
+          }}
+        >
+          {t('cancel_add_on')}
+        </OLButton>
+      </OLModalFooter>
+    </OLModal>
+  )
+}

+ 33 - 3
services/web/frontend/js/features/subscription/components/dashboard/states/active/pending-plan-change.tsx

@@ -1,5 +1,7 @@
 import { Trans } from 'react-i18next'
 import { Trans } from 'react-i18next'
 import { RecurlySubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
 import { RecurlySubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
+import { PendingRecurlyPlan } from '../../../../../../../../types/subscription/plan'
+import { AI_ADD_ON_CODE, ADD_ON_NAME } from '../../../../data/add-on-codes'
 
 
 export function PendingPlanChange({
 export function PendingPlanChange({
   subscription,
   subscription,
@@ -8,6 +10,21 @@ export function PendingPlanChange({
 }) {
 }) {
   if (!subscription.pendingPlan) return null
   if (!subscription.pendingPlan) return null
 
 
+  const pendingPlan = subscription.pendingPlan as PendingRecurlyPlan
+
+  const hasAiAddon = subscription.addOns?.some(
+    addOn => addOn.addOnCode === AI_ADD_ON_CODE
+  )
+
+  const pendingAiAddonCancellation =
+    hasAiAddon &&
+    !pendingPlan.addOns?.some(addOn => addOn.add_on_code === AI_ADD_ON_CODE)
+
+  const pendingAdditionalLicenses =
+    (subscription.recurly.pendingAdditionalLicenses &&
+      subscription.recurly.pendingAdditionalLicenses > 0) ||
+    subscription.recurly.additionalLicenses > 0
+
   return (
   return (
     <>
     <>
       {subscription.pendingPlan.name !== subscription.plan.name && (
       {subscription.pendingPlan.name !== subscription.plan.name && (
@@ -25,9 +42,7 @@ export function PendingPlanChange({
         />
         />
       )}
       )}
 
 
-      {((subscription.recurly.pendingAdditionalLicenses &&
-        subscription.recurly.pendingAdditionalLicenses > 0) ||
-        subscription.recurly.additionalLicenses > 0) && (
+      {pendingAdditionalLicenses && (
         <>
         <>
           {' '}
           {' '}
           <Trans
           <Trans
@@ -48,6 +63,21 @@ export function PendingPlanChange({
           />
           />
         </>
         </>
       )}
       )}
+
+      {pendingAiAddonCancellation && (
+        <>
+          {' '}
+          <Trans
+            i18nKey="pending_addon_cancellation"
+            values={{
+              addOnName: ADD_ON_NAME,
+            }}
+            shouldUnescape
+            tOptions={{ interpolation: { escapeValue: true } }}
+            components={{ strong: <strong /> }}
+          />
+        </>
+      )}
     </>
     </>
   )
   )
 }
 }

+ 10 - 3
services/web/frontend/js/features/subscription/components/dashboard/states/active/subscription-remainder.tsx

@@ -3,19 +3,26 @@ import { RecurlySubscription } from '../../../../../../../../types/subscription/
 
 
 type SubscriptionRemainderProps = {
 type SubscriptionRemainderProps = {
   subscription: RecurlySubscription
   subscription: RecurlySubscription
+  hideTime?: boolean
 }
 }
 
 
-function SubscriptionRemainder({ subscription }: SubscriptionRemainderProps) {
+function SubscriptionRemainder({
+  subscription,
+  hideTime,
+}: SubscriptionRemainderProps) {
   const stillInATrial =
   const stillInATrial =
     subscription.recurly.trialEndsAtFormatted &&
     subscription.recurly.trialEndsAtFormatted &&
     subscription.recurly.trial_ends_at &&
     subscription.recurly.trial_ends_at &&
     new Date(subscription.recurly.trial_ends_at).getTime() > Date.now()
     new Date(subscription.recurly.trial_ends_at).getTime() > Date.now()
 
 
+  const terminationDate = hideTime
+    ? subscription.recurly.nextPaymentDueDate
+    : subscription.recurly.nextPaymentDueAt
   return stillInATrial ? (
   return stillInATrial ? (
     <Trans
     <Trans
       i18nKey="subscription_will_remain_active_until_end_of_trial_period_x"
       i18nKey="subscription_will_remain_active_until_end_of_trial_period_x"
       values={{
       values={{
-        terminationDate: subscription.recurly.nextPaymentDueAt,
+        terminationDate,
       }}
       }}
       shouldUnescape
       shouldUnescape
       tOptions={{ interpolation: { escapeValue: true } }}
       tOptions={{ interpolation: { escapeValue: true } }}
@@ -28,7 +35,7 @@ function SubscriptionRemainder({ subscription }: SubscriptionRemainderProps) {
     <Trans
     <Trans
       i18nKey="subscription_will_remain_active_until_end_of_billing_period_x"
       i18nKey="subscription_will_remain_active_until_end_of_billing_period_x"
       values={{
       values={{
-        terminationDate: subscription.recurly.nextPaymentDueAt,
+        terminationDate,
       }}
       }}
       shouldUnescape
       shouldUnescape
       tOptions={{ interpolation: { escapeValue: true } }}
       tOptions={{ interpolation: { escapeValue: true } }}

+ 2 - 1
services/web/frontend/js/features/subscription/components/dashboard/states/expired.tsx

@@ -1,6 +1,7 @@
 import { useTranslation } from 'react-i18next'
 import { useTranslation } from 'react-i18next'
 import { RecurlySubscription } from '../../../../../../../types/subscription/dashboard/subscription'
 import { RecurlySubscription } from '../../../../../../../types/subscription/dashboard/subscription'
 import { isSplitTestEnabled } from '@/utils/splitTestUtils'
 import { isSplitTestEnabled } from '@/utils/splitTestUtils'
+import { AI_STANDALONE_PLAN_CODE } from '../../../data/add-on-codes'
 
 
 export function ExpiredSubscription({
 export function ExpiredSubscription({
   subscription,
   subscription,
@@ -24,7 +25,7 @@ export function ExpiredSubscription({
         {isSplitTestEnabled('ai-add-on') && (
         {isSplitTestEnabled('ai-add-on') && (
           <a
           <a
             className="btn btn-secondary me-1"
             className="btn btn-secondary me-1"
-            href="/user/subscription/new?planCode=assistant"
+            href={`/user/subscription/new?planCode=${AI_STANDALONE_PLAN_CODE}`}
           >
           >
             {t('buy_overleaf_assist')}
             {t('buy_overleaf_assist')}
           </a>
           </a>

+ 6 - 0
services/web/frontend/js/features/subscription/data/add-on-codes.ts

@@ -0,0 +1,6 @@
+export const AI_STANDALONE_PLAN_CODE = 'assistant'
+export const AI_ADD_ON_CODE = 'assistant'
+// we dont want translations on plan or add-on names
+export const ADD_ON_NAME = "Error Assist"
+export const AI_STANDALONE_PLAN_NAME = "Overleaf Free"
+export const AI_STANDALONE_ANNUAL_PLAN_CODE = 'assistant-annual'

+ 10 - 0
services/web/locales/en.json

@@ -78,6 +78,7 @@
   "add_more_managers": "Add more managers",
   "add_more_managers": "Add more managers",
   "add_more_members": "Add more members",
   "add_more_members": "Add more members",
   "add_new_email": "Add new email",
   "add_new_email": "Add new email",
+  "add_ons_are": "<strong>Add-ons:</strong> __addOnName__",
   "add_or_remove_project_from_tag": "Add or remove project from tag __tagName__",
   "add_or_remove_project_from_tag": "Add or remove project from tag __tagName__",
   "add_people": "Add people",
   "add_people": "Add people",
   "add_role_and_department": "Add role and department",
   "add_role_and_department": "Add role and department",
@@ -154,6 +155,7 @@
   "are_you_getting_an_undefined_control_sequence_error": "Are you getting an Undefined Control Sequence error? If you are, make sure you’ve loaded the graphicx package—<0>\\usepackage{graphicx}</0>—in the preamble (first section of code) in your document. <1>Learn more</1>",
   "are_you_getting_an_undefined_control_sequence_error": "Are you getting an Undefined Control Sequence error? If you are, make sure you’ve loaded the graphicx package—<0>\\usepackage{graphicx}</0>—in the preamble (first section of code) in your document. <1>Learn more</1>",
   "are_you_still_at": "Are you still at <0>__institutionName__</0>?",
   "are_you_still_at": "Are you still at <0>__institutionName__</0>?",
   "are_you_sure": "Are you sure?",
   "are_you_sure": "Are you sure?",
+  "are_you_sure_you_want_to_cancel_add_on": "Are you sure you want to cancel the <strong>__addOnName__</strong> add-on?",
   "article": "Article",
   "article": "Article",
   "articles": "Articles",
   "articles": "Articles",
   "as_a_member_of_sso_required": "As a member of <b>__institutionName__</b>, you must log in to <b>__appName__</b> through your institution.",
   "as_a_member_of_sso_required": "As a member of <b>__institutionName__</b>, you must log in to <b>__appName__</b> through your institution.",
@@ -223,6 +225,7 @@
   "can_now_relink_dropbox": "You can now <0>relink your Dropbox account</0>.",
   "can_now_relink_dropbox": "You can now <0>relink your Dropbox account</0>.",
   "can_view": "Can view",
   "can_view": "Can view",
   "cancel": "Cancel",
   "cancel": "Cancel",
+  "cancel_add_on": "Cancel add-on",
   "cancel_anytime": "We’re confident that you’ll love __appName__, but if not you can cancel anytime. We’ll give you your money back, no questions asked, if you let us know within 30 days.",
   "cancel_anytime": "We’re confident that you’ll love __appName__, but if not you can cancel anytime. We’ll give you your money back, no questions asked, if you let us know within 30 days.",
   "cancel_my_account": "Cancel my subscription",
   "cancel_my_account": "Cancel my subscription",
   "cancel_my_subscription": "Cancel my subscription",
   "cancel_my_subscription": "Cancel my subscription",
@@ -1495,6 +1498,7 @@
   "pdf_viewer_error": "There was a problem displaying the PDF for this project.",
   "pdf_viewer_error": "There was a problem displaying the PDF for this project.",
   "pending": "Pending",
   "pending": "Pending",
   "pending_additional_licenses": "Your subscription is changing to include <0>__pendingAdditionalLicenses__</0> additional license(s) for a total of <1>__pendingTotalLicenses__</1> licenses.",
   "pending_additional_licenses": "Your subscription is changing to include <0>__pendingAdditionalLicenses__</0> additional license(s) for a total of <1>__pendingTotalLicenses__</1> licenses.",
+  "pending_addon_cancellation": "Your subscription will change to remove the <strong>__addOnName__</strong> add-on at the end of the current billing period.",
   "pending_invite": "Pending invite",
   "pending_invite": "Pending invite",
   "per_month": "per month",
   "per_month": "per month",
   "per_user": "per user",
   "per_user": "per user",
@@ -1698,6 +1702,7 @@
   "remote_service_error": "The remote service produced an error",
   "remote_service_error": "The remote service produced an error",
   "remove": "Remove",
   "remove": "Remove",
   "remove_access": "Remove access",
   "remove_access": "Remove access",
+  "remove_add_on": "Remove add-on",
   "remove_collaborator": "Remove collaborator",
   "remove_collaborator": "Remove collaborator",
   "remove_from_group": "Remove from group",
   "remove_from_group": "Remove from group",
   "remove_link": "Remove link",
   "remove_link": "Remove link",
@@ -2053,6 +2058,7 @@
   "sure_you_want_to_delete": "Are you sure you want to permanently delete the following files?",
   "sure_you_want_to_delete": "Are you sure you want to permanently delete the following files?",
   "sure_you_want_to_leave_group": "Are you sure you want to leave this group?",
   "sure_you_want_to_leave_group": "Are you sure you want to leave this group?",
   "sv": "Swedish",
   "sv": "Swedish",
+  "switch_plan": "Switch plan",
   "switch_to_editor": "Switch to editor",
   "switch_to_editor": "Switch to editor",
   "switch_to_pdf": "Switch to PDF",
   "switch_to_pdf": "Switch to PDF",
   "symbol_palette": "Symbol palette",
   "symbol_palette": "Symbol palette",
@@ -2112,6 +2118,7 @@
   "thanks_for_subscribing": "Thanks for subscribing!",
   "thanks_for_subscribing": "Thanks for subscribing!",
   "thanks_for_subscribing_you_help_sl": "Thank you for subscribing to the __planName__ plan. It’s support from people like yourself that allows __appName__ to continue to grow and improve.",
   "thanks_for_subscribing_you_help_sl": "Thank you for subscribing to the __planName__ plan. It’s support from people like yourself that allows __appName__ to continue to grow and improve.",
   "thanks_settings_updated": "Thanks, your settings have been updated.",
   "thanks_settings_updated": "Thanks, your settings have been updated.",
+  "the_add_on_will_remain_active_until": "The add-on will remain active until the end of the current billing period.",
   "the_file_supplied_is_of_an_unsupported_type ": "The link to open this content on Overleaf pointed to the wrong kind of file. Valid file types are .tex documents and .zip files. If this keeps happening for links on a particular site, please report this to them.",
   "the_file_supplied_is_of_an_unsupported_type ": "The link to open this content on Overleaf pointed to the wrong kind of file. Valid file types are .tex documents and .zip files. If this keeps happening for links on a particular site, please report this to them.",
   "the_following_files_already_exist_in_this_project": "The following files already exist in this project:",
   "the_following_files_already_exist_in_this_project": "The following files already exist in this project:",
   "the_following_files_and_folders_already_exist_in_this_project": "The following files and folders already exist in this project:",
   "the_following_files_and_folders_already_exist_in_this_project": "The following files and folders already exist in this project:",
@@ -2326,6 +2333,7 @@
   "up_to": "Up to",
   "up_to": "Up to",
   "update": "Update",
   "update": "Update",
   "update_account_info": "Update Account Info",
   "update_account_info": "Update Account Info",
+  "update_billing_details": "Update billing details",
   "update_dropbox_settings": "Update Dropbox Settings",
   "update_dropbox_settings": "Update Dropbox Settings",
   "update_your_billing_details": "Update Your Billing Details",
   "update_your_billing_details": "Update Your Billing Details",
   "updates_to_project_sharing": "Updates to project sharing",
   "updates_to_project_sharing": "Updates to project sharing",
@@ -2387,6 +2395,7 @@
   "view_hub_subtext": "Access and download subscription statistics and a list of users",
   "view_hub_subtext": "Access and download subscription statistics and a list of users",
   "view_in_template_gallery": "View it in the template gallery",
   "view_in_template_gallery": "View it in the template gallery",
   "view_invitation": "View Invitation",
   "view_invitation": "View Invitation",
+  "view_invoices": "View Invoices",
   "view_labs_experiments": "View Labs Experiments",
   "view_labs_experiments": "View Labs Experiments",
   "view_less": "View less",
   "view_less": "View less",
   "view_logs": "View logs",
   "view_logs": "View logs",
@@ -2527,6 +2536,7 @@
   "your_password_has_been_successfully_changed": "Your password has been successfully changed",
   "your_password_has_been_successfully_changed": "Your password has been successfully changed",
   "your_password_was_detected": "Your password is on a <0>public list of known compromised passwords</0>. Keep your account safe by changing your password now.",
   "your_password_was_detected": "Your password is on a <0>public list of known compromised passwords</0>. Keep your account safe by changing your password now.",
   "your_plan": "Your plan",
   "your_plan": "Your plan",
+  "your_plan_is": "<strong>Your plan:</strong> __planName__",
   "your_plan_is_changing_at_term_end": "Your plan is changing to <0>__pendingPlanName__</0> at the end of the current billing period.",
   "your_plan_is_changing_at_term_end": "Your plan is changing to <0>__pendingPlanName__</0> at the end of the current billing period.",
   "your_plan_is_limited_to_n_editors": "Your plan allows __count__ collaborator with edit access and unlimited viewers.",
   "your_plan_is_limited_to_n_editors": "Your plan allows __count__ collaborator with edit access and unlimited viewers.",
   "your_plan_is_limited_to_n_editors_plural": "Your plan allows __count__ collaborators with edit access and unlimited viewers.",
   "your_plan_is_limited_to_n_editors_plural": "Your plan allows __count__ collaborators with edit access and unlimited viewers.",

+ 12 - 0
services/web/test/frontend/features/subscription/fixtures/subscriptions.ts

@@ -8,6 +8,7 @@ import dateformat from 'dateformat'
 const today = new Date()
 const today = new Date()
 const oneYearFromToday = new Date().setFullYear(today.getFullYear() + 1)
 const oneYearFromToday = new Date().setFullYear(today.getFullYear() + 1)
 const nextPaymentDueAt = dateformat(oneYearFromToday, 'dS mmmm yyyy')
 const nextPaymentDueAt = dateformat(oneYearFromToday, 'dS mmmm yyyy')
+const nextPaymentDueDate = dateformat(oneYearFromToday, 'dS mmmm yyyy')
 const sevenDaysFromToday = new Date().setDate(today.getDate() + 7)
 const sevenDaysFromToday = new Date().setDate(today.getDate() + 7)
 const sevenDaysFromTodayFormatted = dateformat(
 const sevenDaysFromTodayFormatted = dateformat(
   sevenDaysFromToday,
   sevenDaysFromToday,
@@ -40,6 +41,7 @@ export const annualActiveSubscription: RecurlySubscription = {
     additionalLicenses: 0,
     additionalLicenses: 0,
     totalLicenses: 0,
     totalLicenses: 0,
     nextPaymentDueAt,
     nextPaymentDueAt,
+    nextPaymentDueDate,
     currency: 'USD',
     currency: 'USD',
     state: 'active',
     state: 'active',
     trialEndsAtFormatted: null,
     trialEndsAtFormatted: null,
@@ -80,6 +82,7 @@ export const annualActiveSubscriptionEuro: RecurlySubscription = {
     additionalLicenses: 0,
     additionalLicenses: 0,
     totalLicenses: 0,
     totalLicenses: 0,
     nextPaymentDueAt,
     nextPaymentDueAt,
+    nextPaymentDueDate,
     currency: 'EUR',
     currency: 'EUR',
     state: 'active',
     state: 'active',
     trialEndsAtFormatted: null,
     trialEndsAtFormatted: null,
@@ -119,6 +122,7 @@ export const annualActiveSubscriptionPro: RecurlySubscription = {
     additionalLicenses: 0,
     additionalLicenses: 0,
     totalLicenses: 0,
     totalLicenses: 0,
     nextPaymentDueAt,
     nextPaymentDueAt,
+    nextPaymentDueDate,
     currency: 'USD',
     currency: 'USD',
     state: 'active',
     state: 'active',
     trialEndsAtFormatted: null,
     trialEndsAtFormatted: null,
@@ -159,6 +163,7 @@ export const pastDueExpiredSubscription: RecurlySubscription = {
     additionalLicenses: 0,
     additionalLicenses: 0,
     totalLicenses: 0,
     totalLicenses: 0,
     nextPaymentDueAt,
     nextPaymentDueAt,
+    nextPaymentDueDate,
     currency: 'USD',
     currency: 'USD',
     state: 'expired',
     state: 'expired',
     trialEndsAtFormatted: null,
     trialEndsAtFormatted: null,
@@ -199,6 +204,7 @@ export const canceledSubscription: RecurlySubscription = {
     additionalLicenses: 0,
     additionalLicenses: 0,
     totalLicenses: 0,
     totalLicenses: 0,
     nextPaymentDueAt,
     nextPaymentDueAt,
+    nextPaymentDueDate,
     currency: 'USD',
     currency: 'USD',
     state: 'canceled',
     state: 'canceled',
     trialEndsAtFormatted: null,
     trialEndsAtFormatted: null,
@@ -239,6 +245,7 @@ export const pendingSubscriptionChange: RecurlySubscription = {
     additionalLicenses: 0,
     additionalLicenses: 0,
     totalLicenses: 0,
     totalLicenses: 0,
     nextPaymentDueAt,
     nextPaymentDueAt,
+    nextPaymentDueDate,
     currency: 'USD',
     currency: 'USD',
     state: 'active',
     state: 'active',
     trialEndsAtFormatted: null,
     trialEndsAtFormatted: null,
@@ -290,6 +297,7 @@ export const groupActiveSubscription: GroupSubscription = {
     additionalLicenses: 0,
     additionalLicenses: 0,
     totalLicenses: 10,
     totalLicenses: 10,
     nextPaymentDueAt,
     nextPaymentDueAt,
+    nextPaymentDueDate,
     currency: 'USD',
     currency: 'USD',
     state: 'active',
     state: 'active',
     trialEndsAtFormatted: null,
     trialEndsAtFormatted: null,
@@ -335,6 +343,7 @@ export const groupActiveSubscriptionWithPendingLicenseChange: GroupSubscription
       additionalLicenses: 11,
       additionalLicenses: 11,
       totalLicenses: 21,
       totalLicenses: 21,
       nextPaymentDueAt,
       nextPaymentDueAt,
+      nextPaymentDueDate,
       currency: 'USD',
       currency: 'USD',
       state: 'active',
       state: 'active',
       trialEndsAtFormatted: null,
       trialEndsAtFormatted: null,
@@ -398,6 +407,7 @@ export const trialSubscription: RecurlySubscription = {
     additionalLicenses: 0,
     additionalLicenses: 0,
     totalLicenses: 0,
     totalLicenses: 0,
     nextPaymentDueAt: sevenDaysFromTodayFormatted,
     nextPaymentDueAt: sevenDaysFromTodayFormatted,
+    nextPaymentDueDate: sevenDaysFromTodayFormatted,
     currency: 'USD',
     currency: 'USD',
     state: 'active',
     state: 'active',
     trialEndsAtFormatted: sevenDaysFromTodayFormatted,
     trialEndsAtFormatted: sevenDaysFromTodayFormatted,
@@ -469,6 +479,7 @@ export const trialCollaboratorSubscription: RecurlySubscription = {
     additionalLicenses: 0,
     additionalLicenses: 0,
     totalLicenses: 0,
     totalLicenses: 0,
     nextPaymentDueAt: sevenDaysFromTodayFormatted,
     nextPaymentDueAt: sevenDaysFromTodayFormatted,
+    nextPaymentDueDate: sevenDaysFromTodayFormatted,
     currency: 'USD',
     currency: 'USD',
     state: 'active',
     state: 'active',
     trialEndsAtFormatted: sevenDaysFromTodayFormatted,
     trialEndsAtFormatted: sevenDaysFromTodayFormatted,
@@ -518,6 +529,7 @@ export const monthlyActiveCollaborator: RecurlySubscription = {
     additionalLicenses: 0,
     additionalLicenses: 0,
     totalLicenses: 0,
     totalLicenses: 0,
     nextPaymentDueAt,
     nextPaymentDueAt,
+    nextPaymentDueDate,
     currency: 'USD',
     currency: 'USD',
     state: 'active',
     state: 'active',
     trialEndsAtFormatted: null,
     trialEndsAtFormatted: null,

+ 1 - 0
services/web/types/subscription/dashboard/modal-ids.ts

@@ -4,3 +4,4 @@ export type SubscriptionDashModalIds =
   | 'keep-current-plan'
   | 'keep-current-plan'
   | 'leave-group'
   | 'leave-group'
   | 'change-plan'
   | 'change-plan'
+  | 'cancel-ai-add-on'

+ 3 - 11
services/web/types/subscription/dashboard/subscription.ts

@@ -1,26 +1,16 @@
 import { CurrencyCode } from '../currency'
 import { CurrencyCode } from '../currency'
 import { Nullable } from '../../utils'
 import { Nullable } from '../../utils'
-import { Plan } from '../plan'
+import { Plan, AddOn } from '../plan'
 import { User } from '../../user'
 import { User } from '../../user'
 
 
 type SubscriptionState = 'active' | 'canceled' | 'expired'
 type SubscriptionState = 'active' | 'canceled' | 'expired'
 
 
-// the add-ons attached to a recurly subsription
-export type AddOn = {
-  add_on_code: string
-  add_on_type: string
-  quantity: number
-  revenue_schedule_type: string
-  unit_amount_in_cents: number
-}
-
 // when puchasing a new add-on in recurly, we only need to provide the code
 // when puchasing a new add-on in recurly, we only need to provide the code
 export type PurchasingAddOnCode = {
 export type PurchasingAddOnCode = {
   code: string
   code: string
 }
 }
 
 
 type Recurly = {
 type Recurly = {
-  addOns?: AddOn[]
   tax: number
   tax: number
   taxRate: number
   taxRate: number
   billingDetailsLink: string
   billingDetailsLink: string
@@ -28,6 +18,7 @@ type Recurly = {
   additionalLicenses: number
   additionalLicenses: number
   totalLicenses: number
   totalLicenses: number
   nextPaymentDueAt: string
   nextPaymentDueAt: string
+  nextPaymentDueDate: string
   currency: CurrencyCode
   currency: CurrencyCode
   state?: SubscriptionState
   state?: SubscriptionState
   trialEndsAtFormatted: Nullable<string>
   trialEndsAtFormatted: Nullable<string>
@@ -73,6 +64,7 @@ export type Subscription = {
   recurlySubscription_id: string
   recurlySubscription_id: string
   plan: Plan
   plan: Plan
   pendingPlan?: Plan
   pendingPlan?: Plan
+  addOns?: AddOn[]
 }
 }
 
 
 export type RecurlySubscription = Subscription & {
 export type RecurlySubscription = Subscription & {

+ 30 - 0
services/web/types/subscription/plan.ts

@@ -15,10 +15,40 @@ type Features = {
   zotero: boolean
   zotero: boolean
 }
 }
 
 
+// add-ons stored on the subscription
+export type AddOn = {
+  addOnCode: string
+  quantity: number
+  unitAmountInCents: number
+}
+
+// add-ons directly accessed through recurly
+export type RecurlyAddOn = {
+  add_on_code: string
+  quantity: number
+  unit_amount_in_cents: number
+}
+
+export type PendingRecurlyPlan = {
+  annual?: boolean
+  displayPrice?: string
+  featureDescription?: Record<string, unknown>[]
+  addOns?: RecurlyAddOn[]
+  features?: Features
+  groupPlan?: boolean
+  hideFromUsers?: boolean
+  membersLimit?: number
+  membersLimitAddOn?: string
+  name: string
+  planCode: string
+  price_in_cents: number
+}
+
 export type Plan = {
 export type Plan = {
   annual?: boolean
   annual?: boolean
   displayPrice?: string
   displayPrice?: string
   featureDescription?: Record<string, unknown>[]
   featureDescription?: Record<string, unknown>[]
+  addOns?: AddOn[]
   features?: Features
   features?: Features
   groupPlan?: boolean
   groupPlan?: boolean
   hideFromUsers?: boolean
   hideFromUsers?: boolean