diff --git a/assets/images/calendar-solid.svg b/assets/images/calendar-solid.svg new file mode 100644 index 000000000000..168aabaa4a49 --- /dev/null +++ b/assets/images/calendar-solid.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/CONST.ts b/src/CONST.ts index e03f42b282e4..e354ba663923 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -5332,6 +5332,12 @@ const CONST = { }, EXCLUDE_FROM_LAST_VISITED_PATH: [SCREENS.NOT_FOUND, SCREENS.SAML_SIGN_IN, SCREENS.VALIDATE_LOGIN] as string[], + + CANCELLATION_TYPE: { + MANUAL: 'manual', + AUTOMATIC: 'automatic', + NONE: 'none', + }, EMPTY_STATE_MEDIA: { ANIMATION: 'animation', ILLUSTRATION: 'illustration', @@ -5400,7 +5406,8 @@ type IOURequestType = ValueOf; type FeedbackSurveyOptionID = ValueOf, 'ID'>>; type SubscriptionType = ValueOf; +type CancellationType = ValueOf; -export type {Country, IOUAction, IOUType, RateAndUnit, OnboardingPurposeType, IOURequestType, SubscriptionType, FeedbackSurveyOptionID}; +export type {Country, IOUAction, IOUType, RateAndUnit, OnboardingPurposeType, IOURequestType, SubscriptionType, FeedbackSurveyOptionID, CancellationType}; export default CONST; diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 62c32e15f3b6..615d0217f0d4 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -385,6 +385,8 @@ const ONYXKEYS = { /** Stores the information about the state of issuing a new card */ ISSUE_NEW_EXPENSIFY_CARD: 'issueNewExpensifyCard', + NVP_PRIVATE_CANCELLATION_DETAILS: 'nvp_private_cancellationDetails', + /** Collection Keys */ COLLECTION: { DOWNLOAD: 'download_', @@ -850,6 +852,7 @@ type OnyxValuesMapping = { [ONYXKEYS.NVP_BILLING_FUND_ID]: number; [ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED]: number; [ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END]: number; + [ONYXKEYS.NVP_PRIVATE_CANCELLATION_DETAILS]: OnyxTypes.CancellationDetails[]; }; type OnyxValues = OnyxValuesMapping & OnyxCollectionValuesMapping & OnyxFormValuesMapping & OnyxFormDraftValuesMapping; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index ae7781318c52..3bda25d4699b 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -123,6 +123,7 @@ const ROUTES = { SETTINGS_SUBSCRIPTION_CHANGE_BILLING_CURRENCY: 'settings/subscription/change-billing-currency', SETTINGS_SUBSCRIPTION_CHANGE_PAYMENT_CURRENCY: 'settings/subscription/add-payment-card/change-payment-currency', SETTINGS_SUBSCRIPTION_DISABLE_AUTO_RENEW_SURVEY: 'settings/subscription/disable-auto-renew-survey', + SETTINGS_SUBSCRIPTION_REQUEST_EARLY_CANCELLATION: 'settings/subscription/request-early-cancellation-survey', SETTINGS_PRIORITY_MODE: 'settings/preferences/priority-mode', SETTINGS_LANGUAGE: 'settings/preferences/language', SETTINGS_THEME: 'settings/preferences/theme', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index d7d56089876d..44ac2677db68 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -111,6 +111,7 @@ const SCREENS = { DISABLE_AUTO_RENEW_SURVEY: 'Settings_Subscription_DisableAutoRenewSurvey', CHANGE_BILLING_CURRENCY: 'Settings_Subscription_Change_Billing_Currency', CHANGE_PAYMENT_CURRENCY: 'Settings_Subscription_Change_Payment_Currency', + REQUEST_EARLY_CANCELLATION: 'Settings_Subscription_RequestEarlyCancellation', }, }, SAVE_THE_WORLD: { diff --git a/src/components/FeedbackSurvey.tsx b/src/components/FeedbackSurvey.tsx index a7b0732be1fb..db5db3ee447d 100644 --- a/src/components/FeedbackSurvey.tsx +++ b/src/components/FeedbackSurvey.tsx @@ -25,6 +25,15 @@ type FeedbackSurveyProps = { /** Styles for the option row element */ optionRowStyles?: StyleProp; + + /** Optional text to render over the submit button */ + footerText?: React.ReactNode; + + /** Indicates whether note field is required */ + isNoteRequired?: boolean; + + /** Indicates whether a loading indicator should be shown */ + isLoading?: boolean; }; type Option = { @@ -39,7 +48,7 @@ const OPTIONS: Option[] = [ {key: CONST.FEEDBACK_SURVEY_OPTIONS.BUSINESS_CLOSING.ID, label: CONST.FEEDBACK_SURVEY_OPTIONS.BUSINESS_CLOSING.TRANSLATION_KEY}, ]; -function FeedbackSurvey({title, description, onSubmit, optionRowStyles}: FeedbackSurveyProps) { +function FeedbackSurvey({title, description, onSubmit, optionRowStyles, footerText, isNoteRequired, isLoading}: FeedbackSurveyProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const theme = useTheme(); @@ -55,7 +64,7 @@ function FeedbackSurvey({title, description, onSubmit, optionRowStyles}: Feedbac }; const handleSubmit = () => { - if (!reason) { + if (!reason || (isNoteRequired && !note.trim())) { setShouldShowReasonError(true); return; } @@ -89,12 +98,15 @@ function FeedbackSurvey({title, description, onSubmit, optionRowStyles}: Feedbac )} + {!!footerText && footerText} diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts index 487df5594212..0b9306d1c977 100644 --- a/src/components/Icon/Expensicons.ts +++ b/src/components/Icon/Expensicons.ts @@ -27,6 +27,7 @@ import Bolt from '@assets/images/bolt.svg'; import Briefcase from '@assets/images/briefcase.svg'; import Bug from '@assets/images/bug.svg'; import Building from '@assets/images/building.svg'; +import CalendarSolid from '@assets/images/calendar-solid.svg'; import Calendar from '@assets/images/calendar.svg'; import Camera from '@assets/images/camera.svg'; import CarWithKey from '@assets/images/car-with-key.svg'; @@ -372,4 +373,5 @@ export { CheckCircle, CheckmarkCircle, NetSuiteSquare, + CalendarSolid, }; diff --git a/src/hooks/useCancellationType.ts b/src/hooks/useCancellationType.ts new file mode 100644 index 000000000000..bc34f5feea6f --- /dev/null +++ b/src/hooks/useCancellationType.ts @@ -0,0 +1,43 @@ +import {useEffect, useMemo, useRef, useState} from 'react'; +import {useOnyx} from 'react-native-onyx'; +import type {CancellationType} from '@src/CONST'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +function useCancellationType(): CancellationType | undefined { + const [cancellationDetails] = useOnyx(ONYXKEYS.NVP_PRIVATE_CANCELLATION_DETAILS); + + const [cancellationType, setCancellationType] = useState(); + + // Store initial cancellation details array in a ref for comparison + const previousCancellationDetails = useRef(cancellationDetails); + + const memoizedCancellationType = useMemo(() => { + const pendingManualCancellation = cancellationDetails?.filter((detail) => detail.cancellationType === CONST.CANCELLATION_TYPE.MANUAL).find((detail) => !detail.cancellationDate); + + // There is a pending manual cancellation - return manual cancellation type + if (pendingManualCancellation) { + return CONST.CANCELLATION_TYPE.MANUAL; + } + + // There are no new items in the cancellation details NVP + if (previousCancellationDetails.current?.length === cancellationDetails?.length) { + return; + } + + // There is a new item in the cancellation details NVP, it has to be an automatic cancellation, as pending manual cancellations are handled above + return CONST.CANCELLATION_TYPE.AUTOMATIC; + }, [cancellationDetails]); + + useEffect(() => { + if (!memoizedCancellationType) { + return; + } + + setCancellationType(memoizedCancellationType); + }, [memoizedCancellationType]); + + return cancellationType; +} + +export default useCancellationType; diff --git a/src/languages/en.ts b/src/languages/en.ts index 204fc71d8923..ea7acfcbda6a 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -4224,6 +4224,33 @@ export default { whatsMainReason: "What's the main reason you're disabling auto-renew?", renewsOn: ({date}) => `Renews on ${date}.`, }, + requestEarlyCancellation: { + title: 'Request early cancellation', + subtitle: 'What’s the main reason you’re requesting early cancellation?', + subscriptionCanceled: { + title: 'Subscription canceled', + subtitle: 'Your annual subscription has been canceled.', + info: 'If you want to keep using your workspace(s) on a pay-per-use basis, you’re all set.', + preventFutureActivity: { + part1: 'If you’d like to prevent future activity and charges, you must ', + link: 'delete your workspace(s)', + part2: '. Note that when you delete your workspace(s), you’ll be charged for any outstanding activity that was incurred during the current calendar month.', + }, + }, + requestSubmitted: { + title: 'Request submitted', + subtitle: { + part1: 'Thanks for letting us know you’re interested in canceling your subscription. We’re reviewing your request and will be in touch soon via your chat with ', + link: 'Concierge', + part2: '.', + }, + }, + acknowledgement: { + part1: 'By requesting early cancellation, I acknowledge and agree that Expensify has no obligation to grant such request under the Expensify ', + link: 'Terms of Service', + part2: ' or other applicable services agreement between me and Expensify and that Expensify retains sole discretion with regard to granting any such request.', + }, + }, }, feedbackSurvey: { tooLimited: 'Functionality needs improvement', diff --git a/src/languages/es.ts b/src/languages/es.ts index 934e0de335a2..53d5f8a8dc26 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -4747,6 +4747,33 @@ export default { whatsMainReason: '¿Cuál es la razón principal por la que deseas desactivar la auto-renovación?', renewsOn: ({date}) => `Se renovará el ${date}.`, }, + requestEarlyCancellation: { + title: 'Solicitar cancelación anticipada', + subtitle: '¿Cuál es la razón principal por la que solicitas la cancelación anticipada?', + subscriptionCanceled: { + title: 'Suscripción cancelada', + subtitle: 'Tu suscripción anual ha sido cancelada.', + info: 'Ya puedes seguir utilizando tu(s) espacio(s) de trabajo en la modalidad de pago por uso.', + preventFutureActivity: { + part1: 'Si quieres evitar actividad y cargos futuros, debes ', + link: 'eliminar tu(s) espacio(s) de trabajo.', + part2: ' Ten en cuenta que cuando elimines tu(s) espacio(s) de trabajo, se te cobrará cualquier actividad pendienteque se haya incurrido durante el mes en curso.', + }, + }, + requestSubmitted: { + title: 'Solicitud enviada', + subtitle: { + part1: 'Gracias por hacernos saber que deseas cancelar tu suscripción. Estamos revisando tu solicitud y nos comunicaremos contigo en breve a través de tu chat con ', + link: 'Concierge', + part2: '.', + }, + }, + acknowledgement: { + part1: 'Al solicitar la cancelación anticipada, reconozco y acepto que Expensify no tiene ninguna obligación de conceder dicha solicitud en virtud de las ', + link: 'Condiciones de Servicio', + part2: ' de Expensify u otro acuerdo de servicios aplicable entre Expensify y yo, y que Expensify se reserva el derecho exclusivo a conceder dicha solicitud.', + }, + }, }, feedbackSurvey: { tooLimited: 'Hay que mejorar la funcionalidad', diff --git a/src/libs/API/parameters/CancelBillingSubscriptionParams.ts b/src/libs/API/parameters/CancelBillingSubscriptionParams.ts new file mode 100644 index 000000000000..252a7420d27d --- /dev/null +++ b/src/libs/API/parameters/CancelBillingSubscriptionParams.ts @@ -0,0 +1,8 @@ +import type {FeedbackSurveyOptionID} from '@src/CONST'; + +type CancelBillingSubscriptionParams = { + cancellationReason: FeedbackSurveyOptionID; + cancellationNote: string; +}; + +export default CancelBillingSubscriptionParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 0b3b0e1ece93..997eb415a848 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -260,6 +260,7 @@ export type {default as RemoveWorkspaceReportFieldListValueParams} from './Remov export type {default as OpenPolicyExpensifyCardsPageParams} from './OpenPolicyExpensifyCardsPageParams'; export type {default as RequestExpensifyCardLimitIncreaseParams} from './RequestExpensifyCardLimitIncreaseParams'; export type {default as UpdateNetSuiteGenericTypeParams} from './UpdateNetSuiteGenericTypeParams'; +export type {default as CancelBillingSubscriptionParams} from './CancelBillingSubscriptionParams'; export type {default as UpdateNetSuiteCustomFormIDParams} from './UpdateNetSuiteCustomFormIDParams'; export type {default as UpdateSageIntacctGenericTypeParams} from './UpdateSageIntacctGenericTypeParams'; export type {default as UpdateNetSuiteCustomersJobsParams} from './UpdateNetSuiteCustomersJobsParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 5c9d4a9e93b9..70a0e91aba10 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -300,6 +300,7 @@ const WRITE_COMMANDS = { UPDATE_SAGE_INTACCT_SYNC_REIMBURSEMENT_ACCOUNT_ID: 'UpdateSageIntacctSyncReimbursementAccountID', CONNECT_POLICY_TO_NETSUITE: 'ConnectPolicyToNetSuite', CLEAR_OUTSTANDING_BALANCE: 'ClearOutstandingBalance', + CANCEL_BILLING_SUBSCRIPTION: 'CancelBillingSubscriptionNewDot', UPDATE_SAGE_INTACCT_ENTITY: 'UpdateSageIntacctEntity', UPDATE_SAGE_INTACCT_BILLABLE: 'UpdateSageIntacctBillable', UPDATE_SAGE_INTACCT_DEPARTMENT_MAPPING: 'UpdateSageIntacctDepartmentsMapping', @@ -532,6 +533,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.MARK_AS_EXPORTED]: Parameters.MarkAsExportedParams; [WRITE_COMMANDS.REQUEST_EXPENSIFY_CARD_LIMIT_INCREASE]: Parameters.RequestExpensifyCardLimitIncreaseParams; [WRITE_COMMANDS.CLEAR_OUTSTANDING_BALANCE]: null; + [WRITE_COMMANDS.CANCEL_BILLING_SUBSCRIPTION]: Parameters.CancelBillingSubscriptionParams; [WRITE_COMMANDS.UPDATE_POLICY_CONNECTION_CONFIG]: Parameters.UpdatePolicyConnectionConfigParams; [WRITE_COMMANDS.UPDATE_MANY_POLICY_CONNECTION_CONFIGS]: Parameters.UpdateManyPolicyConnectionConfigurationsParams; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index d22efe973eeb..b612d4a3f0cd 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -218,6 +218,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/settings/Profile/CustomStatus/SetTimePage').default, [SCREENS.SETTINGS.SUBSCRIPTION.SIZE]: () => require('../../../../pages/settings/Subscription/SubscriptionSize').default, [SCREENS.SETTINGS.SUBSCRIPTION.DISABLE_AUTO_RENEW_SURVEY]: () => require('../../../../pages/settings/Subscription/DisableAutoRenewSurveyPage').default, + [SCREENS.SETTINGS.SUBSCRIPTION.REQUEST_EARLY_CANCELLATION]: () => require('../../../../pages/settings/Subscription/RequestEarlyCancellationPage').default, [SCREENS.WORKSPACE.RATE_AND_UNIT]: () => require('../../../../pages/workspace/reimburse/WorkspaceRateAndUnitPage/InitialPage').default, [SCREENS.WORKSPACE.RATE_AND_UNIT_RATE]: () => require('../../../../pages/workspace/reimburse/WorkspaceRateAndUnitPage/RatePage').default, [SCREENS.WORKSPACE.RATE_AND_UNIT_UNIT]: () => require('../../../../pages/workspace/reimburse/WorkspaceRateAndUnitPage/UnitPage').default, diff --git a/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts index c6f3aa2f17b5..8aa259b769b0 100755 --- a/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts +++ b/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts @@ -43,6 +43,7 @@ const CENTRAL_PANE_TO_RHP_MAPPING: Partial> = SCREENS.SETTINGS.SUBSCRIPTION.ADD_PAYMENT_CARD, SCREENS.SETTINGS.SUBSCRIPTION.SIZE, SCREENS.SETTINGS.SUBSCRIPTION.DISABLE_AUTO_RENEW_SURVEY, + SCREENS.SETTINGS.SUBSCRIPTION.REQUEST_EARLY_CANCELLATION, SCREENS.SETTINGS.SUBSCRIPTION.CHANGE_BILLING_CURRENCY, SCREENS.SETTINGS.SUBSCRIPTION.CHANGE_PAYMENT_CURRENCY, ], diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 77fafb9fba4d..47a9d3f70d6a 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -294,6 +294,9 @@ const config: LinkingOptions['config'] = { [SCREENS.SETTINGS.SUBSCRIPTION.DISABLE_AUTO_RENEW_SURVEY]: { path: ROUTES.SETTINGS_SUBSCRIPTION_DISABLE_AUTO_RENEW_SURVEY, }, + [SCREENS.SETTINGS.SUBSCRIPTION.REQUEST_EARLY_CANCELLATION]: { + path: ROUTES.SETTINGS_SUBSCRIPTION_REQUEST_EARLY_CANCELLATION, + }, [SCREENS.WORKSPACE.CURRENCY]: { path: ROUTES.WORKSPACE_PROFILE_CURRENCY.route, }, diff --git a/src/libs/actions/Subscription.ts b/src/libs/actions/Subscription.ts index beed2b1b2962..aaec38dbf5f8 100644 --- a/src/libs/actions/Subscription.ts +++ b/src/libs/actions/Subscription.ts @@ -1,7 +1,7 @@ import type {OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import * as API from '@libs/API'; -import type {UpdateSubscriptionAddNewUsersAutomaticallyParams, UpdateSubscriptionAutoRenewParams, UpdateSubscriptionTypeParams} from '@libs/API/parameters'; +import type {CancelBillingSubscriptionParams, UpdateSubscriptionAddNewUsersAutomaticallyParams, UpdateSubscriptionAutoRenewParams, UpdateSubscriptionTypeParams} from '@libs/API/parameters'; import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import CONST from '@src/CONST'; import type {FeedbackSurveyOptionID, SubscriptionType} from '@src/CONST'; @@ -279,6 +279,15 @@ function clearOutstandingBalance() { API.write(WRITE_COMMANDS.CLEAR_OUTSTANDING_BALANCE, null, onyxData); } +function cancelBillingSubscription(cancellationReason: FeedbackSurveyOptionID, cancellationNote: string) { + const parameters: CancelBillingSubscriptionParams = { + cancellationReason, + cancellationNote, + }; + + API.write(WRITE_COMMANDS.CANCEL_BILLING_SUBSCRIPTION, parameters); +} + export { openSubscriptionPage, updateSubscriptionAutoRenew, @@ -287,4 +296,5 @@ export { clearUpdateSubscriptionSizeError, updateSubscriptionType, clearOutstandingBalance, + cancelBillingSubscription, }; diff --git a/src/pages/settings/Subscription/CardSection/CardSection.tsx b/src/pages/settings/Subscription/CardSection/CardSection.tsx index d139339da39a..ccf838127a5e 100644 --- a/src/pages/settings/Subscription/CardSection/CardSection.tsx +++ b/src/pages/settings/Subscription/CardSection/CardSection.tsx @@ -29,6 +29,7 @@ import TrialEndedBillingBanner from './BillingBanner/TrialEndedBillingBanner'; import TrialStartedBillingBanner from './BillingBanner/TrialStartedBillingBanner'; import CardSectionActions from './CardSectionActions'; import CardSectionDataEmpty from './CardSectionDataEmpty'; +import RequestEarlyCancellationMenuItem from './RequestEarlyCancellationMenuItem'; import type {BillingStatusResult} from './utils'; import CardSectionUtils from './utils'; @@ -129,7 +130,6 @@ function CardSection() { {isEmptyObject(defaultCard?.accountData) && } - {billingStatus?.isRetryAvailable !== undefined && (