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 99292ed33231..6674a089de9a 100644
--- a/src/CONST/index.ts
+++ b/src/CONST/index.ts
@@ -6825,6 +6825,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 2f48f53a9987..ed77f5dfbf27 100644
--- a/src/GlobalModals.tsx
+++ b/src/GlobalModals.tsx
@@ -11,6 +11,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.
@@ -67,6 +68,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 ab1332db0b2d..71c76ba11955 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -278,6 +278,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',
@@ -1535,6 +1538,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 f3d5ed5ccb6e..f264fe895ab5 100644
--- a/src/components/Icon/chunks/illustrations.chunk.ts
+++ b/src/components/Icon/chunks/illustrations.chunk.ts
@@ -57,6 +57,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';
@@ -255,6 +256,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..9677e2aad6df
--- /dev/null
+++ b/src/components/TrialPaymentReminderModal.tsx
@@ -0,0 +1,95 @@
+import React from 'react';
+import {View} from 'react-native';
+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 colors from '@styles/theme/colors';
+import CONST from '@src/CONST';
+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}
+ >
+
+
+
+
+ {variant === CONST.TRIAL_REMINDER_VARIANT.NEAR_END && daysRemaining !== undefined && (
+ {translate('trialPaymentReminder.trialEndsInDays', {days: 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..2f2941d0343a
--- /dev/null
+++ b/src/components/TrialPaymentReminderModalManager.tsx
@@ -0,0 +1,50 @@
+import React, {useCallback, useState} from 'react';
+import useOnyx from '@hooks/useOnyx';
+import useTrialPaymentReminder from '@hooks/useTrialPaymentReminder';
+import Navigation from '@libs/Navigation/Navigation';
+import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+import TrialPaymentReminderModal from './TrialPaymentReminderModal';
+
+function TrialPaymentReminderModalManager() {
+ const {isEligibleToShow, currentVariation, countdownTime, dismiss} = useTrialPaymentReminder();
+ const [modal] = useOnyx(ONYXKEYS.MODAL);
+ const [isModalOpen, setIsModalOpen] = useState(false);
+
+ const isOtherModalActive = !!modal?.isVisible || !!modal?.willAlertModalBecomeVisible;
+
+ if (isEligibleToShow && !isOtherModalActive && !isModalOpen) {
+ setIsModalOpen(true);
+ }
+ if (!isEligibleToShow && isModalOpen) {
+ setIsModalOpen(false);
+ }
+
+ const handleClose = useCallback(() => {
+ setIsModalOpen(false);
+ dismiss();
+ }, [dismiss]);
+
+ const handleAddPaymentCard = useCallback(() => {
+ setIsModalOpen(false);
+ dismiss();
+ Navigation.navigate(ROUTES.SETTINGS_SUBSCRIPTION_ADD_PAYMENT_CARD);
+ }, [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..84c1c9b070e2
--- /dev/null
+++ b/src/hooks/useTrialPaymentReminder.ts
@@ -0,0 +1,247 @@
+import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
+import type {ValueOf} from 'type-fest';
+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 useOnyx from './useOnyx';
+
+const FIVE_MINUTES_MS = 5 * 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 is missing/invalid or the grace window has already elapsed.
+ */
+function getTrialStartupGraceRemainingMs(firstDayFreeTrial: string | undefined): number {
+ if (!firstDayFreeTrial) {
+ return 0;
+ }
+ 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 useTrialPaymentReminder() {
+ const [firstDayFreeTrial, firstDayFreeTrialResult] = useOnyx(ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL);
+ const [lastDayFreeTrial] = useOnyx(ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL);
+ const [billingFundID] = 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);
+ }
+ }, [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) {
+ return;
+ }
+ const timer = setTimeout(() => setReadinessState(READINESS_STATE.READY), getTrialStartupGraceRemainingMs(firstDayFreeTrial));
+ return () => clearTimeout(timer);
+ }, [readinessState, firstDayFreeTrial]);
+
+ const remainingSecondsRef = useRef(0);
+
+ const [countdownTime, setCountdownTime] = useState({hours: 0, minutes: 0, seconds: 0});
+
+ const currentVariation = useMemo(() => computeCurrentVariation(firstDayFreeTrial, lastDayFreeTrial), [firstDayFreeTrial, lastDayFreeTrial]);
+
+ // Run countdown timer when variant is 'countdown'
+ useEffect(() => {
+ if (currentVariation?.variant !== CONST.TRIAL_REMINDER_VARIANT.COUNTDOWN) {
+ return;
+ }
+
+ remainingSecondsRef.current = calculateRemainingTrialSeconds(lastDayFreeTrial);
+
+ const updateCountdown = () => {
+ remainingSecondsRef.current -= 1;
+ const secs = remainingSecondsRef.current;
+ 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);
+ }, [currentVariation?.variant, lastDayFreeTrial]);
+
+ const isEligibleToShow = useMemo(() => {
+ if (!isUserOnFreeTrial(firstDayFreeTrial, lastDayFreeTrial)) {
+ 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, readinessState, currentVariation, dismissedTimestamp, dismissedTimestampResult]);
+
+ 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 15267c184690..5a7809025d0c 100644
--- a/src/languages/de.ts
+++ b/src/languages/de.ts
@@ -9357,6 +9357,14 @@ 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: ({days}: {days: number}) => `Testphase endet in ${days} ${days === 1 ? 'Tag' : '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'},
};
export default translations;
diff --git a/src/languages/en.ts b/src/languages/en.ts
index a9d6b6dec137..a1c3a5bcf3f2 100644
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -4287,6 +4287,14 @@ 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: ({days}: {days: number}) => `Trial ends in ${days} ${days === 1 ? 'day' : '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 ce013eef9e8e..8ee565566a5a 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -4090,6 +4090,14 @@ ${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: ({days}: {days: number}) => `La prueba termina en ${days} ${days === 1 ? 'día' : '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 b088ec0e078b..671f72036131 100644
--- a/src/languages/fr.ts
+++ b/src/languages/fr.ts
@@ -9382,6 +9382,14 @@ 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: ({days}: {days: number}) => `La période d'essai se termine dans ${days} ${days === 1 ? 'jour' : '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'},
};
export default translations;
diff --git a/src/languages/it.ts b/src/languages/it.ts
index 9e855aaf8b09..8866a12e552f 100644
--- a/src/languages/it.ts
+++ b/src/languages/it.ts
@@ -9349,6 +9349,14 @@ 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: ({days}: {days: number}) => `Il periodo di prova termina tra ${days} ${days === 1 ? 'giorno' : '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'},
};
export default translations;
diff --git a/src/languages/ja.ts b/src/languages/ja.ts
index 0581b7bced4f..8247c5336122 100644
--- a/src/languages/ja.ts
+++ b/src/languages/ja.ts
@@ -9229,6 +9229,14 @@ ${reportName}
positiveButton: 'やった!',
negativeButton: 'そうでもありません',
},
+ trialPaymentReminder: {
+ title: '期限に遅れないようにしましょう',
+ subtitle: 'ぎりぎりまで待たずに、今日お支払い方法を追加して、Expensify での経費への継続的なアクセスを確保しましょう。',
+ trialEndsInDays: ({days}: {days: number}) => `トライアル終了まであと${days}日`,
+ trialEndsCountdown: ({hours, minutes, seconds}: {hours: string; minutes: string; seconds: string}) => `トライアル終了まで ${hours}時間 : ${minutes}分 : ${seconds}秒`,
+ closeButton: '閉じる',
+ addPaymentCardButton: '支払いカードを追加',
+ },
monthPickerPage: {month: '月', selectMonth: '月を選択してください'},
};
export default translations;
diff --git a/src/languages/nl.ts b/src/languages/nl.ts
index 452828ad9d8d..4447d5a2cfd4 100644
--- a/src/languages/nl.ts
+++ b/src/languages/nl.ts
@@ -9316,6 +9316,14 @@ 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: ({days}: {days: number}) => `Proefperiode eindigt over ${days} ${days === 1 ? 'dag' : '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'},
};
export default translations;
diff --git a/src/languages/pl.ts b/src/languages/pl.ts
index 512c68f6b0e0..715544f69ec3 100644
--- a/src/languages/pl.ts
+++ b/src/languages/pl.ts
@@ -9299,6 +9299,19 @@ 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: ({days}: {days: number}) => {
+ if (days === 1) {
+ return 'Okres próbny kończy się za 1 dzień';
+ }
+ return `Okres próbny kończy się za ${days} 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'},
};
export default translations;
diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts
index a318d25ca7cf..4cb73fec8d25 100644
--- a/src/languages/pt-BR.ts
+++ b/src/languages/pt-BR.ts
@@ -9309,6 +9309,14 @@ 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: ({days}: {days: number}) => `O período de teste termina em ${days} ${days === 1 ? 'dia' : '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'},
};
export default translations;
diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts
index 3635b52d2046..2993a15154a0 100644
--- a/src/languages/zh-hans.ts
+++ b/src/languages/zh-hans.ts
@@ -9069,6 +9069,14 @@ ${reportName}
},
},
proactiveAppReview: {title: '喜欢全新的 Expensify 吗?', description: '请告诉我们,这样我们就能帮助您让报销体验变得更好。', positiveButton: '太棒了!', negativeButton: '不太是'},
+ trialPaymentReminder: {
+ title: '提前做好准备',
+ subtitle: '不要等到最后一刻,立即添加您的付款方式,以确保您可以持续访问 Expensify 上的费用。',
+ trialEndsInDays: ({days}: {days: number}) => `试用期将在${days}天后结束`,
+ trialEndsCountdown: ({hours, minutes, seconds}: {hours: string; minutes: string; seconds: string}) => `试用期将在 ${hours}小时 : ${minutes}分 : ${seconds}秒 后结束`,
+ closeButton: '关闭',
+ addPaymentCardButton: '添加付款卡',
+ },
monthPickerPage: {month: '月份', selectMonth: '请选择月份'},
};
export default translations;
diff --git a/src/libs/SubscriptionUtils.ts b/src/libs/SubscriptionUtils.ts
index 87e2ba49476b..0760d1f0a35b 100644
--- a/src/libs/SubscriptionUtils.ts
+++ b/src/libs/SubscriptionUtils.ts
@@ -634,6 +634,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,
@@ -659,6 +689,8 @@ export {
shouldUseSimplifiedCollectSubscriptionUI,
shouldShowTrialEndedUI,
isSubscriptionTypeOfInvoicing,
+ calculateTrialDayNumber,
+ calculateRemainingTrialSeconds,
hasInsufficientFundsError,
};
diff --git a/src/styles/utils/spacing.ts b/src/styles/utils/spacing.ts
index 18b4fd30e331..08867fc8789f 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 97d0486830fb..76742b50f92b 100644
--- a/tests/unit/SubscriptionUtilsTest.ts
+++ b/tests/unit/SubscriptionUtilsTest.ts
@@ -5,6 +5,8 @@ import type {OnyxEntry} from 'react-native-onyx';
import type {LocalizedTranslate} from '@components/LocaleContextProvider';
import {
calculateRemainingFreeTrialDays,
+ calculateRemainingTrialSeconds,
+ calculateTrialDayNumber,
canCancelSubscription,
doesUserHavePaymentCardAdded,
getEarlyDiscountInfo,
@@ -1438,6 +1440,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';