diff --git a/assets/images/product-illustrations/arm_with_card_pos.svg b/assets/images/product-illustrations/arm_with_card_pos.svg
new file mode 100644
index 000000000000..069df6a5b38a
--- /dev/null
+++ b/assets/images/product-illustrations/arm_with_card_pos.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/CONST/index.ts b/src/CONST/index.ts
index e66cc1327311..f9ad544986b5 100644
--- a/src/CONST/index.ts
+++ b/src/CONST/index.ts
@@ -6448,6 +6448,11 @@ const CONST = {
},
ONBOARDING_JOINABLE_WORKSPACES_LIMIT: 5,
ACTIONABLE_TRACK_EXPENSE_WHISPER_MESSAGE: 'What would you like to do with this expense?',
+ TRIAL_REMINDER_VARIANT: {
+ BASIC: 'basic',
+ NEAR_END: 'nearEnd',
+ COUNTDOWN: 'countdown',
+ },
ONBOARDING_ACCOUNTING_MAPPING,
REPORT_FIELD_TITLE_FIELD_ID: 'text_title',
diff --git a/src/GlobalModals.tsx b/src/GlobalModals.tsx
index 09c6cb1ce963..a29dca9321cc 100644
--- a/src/GlobalModals.tsx
+++ b/src/GlobalModals.tsx
@@ -12,6 +12,7 @@ const LazyPopoverReportActionContextMenu = React.lazy(() => import('./pages/inbo
const LazyUpdateAppModal = React.lazy(() => import('./components/UpdateAppModal'));
const LazyScreenShareRequestModal = React.lazy(() => import('./components/ScreenShareRequestModal'));
const LazyProactiveAppReviewModalManager = React.lazy(() => import('./components/ProactiveAppReviewModalManager'));
+const LazyTrialPaymentReminderModalManager = React.lazy(() => import('./components/TrialPaymentReminderModalManager'));
// Maximum time (ms) the context menu mount can stay deferred before requestIdleCallback forces it to run,
// guaranteeing mount even if the main thread never becomes idle.
@@ -68,6 +69,9 @@ function GlobalModals() {
{/* Proactive app review modal shown when user has completed a trigger action */}
+
+
+
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index d673456d2bfc..508cb88fa9c7 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -302,6 +302,9 @@ const ONYXKEYS = {
/** ID associated with the payment card added by the user. */
NVP_BILLING_FUND_ID: 'nvp_expensify_billingFundID',
+ /** ISO timestamp of the last time the trial payment reminder was dismissed */
+ NVP_DISMISSED_TRIAL_PAYMENT_REMINDER: 'nvp_dismissedTrialPaymentReminder',
+
/** The user's freebie credits balance (in cents). */
NVP_PRIVATE_FREEBIE_CREDITS: 'nvp_private_freebieCredits',
@@ -1659,6 +1662,7 @@ type OnyxValuesMapping = {
[ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL]: string;
[ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL]: string;
[ONYXKEYS.NVP_BILLING_FUND_ID]: number;
+ [ONYXKEYS.NVP_DISMISSED_TRIAL_PAYMENT_REMINDER]: string;
[ONYXKEYS.NVP_PRIVATE_FREEBIE_CREDITS]: number;
[ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED]: number;
[ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END]: number;
diff --git a/src/components/Icon/chunks/illustrations.chunk.ts b/src/components/Icon/chunks/illustrations.chunk.ts
index 1cde69896a3d..46d85d7b754d 100644
--- a/src/components/Icon/chunks/illustrations.chunk.ts
+++ b/src/components/Icon/chunks/illustrations.chunk.ts
@@ -56,6 +56,7 @@ import RunOutOfTime from '@assets/images/multifactorAuthentication/running-out-o
import PendingTravel from '@assets/images/pending-travel.svg';
// Product Illustrations
import Abracadabra from '@assets/images/product-illustrations/abracadabra.svg';
+import ArmWithCardPos from '@assets/images/product-illustrations/arm_with_card_pos.svg';
import BigVault from '@assets/images/product-illustrations/big-vault.svg';
import BrokenCompanyCardBankConnection from '@assets/images/product-illustrations/broken-humpty-dumpty.svg';
import BrokenMagnifyingGlass from '@assets/images/product-illustrations/broken-magnifying-glass.svg';
@@ -270,6 +271,7 @@ const Illustrations = {
// Product Illustrations
Abracadabra,
+ ArmWithCardPos,
BigVault,
BrokenCompanyCardBankConnection,
BrokenMagnifyingGlass,
diff --git a/src/components/TrialPaymentReminderModal.tsx b/src/components/TrialPaymentReminderModal.tsx
new file mode 100644
index 000000000000..ffe77c40f999
--- /dev/null
+++ b/src/components/TrialPaymentReminderModal.tsx
@@ -0,0 +1,98 @@
+import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset';
+import useLocalize from '@hooks/useLocalize';
+import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useThemeStyles from '@hooks/useThemeStyles';
+import type {CountdownTime, TrialReminderVariant} from '@hooks/useTrialPaymentReminder';
+
+import CONST from '@src/CONST';
+
+import React from 'react';
+import {View} from 'react-native';
+
+import Button from './Button';
+import ImageSVG from './ImageSVG';
+import Modal from './Modal';
+import Text from './Text';
+
+type TrialPaymentReminderModalProps = {
+ /** Whether the modal is visible */
+ isVisible: boolean;
+
+ /** The variant of the modal to display */
+ variant: TrialReminderVariant;
+
+ /** Number of days remaining for 'nearEnd' variant */
+ daysRemaining?: number;
+
+ /** Countdown time for 'countdown' variant */
+ countdownTime?: CountdownTime;
+
+ /** Called when user presses Close */
+ onClose: () => void;
+
+ /** Called when user presses Add payment card */
+ onAddPaymentCard: () => void;
+};
+
+function padZero(num: number): string {
+ return num.toString().padStart(2, '0');
+}
+
+function TrialPaymentReminderModal({isVisible, variant, daysRemaining, countdownTime, onClose, onAddPaymentCard}: TrialPaymentReminderModalProps) {
+ const {shouldUseNarrowLayout} = useResponsiveLayout();
+ const styles = useThemeStyles();
+ const illustrations = useMemoizedLazyIllustrations(['ArmWithCardPos']);
+ const {translate} = useLocalize();
+
+ return (
+ {}}
+ isVisible={isVisible}
+ type={shouldUseNarrowLayout ? CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED : CONST.MODAL.MODAL_TYPE.CONFIRM}
+ innerContainerStyle={styles.pv0}
+ shouldHandleNavigationBack
+ >
+
+
+
+
+ {variant === CONST.TRIAL_REMINDER_VARIANT.NEAR_END && daysRemaining !== undefined && (
+ {translate('trialPaymentReminder.trialEndsInDays', {count: daysRemaining})}
+ )}
+ {variant === CONST.TRIAL_REMINDER_VARIANT.COUNTDOWN && !!countdownTime && (
+
+ {translate('trialPaymentReminder.trialEndsCountdown', {
+ hours: padZero(countdownTime.hours),
+ minutes: padZero(countdownTime.minutes),
+ seconds: padZero(countdownTime.seconds),
+ })}
+
+ )}
+
+ {translate('trialPaymentReminder.title')}
+ {translate('trialPaymentReminder.subtitle')}
+
+
+
+
+
+ );
+}
+
+export default TrialPaymentReminderModal;
diff --git a/src/components/TrialPaymentReminderModalManager.tsx b/src/components/TrialPaymentReminderModalManager.tsx
new file mode 100644
index 000000000000..76f9b56030b7
--- /dev/null
+++ b/src/components/TrialPaymentReminderModalManager.tsx
@@ -0,0 +1,59 @@
+import useOnyx from '@hooks/useOnyx';
+import useRootNavigationState from '@hooks/useRootNavigationState';
+import useTrialPaymentReminder from '@hooks/useTrialPaymentReminder';
+
+import {isModalNavigatorName} from '@libs/Navigation/helpers/isNavigatorName';
+
+import navigateToSubscriptionPayment from '@pages/home/common/navigateToSubscriptionPayment';
+
+import ONYXKEYS from '@src/ONYXKEYS';
+
+import React, {useCallback, useState} from 'react';
+
+import TrialPaymentReminderModal from './TrialPaymentReminderModal';
+
+function TrialPaymentReminderModalManager() {
+ const {isEligibleToShow, currentVariation, countdownTime, dismiss} = useTrialPaymentReminder();
+ const [modal] = useOnyx(ONYXKEYS.MODAL);
+ const [isModalOpen, setIsModalOpen] = useState(false);
+
+ const isModalNavigatorActive = useRootNavigationState((state) => isModalNavigatorName(state?.routes?.at(-1)?.name));
+
+ const isOtherModalActive = !!modal?.isVisible || !!modal?.willAlertModalBecomeVisible || isModalNavigatorActive;
+
+ if (isEligibleToShow && !isOtherModalActive && !isModalOpen) {
+ setIsModalOpen(true);
+ }
+ if (!isEligibleToShow && isModalOpen) {
+ setIsModalOpen(false);
+ }
+
+ const handleClose = useCallback(() => {
+ setIsModalOpen(false);
+ dismiss();
+ }, [dismiss]);
+
+ const handleAddPaymentCard = useCallback(() => {
+ setIsModalOpen(false);
+ dismiss();
+ // Adding a payment card is web-only; on native this routes to the subscription page instead.
+ navigateToSubscriptionPayment();
+ }, [dismiss]);
+
+ if (!currentVariation) {
+ return null;
+ }
+
+ return (
+
+ );
+}
+
+export default TrialPaymentReminderModalManager;
diff --git a/src/hooks/useTrialPaymentReminder.ts b/src/hooks/useTrialPaymentReminder.ts
new file mode 100644
index 000000000000..90c3bd188b02
--- /dev/null
+++ b/src/hooks/useTrialPaymentReminder.ts
@@ -0,0 +1,286 @@
+/**
+ * Determines whether the trial payment reminder modal is eligible to show and which variant/countdown
+ * to display, based on the user's free-trial dates, payment-card status, and prior dismissals.
+ */
+import {calculateRemainingTrialSeconds, calculateTrialDayNumber, doesUserHavePaymentCardAdded, isUserOnFreeTrial} from '@libs/SubscriptionUtils';
+
+import {setNameValuePair} from '@userActions/User';
+
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue';
+
+import type {ValueOf} from 'type-fest';
+
+import {useCallback, useEffect, useMemo, useState} from 'react';
+
+import useOnyx from './useOnyx';
+
+const FIVE_MINUTES_MS = 5 * 60 * 1000;
+const ONE_MINUTE_MS = 60 * 1000;
+const TWENTY_FOUR_HOURS_IN_SECONDS = 24 * 60 * 60;
+
+/** Onyx values not yet resolved */
+const READINESS_LOADING = 'loading';
+/** Onyx loaded, no trial yet */
+const READINESS_PRE_TRIAL = 'preTrial';
+/** Trial just appeared, 5-minute settle window before showing the modal */
+const READINESS_TRIAL_STARTUP_GRACE = 'trialStartupGrace';
+/** Eligible to show the modal */
+const READINESS_READY = 'ready';
+
+const READINESS_STATE = {
+ LOADING: READINESS_LOADING,
+ PRE_TRIAL: READINESS_PRE_TRIAL,
+ TRIAL_STARTUP_GRACE: READINESS_TRIAL_STARTUP_GRACE,
+ READY: READINESS_READY,
+} as const;
+
+type TrialReminderVariant = ValueOf;
+type ReadinessState = ValueOf;
+
+type TrialReminderVariation = {
+ id: string;
+ variant: TrialReminderVariant;
+ daysRemaining?: number;
+};
+
+type CountdownTime = {
+ hours: number;
+ minutes: number;
+ seconds: number;
+};
+
+const TRIAL_REMINDER_VARIATIONS = [
+ {id: 'day1', dayOfTrial: 1, variant: CONST.TRIAL_REMINDER_VARIANT.BASIC},
+ {id: 'day2', dayOfTrial: 2, variant: CONST.TRIAL_REMINDER_VARIANT.BASIC},
+ {id: 'day3', dayOfTrial: 3, variant: CONST.TRIAL_REMINDER_VARIANT.BASIC},
+ {id: 'day7', dayOfTrial: 7, variant: CONST.TRIAL_REMINDER_VARIANT.BASIC},
+ {id: 'day15', dayOfTrial: 15, variant: CONST.TRIAL_REMINDER_VARIANT.BASIC},
+ {id: 'day28', dayOfTrial: 28, variant: CONST.TRIAL_REMINDER_VARIANT.NEAR_END},
+ {id: 'day29', dayOfTrial: 29, variant: CONST.TRIAL_REMINDER_VARIANT.NEAR_END},
+ {id: 'last24h', dayOfTrial: -1, variant: CONST.TRIAL_REMINDER_VARIANT.COUNTDOWN},
+] as const;
+
+/**
+ * Returns the remaining time (ms) of the 5-minute startup grace window relative to the trial's start time.
+ * Returns 0 when the trial start is unparsable or the grace window has already elapsed.
+ */
+function getTrialStartupGraceRemainingMs(firstDayFreeTrial: string): number {
+ const trialStartMs = new Date(`${firstDayFreeTrial}Z`).getTime();
+ if (!Number.isFinite(trialStartMs)) {
+ return 0;
+ }
+ return Math.max(trialStartMs + FIVE_MINUTES_MS - Date.now(), 0);
+}
+
+/**
+ * Returns the timestamp (ms) when the current variation's window started. A dismissal whose timestamp
+ * is at or after this is considered to apply to the current variation.
+ */
+function getVariationWindowStart(variationId: string, firstDayFreeTrial: string | undefined, lastDayFreeTrial: string | undefined): number | null {
+ if (variationId === 'last24h') {
+ if (!lastDayFreeTrial) {
+ return null;
+ }
+ return new Date(`${lastDayFreeTrial}Z`).getTime() - TWENTY_FOUR_HOURS_IN_SECONDS * 1000;
+ }
+ if (!firstDayFreeTrial) {
+ return null;
+ }
+ const variation = TRIAL_REMINDER_VARIATIONS.find((v) => v.id === variationId);
+ if (!variation || variation.dayOfTrial <= 0) {
+ return null;
+ }
+ return new Date(`${firstDayFreeTrial}Z`).getTime() + (variation.dayOfTrial - 1) * TWENTY_FOUR_HOURS_IN_SECONDS * 1000;
+}
+
+function computeCurrentVariation(firstDayFreeTrial: string | undefined, lastDayFreeTrial: string | undefined): TrialReminderVariation | null {
+ if (!isUserOnFreeTrial(firstDayFreeTrial, lastDayFreeTrial)) {
+ return null;
+ }
+
+ const remainingSeconds = calculateRemainingTrialSeconds(lastDayFreeTrial);
+
+ // Last 24 hours takes priority
+ if (remainingSeconds <= TWENTY_FOUR_HOURS_IN_SECONDS && remainingSeconds > 0) {
+ return {
+ id: 'last24h',
+ variant: CONST.TRIAL_REMINDER_VARIANT.COUNTDOWN,
+ };
+ }
+
+ const currentTrialDay = calculateTrialDayNumber(firstDayFreeTrial);
+ if (currentTrialDay <= 0) {
+ return null;
+ }
+
+ for (let i = TRIAL_REMINDER_VARIATIONS.length - 1; i >= 0; i--) {
+ const variation = TRIAL_REMINDER_VARIATIONS[i];
+ if (variation.dayOfTrial > 0 && variation.dayOfTrial <= currentTrialDay) {
+ if (variation.variant === CONST.TRIAL_REMINDER_VARIANT.NEAR_END) {
+ const daysRemaining = Math.ceil(remainingSeconds / TWENTY_FOUR_HOURS_IN_SECONDS);
+ return {
+ id: variation.id,
+ variant: variation.variant,
+ daysRemaining,
+ };
+ }
+ return {
+ id: variation.id,
+ variant: variation.variant,
+ };
+ }
+ }
+
+ return null;
+}
+
+function isSameVariation(a: TrialReminderVariation | null, b: TrialReminderVariation | null): boolean {
+ if (a === b) {
+ return true;
+ }
+ if (!a || !b) {
+ return false;
+ }
+ return a.id === b.id && a.variant === b.variant && a.daysRemaining === b.daysRemaining;
+}
+
+function useTrialPaymentReminder() {
+ const [firstDayFreeTrial, firstDayFreeTrialResult] = useOnyx(ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL);
+ const [lastDayFreeTrial] = useOnyx(ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL);
+ const [billingFundID, billingFundIDResult] = useOnyx(ONYXKEYS.NVP_BILLING_FUND_ID);
+ const [dismissedTimestamp, dismissedTimestampResult] = useOnyx(ONYXKEYS.NVP_DISMISSED_TRIAL_PAYMENT_REMINDER);
+
+ const [readinessState, setReadinessState] = useState(READINESS_STATE.LOADING);
+
+ useEffect(() => {
+ if (isLoadingOnyxValue(firstDayFreeTrialResult)) {
+ return;
+ }
+
+ if (readinessState === READINESS_STATE.LOADING) {
+ // Onyx resolves asynchronously after mount, so the initial LOADING → READY/PRE_TRIAL/GRACE transition
+ // must be driven by an effect once the Onyx value settles.
+ if (!firstDayFreeTrial) {
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setReadinessState(READINESS_STATE.PRE_TRIAL);
+ return;
+ }
+ // Trial is already present on first observation — apply the grace window if it just started
+ // (e.g., the modal manager mounted right after first-workspace creation), otherwise it's safe to show.
+ setReadinessState(getTrialStartupGraceRemainingMs(firstDayFreeTrial) > 0 ? READINESS_STATE.TRIAL_STARTUP_GRACE : READINESS_STATE.READY);
+ return;
+ }
+
+ if (readinessState === READINESS_STATE.PRE_TRIAL && firstDayFreeTrial) {
+ // PRE_TRIAL → TRIAL_STARTUP_GRACE fires when firstDayFreeTrial appears mid-session
+ // (trial was just created); this can only be detected by reacting to the Onyx change,
+ // hence setState inside the effect.
+ setReadinessState(READINESS_STATE.TRIAL_STARTUP_GRACE);
+ return;
+ }
+
+ // Trial disappeared mid-session (logout, account switch, or trial revoked) — drop back to PRE_TRIAL
+ // so a subsequent login with a new trial re-runs the grace window against the new firstDayFreeTrial.
+ if (readinessState !== READINESS_STATE.PRE_TRIAL && !firstDayFreeTrial) {
+ setReadinessState(READINESS_STATE.PRE_TRIAL);
+ }
+ }, [readinessState, firstDayFreeTrial, firstDayFreeTrialResult]);
+
+ // Run the grace timer after a trial is created. Use the trial's actual start time so a late mount
+ // only waits out the remainder of the 5-minute window, not a fresh 5 minutes from mount.
+ useEffect(() => {
+ if (readinessState !== READINESS_STATE.TRIAL_STARTUP_GRACE || !firstDayFreeTrial) {
+ return;
+ }
+ const timer = setTimeout(() => setReadinessState(READINESS_STATE.READY), getTrialStartupGraceRemainingMs(firstDayFreeTrial));
+ return () => clearTimeout(timer);
+ }, [readinessState, firstDayFreeTrial]);
+
+ const [countdownTime, setCountdownTime] = useState({hours: 0, minutes: 0, seconds: 0});
+
+ const [currentVariation, setCurrentVariation] = useState(null);
+
+ useEffect(() => {
+ const recompute = () =>
+ setCurrentVariation((prev) => {
+ const next = computeCurrentVariation(firstDayFreeTrial, lastDayFreeTrial);
+ return isSameVariation(prev, next) ? prev : next;
+ });
+
+ if (!isUserOnFreeTrial(firstDayFreeTrial, lastDayFreeTrial) || doesUserHavePaymentCardAdded(billingFundID)) {
+ return;
+ }
+
+ recompute();
+ const interval = setInterval(recompute, ONE_MINUTE_MS);
+ return () => clearInterval(interval);
+ }, [firstDayFreeTrial, lastDayFreeTrial, billingFundID]);
+
+ const isEligibleToShow = useMemo(() => {
+ if (!isUserOnFreeTrial(firstDayFreeTrial, lastDayFreeTrial)) {
+ return false;
+ }
+ if (isLoadingOnyxValue(billingFundIDResult)) {
+ return false;
+ }
+ if (doesUserHavePaymentCardAdded(billingFundID)) {
+ return false;
+ }
+ if (readinessState !== READINESS_STATE.READY) {
+ return false;
+ }
+ if (isLoadingOnyxValue(dismissedTimestampResult)) {
+ return false;
+ }
+ if (!currentVariation) {
+ return false;
+ }
+ if (dismissedTimestamp) {
+ const windowStart = getVariationWindowStart(currentVariation.id, firstDayFreeTrial, lastDayFreeTrial);
+ const dismissedMs = new Date(dismissedTimestamp).getTime();
+ if (windowStart !== null && Number.isFinite(dismissedMs) && dismissedMs >= windowStart) {
+ return false;
+ }
+ }
+ return true;
+ }, [firstDayFreeTrial, lastDayFreeTrial, billingFundID, billingFundIDResult, readinessState, currentVariation, dismissedTimestamp, dismissedTimestampResult]);
+
+ // Run the 1s countdown only while the countdown modal is actually eligible to show, so we don't tick every
+ // second in the background when it isn't rendered.
+ useEffect(() => {
+ if (!isEligibleToShow || currentVariation?.variant !== CONST.TRIAL_REMINDER_VARIANT.COUNTDOWN) {
+ return;
+ }
+
+ const updateCountdown = () => {
+ const secs = calculateRemainingTrialSeconds(lastDayFreeTrial);
+ if (secs <= 0) {
+ setCountdownTime({hours: 0, minutes: 0, seconds: 0});
+ return;
+ }
+ setCountdownTime({
+ hours: Math.floor(secs / 3600),
+ minutes: Math.floor((secs % 3600) / 60),
+ seconds: Math.floor(secs % 60),
+ });
+ };
+
+ updateCountdown();
+ const interval = setInterval(updateCountdown, 1000);
+ return () => clearInterval(interval);
+ }, [isEligibleToShow, currentVariation?.variant, lastDayFreeTrial]);
+
+ const dismiss = useCallback(() => {
+ if (!currentVariation) {
+ return;
+ }
+ setNameValuePair(ONYXKEYS.NVP_DISMISSED_TRIAL_PAYMENT_REMINDER, new Date().toISOString(), dismissedTimestamp ?? '');
+ }, [currentVariation, dismissedTimestamp]);
+
+ return {isEligibleToShow, currentVariation, countdownTime, dismiss};
+}
+
+export default useTrialPaymentReminder;
+export type {TrialReminderVariant, CountdownTime};
diff --git a/src/languages/de.ts b/src/languages/de.ts
index 4c0cd55cbedc..b67efd7cceaa 100644
--- a/src/languages/de.ts
+++ b/src/languages/de.ts
@@ -10364,6 +10364,17 @@ Hier ist ein *Testbeleg*, um dir zu zeigen, wie es funktioniert:`,
positiveButton: 'Ja!',
negativeButton: 'Nicht wirklich',
},
+ trialPaymentReminder: {
+ title: 'Bleib der Frist voraus',
+ subtitle: 'Warte nicht bis zur letzten Minute – füge noch heute deine Zahlungsmethode hinzu, um den kontinuierlichen Zugang zu deinen Ausgaben auf Expensify sicherzustellen.',
+ trialEndsInDays: () => ({
+ one: 'Testphase endet in 1 Tag',
+ other: (pluralCount: number) => `Testphase endet in ${pluralCount} Tagen`,
+ }),
+ trialEndsCountdown: ({hours, minutes, seconds}: {hours: string; minutes: string; seconds: string}) => `Testphase endet in ${hours}h : ${minutes}m : ${seconds}s`,
+ closeButton: 'Schließen',
+ addPaymentCardButton: 'Zahlungskarte hinzufügen',
+ },
monthPickerPage: {month: 'Monat', selectMonth: 'Bitte wählen Sie einen Monat aus'},
aiFeaturesPromoModal: {
subtitle: 'Neu bei Concierge AI',
diff --git a/src/languages/en.ts b/src/languages/en.ts
index 0a5726ebd2a8..c5c270cb8ffe 100644
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -4530,6 +4530,17 @@ const translations = {
positiveButton: 'Yeah!',
negativeButton: 'Not really',
},
+ trialPaymentReminder: {
+ title: 'Stay ahead of the deadline',
+ subtitle: "Don't wait until the last minute, add your payment method today to ensure continuous access to your expenses on Expensify.",
+ trialEndsInDays: () => ({
+ one: 'Trial ends in 1 day',
+ other: (pluralCount: number) => `Trial ends in ${pluralCount} days`,
+ }),
+ trialEndsCountdown: ({hours, minutes, seconds}: {hours: string; minutes: string; seconds: string}) => `Trial ends in ${hours}h : ${minutes}m : ${seconds}s`,
+ closeButton: 'Close',
+ addPaymentCardButton: 'Add payment card',
+ },
workspace: {
common: {
card: 'Cards',
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 4f3f90e1734b..3d156a29c710 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -4303,6 +4303,17 @@ ${amount} para ${merchant} - ${date}`,
positiveButton: '¡Sí!',
negativeButton: 'No mucho',
},
+ trialPaymentReminder: {
+ title: 'Adelántate al plazo',
+ subtitle: 'No esperes hasta el último momento, añade tu método de pago hoy para asegurar el acceso continuo a tus gastos en Expensify.',
+ trialEndsInDays: () => ({
+ one: 'La prueba termina en 1 día',
+ other: (pluralCount: number) => `La prueba termina en ${pluralCount} días`,
+ }),
+ trialEndsCountdown: ({hours, minutes, seconds}: {hours: string; minutes: string; seconds: string}) => `La prueba termina en ${hours}h : ${minutes}m : ${seconds}s`,
+ closeButton: 'Cerrar',
+ addPaymentCardButton: 'Añadir tarjeta de pago',
+ },
workspace: {
common: {
card: 'Tarjetas',
diff --git a/src/languages/fr.ts b/src/languages/fr.ts
index 9aaef668b193..f54011070109 100644
--- a/src/languages/fr.ts
+++ b/src/languages/fr.ts
@@ -10402,6 +10402,17 @@ Voici un *reçu test* pour vous montrer comment ça fonctionne :`,
positiveButton: 'Oui !',
negativeButton: 'Pas vraiment',
},
+ trialPaymentReminder: {
+ title: "Anticipez l'échéance",
+ subtitle: "N'attendez pas la dernière minute, ajoutez votre méthode de paiement dès aujourd'hui pour garantir un accès continu à vos dépenses sur Expensify.",
+ trialEndsInDays: () => ({
+ one: "La période d'essai se termine dans 1 jour",
+ other: (pluralCount: number) => `La période d'essai se termine dans ${pluralCount} jours`,
+ }),
+ trialEndsCountdown: ({hours, minutes, seconds}: {hours: string; minutes: string; seconds: string}) => `La période d'essai se termine dans ${hours}h : ${minutes}m : ${seconds}s`,
+ closeButton: 'Fermer',
+ addPaymentCardButton: 'Ajouter une carte de paiement',
+ },
monthPickerPage: {month: 'Mois', selectMonth: 'Veuillez sélectionner un mois'},
aiFeaturesPromoModal: {
subtitle: 'Nouveau dans Concierge IA',
diff --git a/src/languages/it.ts b/src/languages/it.ts
index 5bdeb2a21ec3..3078866f5a2d 100644
--- a/src/languages/it.ts
+++ b/src/languages/it.ts
@@ -10354,6 +10354,17 @@ Ecco una *ricevuta di prova* per mostrarti come funziona:`,
positiveButton: 'Sì!',
negativeButton: 'Non proprio',
},
+ trialPaymentReminder: {
+ title: 'Anticipa la scadenza',
+ subtitle: "Non aspettare l'ultimo minuto, aggiungi il tuo metodo di pagamento oggi per garantire l'accesso continuo alle tue spese su Expensify.",
+ trialEndsInDays: () => ({
+ one: 'Il periodo di prova termina tra 1 giorno',
+ other: (pluralCount: number) => `Il periodo di prova termina tra ${pluralCount} giorni`,
+ }),
+ trialEndsCountdown: ({hours, minutes, seconds}: {hours: string; minutes: string; seconds: string}) => `Il periodo di prova termina tra ${hours}h : ${minutes}m : ${seconds}s`,
+ closeButton: 'Chiudi',
+ addPaymentCardButton: 'Aggiungi carta di pagamento',
+ },
monthPickerPage: {month: 'Mese', selectMonth: 'Seleziona un mese'},
aiFeaturesPromoModal: {
subtitle: 'Nuovo in Concierge AI',
diff --git a/src/languages/ja.ts b/src/languages/ja.ts
index 87462798984c..10a84d459fe0 100644
--- a/src/languages/ja.ts
+++ b/src/languages/ja.ts
@@ -10205,6 +10205,17 @@ ${reportName}`,
positiveButton: 'やった!',
negativeButton: 'そうでもありません',
},
+ trialPaymentReminder: {
+ title: '期限に遅れないようにしましょう',
+ subtitle: 'ぎりぎりまで待たずに、今日お支払い方法を追加して、Expensify での経費への継続的なアクセスを確保しましょう。',
+ trialEndsInDays: () => ({
+ one: 'トライアル終了まであと1日',
+ other: (pluralCount: number) => `トライアル終了まであと${pluralCount}日`,
+ }),
+ trialEndsCountdown: ({hours, minutes, seconds}: {hours: string; minutes: string; seconds: string}) => `トライアル終了まで ${hours}時間 : ${minutes}分 : ${seconds}秒`,
+ closeButton: '閉じる',
+ addPaymentCardButton: '支払いカードを追加',
+ },
monthPickerPage: {month: '月', selectMonth: '月を選択してください'},
aiFeaturesPromoModal: {
subtitle: 'はじめての Concierge AI',
diff --git a/src/languages/nl.ts b/src/languages/nl.ts
index 1e6128f9a954..23b8c5995d8e 100644
--- a/src/languages/nl.ts
+++ b/src/languages/nl.ts
@@ -10319,6 +10319,17 @@ Hier is een *proefbon* om je te laten zien hoe het werkt:`,
positiveButton: 'Ja!',
negativeButton: 'Niet echt',
},
+ trialPaymentReminder: {
+ title: 'Blijf de deadline voor',
+ subtitle: 'Wacht niet tot het laatste moment, voeg vandaag nog je betaalmethode toe om doorlopende toegang tot je uitgaven op Expensify te garanderen.',
+ trialEndsInDays: () => ({
+ one: 'Proefperiode eindigt over 1 dag',
+ other: (pluralCount: number) => `Proefperiode eindigt over ${pluralCount} dagen`,
+ }),
+ trialEndsCountdown: ({hours, minutes, seconds}: {hours: string; minutes: string; seconds: string}) => `Proefperiode eindigt over ${hours}u : ${minutes}m : ${seconds}s`,
+ closeButton: 'Sluiten',
+ addPaymentCardButton: 'Betaalkaart toevoegen',
+ },
monthPickerPage: {month: 'Maand', selectMonth: 'Selecteer een maand'},
aiFeaturesPromoModal: {
subtitle: 'Nieuw bij Concierge AI',
diff --git a/src/languages/pl.ts b/src/languages/pl.ts
index b86ec97003cb..b3185d4f35be 100644
--- a/src/languages/pl.ts
+++ b/src/languages/pl.ts
@@ -10288,6 +10288,17 @@ Oto *paragon testowy*, żeby pokazać Ci, jak to działa:`,
positiveButton: 'Tak!',
negativeButton: 'Niekoniecznie',
},
+ trialPaymentReminder: {
+ title: 'Wyprzedź termin',
+ subtitle: 'Nie czekaj do ostatniej chwili – dodaj swoją metodę płatności już dziś, aby zapewnić ciągły dostęp do swoich wydatków w Expensify.',
+ trialEndsInDays: () => ({
+ one: 'Okres próbny kończy się za 1 dzień',
+ other: (pluralCount: number) => `Okres próbny kończy się za ${pluralCount} dni`,
+ }),
+ trialEndsCountdown: ({hours, minutes, seconds}: {hours: string; minutes: string; seconds: string}) => `Okres próbny kończy się za ${hours}godz. : ${minutes}min : ${seconds}s`,
+ closeButton: 'Zamknij',
+ addPaymentCardButton: 'Dodaj kartę płatniczą',
+ },
monthPickerPage: {month: 'Miesiąc', selectMonth: 'Wybierz miesiąc'},
aiFeaturesPromoModal: {
subtitle: 'Nowość w Concierge AI',
diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts
index 8ad639ba9204..b0ce6487b32c 100644
--- a/src/languages/pt-BR.ts
+++ b/src/languages/pt-BR.ts
@@ -10305,6 +10305,17 @@ Aqui está um *comprovante de teste* para mostrar como funciona:`,
positiveButton: 'Sim!',
negativeButton: 'Na verdade, não',
},
+ trialPaymentReminder: {
+ title: 'Antecipe-se ao prazo',
+ subtitle: 'Não espere até o último minuto, adicione seu método de pagamento hoje para garantir o acesso contínuo às suas despesas no Expensify.',
+ trialEndsInDays: () => ({
+ one: 'O período de teste termina em 1 dia',
+ other: (pluralCount: number) => `O período de teste termina em ${pluralCount} dias`,
+ }),
+ trialEndsCountdown: ({hours, minutes, seconds}: {hours: string; minutes: string; seconds: string}) => `O período de teste termina em ${hours}h : ${minutes}m : ${seconds}s`,
+ closeButton: 'Fechar',
+ addPaymentCardButton: 'Adicionar cartão de pagamento',
+ },
monthPickerPage: {month: 'Mês', selectMonth: 'Selecione um mês por favor'},
aiFeaturesPromoModal: {
subtitle: 'Novo no Concierge AI',
diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts
index 5bd11c30c39b..0857a1be334f 100644
--- a/src/languages/zh-hans.ts
+++ b/src/languages/zh-hans.ts
@@ -9996,6 +9996,17 @@ ${reportName}`,
},
},
proactiveAppReview: {title: '喜欢全新的 Expensify 吗?', description: '请告诉我们,这样我们就能帮助您让报销体验变得更好。', positiveButton: '太棒了!', negativeButton: '不太是'},
+ trialPaymentReminder: {
+ title: '提前做好准备',
+ subtitle: '不要等到最后一刻,立即添加您的付款方式,以确保您可以持续访问 Expensify 上的费用。',
+ trialEndsInDays: () => ({
+ one: '试用期将在1天后结束',
+ other: (pluralCount: number) => `试用期将在${pluralCount}天后结束`,
+ }),
+ trialEndsCountdown: ({hours, minutes, seconds}: {hours: string; minutes: string; seconds: string}) => `试用期将在 ${hours}小时 : ${minutes}分 : ${seconds}秒 后结束`,
+ closeButton: '关闭',
+ addPaymentCardButton: '添加付款卡',
+ },
monthPickerPage: {month: '月份', selectMonth: '请选择月份'},
aiFeaturesPromoModal: {
subtitle: 'Concierge AI 新手指南',
diff --git a/src/libs/ExportOnyxState/common.ts b/src/libs/ExportOnyxState/common.ts
index 36dd807d3927..ec091a02b70b 100644
--- a/src/libs/ExportOnyxState/common.ts
+++ b/src/libs/ExportOnyxState/common.ts
@@ -219,6 +219,7 @@ const safeOnyxKeys = new Set([
ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING,
ONYXKEYS.NVP_DISMISSED_REFERRAL_BANNERS,
ONYXKEYS.NVP_DISMISSED_REJECT_USE_EXPLANATION,
+ ONYXKEYS.NVP_DISMISSED_TRIAL_PAYMENT_REMINDER,
ONYXKEYS.NVP_EMPTY_REPORTS_CONFIRMATION_DISMISSED,
ONYXKEYS.NVP_HAS_SEEN_FOR_YOU_TODO,
ONYXKEYS.NVP_HAS_SEEN_TRACK_TRAINING,
diff --git a/src/libs/Navigation/helpers/isNavigatorName.ts b/src/libs/Navigation/helpers/isNavigatorName.ts
index a436dad60384..a9dc05407e6b 100644
--- a/src/libs/Navigation/helpers/isNavigatorName.ts
+++ b/src/libs/Navigation/helpers/isNavigatorName.ts
@@ -5,6 +5,19 @@ import NAVIGATORS from '@src/NAVIGATORS';
import SCREENS from '@src/SCREENS';
const FULL_SCREENS_SET = new Set([...Object.values(SIDEBAR_TO_SPLIT), NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, NAVIGATORS.TAB_NAVIGATOR, SCREENS.HOME, NAVIGATORS.WORKSPACE_NAVIGATOR]);
+// Root-level navigators that render as an overlay/modal on top of the current screen. These do NOT go through the
+// shared `Modal` component, so they never set `ONYXKEYS.MODAL` — the only way to know one is open is the navigation state.
+const MODAL_NAVIGATORS_SET = new Set([
+ NAVIGATORS.RIGHT_MODAL_NAVIGATOR,
+ NAVIGATORS.ONBOARDING_MODAL_NAVIGATOR,
+ NAVIGATORS.FEATURE_TRAINING_MODAL_NAVIGATOR,
+ NAVIGATORS.MIGRATED_USER_MODAL_NAVIGATOR,
+ NAVIGATORS.SUBMIT_PLAN_MODAL_NAVIGATOR,
+ NAVIGATORS.AI_FEATURES_PROMO_MODAL_NAVIGATOR,
+ NAVIGATORS.TEST_DRIVE_DEMO_NAVIGATOR,
+ NAVIGATORS.SHARE_MODAL_NAVIGATOR,
+ NAVIGATORS.TEST_TOOLS_MODAL_NAVIGATOR,
+]);
const SIDEBARS_SET = new Set(Object.values(SPLIT_TO_SIDEBAR));
const ONBOARDING_SCREENS_SET = new Set(Object.values(SCREENS.ONBOARDING));
const SPLIT_NAVIGATORS_SET = new Set(Object.values(SIDEBAR_TO_SPLIT));
@@ -34,6 +47,10 @@ function isFullScreenName(screen: string | undefined) {
return checkIfScreenHasMatchingNameToSetValues(screen, FULL_SCREENS_SET);
}
+function isModalNavigatorName(screen: string | undefined) {
+ return checkIfScreenHasMatchingNameToSetValues(screen, MODAL_NAVIGATORS_SET);
+}
+
function isSidebarScreenName(screen: string | undefined) {
return checkIfScreenHasMatchingNameToSetValues(screen, SIDEBARS_SET);
}
@@ -42,4 +59,4 @@ function isWorkspaceNavigatorRouteName(screen: string | undefined) {
return checkIfScreenHasMatchingNameToSetValues(screen, WORKSPACES_TAB_SET);
}
-export {isFullScreenName, isOnboardingFlowName, isSidebarScreenName, isSplitNavigatorName, isWorkspaceNavigatorRouteName};
+export {isFullScreenName, isModalNavigatorName, isOnboardingFlowName, isSidebarScreenName, isSplitNavigatorName, isWorkspaceNavigatorRouteName};
diff --git a/src/libs/SubscriptionUtils.ts b/src/libs/SubscriptionUtils.ts
index b5463cbb725f..4bb884e51cfe 100644
--- a/src/libs/SubscriptionUtils.ts
+++ b/src/libs/SubscriptionUtils.ts
@@ -631,6 +631,36 @@ function isSubscriptionTypeOfInvoicing(privateSubscriptionType: SubscriptionType
return privateSubscriptionType === CONST.SUBSCRIPTION.TYPE.INVOICING;
}
+/**
+ * Calculates the current day number of the trial (1-indexed).
+ * Returns 0 if firstDayFreeTrial is undefined or trial hasn't started yet.
+ */
+function calculateTrialDayNumber(firstDayFreeTrial: string | undefined): number {
+ if (!firstDayFreeTrial) {
+ return 0;
+ }
+ const currentDate = new Date();
+ const firstDayDate = new Date(`${firstDayFreeTrial}Z`);
+ const diffInMs = currentDate.getTime() - firstDayDate.getTime();
+ if (diffInMs < 0) {
+ return 0;
+ }
+ return Math.floor(diffInMs / (24 * 60 * 60 * 1000)) + 1;
+}
+
+/**
+ * Calculates the remaining time in seconds until the trial ends.
+ */
+function calculateRemainingTrialSeconds(lastDayFreeTrial: string | undefined): number {
+ if (!lastDayFreeTrial) {
+ return 0;
+ }
+ const currentDate = new Date();
+ const lastDayDate = new Date(`${lastDayFreeTrial}Z`);
+ const diffInSeconds = differenceInSeconds(lastDayDate, currentDate);
+ return diffInSeconds < 0 ? 0 : diffInSeconds;
+}
+
export {
calculateRemainingFreeTrialDays,
canCancelSubscription,
@@ -656,6 +686,8 @@ export {
shouldUseSimplifiedCollectSubscriptionUI,
shouldShowTrialEndedUI,
isSubscriptionTypeOfInvoicing,
+ calculateTrialDayNumber,
+ calculateRemainingTrialSeconds,
hasInsufficientFundsError,
};
diff --git a/src/styles/index.ts b/src/styles/index.ts
index 167081fda20a..b6662f0d854f 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -2852,6 +2852,12 @@ const staticStyles = (theme: ThemeColors) =>
backgroundColor: 'black',
},
+ trialReminderIllustrationContainer: {
+ // Fixed brand navy used as the illustration backdrop; intentionally not theme-dependent.
+ backgroundColor: colors.blue800,
+ height: CONST.CONFIRM_CONTENT_SVG_SIZE.HEIGHT,
+ },
+
reportActionContextMenuMiniButton: {
height: 28,
width: 28,
diff --git a/src/styles/utils/spacing.ts b/src/styles/utils/spacing.ts
index 170f916038e7..dda9fe4aead5 100644
--- a/src/styles/utils/spacing.ts
+++ b/src/styles/utils/spacing.ts
@@ -799,6 +799,10 @@ export default {
paddingBottom: 24,
},
+ pb7: {
+ paddingBottom: 28,
+ },
+
pb8: {
paddingBottom: 32,
},
diff --git a/tests/unit/SubscriptionUtilsTest.ts b/tests/unit/SubscriptionUtilsTest.ts
index c65610fc5b80..09ea8e8b7da0 100644
--- a/tests/unit/SubscriptionUtilsTest.ts
+++ b/tests/unit/SubscriptionUtilsTest.ts
@@ -4,6 +4,8 @@ import type {LocalizedTranslate} from '@components/LocaleContextProvider';
import {
calculateRemainingFreeTrialDays,
+ calculateRemainingTrialSeconds,
+ calculateTrialDayNumber,
canCancelSubscription,
doesUserHavePaymentCardAdded,
getEarlyDiscountInfo,
@@ -1452,6 +1454,72 @@ describe('SubscriptionUtils', () => {
});
});
+ // Helper to format a Date as UTC string in the format the functions expect (yyyy-MM-dd HH:mm:ss)
+ function formatUTC(date: Date): string {
+ return date
+ .toISOString()
+ .replace('T', ' ')
+ .replace(/\.\d+Z$/, '');
+ }
+
+ describe('calculateTrialDayNumber', () => {
+ it('should return 0 when firstDayFreeTrial is undefined', () => {
+ expect(calculateTrialDayNumber(undefined)).toBe(0);
+ });
+
+ it('should return 0 when firstDayFreeTrial is in the future', () => {
+ const futureDate = formatUTC(addDays(new Date(), 2));
+ expect(calculateTrialDayNumber(futureDate)).toBe(0);
+ });
+
+ it('should return 1 on the first day of the trial', () => {
+ const today = formatUTC(new Date());
+ expect(calculateTrialDayNumber(today)).toBe(1);
+ });
+
+ it('should return the correct day number during the trial', () => {
+ const fiveDaysAgo = formatUTC(subDays(new Date(), 4));
+ expect(calculateTrialDayNumber(fiveDaysAgo)).toBe(5);
+ });
+
+ it('should return the correct day number for day 28', () => {
+ const day28 = formatUTC(subDays(new Date(), 27));
+ expect(calculateTrialDayNumber(day28)).toBe(28);
+ });
+ });
+
+ describe('calculateRemainingTrialSeconds', () => {
+ it('should return 0 when lastDayFreeTrial is undefined', () => {
+ expect(calculateRemainingTrialSeconds(undefined)).toBe(0);
+ });
+
+ it('should return 0 when the trial has already ended', () => {
+ const pastDate = formatUTC(subDays(new Date(), 1));
+ expect(calculateRemainingTrialSeconds(pastDate)).toBe(0);
+ });
+
+ it('should return a positive number when the trial is still active', () => {
+ const futureDate = formatUTC(addDays(new Date(), 1));
+ expect(calculateRemainingTrialSeconds(futureDate)).toBeGreaterThan(0);
+ });
+
+ it('should return approximately 3600 seconds when trial ends in 1 hour', () => {
+ const oneHourFromNow = formatUTC(new Date(Date.now() + 3600 * 1000));
+ const remaining = calculateRemainingTrialSeconds(oneHourFromNow);
+ // Allow 5 seconds tolerance for test execution time
+ expect(remaining).toBeGreaterThanOrEqual(3595);
+ expect(remaining).toBeLessThanOrEqual(3600);
+ });
+
+ it('should return approximately 86400 seconds when trial ends in 24 hours', () => {
+ const oneDayFromNow = formatUTC(new Date(Date.now() + 86400 * 1000));
+ const remaining = calculateRemainingTrialSeconds(oneDayFromNow);
+ // Allow 5 seconds tolerance for test execution time
+ expect(remaining).toBeGreaterThanOrEqual(86395);
+ expect(remaining).toBeLessThanOrEqual(86400);
+ });
+ });
+
describe('shouldShowTrialEndedUI', () => {
const ownerAccountID = 345;
const policyID = '200012';