From d79ed6dd6510f034f9267fc7d4afb111a90b42a2 Mon Sep 17 00:00:00 2001 From: Xavier Abad Date: Tue, 5 May 2026 11:31:30 +0200 Subject: [PATCH 1/4] feat: add monthly plans --- package.json | 2 +- src/app/banners/BannerManager.test.ts | 3 + src/app/i18n/locales/de.json | 8 + src/app/i18n/locales/en.json | 8 + src/app/i18n/locales/es.json | 8 + src/app/i18n/locales/fr.json | 8 + src/app/i18n/locales/it.json | 8 + src/app/i18n/locales/ru.json | 8 + src/app/i18n/locales/tw.json | 8 + src/app/i18n/locales/zh.json | 8 + src/app/store/slices/plan/plan.test.ts | 2 + .../Account/Billing/BillingAccountSection.tsx | 2 + .../Billing/components/CancelSubscription.tsx | 5 +- .../Sections/Account/Plans/PlansSection.tsx | 1 + .../Account/Plans/components/PlanCard.tsx | 2 +- .../PlanSelection/PlanSelectionComponent.tsx | 2 +- .../Billing/BillingWorkspaceSection.tsx | 1 + .../Billing/CancelSubscriptionModal.tsx | 267 +++++++----------- .../utils/suscriptionUtils.test.ts | 6 + yarn.lock | 49 ++-- 20 files changed, 207 insertions(+), 199 deletions(-) diff --git a/package.json b/package.json index 3d3b4ea0a..53abccc24 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "@iconscout/react-unicons": "^1.1.6", "@internxt/css-config": "1.1.0", "@internxt/lib": "1.4.1", - "@internxt/sdk": "=1.15.6", + "@internxt/sdk": "=1.15.13", "@internxt/ui": "=0.1.15", "@phosphor-icons/react": "^2.1.7", "@popperjs/core": "^2.11.6", diff --git a/src/app/banners/BannerManager.test.ts b/src/app/banners/BannerManager.test.ts index e399db699..0a62ddaa0 100644 --- a/src/app/banners/BannerManager.test.ts +++ b/src/app/banners/BannerManager.test.ts @@ -76,6 +76,7 @@ describe('BannerManager - showFreeBanner', () => { storageLimit: 0, amountOfSeats: 1, seats: { minimumSeats: 1, maximumSeats: 1 }, + commitment: { enabled: false }, }, businessPlan: null, planLimit: 0, @@ -181,6 +182,7 @@ describe('BannerManager - showSubscriptionBanner', () => { storageLimit: 0, amountOfSeats: 1, seats: { minimumSeats: 1, maximumSeats: 1 }, + commitment: { enabled: false }, }, businessPlan: null, planLimit: 0, @@ -220,6 +222,7 @@ describe('BannerManager - showSubscriptionBanner', () => { storageLimit: 0, amountOfSeats: 1, seats: { minimumSeats: 1, maximumSeats: 1 }, + commitment: { enabled: false }, }, businessPlan: null, planLimit: 0, diff --git a/src/app/i18n/locales/de.json b/src/app/i18n/locales/de.json index 621c5fb84..08bb5e90c 100644 --- a/src/app/i18n/locales/de.json +++ b/src/app/i18n/locales/de.json @@ -489,6 +489,13 @@ "individual": "Sie sind dabei, Ihren Internxt-Tarif {{currentPlanName}} zu kündigen. Durch die Kündigung Ihres Tarifs werden Sie auf den kostenlosen Internxt-Tarif {{freePlanName}} herabgestuft. Bitte bestätigen Sie Ihre Wahl, um fortzufahren.", "business": "Durch die Kündigung Ihres Tarifs wird der Workspace zusammen mit allen Daten gelöscht. Bitte bestätigen Sie Ihre Wahl, um fortzufahren." }, + "commitment": { + "firstMonthDescription": "Da Sie sich noch in Ihrer 30-tägigen Testphase befinden, wird Ihre Kündigung sofort wirksam. Sie werden nicht mehr belastet und Ihr Konto wird jetzt auf den kostenlosen 1-GB-Plan zurückgesetzt.", + "description": "Sie haben ein Jahresabonnement, das monatlich abgerechnet wird. Wenn Sie jetzt kündigen, bleibt Ihr Abonnement bis zum {{end_date}} aktiv, und Sie werden bis zu diesem Datum weiterhin monatlich belastet.", + "monthsRemaining": "Verbleibende Zahlungen: {{monthsRemaining}}", + "amountPerMonth": "Betrag pro Monat: €{{amount}}", + "afterLabel": "Nach dem {{monthsRemaining}}" + }, "keepSubscription": "Abonnement behalten", "continue": "Weiter", "cancelSubscription": "Abonnement beenden", @@ -1961,6 +1968,7 @@ "manageBilling": "Abrechnung verwalten", "freeForever": "Für immer kostenlos", "billedAnnually": "jährlich abgerechnet", + "billedMonthly": "monatlich abgerechnet", "year": "Jahr", "month": "Monat", "lifetime": "Lebenslang", diff --git a/src/app/i18n/locales/en.json b/src/app/i18n/locales/en.json index 0fe2edfda..dd8f0b240 100644 --- a/src/app/i18n/locales/en.json +++ b/src/app/i18n/locales/en.json @@ -549,6 +549,13 @@ "individual": "You are about to cancel your {{currentPlanName}} Internxt plan. By cancelling your plan, you will be downgraded to free Internxt {{freePlanName}} plan. Confirm your choice to proceed, please.", "business": "By cancelling your plan, the workspace will be deleted along with all data. Confirm your choice to proceed, please." }, + "commitment": { + "firstMonthDescription": "As you're still in your 30 day trial period, your cancellation will be immediate. You will no longer be billed and your account will revert to a free 1GB plan now.", + "description": "You have an annual subscription billed monthly. If you cancel now, your subscription will remain active until {{end_date}}, and you will continue to be charged monthly until that date.", + "monthsRemaining": "Remaining payments: {{monthsRemaining}}", + "amountPerMonth": "Amount per month: €{{amount}}", + "afterLabel": "After {{monthsRemaining}}" + }, "keepSubscription": "Keep subscription", "continue": "Continue", "cancelSubscription": "Cancel subscription", @@ -2045,6 +2052,7 @@ "manageBilling": "Manage billing", "freeForever": "Free forever", "billedAnnually": "billed annually", + "billedMonthly": "billed monthly", "year": "Year", "month": "Month", "lifetime": "Lifetime", diff --git a/src/app/i18n/locales/es.json b/src/app/i18n/locales/es.json index f2252d813..e0a57a76d 100644 --- a/src/app/i18n/locales/es.json +++ b/src/app/i18n/locales/es.json @@ -531,6 +531,13 @@ "individual": "Estás a punto de cancelar tu plan de Internxt {{currentPlanName}}. Al cancelar tu plan, pasarás al plan gratuito de Internxt {{freePlanName}}. Por favor, confirma tu elección para continuar.", "business": "Al cancelar tu plan, el espacio de trabajo se eliminará junto con todos los datos. Por favor, confirma tu elección para continuar." }, + "commitment": { + "firstMonthDescription": "Como todavía estás en tu período de prueba de 30 días, tu cancelación será inmediata. Ya no se te cobrará y tu cuenta volverá al plan gratuito de 1 GB ahora.", + "description": "Tienes una suscripción anual facturada mensualmente. Si cancelas ahora, tu suscripción permanecerá activa hasta el {{end_date}}, y seguirás siendo cobrado mensualmente hasta esa fecha.", + "monthsRemaining": "Pagos restantes: {{monthsRemaining}}", + "amountPerMonth": "Importe por mes: €{{amount}}", + "afterLabel": "Después del {{monthsRemaining}}" + }, "keepSubscription": "Mantener suscripción", "continue": "Continuar", "cancelSubscription": "Cancelar suscripción", @@ -2024,6 +2031,7 @@ "month": "Mes", "lifetime": "Lifetime", "billedAnnually": "facturado anualmente", + "billedMonthly": "facturado mensualmente", "types": { "essential": "Essential", "premium": "Premium", diff --git a/src/app/i18n/locales/fr.json b/src/app/i18n/locales/fr.json index edd4d9bfa..d3d11b886 100644 --- a/src/app/i18n/locales/fr.json +++ b/src/app/i18n/locales/fr.json @@ -501,6 +501,13 @@ "individual": "Vous êtes sur le point d’annuler votre abonnement Internxt {{currentPlanName}}. En annulant votre abonnement, vous serez rétrogradé(e) à l’abonnement gratuit Internxt {{freePlanName}}. Veuillez confirmer votre choix pour continuer.", "business": "En annulant votre abonnement, l’espace de travail sera supprimé ainsi que toutes les données. Veuillez confirmer votre choix pour continuer." }, + "commitment": { + "firstMonthDescription": "Comme vous êtes encore dans votre période d'essai de 30 jours, votre annulation sera immédiate. Vous ne serez plus facturé et votre compte reviendra au plan gratuit de 1 Go maintenant.", + "description": "Vous avez un abonnement annuel facturé mensuellement. Si vous annulez maintenant, votre abonnement restera actif jusqu’au {{end_date}}, et vous continuerez à être facturé mensuellement jusqu’à cette date.", + "monthsRemaining": "Paiements restants : {{monthsRemaining}}", + "amountPerMonth": "Montant par mois : €{{amount}}", + "afterLabel": "Après le {{monthsRemaining}}" + }, "keepSubscription": "Conserver l’abonnement", "continue": "Continuer", "cancelSubscription": "Annuler l’abonnement", @@ -1967,6 +1974,7 @@ "manageBilling": "Gérer la facturation", "freeForever": "Gratuit à vie", "billedAnnually": "facturé annuellement", + "billedMonthly": "facturé mensuellement", "year": "Année", "month": "Mois", "lifetime": "À vie", diff --git a/src/app/i18n/locales/it.json b/src/app/i18n/locales/it.json index 4207b9802..1c631802c 100644 --- a/src/app/i18n/locales/it.json +++ b/src/app/i18n/locales/it.json @@ -592,6 +592,13 @@ "individual": "Stai per annullare il tuo piano Internxt {{currentPlanName}}. Annullando il tuo piano, verrai retrocesso al piano gratuito di Internxt {{freePlanName}}. Conferma la tua scelta per procedere, per favore.", "business": "Annullando il tuo piano, lo spazio di lavoro verrà eliminato insieme a tutti i dati. Per favore, conferma la tua scelta per procedere." }, + "commitment": { + "firstMonthDescription": "Poiché sei ancora nel tuo periodo di prova di 30 giorni, la tua cancellazione sarà immediata. Non ti verrà più addebitato nulla e il tuo account tornerà ora al piano gratuito da 1 GB.", + "description": "Hai un abbonamento annuale fatturato mensilmente. Se annulli ora, il tuo abbonamento rimarrà attivo fino al {{end_date}} e continuerai a essere addebitato mensilmente fino a quella data.", + "monthsRemaining": "Pagamenti rimanenti: {{monthsRemaining}}", + "amountPerMonth": "Importo al mese: €{{amount}}", + "afterLabel": "Dopo il {{monthsRemaining}}" + }, "keepSubscription": "Mantieni l'abbonamento", "continua": "Continua", "cancelSubscription": "Annulla abbonamento", @@ -2074,6 +2081,7 @@ "manageBilling": "Gestisci la fatturazione", "freeForever": "Gratis per sempre", "billedAnnually": "fatturato annualmente", + "billedMonthly": "fatturato mensilmente", "year": "Anno", "month": "Mese", "lifetime": "A vita", diff --git a/src/app/i18n/locales/ru.json b/src/app/i18n/locales/ru.json index f5276db56..f1b80dc83 100644 --- a/src/app/i18n/locales/ru.json +++ b/src/app/i18n/locales/ru.json @@ -501,6 +501,13 @@ "individual": "Вы собираетесь отменить свой план Internxt {{currentPlanName}}. Отменив этот план, вы будете переведены на бесплатный план Internxt {{freePlanName}}. Пожалуйста, подтвердите свой выбор, чтобы продолжить.", "business": "Отменив свой план, рабочая область будет удалена вместе со всеми данными. Пожалуйста, подтвердите свой выбор, чтобы продолжить." }, + "commitment": { + "firstMonthDescription": "Поскольку вы всё ещё находитесь в 30-дневном пробном периоде, отмена будет немедленной. С вас больше не будут взиматься платежи, и ваш аккаунт сейчас вернётся к бесплатному плану на 1 ГБ.", + "description": "У вас есть годовая подписка с ежемесячной оплатой. Если вы отмените сейчас, ваша подписка останется активной до {{end_date}}, и с вас будет ежемесячно взиматься плата до этой даты.", + "monthsRemaining": "Оставшихся платежей: {{monthsRemaining}}", + "amountPerMonth": "Сумма в месяц: €{{amount}}", + "afterLabel": "После {{monthsRemaining}}" + }, "keepSubscription": "Оставить подписку", "continue": "Продолжить", "cancelSubscription": "Отменить подписку", @@ -1982,6 +1989,7 @@ "manageBilling": "Управление платежами", "freeForever": "Бесплатно навсегда", "billedAnnually": "оплачиваемый ежегодно", + "billedMonthly": "оплачиваемый ежемесячно", "year": "Год", "month": "Месяц", "lifetime": "Пожизненно", diff --git a/src/app/i18n/locales/tw.json b/src/app/i18n/locales/tw.json index 2b076749e..c24e834cb 100644 --- a/src/app/i18n/locales/tw.json +++ b/src/app/i18n/locales/tw.json @@ -517,6 +517,13 @@ "individual": "您即將取消您的 Internxt {{currentPlanName}} 方案。取消方案後,您將降級為免費的 Internxt {{freePlanName}} 方案。請確認您的選擇以繼續。", "business": "取消方案後,工作區以及所有資料將被刪除。請確認您的選擇以繼續。" }, + "commitment": { + "firstMonthDescription": "由於您仍在 30 天試用期內,您的取消將立即生效。您將不再被收費,您的帳戶現在將恢復為免費的 1GB 方案。", + "description": "您擁有按月計費的年度訂閱。如果您現在取消,您的訂閱將保持有效至 {{end_date}},且在該日期之前您將繼續被按月收費。", + "monthsRemaining": "剩餘付款次數:{{monthsRemaining}}", + "amountPerMonth": "每月金額:€{{amount}}", + "afterLabel": "{{monthsRemaining}} 之後" + }, "keepSubscription": "保留訂閱", "continue": "繼續", "cancelSubscription": "取消訂閱", @@ -1971,6 +1978,7 @@ "manageBilling": "管理帳單", "freeForever": "永久免費", "billedAnnually": "按年計費", + "billedMonthly": "按月計費", "year": "年", "month": "月", "lifetime": "終身", diff --git a/src/app/i18n/locales/zh.json b/src/app/i18n/locales/zh.json index 242cfdcdb..54edf9d12 100644 --- a/src/app/i18n/locales/zh.json +++ b/src/app/i18n/locales/zh.json @@ -515,6 +515,13 @@ "individual": "您即将取消您的 Internxt {{currentPlanName}} 计划。取消计划后,您将降级为免费的 Internxt {{freePlanName}} 计划。请确认您的选择以继续。", "business": "取消计划后,工作区以及所有数据将被删除。请确认您的选择以继续。" }, + "commitment": { + "firstMonthDescription": "由于您仍在 30 天试用期内,您的取消将立即生效。您将不再被收费,您的账户现在将恢复为免费的 1GB 计划。", + "description": "您拥有按月计费的年度订阅。如果您现在取消,您的订阅将保持有效至 {{end_date}},且在该日期之前您将继续被按月收费。", + "monthsRemaining": "剩余付款次数:{{monthsRemaining}}", + "amountPerMonth": "每月金额:€{{amount}}", + "afterLabel": "{{monthsRemaining}} 之后" + }, "keepSubscription": "继续订阅", "continue": "继续", "cancelSubscription": "取消订阅", @@ -2009,6 +2016,7 @@ "manageBilling": "管理账单", "freeForever": "永久免费", "billedAnnually": "按年计费", + "billedMonthly": "按月计费", "year": "年", "month": "月", "lifetime": "终身", diff --git a/src/app/store/slices/plan/plan.test.ts b/src/app/store/slices/plan/plan.test.ts index 59f026fe5..f7165e499 100644 --- a/src/app/store/slices/plan/plan.test.ts +++ b/src/app/store/slices/plan/plan.test.ts @@ -17,6 +17,7 @@ const mockIndividualPlan: StoragePlan = { renewalPeriod: RenewalPeriod.Monthly, storageLimit: 1000000000, amountOfSeats: 1, + commitment: { enabled: false }, }; const mockBusinessPlan: StoragePlan = { @@ -33,6 +34,7 @@ const mockBusinessPlan: StoragePlan = { renewalPeriod: RenewalPeriod.Monthly, storageLimit: 5000000000, amountOfSeats: 5, + commitment: { enabled: false }, }; const createMockState = (planState: Partial, hasSelectedWorkspace = false): Partial => ({ diff --git a/src/views/NewSettings/components/Sections/Account/Billing/BillingAccountSection.tsx b/src/views/NewSettings/components/Sections/Account/Billing/BillingAccountSection.tsx index 0583634b6..6e614bf89 100644 --- a/src/views/NewSettings/components/Sections/Account/Billing/BillingAccountSection.tsx +++ b/src/views/NewSettings/components/Sections/Account/Billing/BillingAccountSection.tsx @@ -23,6 +23,7 @@ interface BillingAccountSectionProps { const BillingAccountSection = ({ changeSection, onClosePreferences }: BillingAccountSectionProps) => { const dispatch = useAppDispatch(); const plan = useSelector((state) => state.plan); + console.log('PLAN: ', plan); const [isSubscription, setIsSubscription] = useState(false); const [cancellingSubscription, setCancellingSubscription] = useState(false); const [isCancelSubscriptionModalOpen, setIsCancelSubscriptionModalOpen] = useState(false); @@ -66,6 +67,7 @@ const BillingAccountSection = ({ changeSection, onClosePreferences }: BillingAcc {isSubscription && ( void; cancellingSubscription: boolean; @@ -15,6 +16,7 @@ interface CancelSubscriptionProps { } const CancelSubscription = ({ + individualPlan, isCancelSubscriptionModalOpen, setIsCancelSubscriptionModalOpen, cancellingSubscription, @@ -39,6 +41,7 @@ const CancelSubscription = ({ {t('preferences.workspace.billing.cancelSubscription.button')} { setIsCancelSubscriptionModalOpen(false); diff --git a/src/views/NewSettings/components/Sections/Account/Plans/PlansSection.tsx b/src/views/NewSettings/components/Sections/Account/Plans/PlansSection.tsx index 026231b93..30cb14ea1 100644 --- a/src/views/NewSettings/components/Sections/Account/Plans/PlansSection.tsx +++ b/src/views/NewSettings/components/Sections/Account/Plans/PlansSection.tsx @@ -379,6 +379,7 @@ const PlansSection = ({ changeSection, onClosePreferences }: PlansSectionProps) { setIsCancelSubscriptionModalOpen(false); }} diff --git a/src/views/NewSettings/components/Sections/Account/Plans/components/PlanCard.tsx b/src/views/NewSettings/components/Sections/Account/Plans/components/PlanCard.tsx index cdec26209..766043ab3 100644 --- a/src/views/NewSettings/components/Sections/Account/Plans/components/PlanCard.tsx +++ b/src/views/NewSettings/components/Sections/Account/Plans/components/PlanCard.tsx @@ -48,7 +48,7 @@ const PlanCard = ({ isLoading, disableActionButton, }: PlanCardProps) => { - const userText = ' ' + t('preferences.account.plans.billedAnnually'); + const userText = ' ' + t('preferences.account.plans.billedMonthly'); return (
diff --git a/src/views/NewSettings/components/Sections/Account/Plans/components/PlanSelection/PlanSelectionComponent.tsx b/src/views/NewSettings/components/Sections/Account/Plans/components/PlanSelection/PlanSelectionComponent.tsx index e6fa0aa8e..1b5270a83 100644 --- a/src/views/NewSettings/components/Sections/Account/Plans/components/PlanSelection/PlanSelectionComponent.tsx +++ b/src/views/NewSettings/components/Sections/Account/Plans/components/PlanSelection/PlanSelectionComponent.tsx @@ -47,7 +47,7 @@ export const PlanSelectionComponent = ({ capacity={bytesToString(plan.bytes)} currency={currencyService.getCurrencySymbol(plan.currency.toUpperCase())} amount={displayAmount(plan.amount).replace(/\.00$/, '')} - billing={plan.interval === 'lifetime' ? '' : translate('preferences.account.plans.year')?.toLowerCase()} + billing={plan.interval === 'lifetime' ? '' : translate('preferences.account.plans.month')?.toLowerCase()} isCurrentPlan={isCurrentSubscriptionPlan(plan)} /> ))} diff --git a/src/views/NewSettings/components/Sections/Workspace/Billing/BillingWorkspaceSection.tsx b/src/views/NewSettings/components/Sections/Workspace/Billing/BillingWorkspaceSection.tsx index 250a32a26..7fc90838e 100644 --- a/src/views/NewSettings/components/Sections/Workspace/Billing/BillingWorkspaceSection.tsx +++ b/src/views/NewSettings/components/Sections/Workspace/Billing/BillingWorkspaceSection.tsx @@ -257,6 +257,7 @@ const BillingWorkspaceSection = ({ onClosePreferences }: BillingWorkspaceSection planInfo={planInfo} currentUsage={currentUsage} userType={UserType.Business} + individualPlan={null} /> )} diff --git a/src/views/NewSettings/components/Sections/Workspace/Billing/CancelSubscriptionModal.tsx b/src/views/NewSettings/components/Sections/Workspace/Billing/CancelSubscriptionModal.tsx index 57bc56302..d93ea87a6 100644 --- a/src/views/NewSettings/components/Sections/Workspace/Billing/CancelSubscriptionModal.tsx +++ b/src/views/NewSettings/components/Sections/Workspace/Billing/CancelSubscriptionModal.tsx @@ -1,17 +1,13 @@ -import { UserType } from '@internxt/sdk/dist/drive/payments/types/types'; +import { StoragePlan, UserType } from '@internxt/sdk/dist/drive/payments/types/types'; import { ArrowRight } from '@phosphor-icons/react'; -import { Dispatch, SetStateAction, useEffect, useState } from 'react'; import sizeService from 'app/drive/services/size.service'; import { FreeStoragePlan } from 'app/drive/types'; import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; -import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; -import { paymentService } from 'views/Checkout/services'; import { Button, Modal } from '@internxt/ui'; -import { useAppDispatch } from 'app/store/hooks'; -import { planThunks } from 'app/store/slices/plan'; -import { errorService } from 'services'; +import { dateService } from 'services'; interface CancelSubscriptionModalProps { + individualPlan: StoragePlan | null; isOpen: boolean; onClose: () => void; currentPlanName: string; @@ -23,6 +19,7 @@ interface CancelSubscriptionModalProps { } const CancelSubscriptionModal = ({ + individualPlan, isOpen, onClose, currentPlanName, @@ -32,137 +29,29 @@ const CancelSubscriptionModal = ({ cancelSubscription, userType = UserType.Individual, }: CancelSubscriptionModalProps): JSX.Element => { - const isIndividual = userType === UserType.Individual; - const { translate } = useTranslationContext(); - const [step, setStep] = useState<1 | 2>(2); - const [couponAvailable, setCouponAvailable] = useState(false); - const dispatch = useAppDispatch(); - - useEffect(() => { - if (userType === UserType.Individual && isOpen) - paymentService - .requestPreventCancellation() - .then((response) => { - setCouponAvailable(response.elegible); - }) - .catch((error) => { - const castedError = errorService.castError(error); - notificationsService.show({ - text: translate('notificationMessages.errorApplyCoupon'), - type: ToastType.Error, - requestId: castedError.requestId, - }); - }); - }, [isOpen]); - - useEffect(() => { - if (isIndividual && couponAvailable && isOpen) { - setStep(1); - } - }, [couponAvailable]); - - const applyCoupon = async () => { - try { - await paymentService.preventCancellation(); - notificationsService.show({ text: translate('notificationMessages.successApplyCoupon') }); - setTimeout(() => { - dispatch(planThunks.initializeThunk()).unwrap(); - }, 1000); - } catch (error: any) { - const castedError = errorService.castError(error); - const errorMessage = JSON.parse(error.message); - if (errorMessage.message === 'User already applied coupon') { - notificationsService.show({ - text: translate('notificationMessages.alreadyAppliedCoupon'), - type: ToastType.Error, - requestId: castedError.requestId, - }); - } else { - notificationsService.show({ - text: translate('notificationMessages.errorApplyCoupon'), - type: ToastType.Error, - requestId: castedError.requestId, - }); - } - } finally { - onClose(); - } - }; - return ( - {isIndividual && step === 1 && ( - - )} - - {step === 2 && ( - - )} + ); }; -const Step1 = ({ - currentPlanName, - setStep, - applyCoupon, -}: { - currentPlanName: string; - setStep: Dispatch>; - applyCoupon: () => void; -}): JSX.Element => { - const { translate } = useTranslationContext(); - - return ( - <> -

- {translate('views.account.tabs.billing.cancelSubscriptionModal.coupon.title')} -

-

- {translate('views.account.tabs.billing.cancelSubscriptionModal.coupon.subtitle')} -

-

- {translate('views.account.tabs.billing.cancelSubscriptionModal.coupon.text1')} - {currentPlanName} - {translate('views.account.tabs.billing.cancelSubscriptionModal.coupon.text2')} -

-
- - -
- - ); -}; - -const Step2 = ({ +const CancelPlanModal = ({ currentPlanName, currentPlanInfo, currentUsage, cancellingSubscription, userType, + individualPlan, cancelSubscription, onClose, }: { @@ -171,62 +60,100 @@ const Step2 = ({ currentUsage: number; userType: UserType; cancellingSubscription: boolean; + individualPlan: StoragePlan | null; cancelSubscription: () => void; onClose: () => void; }): JSX.Element => { const { translate } = useTranslationContext(); const isCurrentUsageGreaterThanFreePlan = currentUsage !== -1 && currentUsage >= FreeStoragePlan.storageLimit; + const monthlyAmount = individualPlan?.monthlyPrice; + const commitment = individualPlan?.commitment; + const isCommitmentEnabled = commitment?.enabled; + const remainingMonths = commitment?.remainingMonths; + const commitmentRenewal = + commitment?.cancellationDate && dateService.format(commitment?.cancellationDate, 'DD MMM YYYY'); + const isCommitmentFirstMonth = commitment?.isFirstMonth; + const shouldDisplayCommitmentText = isCommitmentEnabled && !isCommitmentFirstMonth; + + const commitmentFirstMonthCancellationDescription = translate( + 'views.account.tabs.billing.cancelSubscriptionModal.commitment.firstMonthDescription', + ); + const normalDescription = isCommitmentEnabled + ? translate('views.account.tabs.billing.cancelSubscriptionModal.commitment.description', { + end_date: commitmentRenewal, + }) + : translate(`views.account.tabs.billing.cancelSubscriptionModal.description.${userType.toLowerCase()}`, { + currentPlanName, + freePlanName: FreeStoragePlan.simpleName, + }); + + const description = isCommitmentFirstMonth ? commitmentFirstMonthCancellationDescription : normalDescription; + + const commitmentList = [ + translate('views.account.tabs.billing.cancelSubscriptionModal.commitment.monthsRemaining', { + monthsRemaining: remainingMonths, + }), + translate('views.account.tabs.billing.cancelSubscriptionModal.commitment.amountPerMonth', { + amount: monthlyAmount?.toFixed(2), + }), + ]; return ( <> -

- {translate(`views.account.tabs.billing.cancelSubscriptionModal.description.${userType.toLowerCase()}`, { - currentPlanName, - freePlanName: FreeStoragePlan.simpleName, - })} -

- {userType !== UserType.Business && ( -
-
-
- - {translate('views.account.tabs.billing.cancelSubscriptionModal.infoBox.titleCurrent')} - -
-
- {currentPlanName} -
-
- {currentPlanInfo} -
+

{description}

+ + {shouldDisplayCommitmentText && ( +
    + {commitmentList.map((item) => ( +
  • + {item} +
  • + ))} +
+ )} + +
+
+
+

+ {translate('views.account.tabs.billing.cancelSubscriptionModal.infoBox.titleCurrent')} +

-
-
- -
+
+ {currentPlanName}
-
-
- - {translate('views.account.tabs.billing.cancelSubscriptionModal.infoBox.titleNew')} - -
-
- - {FreeStoragePlan.simpleName} - -
-
- - {translate('views.account.tabs.billing.cancelSubscriptionModal.infoBox.free')} - -
+
+ {currentPlanInfo}
- )} +
+
+ +
+
+
+
+

+ {shouldDisplayCommitmentText + ? translate('views.account.tabs.billing.cancelSubscriptionModal.commitment.afterLabel', { + monthsRemaining: commitmentRenewal, + }) + : translate('views.account.tabs.billing.cancelSubscriptionModal.infoBox.titleNew')} +

+
+
+ + {FreeStoragePlan.simpleName} + +
+
+ + {translate('views.account.tabs.billing.cancelSubscriptionModal.infoBox.free')} + +
+
+
{isCurrentUsageGreaterThanFreePlan && (
diff --git a/src/views/NewSettings/utils/suscriptionUtils.test.ts b/src/views/NewSettings/utils/suscriptionUtils.test.ts index 3c3c92547..9b02db7f6 100644 --- a/src/views/NewSettings/utils/suscriptionUtils.test.ts +++ b/src/views/NewSettings/utils/suscriptionUtils.test.ts @@ -42,6 +42,9 @@ const mockIndividualPlan: StoragePlan = { renewalPeriod: RenewalPeriod.Monthly, storageLimit: 1000000000, amountOfSeats: 1, + commitment: { + enabled: false, + }, }; const mockBusinessPlan: StoragePlan = { @@ -58,6 +61,9 @@ const mockBusinessPlan: StoragePlan = { renewalPeriod: RenewalPeriod.Annually, storageLimit: 5000000000, amountOfSeats: 5, + commitment: { + enabled: false, + }, }; const mockIndividualSubscription: UserSubscription = { diff --git a/yarn.lock b/yarn.lock index 5a0496c2f..63c0909eb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1906,13 +1906,13 @@ version "1.0.2" resolved "https://codeload.github.com/internxt/prettier-config/tar.gz/9fa74e9a2805e1538b50c3809324f1c9d0f3e4f9" -"@internxt/sdk@=1.15.6": - version "1.15.6" - resolved "https://registry.yarnpkg.com/@internxt/sdk/-/sdk-1.15.6.tgz#f895cf12b160fd23c386e6d4d007ec29e51d9158" - integrity sha512-DRGlj2XArmBNa9wM55MbR9E4scNPDer+emtrugG1MSZvP/YOSv5XOes5j/df5ZUgMyRs9G0O7hwCtjn9XvW9YA== +"@internxt/sdk@=1.15.13": + version "1.15.13" + resolved "https://registry.yarnpkg.com/@internxt/sdk/-/sdk-1.15.13.tgz#4a754355ea619255d8db9ca23efc3024e840ab2c" + integrity sha512-GQeCWLScG63tCbfyoXkOC4DP79PsSFrh7k4BUG8ejn6+6B8RO9P1ilhO91cPIP3cdtA6oo749ysH9TQH+0GcMw== dependencies: - axios "1.13.6" - internxt-crypto "0.0.14" + axios "1.15.0" + internxt-crypto "1.0.2" "@internxt/ui@=0.1.15": version "0.1.15" @@ -2045,6 +2045,13 @@ resolved "https://registry.yarnpkg.com/@noble/ciphers/-/ciphers-2.1.1.tgz#c8c74fcda8c3d1f88797d0ecda24f9fc8b92b052" integrity sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw== +"@noble/curves@^2.0.1": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-2.2.0.tgz#981be3aadc3bbfbcdb245e78cc97aa6f759246c2" + integrity sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ== + dependencies: + "@noble/hashes" "2.2.0" + "@noble/curves@~2.0.0": version "2.0.1" resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-2.0.1.tgz#64ba8bd5e8564a02942655602515646df1cdb3ad" @@ -2057,6 +2064,11 @@ resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-2.0.1.tgz#fc1a928061d1232b0a52bb754393c37a5216c89e" integrity sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw== +"@noble/hashes@2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-2.2.0.tgz#22da1d16a469954fce877055d559900a6c73b63b" + integrity sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg== + "@noble/hashes@^1.2.0": version "1.3.2" resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.2.tgz#6f26dbc8fbc7205873ce3cee2f690eba0d421b39" @@ -4014,16 +4026,7 @@ available-typed-arrays@^1.0.7: dependencies: possible-typed-array-names "^1.0.0" -axios@1.13.6: - version "1.13.6" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.13.6.tgz#c3f92da917dc209a15dd29936d20d5089b6b6c98" - integrity sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ== - dependencies: - follow-redirects "^1.15.11" - form-data "^4.0.5" - proxy-from-env "^1.1.0" - -axios@^1.15.0: +axios@1.15.0, axios@^1.15.0: version "1.15.0" resolved "https://registry.yarnpkg.com/axios/-/axios-1.15.0.tgz#0fcee91ef03d386514474904b27863b2c683bf4f" integrity sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q== @@ -6363,12 +6366,13 @@ ini@^1.3.5, ini@~1.3.0: resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== -internxt-crypto@0.0.14: - version "0.0.14" - resolved "https://registry.yarnpkg.com/internxt-crypto/-/internxt-crypto-0.0.14.tgz#1290b2a70190c23d25b83483de8200d9eafae00f" - integrity sha512-gIvqgou0r86kSk6x2t6pxAh9dJiob/sQ1Y3TdGnAF4Qq2RD++4Aq1b6NY2UqfUYV4vPhWsd2BkFS71jAyVrXpA== +internxt-crypto@1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/internxt-crypto/-/internxt-crypto-1.0.2.tgz#983fe991dfbb00a453e93070bb34049a88a94707" + integrity sha512-F9PuXci0eU1wlgDwqEbGR7hVDNS0MX8VNh/W+pdpR4ZsEsjRDBrOD2g1DvViR2woCxPiu1AW9Wwekpw2YVKfnA== dependencies: "@noble/ciphers" "^2.1.1" + "@noble/curves" "^2.0.1" "@noble/hashes" "^2.0.1" "@noble/post-quantum" "^0.5.2" "@scure/bip39" "^2.0.1" @@ -7861,11 +7865,6 @@ prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: object-assign "^4.1.1" react-is "^16.13.1" -proxy-from-env@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" - integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== - proxy-from-env@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz#a7487568adad577cfaaa7e88c49cab3ab3081aba" From 25542301cd9636ed929e613d6c2a7041dc4f2b3a Mon Sep 17 00:00:00 2001 From: Xavier Abad Date: Tue, 5 May 2026 11:49:26 +0200 Subject: [PATCH 2/4] fix: remove log --- .../Sections/Account/Billing/BillingAccountSection.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/views/NewSettings/components/Sections/Account/Billing/BillingAccountSection.tsx b/src/views/NewSettings/components/Sections/Account/Billing/BillingAccountSection.tsx index 6e614bf89..9d13f58c2 100644 --- a/src/views/NewSettings/components/Sections/Account/Billing/BillingAccountSection.tsx +++ b/src/views/NewSettings/components/Sections/Account/Billing/BillingAccountSection.tsx @@ -23,7 +23,6 @@ interface BillingAccountSectionProps { const BillingAccountSection = ({ changeSection, onClosePreferences }: BillingAccountSectionProps) => { const dispatch = useAppDispatch(); const plan = useSelector((state) => state.plan); - console.log('PLAN: ', plan); const [isSubscription, setIsSubscription] = useState(false); const [cancellingSubscription, setCancellingSubscription] = useState(false); const [isCancelSubscriptionModalOpen, setIsCancelSubscriptionModalOpen] = useState(false); From cef40e3e14a8d0403cb217add1ce0f8394b11210 Mon Sep 17 00:00:00 2001 From: Xavier Abad Date: Tue, 5 May 2026 13:20:48 +0200 Subject: [PATCH 3/4] fix: cast AxiosResponseError errors correctly --- src/services/error.service.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/services/error.service.ts b/src/services/error.service.ts index 6898f6a08..d1fdb5b30 100644 --- a/src/services/error.service.ts +++ b/src/services/error.service.ts @@ -1,5 +1,6 @@ import { AxiosError } from 'axios'; import { AppError } from '@internxt/sdk'; +import { AxiosResponseError } from '@internxt/sdk/dist/shared/types/errors'; import envService from './env.service'; interface AxiosErrorResponse { @@ -32,6 +33,13 @@ const errorService = { return err; } + if (err instanceof AxiosResponseError) { + const data = err.data as AxiosErrorResponse | undefined; + const message = data?.message || data?.error || err.message || 'Unknown error'; + castedError = new AppError(message, err.status, undefined, { 'x-request-id': err.xRequestId ?? '' }); + return castedError; + } + if (err instanceof AxiosError) { const axiosError = err as AxiosError; const responseData = axiosError.response?.data; From 32f30223d09beded18f0f9ecc73ef7edbd5eb041 Mon Sep 17 00:00:00 2001 From: Xavier Abad Date: Tue, 5 May 2026 13:38:23 +0200 Subject: [PATCH 4/4] test: add coverage to castError --- src/services/error.service.test.ts | 40 +++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/src/services/error.service.test.ts b/src/services/error.service.test.ts index 9ea213dbd..c02752a81 100644 --- a/src/services/error.service.test.ts +++ b/src/services/error.service.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { AxiosError, AxiosResponse } from 'axios'; import { AppError } from '@internxt/sdk'; +import { AxiosResponseError } from '@internxt/sdk/dist/shared/types/errors'; import errorService from './error.service'; import envService from './env.service'; @@ -20,6 +21,17 @@ describe('Error Service', () => { vi.restoreAllMocks(); }); + const createAxiosResponseError = (data: unknown, status: number, xRequestId?: string): AxiosResponseError => { + const response = { + data, + status, + statusText: '', + headers: { 'x-request-id': xRequestId }, + config: {} as never, + } as AxiosResponse; + return new AxiosResponseError('Request failed', 'POST /auth/login', response); + }; + const createAxiosError = (data: unknown, status?: number, headers?: Record): AxiosError => { const error = new AxiosError('Request failed'); if (status !== undefined) { @@ -38,7 +50,33 @@ describe('Error Service', () => { }); }); - describe('castError', () => { + describe('Cast Error', () => { + describe('AxiosResponseError handling', () => { + it('when response body has a message field, then uses it as the error message', () => { + const result = errorService.castError(createAxiosResponseError({ message: 'Wrong login credentials' }, 400)); + expect(result.message).toBe('Wrong login credentials'); + expect(result.status).toBe(400); + }); + + it('when response body has only an error field, then uses it as the error message', () => { + const result = errorService.castError(createAxiosResponseError({ error: 'Wrong login credentials' }, 400)); + expect(result.message).toBe('Wrong login credentials'); + expect(result.status).toBe(400); + }); + + it('when response body has no message or error fields, then falls back to the axios error message', () => { + const result = errorService.castError(createAxiosResponseError({}, 400)); + expect(result.message).toBe('Request failed'); + expect(result.status).toBe(400); + }); + + it('when response includes the id request in the header, then it is extracted correctly', () => { + const result = errorService.castError(createAxiosResponseError({ message: 'Fail' }, 500, 'req-789')); + expect(result.requestId).toBe('req-789'); + expect(result.status).toBe(500); + }); + }); + describe('AxiosError handling', () => { it('uses the error field from API responses when both error and message are present', () => { const result1 = errorService.castError(createAxiosError({ error: 'Custom error message' }, 400));