From 2fa9a1c16b0b9f3265959921f085582576d2b1a0 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sun, 8 Mar 2026 12:44:17 +0300 Subject: [PATCH 1/5] feat: Make Cancel Subscription Option More Visible and Accessible --- src/languages/de.ts | 2 +- src/languages/en.ts | 2 +- src/languages/es.ts | 2 +- src/languages/fr.ts | 2 +- src/languages/it.ts | 2 +- src/languages/ja.ts | 2 +- src/languages/nl.ts | 2 +- src/languages/pl.ts | 2 +- src/languages/pt-BR.ts | 2 +- src/languages/zh-hans.ts | 2 +- .../CancelSubscriptionMenuItem/index.native.tsx | 5 +++++ .../index.tsx | 16 ++++++++++------ .../Subscription/CardSection/CardSection.tsx | 4 ++-- .../index.native.tsx | 5 ----- 14 files changed, 27 insertions(+), 23 deletions(-) create mode 100644 src/pages/settings/Subscription/CardSection/CancelSubscriptionMenuItem/index.native.tsx rename src/pages/settings/Subscription/CardSection/{RequestEarlyCancellationMenuItem => CancelSubscriptionMenuItem}/index.tsx (72%) delete mode 100644 src/pages/settings/Subscription/CardSection/RequestEarlyCancellationMenuItem/index.native.tsx diff --git a/src/languages/de.ts b/src/languages/de.ts index 86848d06001d8..e4811f593ba71 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -8247,7 +8247,7 @@ Fordern Sie Spesendetails wie Belege und Beschreibungen an, legen Sie Limits und changesBasedOn: 'Dies ändert sich basierend auf Ihrer Nutzung der Expensify Karte und den untenstehenden Abooptionen.', }, requestEarlyCancellation: { - title: 'Frühzeitige Kündigung anfordern', + title: 'Abonnement kündigen', subtitle: 'Was ist der Hauptgrund, warum du eine vorzeitige Kündigung beantragst?', subscriptionCanceled: { title: 'Abonnement gekündigt', diff --git a/src/languages/en.ts b/src/languages/en.ts index 861a1d0c6d25e..f28ee477f8cf7 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -8208,7 +8208,7 @@ const translations = { changesBasedOn: 'This changes based on your Expensify Card usage and the subscription options below.', }, requestEarlyCancellation: { - title: 'Request early cancellation', + title: 'Cancel subscription', subtitle: 'What’s the main reason you’re requesting early cancellation?', subscriptionCanceled: { title: 'Subscription canceled', diff --git a/src/languages/es.ts b/src/languages/es.ts index c5ad487e05b2a..04f977c023114 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -8455,7 +8455,7 @@ ${amount} para ${merchant} - ${date}`, changesBasedOn: 'Esto varía según el uso de tu Tarjeta Expensify y las opciones de suscripción que elijas a continuación.', }, requestEarlyCancellation: { - title: 'Solicitar cancelación anticipada', + title: 'Cancelar suscripción', subtitle: '¿Cuál es la razón principal por la que solicitas la cancelación anticipada?', subscriptionCanceled: { title: 'Suscripción cancelada', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index d59681c811c2a..55aa016001320 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -8268,7 +8268,7 @@ Rendez obligatoires des informations de dépense comme les reçus et les descrip changesBasedOn: 'Cela varie en fonction de votre utilisation de la Carte Expensify et des options d’abonnement ci-dessous.', }, requestEarlyCancellation: { - title: 'Demander une résiliation anticipée', + title: "Annuler l'abonnement", subtitle: 'Quelle est la principale raison pour laquelle vous demandez une résiliation anticipée ?', subscriptionCanceled: { title: 'Abonnement annulé', diff --git a/src/languages/it.ts b/src/languages/it.ts index d70dfafb4ee0a..eaa19912ed7b0 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -8232,7 +8232,7 @@ Richiedi dettagli sulle spese come ricevute e descrizioni, imposta limiti e valo changesBasedOn: "Questo cambia in base all'utilizzo della tua Carta Expensify e alle opzioni di abbonamento qui sotto.", }, requestEarlyCancellation: { - title: 'Richiedi annullamento anticipato', + title: 'Annulla abbonamento', subtitle: 'Qual è il motivo principale per cui stai richiedendo l’annullamento anticipato?', subscriptionCanceled: { title: 'Abbonamento annullato', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index b1708b38bbd67..ab786a5988df5 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -8140,7 +8140,7 @@ ${reportName} changesBasedOn: 'これは、お客様の Expensify カードの利用状況と、以下のサブスクリプションオプションによって変わります。', }, requestEarlyCancellation: { - title: '早期解約をリクエスト', + title: 'サブスクリプションをキャンセル', subtitle: '早期解約を申請する主な理由を教えてください。', subscriptionCanceled: { title: 'サブスクリプションを解約しました', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 7de90468cf30c..c55fe88925a44 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -8209,7 +8209,7 @@ Vereis onkostendetails zoals bonnen en beschrijvingen, stel limieten en standaar changesBasedOn: 'Dit verandert op basis van je gebruik van de Expensify Kaart en de abonnementsopties hieronder.', }, requestEarlyCancellation: { - title: 'Vroegtijdige annulering aanvragen', + title: 'Abonnement opzeggen', subtitle: 'Wat is de belangrijkste reden dat je om vervroegde annulering vraagt?', subscriptionCanceled: { title: 'Abonnement geannuleerd', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index eeb3fcacf9581..bca74767d5296 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -8197,7 +8197,7 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i changesBasedOn: 'To się zmienia w zależności od korzystania z Karty Expensify i poniższych opcji subskrypcji.', }, requestEarlyCancellation: { - title: 'Poproś o wcześniejsze anulowanie', + title: 'Anuluj subskrypcję', subtitle: 'Jaki jest główny powód, dla którego prosisz o wcześniejsze anulowanie?', subscriptionCanceled: { title: 'Subskrypcja anulowana', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 62fbef1178f50..7eae42337833b 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -8201,7 +8201,7 @@ Exija dados de despesas como recibos e descrições, defina limites e padrões e changesBasedOn: 'Isso muda de acordo com o uso do seu Cartão Expensify e as opções de assinatura abaixo.', }, requestEarlyCancellation: { - title: 'Solicitar cancelamento antecipado', + title: 'Cancelar assinatura', subtitle: 'Qual é o principal motivo pelo qual você está solicitando o cancelamento antecipado?', subscriptionCanceled: { title: 'Assinatura cancelada', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index b3f9b0391dfab..b94b4ab497831 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -8008,7 +8008,7 @@ ${reportName} changesBasedOn: '这会根据你使用 Expensify 卡的情况以及下方的订阅选项而变化。', }, requestEarlyCancellation: { - title: '请求提前取消', + title: '取消订阅', subtitle: '您申请提前取消的主要原因是什么?', subscriptionCanceled: { title: '订阅已取消', diff --git a/src/pages/settings/Subscription/CardSection/CancelSubscriptionMenuItem/index.native.tsx b/src/pages/settings/Subscription/CardSection/CancelSubscriptionMenuItem/index.native.tsx new file mode 100644 index 0000000000000..5b3266d37961c --- /dev/null +++ b/src/pages/settings/Subscription/CardSection/CancelSubscriptionMenuItem/index.native.tsx @@ -0,0 +1,5 @@ +function CancelSubscriptionMenuItem() { + return null; +} + +export default CancelSubscriptionMenuItem; diff --git a/src/pages/settings/Subscription/CardSection/RequestEarlyCancellationMenuItem/index.tsx b/src/pages/settings/Subscription/CardSection/CancelSubscriptionMenuItem/index.tsx similarity index 72% rename from src/pages/settings/Subscription/CardSection/RequestEarlyCancellationMenuItem/index.tsx rename to src/pages/settings/Subscription/CardSection/CancelSubscriptionMenuItem/index.tsx index e885f1f6bf97c..9caeeaf22dcd2 100644 --- a/src/pages/settings/Subscription/CardSection/RequestEarlyCancellationMenuItem/index.tsx +++ b/src/pages/settings/Subscription/CardSection/CancelSubscriptionMenuItem/index.tsx @@ -3,19 +3,21 @@ import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/ import MenuItem from '@components/MenuItem'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; -function RequestEarlyCancellationMenuItem() { - const icons = useMemoizedLazyExpensifyIcons(['CalendarSolid']); +function CancelSubscriptionMenuItem() { + const icons = useMemoizedLazyExpensifyIcons(['CircleSlash']); const {translate} = useLocalize(); const styles = useThemeStyles(); const {isActingAsDelegate} = useDelegateNoAccessState(); const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); + const {isOffline} = useNetwork(); - const handleRequestEarlyCancellationPress = () => { + const handleCancelSubscriptionPress = () => { if (isActingAsDelegate) { showDelegateNoAccessModal(); return; @@ -25,13 +27,15 @@ function RequestEarlyCancellationMenuItem() { return ( ); } -export default RequestEarlyCancellationMenuItem; +export default CancelSubscriptionMenuItem; diff --git a/src/pages/settings/Subscription/CardSection/CardSection.tsx b/src/pages/settings/Subscription/CardSection/CardSection.tsx index 69ff8cc4d00c2..a803527a93217 100644 --- a/src/pages/settings/Subscription/CardSection/CardSection.tsx +++ b/src/pages/settings/Subscription/CardSection/CardSection.tsx @@ -33,11 +33,11 @@ import PreTrialBillingBanner from './BillingBanner/PreTrialBillingBanner'; import SubscriptionBillingBanner from './BillingBanner/SubscriptionBillingBanner'; import TrialEndedBillingBanner from './BillingBanner/TrialEndedBillingBanner'; import TrialStartedBillingBanner from './BillingBanner/TrialStartedBillingBanner'; +import CancelSubscriptionMenuItem from './CancelSubscriptionMenuItem'; import CardSectionActions from './CardSectionActions'; import CardSectionButton from './CardSectionButton'; import CardSectionDataEmpty from './CardSectionDataEmpty'; import getSectionSubtitle from './CardSectionSubtitle'; -import RequestEarlyCancellationMenuItem from './RequestEarlyCancellationMenuItem'; import type {BillingStatusResult} from './utils'; import CardSectionUtils from './utils'; @@ -299,7 +299,7 @@ function CardSection() { /> )} - {!!(privateSubscription?.type === CONST.SUBSCRIPTION.TYPE.ANNUAL && account?.hasPurchases) && } + {!!(privateSubscription?.type === CONST.SUBSCRIPTION.TYPE.ANNUAL) && } ); } diff --git a/src/pages/settings/Subscription/CardSection/RequestEarlyCancellationMenuItem/index.native.tsx b/src/pages/settings/Subscription/CardSection/RequestEarlyCancellationMenuItem/index.native.tsx deleted file mode 100644 index 0f9b55ba306e8..0000000000000 --- a/src/pages/settings/Subscription/CardSection/RequestEarlyCancellationMenuItem/index.native.tsx +++ /dev/null @@ -1,5 +0,0 @@ -function RequestEarlyCancellationMenuItem() { - return null; -} - -export default RequestEarlyCancellationMenuItem; From 2b0eb5119c3bb18ba35faa09e822cbd31011ccad Mon Sep 17 00:00:00 2001 From: TaduJR Date: Mon, 9 Mar 2026 15:16:00 +0300 Subject: [PATCH 2/5] fix: hide cancel subscription button for free trial users --- src/pages/settings/Subscription/CardSection/CardSection.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/settings/Subscription/CardSection/CardSection.tsx b/src/pages/settings/Subscription/CardSection/CardSection.tsx index a803527a93217..e1cdc4eb79873 100644 --- a/src/pages/settings/Subscription/CardSection/CardSection.tsx +++ b/src/pages/settings/Subscription/CardSection/CardSection.tsx @@ -299,7 +299,7 @@ function CardSection() { /> )} - {!!(privateSubscription?.type === CONST.SUBSCRIPTION.TYPE.ANNUAL) && } + {!!(privateSubscription?.type === CONST.SUBSCRIPTION.TYPE.ANNUAL && !isUserOnFreeTrial(firstDayFreeTrial, lastDayFreeTrial)) && } ); } From 057bf88a6aaefb80817fb3143e263f62018307f2 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Mon, 9 Mar 2026 15:23:56 +0300 Subject: [PATCH 3/5] fix: also hide cancel subscription for expired trial users without a payment card --- .../Subscription/CardSection/CardSection.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/pages/settings/Subscription/CardSection/CardSection.tsx b/src/pages/settings/Subscription/CardSection/CardSection.tsx index e1cdc4eb79873..f714969a2b2aa 100644 --- a/src/pages/settings/Subscription/CardSection/CardSection.tsx +++ b/src/pages/settings/Subscription/CardSection/CardSection.tsx @@ -21,7 +21,14 @@ import DateUtils from '@libs/DateUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getPaymentMethodDescription} from '@libs/PaymentUtils'; import {buildQueryStringFromFilterFormValues} from '@libs/SearchQueryUtils'; -import {hasCardAuthenticatedError, hasUserFreeTrialEnded, isUserOnFreeTrial, shouldShowDiscountBanner, shouldShowPreTrialBillingBanner} from '@libs/SubscriptionUtils'; +import { + doesUserHavePaymentCardAdded, + hasCardAuthenticatedError, + hasUserFreeTrialEnded, + isUserOnFreeTrial, + shouldShowDiscountBanner, + shouldShowPreTrialBillingBanner, +} from '@libs/SubscriptionUtils'; import {verifySetupIntent} from '@userActions/PaymentMethods'; import {clearOutstandingBalance} from '@userActions/Subscription'; import CONST from '@src/CONST'; @@ -299,7 +306,11 @@ function CardSection() { /> )} - {!!(privateSubscription?.type === CONST.SUBSCRIPTION.TYPE.ANNUAL && !isUserOnFreeTrial(firstDayFreeTrial, lastDayFreeTrial)) && } + {!!( + privateSubscription?.type === CONST.SUBSCRIPTION.TYPE.ANNUAL && + !isUserOnFreeTrial(firstDayFreeTrial, lastDayFreeTrial) && + !(hasUserFreeTrialEnded(lastDayFreeTrial) && !doesUserHavePaymentCardAdded(userBillingFundID)) + ) && } ); } From 773e54d3f7d9b6dc75d37cbcd8383bbed93bacb6 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sun, 5 Apr 2026 00:25:20 +0300 Subject: [PATCH 4/5] fix: Add error handling, route guard, and hardened eligibility for cancel subscription flow --- src/languages/de.ts | 2 +- src/languages/en.ts | 2 +- src/languages/es.ts | 2 +- src/languages/fr.ts | 2 +- src/languages/it.ts | 2 +- src/languages/ja.ts | 2 +- src/languages/nl.ts | 2 +- src/languages/pl.ts | 2 +- src/languages/pt-BR.ts | 2 +- src/languages/zh-hans.ts | 2 +- src/libs/SubscriptionUtils.ts | 35 ++++++++++ src/libs/actions/Subscription.ts | 26 ++++++- .../CancelSubscriptionMenuItem/index.tsx | 3 - .../Subscription/CardSection/CardSection.tsx | 11 ++- .../RequestEarlyCancellationPage/index.tsx | 69 +++++++++++++++---- tests/unit/SubscriptionUtilsTest.ts | 57 +++++++++++++++ 16 files changed, 185 insertions(+), 36 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 98d4272914c7f..dbff4acb014ac 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -8475,7 +8475,7 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc }, requestEarlyCancellation: { title: 'Abonnement kündigen', - subtitle: 'Was ist der Hauptgrund, warum du eine vorzeitige Kündigung beantragst?', + subtitle: 'Was ist der Hauptgrund, warum du dein Abonnement kündigst?', subscriptionCanceled: { title: 'Abonnement gekündigt', subtitle: 'Dein Jahresabonnement wurde gekündigt.', diff --git a/src/languages/en.ts b/src/languages/en.ts index d834c526dbed3..c81f567d498b8 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -8450,7 +8450,7 @@ const translations = { }, requestEarlyCancellation: { title: 'Cancel subscription', - subtitle: 'What’s the main reason you’re requesting early cancellation?', + subtitle: 'What’s the main reason you’re canceling your subscription?', subscriptionCanceled: { title: 'Subscription canceled', subtitle: 'Your annual subscription has been canceled.', diff --git a/src/languages/es.ts b/src/languages/es.ts index b891d5af24102..3d3c72065eb57 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -8726,7 +8726,7 @@ ${amount} para ${merchant} - ${date}`, }, requestEarlyCancellation: { title: 'Cancelar suscripción', - subtitle: '¿Cuál es la razón principal por la que solicitas la cancelación anticipada?', + subtitle: '¿Cuál es la razón principal por la que solicitas cancelar tu suscripción?', subscriptionCanceled: { title: 'Suscripción cancelada', subtitle: 'Tu suscripción anual ha sido cancelada.', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index bf1f73d65317c..ab941c08b734c 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -8497,7 +8497,7 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e }, requestEarlyCancellation: { title: "Annuler l'abonnement", - subtitle: 'Quelle est la principale raison pour laquelle vous demandez une résiliation anticipée ?', + subtitle: 'Quelle est la principale raison pour laquelle vous souhaitez résilier votre abonnement ?', subscriptionCanceled: { title: 'Abonnement annulé', subtitle: 'Votre abonnement annuel a été annulé.', diff --git a/src/languages/it.ts b/src/languages/it.ts index 69be508ff383c..1b902cb425d24 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -8463,7 +8463,7 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, }, requestEarlyCancellation: { title: 'Annulla abbonamento', - subtitle: 'Qual è il motivo principale per cui stai richiedendo l’annullamento anticipato?', + subtitle: 'Qual è il motivo principale per cui vuoi annullare il tuo abbonamento?', subscriptionCanceled: { title: 'Abbonamento annullato', subtitle: 'Il tuo abbonamento annuale è stato annullato.', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index fa1c27e1f3242..474f8f323cec1 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -8357,7 +8357,7 @@ ${reportName} }, requestEarlyCancellation: { title: 'サブスクリプションをキャンセル', - subtitle: '早期解約を申請する主な理由を教えてください。', + subtitle: 'サブスクリプションをキャンセルする主な理由を教えてください。', subscriptionCanceled: { title: 'サブスクリプションを解約しました', subtitle: '年間サブスクリプションは解約されました。', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 6cbb5ec22c067..74e4388939053 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -8441,7 +8441,7 @@ Voeg meer bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, }, requestEarlyCancellation: { title: 'Abonnement opzeggen', - subtitle: 'Wat is de belangrijkste reden dat je om vervroegde annulering vraagt?', + subtitle: 'Wat is de belangrijkste reden dat je je abonnement opzegt?', subscriptionCanceled: { title: 'Abonnement geannuleerd', subtitle: 'Je jaarlijkse abonnement is opgezegd.', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 9a872ee9b11e9..971492aabbbff 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -8422,7 +8422,7 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, }, requestEarlyCancellation: { title: 'Anuluj subskrypcję', - subtitle: 'Jaki jest główny powód, dla którego prosisz o wcześniejsze anulowanie?', + subtitle: 'Jaki jest główny powód, dla którego prosisz o anulowanie subskrypcji?', subscriptionCanceled: { title: 'Subskrypcja anulowana', subtitle: 'Twoja subskrypcja roczna została anulowana.', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index b2876aa8f1af4..d099633aaa306 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -8431,7 +8431,7 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, }, requestEarlyCancellation: { title: 'Cancelar assinatura', - subtitle: 'Qual é o principal motivo pelo qual você está solicitando o cancelamento antecipado?', + subtitle: 'Qual é o principal motivo pelo qual você está deseja cancelar sua assinatura?', subscriptionCanceled: { title: 'Assinatura cancelada', subtitle: 'Sua assinatura anual foi cancelada.', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 4f652dfd70779..9feb92b013443 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -8194,7 +8194,7 @@ ${reportName} }, requestEarlyCancellation: { title: '取消订阅', - subtitle: '您申请提前取消的主要原因是什么?', + subtitle: '您取消订阅的主要原因是什么?', subscriptionCanceled: { title: '订阅已取消', subtitle: '您的年度订阅已被取消。', diff --git a/src/libs/SubscriptionUtils.ts b/src/libs/SubscriptionUtils.ts index afca7d2134819..732f45d6e3bd7 100644 --- a/src/libs/SubscriptionUtils.ts +++ b/src/libs/SubscriptionUtils.ts @@ -452,6 +452,40 @@ function doesUserHavePaymentCardAdded(userBillingFundID: number | undefined): bo return userBillingFundID !== undefined; } +/** + * Whether the user is eligible to cancel their subscription. + * Annual subscribers who have committed to a subscription can cancel. + * Excludes: non-annual users, active trial users, pre-trial users, and expired trial users who never subscribed. + */ +function canCancelSubscription( + subscriptionType: SubscriptionType | undefined, + firstDayFreeTrial: string | undefined, + lastDayFreeTrial: string | undefined, + userBillingFundID: number | undefined, + hasPurchases: boolean | undefined, +): boolean { + if (subscriptionType !== CONST.SUBSCRIPTION.TYPE.ANNUAL) { + return false; + } + + // User is currently on a free trial + if (isUserOnFreeTrial(firstDayFreeTrial, lastDayFreeTrial)) { + return false; + } + + // User is in pre-trial state (trial dates exist but trial hasn't started yet) + if (firstDayFreeTrial && !hasUserFreeTrialEnded(lastDayFreeTrial)) { + return false; + } + + // User's trial ended — only allow cancellation if they have a card or have been billed before + if (hasUserFreeTrialEnded(lastDayFreeTrial) && !doesUserHavePaymentCardAdded(userBillingFundID) && !hasPurchases) { + return false; + } + + return true; +} + /** * Whether the user's billable actions should be restricted. */ @@ -609,6 +643,7 @@ function isSubscriptionTypeOfInvoicing(privateSubscriptionType: SubscriptionType export { calculateRemainingFreeTrialDays, + canCancelSubscription, doesUserHavePaymentCardAdded, getCardForSubscriptionBilling, getFreeTrialText, diff --git a/src/libs/actions/Subscription.ts b/src/libs/actions/Subscription.ts index 5f7631d68767f..5b6b1a316a7cb 100644 --- a/src/libs/actions/Subscription.ts +++ b/src/libs/actions/Subscription.ts @@ -310,7 +310,31 @@ function cancelBillingSubscription(cancellationReason: FeedbackSurveyOptionID, c cancellationNote, }; - API.write(WRITE_COMMANDS.CANCEL_BILLING_SUBSCRIPTION, parameters); + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.FORMS.REQUEST_EARLY_CANCELLATION_FORM, + value: {isLoading: true}, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.FORMS.REQUEST_EARLY_CANCELLATION_FORM, + value: {isLoading: false}, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.FORMS.REQUEST_EARLY_CANCELLATION_FORM, + value: {isLoading: false}, + }, + ], + }; + + API.write(WRITE_COMMANDS.CANCEL_BILLING_SUBSCRIPTION, parameters, onyxData); } function requestTaxExempt() { diff --git a/src/pages/settings/Subscription/CardSection/CancelSubscriptionMenuItem/index.tsx b/src/pages/settings/Subscription/CardSection/CancelSubscriptionMenuItem/index.tsx index 9caeeaf22dcd2..8202cbaee3099 100644 --- a/src/pages/settings/Subscription/CardSection/CancelSubscriptionMenuItem/index.tsx +++ b/src/pages/settings/Subscription/CardSection/CancelSubscriptionMenuItem/index.tsx @@ -3,7 +3,6 @@ import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/ import MenuItem from '@components/MenuItem'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; @@ -15,7 +14,6 @@ function CancelSubscriptionMenuItem() { const styles = useThemeStyles(); const {isActingAsDelegate} = useDelegateNoAccessState(); const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); - const {isOffline} = useNetwork(); const handleCancelSubscriptionPress = () => { if (isActingAsDelegate) { @@ -31,7 +29,6 @@ function CancelSubscriptionMenuItem() { shouldShowRightIcon wrapperStyle={styles.sectionMenuItemTopDescription} titleStyle={styles.textStrong} - disabled={isOffline} onPress={handleCancelSubscriptionPress} sentryLabel={CONST.SENTRY_LABEL.SETTINGS_SUBSCRIPTION.REQUEST_EARLY_CANCELLATION} /> diff --git a/src/pages/settings/Subscription/CardSection/CardSection.tsx b/src/pages/settings/Subscription/CardSection/CardSection.tsx index 87d41abe4f26d..5cea00db91def 100644 --- a/src/pages/settings/Subscription/CardSection/CardSection.tsx +++ b/src/pages/settings/Subscription/CardSection/CardSection.tsx @@ -22,9 +22,8 @@ import Navigation from '@libs/Navigation/Navigation'; import {getPaymentMethodDescription} from '@libs/PaymentUtils'; import {buildQueryStringFromFilterFormValues} from '@libs/SearchQueryUtils'; import { - doesUserHavePaymentCardAdded, + canCancelSubscription, hasCardAuthenticatedError, - hasUserFreeTrialEnded, isUserOnFreeTrial, shouldShowDiscountBanner, shouldShowPreTrialBillingBanner, @@ -308,11 +307,9 @@ function CardSection() { /> )} - {!!( - privateSubscription?.type === CONST.SUBSCRIPTION.TYPE.ANNUAL && - !isUserOnFreeTrial(firstDayFreeTrial, lastDayFreeTrial) && - !(hasUserFreeTrialEnded(lastDayFreeTrial) && !doesUserHavePaymentCardAdded(userBillingFundID)) - ) && } + {!privateSubscription?.pendingFields?.type && canCancelSubscription(privateSubscription?.type, firstDayFreeTrial, lastDayFreeTrial, userBillingFundID, account?.hasPurchases) && ( + + )} ); } diff --git a/src/pages/settings/Subscription/RequestEarlyCancellationPage/index.tsx b/src/pages/settings/Subscription/RequestEarlyCancellationPage/index.tsx index 82fa8fa1cbe37..c3565a6f9f367 100644 --- a/src/pages/settings/Subscription/RequestEarlyCancellationPage/index.tsx +++ b/src/pages/settings/Subscription/RequestEarlyCancellationPage/index.tsx @@ -1,10 +1,12 @@ import type {ReactNode} from 'react'; -import React, {useMemo, useState} from 'react'; +import React, {useMemo} from 'react'; import {View} from 'react-native'; +import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import Button from '@components/Button'; import DelegateNoAccessWrapper from '@components/DelegateNoAccessWrapper'; import FeedbackSurvey from '@components/FeedbackSurvey'; import FixedFooter from '@components/FixedFooter'; +import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import RenderHTML from '@components/RenderHTML'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -13,26 +15,57 @@ import Text from '@components/Text'; import useCancellationType from '@hooks/useCancellationType'; import useEnvironment from '@hooks/useEnvironment'; import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import {cancelBillingSubscription} from '@libs/actions/Subscription'; import Navigation from '@libs/Navigation/Navigation'; +import {canCancelSubscription} from '@libs/SubscriptionUtils'; import type {CancellationType, FeedbackSurveyOptionID} from '@src/CONST'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; function RequestEarlyCancellationPage() { const {environmentURL} = useEnvironment(); const workspacesListRoute = `${environmentURL}/${ROUTES.WORKSPACES_LIST.route}`; const {translate} = useLocalize(); const styles = useThemeStyles(); + const [privateSubscription, privateSubscriptionResult] = useOnyx(ONYXKEYS.NVP_PRIVATE_SUBSCRIPTION); + const [firstDayFreeTrial, firstDayFreeTrialResult] = useOnyx(ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL); + const [lastDayFreeTrial, lastDayFreeTrialResult] = useOnyx(ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL); + const [userBillingFundID, userBillingFundIDResult] = useOnyx(ONYXKEYS.NVP_BILLING_FUND_ID); + const [account, accountResult] = useOnyx(ONYXKEYS.ACCOUNT); + const isLoadingEligibilityData = isLoadingOnyxValue(privateSubscriptionResult, firstDayFreeTrialResult, lastDayFreeTrialResult, userBillingFundIDResult, accountResult); - const [isLoading, setIsLoading] = useState(false); + const [formState] = useOnyx(ONYXKEYS.FORMS.REQUEST_EARLY_CANCELLATION_FORM); + const [cancellationDetails, cancellationDetailsResult] = useOnyx(ONYXKEYS.NVP_PRIVATE_CANCELLATION_DETAILS); + const isLoadingGuardData = isLoadingEligibilityData || isLoadingOnyxValue(cancellationDetailsResult); - const cancellationType = useCancellationType(); + const cancellationTypeFromHook = useCancellationType(); + const isEligibleToCancel = canCancelSubscription(privateSubscription?.type, firstDayFreeTrial, lastDayFreeTrial, userBillingFundID, account?.hasPurchases); + + // Falls back to reading cancellation details directly on remount (hook only detects array growth). + // Skipped for eligible users so they see the survey, not a historical success screen. + const resolvedCancellationType = useMemo(() => { + if (cancellationTypeFromHook) { + return cancellationTypeFromHook; + } + if (isEligibleToCancel || !cancellationDetails?.length) { + return undefined; + } + const pendingManual = cancellationDetails.find((detail) => detail.cancellationType === CONST.CANCELLATION_TYPE.MANUAL && !detail.cancellationDate); + if (pendingManual) { + return CONST.CANCELLATION_TYPE.MANUAL; + } + const noneEntry = cancellationDetails.find((detail) => detail.cancellationType === CONST.CANCELLATION_TYPE.NONE); + if (noneEntry) { + return CONST.CANCELLATION_TYPE.NONE; + } + return CONST.CANCELLATION_TYPE.AUTOMATIC; + }, [cancellationTypeFromHook, cancellationDetails, isEligibleToCancel]); const handleSubmit = (cancellationReason: FeedbackSurveyOptionID, cancellationNote = '') => { - setIsLoading(true); cancelBillingSubscription(cancellationReason, cancellationNote); }; @@ -92,11 +125,11 @@ function RequestEarlyCancellationPage() { optionRowStyles={styles.flex1} footerText={{acknowledgementText}} isNoteRequired - isLoading={isLoading} + isLoading={!!formState?.isLoading} enabledWhenOffline={false} /> ), - [acknowledgementText, isLoading, styles.flex1, styles.mb2, styles.mt4, translate], + [acknowledgementText, formState?.isLoading, styles.flex1, styles.mb2, styles.mt4, translate], ); const contentMap: Partial> = { @@ -105,7 +138,11 @@ function RequestEarlyCancellationPage() { [CONST.CANCELLATION_TYPE.NONE]: manualCancellationContent, }; - const screenContent = cancellationType ? contentMap[cancellationType] : surveyContent; + const screenContent = resolvedCancellationType ? contentMap[resolvedCancellationType] : surveyContent; + + if (isLoadingGuardData) { + return ; + } return ( - - - {screenContent} - + + + + {screenContent} + + ); } diff --git a/tests/unit/SubscriptionUtilsTest.ts b/tests/unit/SubscriptionUtilsTest.ts index 73486023b7866..9c6ec84fa955b 100644 --- a/tests/unit/SubscriptionUtilsTest.ts +++ b/tests/unit/SubscriptionUtilsTest.ts @@ -4,6 +4,7 @@ import Onyx from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; import { calculateRemainingFreeTrialDays, + canCancelSubscription, doesUserHavePaymentCardAdded, getEarlyDiscountInfo, getSubscriptionStatus, @@ -255,6 +256,62 @@ describe('SubscriptionUtils', () => { }); }); + describe('canCancelSubscription', () => { + const activeTrial = { + firstDay: formatDate(subDays(new Date(), 1), CONST.DATE.FNS_DATE_TIME_FORMAT_STRING), + lastDay: formatDate(addDays(new Date(), 5), CONST.DATE.FNS_DATE_TIME_FORMAT_STRING), + }; + const expiredTrial = { + firstDay: formatDate(subDays(new Date(), 10), CONST.DATE.FNS_DATE_TIME_FORMAT_STRING), + lastDay: formatDate(subDays(new Date(), 3), CONST.DATE.FNS_DATE_TIME_FORMAT_STRING), + }; + const BILLING_FUND_ID = 8010; + + it('should return true for an active annual subscriber with a payment card', () => { + expect(canCancelSubscription(CONST.SUBSCRIPTION.TYPE.ANNUAL, undefined, undefined, BILLING_FUND_ID, true)).toBeTruthy(); + }); + + it('should return true for an annual subscriber who never had a trial', () => { + expect(canCancelSubscription(CONST.SUBSCRIPTION.TYPE.ANNUAL, undefined, undefined, undefined, undefined)).toBeTruthy(); + }); + + it('should return true for an expired trial user who added a payment card', () => { + expect(canCancelSubscription(CONST.SUBSCRIPTION.TYPE.ANNUAL, expiredTrial.firstDay, expiredTrial.lastDay, BILLING_FUND_ID, false)).toBeTruthy(); + }); + + it('should return true for an expired trial user who lost their card but has purchase history', () => { + expect(canCancelSubscription(CONST.SUBSCRIPTION.TYPE.ANNUAL, expiredTrial.firstDay, expiredTrial.lastDay, undefined, true)).toBeTruthy(); + }); + + it('should return false for a pay-per-use subscriber', () => { + expect(canCancelSubscription(CONST.SUBSCRIPTION.TYPE.PAY_PER_USE, undefined, undefined, BILLING_FUND_ID, true)).toBeFalsy(); + }); + + it('should return false when subscription type is undefined', () => { + expect(canCancelSubscription(undefined, undefined, undefined, undefined, undefined)).toBeFalsy(); + }); + + it('should return false for a user on an active free trial', () => { + expect(canCancelSubscription(CONST.SUBSCRIPTION.TYPE.ANNUAL, activeTrial.firstDay, activeTrial.lastDay, undefined, undefined)).toBeFalsy(); + }); + + it('should return false for an expired trial user without a payment card or purchases', () => { + expect(canCancelSubscription(CONST.SUBSCRIPTION.TYPE.ANNUAL, expiredTrial.firstDay, expiredTrial.lastDay, undefined, false)).toBeFalsy(); + }); + + it('should return false for an invoicing subscriber', () => { + expect(canCancelSubscription(CONST.SUBSCRIPTION.TYPE.INVOICING, undefined, undefined, BILLING_FUND_ID, true)).toBeFalsy(); + }); + + it('should return false for a pre-trial user whose trial has not started yet', () => { + const preTrial = { + firstDay: formatDate(addDays(new Date(), 2), CONST.DATE.FNS_DATE_TIME_FORMAT_STRING), + lastDay: formatDate(addDays(new Date(), 9), CONST.DATE.FNS_DATE_TIME_FORMAT_STRING), + }; + expect(canCancelSubscription(CONST.SUBSCRIPTION.TYPE.ANNUAL, preTrial.firstDay, preTrial.lastDay, undefined, undefined)).toBeFalsy(); + }); + }); + describe('shouldRestrictUserBillableActions', () => { afterEach(async () => { await Onyx.clear(); From 889dbc0d1691b78fe3bc0677399a2de5d65e81cf Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sun, 5 Apr 2026 01:22:11 +0300 Subject: [PATCH 5/5] fix: Add required reasonAttributes to FullscreenLoadingIndicator --- .../Subscription/RequestEarlyCancellationPage/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/settings/Subscription/RequestEarlyCancellationPage/index.tsx b/src/pages/settings/Subscription/RequestEarlyCancellationPage/index.tsx index c3565a6f9f367..6134284e5ec0c 100644 --- a/src/pages/settings/Subscription/RequestEarlyCancellationPage/index.tsx +++ b/src/pages/settings/Subscription/RequestEarlyCancellationPage/index.tsx @@ -141,7 +141,7 @@ function RequestEarlyCancellationPage() { const screenContent = resolvedCancellationType ? contentMap[resolvedCancellationType] : surveyContent; if (isLoadingGuardData) { - return ; + return ; } return (