diff --git a/Mobile-Expensify b/Mobile-Expensify
index ba917e414acc0..1e7de7f2db5b6 160000
--- a/Mobile-Expensify
+++ b/Mobile-Expensify
@@ -1 +1 @@
-Subproject commit ba917e414acc0b69f3740eb92db702eb5e1d2b2e
+Subproject commit 1e7de7f2db5b65847fa9f5090b784298c1974b54
diff --git a/assets/images/simple-illustrations/simple-illustration__calendar-monthly.svg b/assets/images/simple-illustrations/simple-illustration__calendar-monthly.svg
new file mode 100644
index 0000000000000..906bd7ae4564d
--- /dev/null
+++ b/assets/images/simple-illustrations/simple-illustration__calendar-monthly.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/images/simple-illustrations/simple-illustration__fastmoney.svg b/assets/images/simple-illustrations/simple-illustration__fastmoney.svg
new file mode 100644
index 0000000000000..d479400e1830d
--- /dev/null
+++ b/assets/images/simple-illustrations/simple-illustration__fastmoney.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/CONST/index.ts b/src/CONST/index.ts
index fbde212ff5430..7aa056864273c 100755
--- a/src/CONST/index.ts
+++ b/src/CONST/index.ts
@@ -7758,6 +7758,11 @@ const CONST = {
REFUND: 'REFUND',
EXCHANGE: 'EXCHANGE',
},
+ /**
+ * The Travel Invoicing feed type constant.
+ * This feed is used for Travel Invoicing cards which are separate from regular Expensify Cards.
+ */
+ PROGRAM_TRAVEL_US: 'TRAVEL_US',
},
LAST_PAYMENT_METHOD: {
LAST_USED: 'lastUsed',
diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts
index 84cd803da8db0..65851e0641676 100644
--- a/src/components/Icon/Illustrations.ts
+++ b/src/components/Icon/Illustrations.ts
@@ -13,6 +13,7 @@ import Approval from '@assets/images/simple-illustrations/simple-illustration__a
import Binoculars from '@assets/images/simple-illustrations/simple-illustration__binoculars.svg';
import BlueShield from '@assets/images/simple-illustrations/simple-illustration__blueshield.svg';
import Buildings from '@assets/images/simple-illustrations/simple-illustration__buildings.svg';
+import CalendarMonthly from '@assets/images/simple-illustrations/simple-illustration__calendar-monthly.svg';
import CarIce from '@assets/images/simple-illustrations/simple-illustration__car-ice.svg';
import Car from '@assets/images/simple-illustrations/simple-illustration__car.svg';
import ChatBubbles from '@assets/images/simple-illustrations/simple-illustration__chatbubbles.svg';
@@ -27,6 +28,7 @@ import EmailAddress from '@assets/images/simple-illustrations/simple-illustratio
import EmptyShelves from '@assets/images/simple-illustrations/simple-illustration__empty-shelves.svg';
import Encryption from '@assets/images/simple-illustrations/simple-illustration__encryption.svg';
import EnvelopeReceipt from '@assets/images/simple-illustrations/simple-illustration__envelopereceipt.svg';
+import FastMoney from '@assets/images/simple-illustrations/simple-illustration__fastmoney.svg';
import Filters from '@assets/images/simple-illustrations/simple-illustration__filters.svg';
import Flash from '@assets/images/simple-illustrations/simple-illustration__flash.svg';
import Gears from '@assets/images/simple-illustrations/simple-illustration__gears.svg';
@@ -45,49 +47,51 @@ import ExpensifyApprovedLogo from '@assets/images/subscription-details__approved
import TurtleInShell from '@assets/images/turtle-in-shell.svg';
export {
- Encryption,
+ Abacus,
+ Alert,
+ Approval,
+ Binoculars,
+ BlueShield,
+ Buildings,
+ CalendarMonthly,
+ Car,
+ CarIce,
ChatBubbles,
- Computer,
+ CheckmarkCircle,
Clock,
+ CommentBubbles,
+ Computer,
+ ConciergeBot,
+ ConciergeBubble,
+ CreditCardEyes,
+ CreditCardsNewGreen,
EmailAddress,
EmptyCardState,
+ EmptyShelves,
+ EmptyStateTravel,
+ Encryption,
EnvelopeReceipt,
+ ExpensifyApprovedLogo,
ExpensifyCardImage,
- Mailbox,
- CreditCardsNewGreen,
+ FastMoney,
+ Filters,
+ Flash,
+ Gears,
+ HeadSet,
+ Hourglass,
+ House,
LaptopWithSecondScreenAndHourglass,
LaptopWithSecondScreenSync,
LaptopWithSecondScreenX,
+ Lightbulb,
+ LockClosed,
+ LockClosedOrange,
LockOpen,
Luggage,
MagnifyingGlassReceipt,
- ConciergeBot,
- ConciergeBubble,
- HeadSet,
- Hourglass,
- CommentBubbles,
- Puzzle,
- LockClosed,
- Gears,
- Approval,
- House,
- Buildings,
- Alert,
- Abacus,
- Binoculars,
- Car,
+ Mailbox,
Pencil,
- CarIce,
- Lightbulb,
- ExpensifyApprovedLogo,
- CheckmarkCircle,
- CreditCardEyes,
- LockClosedOrange,
- Filters,
- TurtleInShell,
- Flash,
PendingTravel,
- EmptyStateTravel,
- EmptyShelves,
- BlueShield,
+ Puzzle,
+ TurtleInShell,
};
diff --git a/src/components/Icon/chunks/illustrations.chunk.ts b/src/components/Icon/chunks/illustrations.chunk.ts
index 304f6e0cf85cf..f86a9b9bd84a9 100644
--- a/src/components/Icon/chunks/illustrations.chunk.ts
+++ b/src/components/Icon/chunks/illustrations.chunk.ts
@@ -95,6 +95,7 @@ import Binoculars from '@assets/images/simple-illustrations/simple-illustration_
import BlueShield from '@assets/images/simple-illustrations/simple-illustration__blueshield.svg';
import Building from '@assets/images/simple-illustrations/simple-illustration__building.svg';
import Buildings from '@assets/images/simple-illustrations/simple-illustration__buildings.svg';
+import CalendarMonthly from '@assets/images/simple-illustrations/simple-illustration__calendar-monthly.svg';
import CarIce from '@assets/images/simple-illustrations/simple-illustration__car-ice.svg';
import Car from '@assets/images/simple-illustrations/simple-illustration__car.svg';
import ChatBubbles from '@assets/images/simple-illustrations/simple-illustration__chatbubbles.svg';
@@ -110,6 +111,7 @@ import EmailAddress from '@assets/images/simple-illustrations/simple-illustratio
import EmptyShelves from '@assets/images/simple-illustrations/simple-illustration__empty-shelves.svg';
import Encryption from '@assets/images/simple-illustrations/simple-illustration__encryption.svg';
import EnvelopeReceipt from '@assets/images/simple-illustrations/simple-illustration__envelopereceipt.svg';
+import FastMoney from '@assets/images/simple-illustrations/simple-illustration__fastmoney.svg';
import Filters from '@assets/images/simple-illustrations/simple-illustration__filters.svg';
import Flash from '@assets/images/simple-illustrations/simple-illustration__flash.svg';
import FolderOpen from '@assets/images/simple-illustrations/simple-illustration__folder-open.svg';
@@ -309,6 +311,7 @@ const Illustrations = {
Approval,
Binoculars,
Buildings,
+ CalendarMonthly,
Car,
ChatBubbles,
CheckmarkCircle,
@@ -320,6 +323,7 @@ const Illustrations = {
EmptyShelves,
Encryption,
EnvelopeReceipt,
+ FastMoney,
Filters,
Flash,
Gears,
diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx
index f329b959c1661..2e19dadd9d1f3 100644
--- a/src/components/MenuItem.tsx
+++ b/src/components/MenuItem.tsx
@@ -400,6 +400,9 @@ type MenuItemBaseProps = ForwardedFSClassProps &
/** Whether the screen containing the item is focused */
isFocused?: boolean;
+ /** Additional styles for the root wrapper View */
+ rootWrapperStyle?: StyleProp;
+
/** The accessibility role to use for this menu item */
role?: Role;
@@ -546,6 +549,7 @@ function MenuItem({
ref,
isFocused,
sentryLabel,
+ rootWrapperStyle,
role = CONST.ROLE.MENUITEM,
shouldBeAccessible = true,
tabIndex = 0,
@@ -700,7 +704,10 @@ function MenuItem({
const isIDPassed = !!iconReportID || !!iconAccountID || iconAccountID === CONST.DEFAULT_NUMBER_ID;
return (
-
+
{!!label && !isLabelHoverable && (
{label}
diff --git a/src/languages/de.ts b/src/languages/de.ts
index addc4c2ab3ac7..67e2631356cb9 100644
--- a/src/languages/de.ts
+++ b/src/languages/de.ts
@@ -5039,6 +5039,25 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU
subtitle: 'Nutzen Sie Expensify Travel für die besten Reiseangebote und verwalten Sie alle Ihre Geschäftsausgaben an einem Ort.',
ctaText: 'Buchen oder verwalten',
},
+ travelInvoicing: {
+ travelBookingSection: {
+ title: 'Reisebuchung',
+ subtitle: 'Glückwunsch! Du kannst jetzt in diesem Workspace Reisen buchen und verwalten.',
+ manageTravelLabel: 'Reisen verwalten',
+ },
+ centralInvoicingSection: {
+ title: 'Zentrale Rechnungsstellung',
+ subtitle: 'Zentralisieren Sie alle Reisekosten in einer monatlichen Rechnung, anstatt zum Zeitpunkt des Kaufs zu bezahlen.',
+ learnHow: "So funktioniert's.",
+ subsections: {
+ currentTravelSpendLabel: 'Aktuelle Reisekosten',
+ currentTravelSpendCta: 'Saldo bezahlen',
+ currentTravelLimitLabel: 'Aktuelles Reiselimit',
+ settlementAccountLabel: 'Ausgleichskonto',
+ settlementFrequencyLabel: 'Abrechnungshäufigkeit',
+ },
+ },
+ },
},
expensifyCard: {
title: 'Expensify Card',
diff --git a/src/languages/en.ts b/src/languages/en.ts
index 812ca9d78cf5d..2ad9bfc0b43ae 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -4945,6 +4945,25 @@ const translations = {
subtitle: 'Use Expensify Travel to get the best travel offers and manage all your business expenses in a single place.',
ctaText: 'Book or manage',
},
+ travelInvoicing: {
+ travelBookingSection: {
+ title: 'Travel booking',
+ subtitle: "Congrats! You're all set to book and manage travel on this workspace.",
+ manageTravelLabel: 'Manage travel',
+ },
+ centralInvoicingSection: {
+ title: 'Central invoicing',
+ subtitle: 'Centralize all travel spend in a monthly invoice instead of paying at time of purchase.',
+ learnHow: 'Learn how.',
+ subsections: {
+ currentTravelSpendLabel: 'Current travel spend',
+ currentTravelSpendCta: 'Pay balance',
+ currentTravelLimitLabel: 'Current travel limit',
+ settlementAccountLabel: 'Settlement account',
+ settlementFrequencyLabel: 'Settlement frequency',
+ },
+ },
+ },
},
expensifyCard: {
title: 'Expensify Card',
diff --git a/src/languages/es.ts b/src/languages/es.ts
index fcb04c9aaec04..c89e1ed8ce5e8 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -4694,6 +4694,25 @@ ${amount} para ${merchant} - ${date}`,
subtitle: 'Usa Expensify Travel para obtener las mejores ofertas de viaje y gestionar todos tus gastos de empresa en un solo lugar.',
ctaText: 'Reservar o gestionar',
},
+ travelInvoicing: {
+ travelBookingSection: {
+ title: 'Reserva de viajes',
+ subtitle: '¡Felicidades! Todo está listo para reservar y gestionar viajes en este espacio de trabajo.',
+ manageTravelLabel: 'Gestionar viajes',
+ },
+ centralInvoicingSection: {
+ title: 'Facturación centralizada',
+ subtitle: 'Centraliza todos los gastos de viaje en una factura mensual en lugar de pagar en el momento de la compra.',
+ learnHow: 'Aprende cómo.',
+ subsections: {
+ currentTravelSpendLabel: 'Gasto actual en viajes',
+ currentTravelSpendCta: 'Pagar saldo',
+ currentTravelLimitLabel: 'Límite actual de viajes',
+ settlementAccountLabel: 'Cuenta de liquidación',
+ settlementFrequencyLabel: 'Frecuencia de liquidación',
+ },
+ },
+ },
},
expensifyCard: {
title: 'Tarjeta Expensify',
diff --git a/src/languages/fr.ts b/src/languages/fr.ts
index 74da4330dfe6c..34625e563998a 100644
--- a/src/languages/fr.ts
+++ b/src/languages/fr.ts
@@ -5046,6 +5046,25 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST.
subtitle: 'Utilisez Expensify Travel pour obtenir les meilleures offres de voyage et gérez toutes vos dépenses professionnelles en un seul endroit.',
ctaText: 'Réserver ou gérer',
},
+ travelInvoicing: {
+ travelBookingSection: {
+ title: 'Réservation de voyage',
+ subtitle: 'Félicitations ! Vous êtes prêt à réserver et gérer les déplacements sur cet espace de travail.',
+ manageTravelLabel: 'Gérer les déplacements',
+ },
+ centralInvoicingSection: {
+ title: 'Facturation centralisée',
+ subtitle: 'Centralisez toutes les dépenses de voyage dans une facture mensuelle plutôt que de payer au moment de l’achat.',
+ learnHow: 'Découvrez comment.',
+ subsections: {
+ currentTravelSpendLabel: 'Dépenses de voyage actuelles',
+ currentTravelSpendCta: 'Payer le solde',
+ currentTravelLimitLabel: 'Limite de déplacement actuelle',
+ settlementAccountLabel: 'Compte de règlement',
+ settlementFrequencyLabel: 'Fréquence de règlement',
+ },
+ },
+ },
},
expensifyCard: {
title: 'Carte Expensify',
diff --git a/src/languages/it.ts b/src/languages/it.ts
index 3f8298ef71ec0..0a09f60c444ad 100644
--- a/src/languages/it.ts
+++ b/src/languages/it.ts
@@ -5026,6 +5026,25 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST.
subtitle: 'Usa Expensify Travel per ottenere le migliori offerte di viaggio e gestisci tutte le tue spese aziendali in un unico posto.',
ctaText: 'Prenota o gestisci',
},
+ travelInvoicing: {
+ travelBookingSection: {
+ title: 'Prenotazione di viaggio',
+ subtitle: 'Complimenti! Ora sei pronto per prenotare e gestire i viaggi in questo spazio di lavoro.',
+ manageTravelLabel: 'Gestisci viaggi',
+ },
+ centralInvoicingSection: {
+ title: 'Fatturazione centralizzata',
+ subtitle: 'Centralizza tutte le spese di viaggio in una fattura mensile invece di pagare al momento dell’acquisto.',
+ learnHow: 'Scopri come.',
+ subsections: {
+ currentTravelSpendLabel: 'Spesa di viaggio attuale',
+ currentTravelSpendCta: 'Paga saldo',
+ currentTravelLimitLabel: 'Limite di viaggio attuale',
+ settlementAccountLabel: 'Conto di regolamento',
+ settlementFrequencyLabel: 'Frequenza di regolamento',
+ },
+ },
+ },
},
expensifyCard: {
title: 'Carta Expensify',
diff --git a/src/languages/ja.ts b/src/languages/ja.ts
index 4cf69368bde3a..86a0ded9a8a76 100644
--- a/src/languages/ja.ts
+++ b/src/languages/ja.ts
@@ -4996,6 +4996,25 @@ _より詳しい手順については、[ヘルプサイトをご覧ください
subtitle: 'Expensify Travelを使用して最高の旅行オファーを取得し、すべてのビジネス経費を一箇所で管理します。',
ctaText: '予約または管理',
},
+ travelInvoicing: {
+ travelBookingSection: {
+ title: '出張予約',
+ subtitle: 'おめでとうございます!このワークスペースで旅行の予約と管理を行う準備が整いました。',
+ manageTravelLabel: '出張を管理',
+ },
+ centralInvoicingSection: {
+ title: '中央請求書管理',
+ subtitle: 'すべての出張費を購入時に都度支払うのではなく、月次請求書にまとめて管理しましょう。',
+ learnHow: '詳しく見る。',
+ subsections: {
+ currentTravelSpendLabel: '現在の出張費用',
+ currentTravelSpendCta: '残高を支払う',
+ currentTravelLimitLabel: '現在の出張上限',
+ settlementAccountLabel: '決済口座',
+ settlementFrequencyLabel: '清算頻度',
+ },
+ },
+ },
},
expensifyCard: {
title: 'Expensify Card',
diff --git a/src/languages/nl.ts b/src/languages/nl.ts
index 66dd298b8cf7c..5f7d88b92c28e 100644
--- a/src/languages/nl.ts
+++ b/src/languages/nl.ts
@@ -5021,6 +5021,21 @@ _Voor gedetailleerdere instructies, [bezoek onze helpsite](${CONST.NETSUITE_IMPO
subtitle: 'Gebruik Expensify Travel om de beste reisaanbiedingen te krijgen en beheer al uw zakelijke uitgaven op één plek.',
ctaText: 'Boeken of beheren',
},
+ travelInvoicing: {
+ travelBookingSection: {title: 'Reisboeking', subtitle: 'Gefeliciteerd! Je kunt nu reizen boeken en beheren in deze werkruimte.', manageTravelLabel: 'Reizen beheren'},
+ centralInvoicingSection: {
+ title: 'Centrale facturatie',
+ subtitle: 'Centraliseer alle reiskosten in één maandelijkse factuur in plaats van bij aankoop te betalen.',
+ learnHow: 'Meer informatie.',
+ subsections: {
+ currentTravelSpendLabel: 'Huidige reiskosten',
+ currentTravelSpendCta: 'Saldo betalen',
+ currentTravelLimitLabel: 'Huidige reislimoet',
+ settlementAccountLabel: 'Afwikkelingsrekening',
+ settlementFrequencyLabel: 'Frequentie van afwikkeling',
+ },
+ },
+ },
},
expensifyCard: {
title: 'Expensify Card',
diff --git a/src/languages/pl.ts b/src/languages/pl.ts
index 6fe3f906cc618..354c4a39ad14b 100644
--- a/src/languages/pl.ts
+++ b/src/languages/pl.ts
@@ -5009,6 +5009,25 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy
subtitle: 'Użyj Expensify Travel, aby uzyskać najlepsze oferty podróży i zarządzaj wszystkimi wydatkami służbowymi w jednym miejscu.',
ctaText: 'Rezerwuj lub zarządzaj',
},
+ travelInvoicing: {
+ travelBookingSection: {
+ title: 'Rezerwacja podróży',
+ subtitle: 'Gratulacje! Wszystko gotowe, aby rezerwować i zarządzać podróżami w tym obszarze roboczym.',
+ manageTravelLabel: 'Zarządzaj podróżami',
+ },
+ centralInvoicingSection: {
+ title: 'Centralne fakturowanie',
+ subtitle: 'Scentralizuj wszystkie wydatki na podróże w miesięcznej fakturze zamiast płacić w momencie zakupu.',
+ learnHow: 'Dowiedz się jak.',
+ subsections: {
+ currentTravelSpendLabel: 'Aktualne wydatki na podróże',
+ currentTravelSpendCta: 'Zapłać saldo',
+ currentTravelLimitLabel: 'Obecny limit podróży',
+ settlementAccountLabel: 'Konto rozliczeniowe',
+ settlementFrequencyLabel: 'Częstotliwość rozliczeń',
+ },
+ },
+ },
},
expensifyCard: {
title: 'Karta Expensify',
diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts
index 6270d4f514422..cf2fb5ce0e85f 100644
--- a/src/languages/pt-BR.ts
+++ b/src/languages/pt-BR.ts
@@ -5009,6 +5009,25 @@ _Para instruções mais detalhadas, [visite nosso site de ajuda](${CONST.NETSUIT
subtitle: 'Use o Expensify Travel para obter as melhores ofertas de viagem e gerencie todas as suas despesas comerciais em um só lugar.',
ctaText: 'Reservar ou gerenciar',
},
+ travelInvoicing: {
+ travelBookingSection: {
+ title: 'Reserva de viagem',
+ subtitle: 'Parabéns! Agora você está pronto para reservar e gerenciar viagens neste workspace.',
+ manageTravelLabel: 'Gerenciar viagens',
+ },
+ centralInvoicingSection: {
+ title: 'Faturamento centralizado',
+ subtitle: 'Centralize todos os gastos de viagem em uma fatura mensal em vez de pagar no momento da compra.',
+ learnHow: 'Saiba como.',
+ subsections: {
+ currentTravelSpendLabel: 'Gasto atual com viagens',
+ currentTravelSpendCta: 'Pagar saldo',
+ currentTravelLimitLabel: 'Limite de viagem atual',
+ settlementAccountLabel: 'Conta de liquidação',
+ settlementFrequencyLabel: 'Frequência de liquidação',
+ },
+ },
+ },
},
expensifyCard: {
title: 'Cartão Expensify',
diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts
index c9fa349a5410d..cf90403b8bdf9 100644
--- a/src/languages/zh-hans.ts
+++ b/src/languages/zh-hans.ts
@@ -4919,6 +4919,21 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM
subtitle: '使用 Expensify Travel 获得最佳旅行优惠,并在一个地方管理所有商务费用。',
ctaText: '预订或管理',
},
+ travelInvoicing: {
+ travelBookingSection: {title: '旅行预订', subtitle: '恭喜!您现在可以在此工作区预订和管理差旅了。', manageTravelLabel: '管理差旅'},
+ centralInvoicingSection: {
+ title: '集中开票',
+ subtitle: '将所有差旅支出集中到月度发票中,而不是在购买时逐笔付款。',
+ learnHow: '了解如何操作。',
+ subsections: {
+ currentTravelSpendLabel: '当前差旅行支出',
+ currentTravelSpendCta: '支付余额',
+ currentTravelLimitLabel: '当前出差限额',
+ settlementAccountLabel: '结算账户',
+ settlementFrequencyLabel: '结算频率',
+ },
+ },
+ },
},
expensifyCard: {
title: 'Expensify Card',
diff --git a/src/libs/API/parameters/OpenPolicyTravelPageParams.ts b/src/libs/API/parameters/OpenPolicyTravelPageParams.ts
new file mode 100644
index 0000000000000..24b2d79b2fff4
--- /dev/null
+++ b/src/libs/API/parameters/OpenPolicyTravelPageParams.ts
@@ -0,0 +1,5 @@
+type OpenPolicyTravelPageParams = {
+ policyID: string;
+};
+
+export default OpenPolicyTravelPageParams;
diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts
index b65ee965c5255..d5e1b7e63f185 100644
--- a/src/libs/API/parameters/index.ts
+++ b/src/libs/API/parameters/index.ts
@@ -308,6 +308,7 @@ export type {default as EnablePolicyInvoicingParams} from './EnablePolicyInvoici
export type {default as CreateWorkspaceReportFieldListValueParams} from './CreateWorkspaceReportFieldListValueParams';
export type {default as RemoveWorkspaceReportFieldListValueParams} from './RemoveWorkspaceReportFieldListValueParams';
export type {default as OpenPolicyExpensifyCardsPageParams} from './OpenPolicyExpensifyCardsPageParams';
+export type {default as OpenPolicyTravelPageParams} from './OpenPolicyTravelPageParams';
export type {default as OpenPolicyEditCardLimitTypePageParams} from './OpenPolicyEditCardLimitTypePageParams';
export type {default as RequestExpensifyCardLimitIncreaseParams} from './RequestExpensifyCardLimitIncreaseParams';
export type {default as UpdateNetSuiteGenericTypeParams} from './UpdateNetSuiteGenericTypeParams';
diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts
index fc338163b81fc..68781b4ae8a56 100644
--- a/src/libs/API/types.ts
+++ b/src/libs/API/types.ts
@@ -1145,6 +1145,7 @@ const READ_COMMANDS = {
OPEN_POLICY_REPORT_FIELDS_PAGE: 'OpenPolicyReportFieldsPage',
OPEN_POLICY_RULES_PAGE: 'OpenPolicyRulesPage',
OPEN_POLICY_EXPENSIFY_CARDS_PAGE: 'OpenPolicyExpensifyCardsPage',
+ OPEN_POLICY_TRAVEL_PAGE: 'OpenPolicyTravelPage',
OPEN_POLICY_COMPANY_CARDS_FEED: 'OpenPolicyCompanyCardsFeed',
OPEN_ASSIGN_FEED_CARD_PAGE: 'OpenAssignFeedCardPage',
OPEN_POLICY_COMPANY_CARDS_PAGE: 'OpenPolicyCompanyCardsPage',
@@ -1237,6 +1238,7 @@ type ReadCommandParameters = {
[READ_COMMANDS.OPEN_POLICY_MORE_FEATURES_PAGE]: Parameters.OpenPolicyMoreFeaturesPageParams;
[READ_COMMANDS.OPEN_POLICY_ACCOUNTING_PAGE]: Parameters.OpenPolicyAccountingPageParams;
[READ_COMMANDS.OPEN_POLICY_EXPENSIFY_CARDS_PAGE]: Parameters.OpenPolicyExpensifyCardsPageParams;
+ [READ_COMMANDS.OPEN_POLICY_TRAVEL_PAGE]: Parameters.OpenPolicyTravelPageParams;
[READ_COMMANDS.OPEN_POLICY_COMPANY_CARDS_PAGE]: Parameters.OpenPolicyExpensifyCardsPageParams;
[READ_COMMANDS.OPEN_POLICY_COMPANY_CARDS_FEED]: Parameters.OpenPolicyCompanyCardsFeedParams;
[READ_COMMANDS.OPEN_ASSIGN_FEED_CARD_PAGE]: Parameters.OpenPolicyCompanyCardsFeedParams;
diff --git a/src/libs/TravelInvoicingUtils.ts b/src/libs/TravelInvoicingUtils.ts
new file mode 100644
index 0000000000000..6e31243e7c58d
--- /dev/null
+++ b/src/libs/TravelInvoicingUtils.ts
@@ -0,0 +1,91 @@
+import type {OnyxEntry} from 'react-native-onyx';
+import CONST from '@src/CONST';
+import type {BankAccountList} from '@src/types/onyx';
+import type ExpensifyCardSettings from '@src/types/onyx/ExpensifyCardSettings';
+import {getLastFourDigits} from './BankAccountUtils';
+
+/**
+ * Checks whether Travel Invoicing is enabled based on the card settings.
+ * Travel Invoicing is considered enabled if the PROGRAM_TRAVEL_US feed has a valid paymentBankAccountID.
+ */
+function getIsTravelInvoicingEnabled(cardSettings: OnyxEntry): boolean {
+ if (!cardSettings) {
+ return false;
+ }
+ return !!cardSettings.paymentBankAccountID;
+}
+
+/**
+ * Checks if a settlement account is configured for Travel Invoicing.
+ */
+function hasTravelInvoicingSettlementAccount(cardSettings: OnyxEntry): boolean {
+ if (!cardSettings) {
+ return false;
+ }
+ return !!cardSettings.paymentBankAccountID && cardSettings.paymentBankAccountID !== CONST.DEFAULT_NUMBER_ID;
+}
+
+/**
+ * Gets the remaining limit for Travel Invoicing.
+ * Returns 0 if no settings are available.
+ */
+function getTravelLimit(cardSettings: OnyxEntry): number {
+ return cardSettings?.remainingLimit ?? 0;
+}
+
+/**
+ * Gets the current spend for Travel Invoicing.
+ * This is the sum of all posted Travel Invoicing card transactions.
+ * Returns 0 if no settings are available.
+ */
+function getTravelSpend(cardSettings: OnyxEntry): number {
+ return cardSettings?.currentBalance ?? 0;
+}
+
+type TravelSettlementAccountInfo = {
+ displayName: string;
+ last4: string;
+ bankAccountID: number;
+};
+
+/**
+ * Gets the settlement account information for Travel Invoicing.
+ * Returns undefined if no settlement account is configured.
+ */
+function getTravelSettlementAccount(cardSettings: OnyxEntry, bankAccountList: OnyxEntry): TravelSettlementAccountInfo | undefined {
+ if (!cardSettings?.paymentBankAccountID) {
+ return undefined;
+ }
+
+ const bankAccountID = cardSettings.paymentBankAccountID;
+ const bankAccountIDStr = bankAccountID.toString();
+ const bankAccount = bankAccountList?.[bankAccountIDStr];
+
+ // Use paymentBankAccountAddressName if available, else fallback to bank account data
+ const displayName = cardSettings.paymentBankAccountAddressName ?? bankAccount?.accountData?.addressName ?? '';
+
+ // Use paymentBankAccountNumber if available, else fallback to bank account data
+ const accountNumber = cardSettings.paymentBankAccountNumber ?? bankAccount?.accountData?.accountNumber ?? '';
+ const last4 = getLastFourDigits(accountNumber);
+
+ return {
+ displayName,
+ last4,
+ bankAccountID,
+ };
+}
+
+/**
+ * Gets the settlement frequency for Travel Invoicing.
+ * Returns 'daily' or 'monthly' based on whether a monthly settlement date is configured.
+ */
+function getTravelSettlementFrequency(cardSettings: OnyxEntry): string {
+ if (!cardSettings) {
+ return CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.DAILY;
+ }
+ return cardSettings.monthlySettlementDate ? CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.MONTHLY : CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.DAILY;
+}
+
+export {getIsTravelInvoicingEnabled, hasTravelInvoicingSettlementAccount, getTravelLimit, getTravelSpend, getTravelSettlementAccount, getTravelSettlementFrequency};
+
+export type {TravelSettlementAccountInfo};
diff --git a/src/libs/actions/TravelInvoicing.ts b/src/libs/actions/TravelInvoicing.ts
new file mode 100644
index 0000000000000..3f98c2a96c39e
--- /dev/null
+++ b/src/libs/actions/TravelInvoicing.ts
@@ -0,0 +1,53 @@
+import Onyx from 'react-native-onyx';
+import type {OnyxUpdate} from 'react-native-onyx';
+import * as API from '@libs/API';
+import type {OpenPolicyTravelPageParams} from '@libs/API/parameters';
+import {READ_COMMANDS} from '@libs/API/types';
+import ONYXKEYS from '@src/ONYXKEYS';
+
+/**
+ * Opens the Travel page for a policy and fetches Travel Invoicing data.
+ * Sets the isLoading state for the card settings while the API request is in flight.
+ */
+function openPolicyTravelPage(policyID: string, workspaceAccountID: number) {
+ const optimisticData: Array> = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${workspaceAccountID}`,
+ value: {
+ isLoading: true,
+ },
+ },
+ ];
+
+ const successData: Array> = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${workspaceAccountID}`,
+ value: {
+ isLoading: false,
+ },
+ },
+ ];
+
+ const failureData: Array> = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${workspaceAccountID}`,
+ value: {
+ isLoading: false,
+ },
+ },
+ ];
+
+ const params: OpenPolicyTravelPageParams = {
+ policyID,
+ };
+
+ API.read(READ_COMMANDS.OPEN_POLICY_TRAVEL_PAGE, params, {optimisticData, successData, failureData});
+}
+
+export {
+ // eslint-disable-next-line import/prefer-default-export
+ openPolicyTravelPage,
+};
diff --git a/src/pages/workspace/travel/CentralInvoicingLearnHow.tsx b/src/pages/workspace/travel/CentralInvoicingLearnHow.tsx
new file mode 100644
index 0000000000000..aa34324bea15d
--- /dev/null
+++ b/src/pages/workspace/travel/CentralInvoicingLearnHow.tsx
@@ -0,0 +1,12 @@
+import React from 'react';
+import TextLink from '@components/TextLink';
+import useLocalize from '@hooks/useLocalize';
+import CONST from '@src/CONST';
+
+function CentralInvoicingLearnHow() {
+ const {translate} = useLocalize();
+
+ return {translate('workspace.moreFeatures.travel.travelInvoicing.centralInvoicingSection.learnHow')};
+}
+
+export default CentralInvoicingLearnHow;
diff --git a/src/pages/workspace/travel/CentralInvoicingSubtitleWrapper.tsx b/src/pages/workspace/travel/CentralInvoicingSubtitleWrapper.tsx
new file mode 100644
index 0000000000000..8bd9a911c04fc
--- /dev/null
+++ b/src/pages/workspace/travel/CentralInvoicingSubtitleWrapper.tsx
@@ -0,0 +1,24 @@
+import React from 'react';
+import {View} from 'react-native';
+import Text from '@components/Text';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+
+type CentralInvoicingSubtitleWrapperProps = {
+ htmlComponent?: React.ReactNode;
+};
+
+function CentralInvoicingSubtitleWrapper({htmlComponent}: CentralInvoicingSubtitleWrapperProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+
+ return (
+
+
+ {translate('workspace.moreFeatures.travel.travelInvoicing.centralInvoicingSection.subtitle')} {htmlComponent}
+
+
+ );
+}
+
+export default CentralInvoicingSubtitleWrapper;
diff --git a/src/pages/workspace/travel/PolicyTravelPage.tsx b/src/pages/workspace/travel/PolicyTravelPage.tsx
index 2212c0c15f3d7..42221a4b2d1e6 100644
--- a/src/pages/workspace/travel/PolicyTravelPage.tsx
+++ b/src/pages/workspace/travel/PolicyTravelPage.tsx
@@ -1,4 +1,5 @@
-import React from 'react';
+import {useFocusEffect, useIsFocused} from '@react-navigation/native';
+import React, {useCallback} from 'react';
import {View} from 'react-native';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
@@ -6,11 +7,14 @@ import ScrollViewWithContext from '@components/ScrollViewWithContext';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
+import useNetwork from '@hooks/useNetwork';
import useOnyx from '@hooks/useOnyx';
import usePermissions from '@hooks/usePermissions';
import usePolicy from '@hooks/usePolicy';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useThemeStyles from '@hooks/useThemeStyles';
+import useWorkspaceAccountID from '@hooks/useWorkspaceAccountID';
+import {openPolicyTravelPage} from '@libs/actions/TravelInvoicing';
import Navigation from '@libs/Navigation/Navigation';
import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types';
@@ -22,6 +26,7 @@ import type SCREENS from '@src/SCREENS';
import BookOrManageYourTrip from './BookOrManageYourTrip';
import GetStartedTravel from './GetStartedTravel';
import ReviewingRequest from './ReviewingRequest';
+import WorkspaceTravelInvoicingSection from './WorkspaceTravelInvoicingSection';
type WorkspaceTravelPageProps = PlatformStackScreenProps;
@@ -37,15 +42,41 @@ function WorkspaceTravelPage({
const {translate} = useLocalize();
const policy = usePolicy(policyID);
const illustrations = useMemoizedLazyIllustrations(['Luggage'] as const);
+ const isTravelInvoicingEnabled = isBetaEnabled(CONST.BETAS.TRAVEL_INVOICING);
+ const workspaceAccountID = useWorkspaceAccountID(policyID);
const {login: currentUserLogin} = useCurrentUserPersonalDetails();
const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: false});
+ const fetchTravelData = useCallback(() => {
+ openPolicyTravelPage(policyID, workspaceAccountID);
+ }, [policyID, workspaceAccountID]);
+
+ const isFocused = useIsFocused();
+
+ useNetwork({
+ onReconnect: () => {
+ if (!isFocused) {
+ return;
+ }
+ fetchTravelData();
+ },
+ });
+
+ useFocusEffect(
+ useCallback(() => {
+ fetchTravelData();
+ }, [fetchTravelData]),
+ );
+
const step = getTravelStep(policy, travelSettings, isBetaEnabled(CONST.BETAS.IS_TRAVEL_VERIFIED), policies, currentUserLogin);
const mainContent = (() => {
switch (step) {
case CONST.TRAVEL.STEPS.BOOK_OR_MANAGE_YOUR_TRIP:
+ if (isTravelInvoicingEnabled) {
+ return ;
+ }
return ;
case CONST.TRAVEL.STEPS.REVIEWING_REQUEST:
return ;
diff --git a/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx b/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx
new file mode 100644
index 0000000000000..22409861b96cd
--- /dev/null
+++ b/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx
@@ -0,0 +1,200 @@
+import React, {useState} from 'react';
+import {View} from 'react-native';
+import AnimatedSubmitButton from '@components/AnimatedSubmitButton';
+import MenuItem from '@components/MenuItem';
+import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
+import Section from '@components/Section';
+import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
+import useLocalize from '@hooks/useLocalize';
+import useOnyx from '@hooks/useOnyx';
+import useSingleExecution from '@hooks/useSingleExecution';
+import useThemeStyles from '@hooks/useThemeStyles';
+import useWorkspaceAccountID from '@hooks/useWorkspaceAccountID';
+import {openExternalLink} from '@libs/actions/Link';
+import {getLastFourDigits} from '@libs/BankAccountUtils';
+import {convertToDisplayString} from '@libs/CurrencyUtils';
+import {
+ getIsTravelInvoicingEnabled,
+ getTravelLimit,
+ getTravelSettlementAccount,
+ getTravelSettlementFrequency,
+ getTravelSpend,
+ hasTravelInvoicingSettlementAccount,
+} from '@libs/TravelInvoicingUtils';
+import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow';
+import type {ToggleSettingOptionRowProps} from '@pages/workspace/workflows/ToggleSettingsOptionRow';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import BookOrManageYourTrip from './BookOrManageYourTrip';
+import CentralInvoicingLearnHow from './CentralInvoicingLearnHow';
+import CentralInvoicingSubtitleWrapper from './CentralInvoicingSubtitleWrapper';
+
+type WorkspaceTravelInvoicingSectionProps = {
+ /** The ID of the policy */
+ policyID: string;
+};
+
+/**
+ * Displays the Travel Invoicing section within the Workspace Travel page.
+ * Shows a setup CTA if Travel Invoicing is not configured, otherwise shows the settings.
+ */
+function WorkspaceTravelInvoicingSection({policyID}: WorkspaceTravelInvoicingSectionProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ const workspaceAccountID = useWorkspaceAccountID(policyID);
+ const {isExecuting, singleExecution} = useSingleExecution();
+ const icons = useMemoizedLazyExpensifyIcons(['LuggageWithLines', 'NewWindow']);
+
+ const [isCentralInvoicingEnabled, setIsCentralInvoicingEnabled] = useState(true);
+
+ // For Travel Invoicing, we use a travel-specific card settings key
+ // The format is: private_expensifyCardSettings_{workspaceAccountID}_{feedType}
+ // where feedType is PROGRAM_TRAVEL_US for Travel Invoicing
+ const [cardSettings] = useOnyx(`${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${workspaceAccountID}_${CONST.TRAVEL.PROGRAM_TRAVEL_US}`, {canBeMissing: true});
+ const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST, {canBeMissing: true});
+
+ // Use pure selectors to derive state
+ const isTravelInvoicingEnabled = getIsTravelInvoicingEnabled(cardSettings);
+ const hasSettlementAccount = hasTravelInvoicingSettlementAccount(cardSettings);
+ const travelSpend = getTravelSpend(cardSettings);
+ const travelLimit = getTravelLimit(cardSettings);
+ const settlementAccount = getTravelSettlementAccount(cardSettings, bankAccountList);
+ const settlementFrequency = getTravelSettlementFrequency(cardSettings);
+ const localizedFrequency =
+ settlementFrequency === CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.MONTHLY
+ ? translate('workspace.expensifyCard.frequency.monthly')
+ : translate('workspace.expensifyCard.frequency.daily');
+
+ // Format currency values (assuming USD for Travel Invoicing based on PROGRAM_TRAVEL_US)
+ const formattedSpend = convertToDisplayString(travelSpend, CONST.CURRENCY.USD);
+ const formattedLimit = convertToDisplayString(travelLimit, CONST.CURRENCY.USD);
+ const settlementAccountNumber = `${CONST.MASKED_PAN_PREFIX}${getLastFourDigits(settlementAccount?.last4 ?? '')}`;
+
+ const getCentralInvoicingSubtitle = () => {
+ if (!isCentralInvoicingEnabled) {
+ return } />;
+ }
+ return ;
+ };
+
+ const optionItems: ToggleSettingOptionRowProps[] = [
+ {
+ title: translate('workspace.moreFeatures.travel.travelInvoicing.centralInvoicingSection.title'),
+ subtitle: getCentralInvoicingSubtitle(),
+ switchAccessibilityLabel: translate('workspace.moreFeatures.travel.travelInvoicing.centralInvoicingSection.subtitle'),
+ isActive: isCentralInvoicingEnabled,
+ onToggle: (isEnabled: boolean) => setIsCentralInvoicingEnabled(isEnabled),
+ // pendingAction: policy?.pendingFields?.autoReporting ?? policy?.pendingFields?.autoReportingFrequency,
+ // errors: getLatestErrorField(policy ?? {}, CONST.POLICY.COLLECTION_KEYS.AUTOREPORTING),
+ // onCloseError: () => clearPolicyErrorField(route.params.policyID, CONST.POLICY.COLLECTION_KEYS.AUTOREPORTING),
+ subMenuItems: (
+ <>
+
+
+
+ {}}
+ isSubmittingAnimationRunning={false}
+ onAnimationFinish={() => {}}
+ // isSubmittingAnimationRunning={isSubmittingAnimationRunning}
+ // onAnimationFinish={stopAnimation}
+ // isDisabled={shouldBlockSubmit}
+ />
+
+
+
+ {}}
+ wrapperStyle={[styles.sectionMenuItemTopDescription]}
+ titleStyle={styles.textNormalThemeText}
+ descriptionTextStyle={styles.textLabelSupportingNormal}
+ shouldShowRightIcon
+ // brickRoadIndicator={hasDelayedSubmissionError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined}
+ />
+ {}}
+ wrapperStyle={[styles.sectionMenuItemTopDescription]}
+ titleStyle={styles.textNormalThemeText}
+ descriptionTextStyle={styles.textLabelSupportingNormal}
+ shouldShowRightIcon
+ // brickRoadIndicator={hasDelayedSubmissionError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined}
+ />
+ >
+ ),
+ },
+ ];
+
+ const renderOptionItem = (item: ToggleSettingOptionRowProps, index: number) => (
+
+ );
+
+ // If Travel Invoicing is not enabled or no settlement account is configured
+ // show the BookOrManageYourTrip component as fallback
+ if (!isTravelInvoicingEnabled || !hasSettlementAccount) {
+ return ;
+ }
+
+ return (
+ <>
+
+
+ {optionItems.map(renderOptionItem)}
+ >
+ );
+}
+
+export default WorkspaceTravelInvoicingSection;
diff --git a/src/styles/utils/spacing.ts b/src/styles/utils/spacing.ts
index 1e6d9b430d565..ad53231c0b12e 100644
--- a/src/styles/utils/spacing.ts
+++ b/src/styles/utils/spacing.ts
@@ -535,6 +535,10 @@ export default {
paddingVertical: 24,
},
+ pv8: {
+ paddingVertical: 32,
+ },
+
pv10: {
paddingVertical: 40,
},
diff --git a/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx b/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx
new file mode 100644
index 0000000000000..4f3a73fb74d4c
--- /dev/null
+++ b/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx
@@ -0,0 +1,278 @@
+/* eslint-disable @typescript-eslint/naming-convention */
+import {act, render, screen} from '@testing-library/react-native';
+import React from 'react';
+import Onyx from 'react-native-onyx';
+import ComposeProviders from '@components/ComposeProviders';
+import {LocaleContextProvider} from '@components/LocaleContextProvider';
+import OnyxListItemProvider from '@components/OnyxListItemProvider';
+import WorkspaceTravelInvoicingSection from '@pages/workspace/travel/WorkspaceTravelInvoicingSection';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {OnyxKey} from '@src/ONYXKEYS';
+import type {Policy} from '@src/types/onyx';
+import createRandomPolicy from '../utils/collections/policies';
+import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct';
+
+// Test constants - these values MUST match the literals used in jest.mock() below
+// because jest.mock() is hoisted before variable declarations are evaluated
+const POLICY_ID = 'testPolicy123';
+const WORKSPACE_ACCOUNT_ID = 999888;
+
+// jest.mock() factories are hoisted and run before imports/variables are defined.
+// Therefore, they cannot reference variables like POLICY_ID or WORKSPACE_ACCOUNT_ID.
+// We use literal values that match the constants above.
+
+jest.mock('@react-navigation/native', () => {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
+ const actualNav = jest.requireActual('@react-navigation/native');
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return
+ return {
+ ...actualNav,
+ useIsFocused: () => true,
+ useRoute: () => ({
+ key: 'test-route',
+ name: 'Workspace_Travel',
+ params: {policyID: 'testPolicy123'}, // Must match POLICY_ID
+ }),
+ usePreventRemove: jest.fn(),
+ };
+});
+
+jest.mock('@src/hooks/useResponsiveLayout');
+
+jest.mock('@hooks/useWorkspaceAccountID', () => ({
+ __esModule: true,
+ default: () => 999888, // Must match WORKSPACE_ACCOUNT_ID
+}));
+
+jest.mock('@hooks/useScreenWrapperTransitionStatus', () => ({
+ __esModule: true,
+ default: () => ({didScreenTransitionEnd: true}),
+}));
+
+const mockPolicy: Policy = {
+ ...createRandomPolicy(parseInt(POLICY_ID, 10) || 1),
+ type: CONST.POLICY.TYPE.CORPORATE,
+ pendingAction: null,
+ role: CONST.POLICY.ROLE.ADMIN,
+};
+
+const renderWorkspaceTravelInvoicingSection = () => {
+ return render(
+
+
+ ,
+ );
+};
+
+describe('WorkspaceTravelInvoicingSection', () => {
+ beforeAll(async () => {
+ Onyx.init({
+ keys: ONYXKEYS,
+ });
+ });
+
+ afterEach(async () => {
+ jest.clearAllMocks();
+ await act(async () => {
+ await Onyx.clear();
+ await waitForBatchedUpdatesWithAct();
+ });
+ });
+
+ describe('When Travel Invoicing is not configured', () => {
+ it('should show BookOrManageYourTrip when card settings are not available', async () => {
+ // Given no Travel Invoicing card settings exist
+ await act(async () => {
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, mockPolicy);
+ await waitForBatchedUpdatesWithAct();
+ });
+
+ // When rendering the component
+ renderWorkspaceTravelInvoicingSection();
+
+ // Wait for component to render
+ await waitForBatchedUpdatesWithAct();
+
+ // Then the fallback component should be visible (BookOrManageYourTrip)
+ expect(screen.getByText('Book or manage your trip')).toBeTruthy();
+ });
+
+ it('should show BookOrManageYourTrip when paymentBankAccountID is not set', async () => {
+ // Given Travel Invoicing card settings exist but without paymentBankAccountID
+ const travelInvoicingKey = `${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${WORKSPACE_ACCOUNT_ID}_TRAVEL_US` as OnyxKey;
+
+ await act(async () => {
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, mockPolicy);
+ await Onyx.merge(travelInvoicingKey, {
+ remainingLimit: 50000,
+ currentBalance: 10000,
+ });
+ await waitForBatchedUpdatesWithAct();
+ });
+
+ // When rendering the component
+ renderWorkspaceTravelInvoicingSection();
+
+ await waitForBatchedUpdatesWithAct();
+
+ // Then the fallback component should be visible
+ expect(screen.getByText('Book or manage your trip')).toBeTruthy();
+ });
+ });
+
+ describe('When Travel Invoicing is configured', () => {
+ const travelInvoicingKey = `${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${WORKSPACE_ACCOUNT_ID}_TRAVEL_US` as OnyxKey;
+ const bankAccountKey = ONYXKEYS.BANK_ACCOUNT_LIST;
+
+ it('should render the section title when card settings are properly configured', async () => {
+ // Given Travel Invoicing is properly configured
+ await act(async () => {
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, mockPolicy);
+ await Onyx.merge(travelInvoicingKey, {
+ paymentBankAccountID: 12345,
+ remainingLimit: 50000,
+ currentBalance: 10000,
+ });
+ await Onyx.merge(bankAccountKey, {
+ 12345: {
+ accountData: {
+ addressName: 'Test Company',
+ accountNumber: '****1234',
+ bankAccountID: 12345,
+ },
+ },
+ });
+ await waitForBatchedUpdatesWithAct();
+ });
+
+ // When rendering the component
+ renderWorkspaceTravelInvoicingSection();
+
+ await waitForBatchedUpdatesWithAct();
+
+ // Then the section title should be visible
+ expect(screen.getByText('Travel booking')).toBeTruthy();
+ });
+
+ it('should display current travel spend label when configured', async () => {
+ // Given Travel Invoicing is configured with current balance
+ await act(async () => {
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, mockPolicy);
+ await Onyx.merge(travelInvoicingKey, {
+ paymentBankAccountID: 12345,
+ remainingLimit: 50000,
+ currentBalance: 25000,
+ });
+ await Onyx.merge(bankAccountKey, {
+ 12345: {
+ accountData: {
+ addressName: 'Test Company',
+ accountNumber: '****1234',
+ bankAccountID: 12345,
+ },
+ },
+ });
+ await waitForBatchedUpdatesWithAct();
+ });
+
+ // When rendering the component
+ renderWorkspaceTravelInvoicingSection();
+
+ await waitForBatchedUpdatesWithAct();
+
+ // Then the current travel spend label should be visible
+ expect(screen.getByText('Current travel spend')).toBeTruthy();
+ });
+
+ it('should display current travel limit label when configured', async () => {
+ // Given Travel Invoicing is configured with remaining limit
+ await act(async () => {
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, mockPolicy);
+ await Onyx.merge(travelInvoicingKey, {
+ paymentBankAccountID: 12345,
+ remainingLimit: 100000,
+ currentBalance: 25000,
+ });
+ await Onyx.merge(bankAccountKey, {
+ 12345: {
+ accountData: {
+ addressName: 'Test Company',
+ accountNumber: '****1234',
+ bankAccountID: 12345,
+ },
+ },
+ });
+ await waitForBatchedUpdatesWithAct();
+ });
+
+ // When rendering the component
+ renderWorkspaceTravelInvoicingSection();
+
+ await waitForBatchedUpdatesWithAct();
+
+ // Then the current travel limit label should be visible
+ expect(screen.getByText('Current travel limit')).toBeTruthy();
+ });
+
+ it('should display settlement account label', async () => {
+ // Given Travel Invoicing is configured with settlement account
+ await act(async () => {
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, mockPolicy);
+ await Onyx.merge(travelInvoicingKey, {
+ paymentBankAccountID: 12345,
+ remainingLimit: 50000,
+ currentBalance: 10000,
+ });
+ await Onyx.merge(bankAccountKey, {
+ 12345: {
+ accountData: {
+ addressName: 'Test Company',
+ accountNumber: '****1234',
+ bankAccountID: 12345,
+ },
+ },
+ });
+ await waitForBatchedUpdatesWithAct();
+ });
+
+ // When rendering the component
+ renderWorkspaceTravelInvoicingSection();
+
+ await waitForBatchedUpdatesWithAct();
+
+ // Then the settlement account label should be visible
+ expect(screen.getByText('Settlement account')).toBeTruthy();
+ });
+
+ it('should display settlement frequency label', async () => {
+ // Given Travel Invoicing is configured
+ await act(async () => {
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, mockPolicy);
+ await Onyx.merge(travelInvoicingKey, {
+ paymentBankAccountID: 12345,
+ remainingLimit: 50000,
+ currentBalance: 10000,
+ });
+ await Onyx.merge(bankAccountKey, {
+ 12345: {
+ accountData: {
+ addressName: 'Test Company',
+ accountNumber: '****1234',
+ bankAccountID: 12345,
+ },
+ },
+ });
+ await waitForBatchedUpdatesWithAct();
+ });
+
+ // When rendering the component
+ renderWorkspaceTravelInvoicingSection();
+
+ await waitForBatchedUpdatesWithAct();
+
+ // Then the settlement frequency label should be visible
+ expect(screen.getByText('Settlement frequency')).toBeTruthy();
+ });
+ });
+});
diff --git a/tests/unit/TravelInvoicingUtilsTest.ts b/tests/unit/TravelInvoicingUtilsTest.ts
new file mode 100644
index 0000000000000..74486947467de
--- /dev/null
+++ b/tests/unit/TravelInvoicingUtilsTest.ts
@@ -0,0 +1,199 @@
+import CONST from '@src/CONST';
+import {
+ getIsTravelInvoicingEnabled,
+ getTravelLimit,
+ getTravelSettlementAccount,
+ getTravelSettlementFrequency,
+ getTravelSpend,
+ hasTravelInvoicingSettlementAccount,
+} from '@src/libs/TravelInvoicingUtils';
+import type {BankAccountList} from '@src/types/onyx';
+import type ExpensifyCardSettings from '@src/types/onyx/ExpensifyCardSettings';
+
+describe('TravelInvoicingUtils', () => {
+ describe('PROGRAM_TRAVEL_US constant', () => {
+ it('Should be defined as TRAVEL_US', () => {
+ expect(CONST.TRAVEL.PROGRAM_TRAVEL_US).toBe('TRAVEL_US');
+ });
+ });
+
+ describe('getIsTravelInvoicingEnabled', () => {
+ it('Should return false when cardSettings is undefined', () => {
+ const result = getIsTravelInvoicingEnabled(undefined);
+ expect(result).toBe(false);
+ });
+
+ it('Should return false when cardSettings is null', () => {
+ // Using undefined since OnyxEntry doesn't accept null
+ const result = getIsTravelInvoicingEnabled(undefined);
+ expect(result).toBe(false);
+ });
+
+ it('Should return false when paymentBankAccountID is not set', () => {
+ const cardSettings = {} as ExpensifyCardSettings;
+ const result = getIsTravelInvoicingEnabled(cardSettings);
+ expect(result).toBe(false);
+ });
+
+ it('Should return false when paymentBankAccountID is 0', () => {
+ const cardSettings = {paymentBankAccountID: 0} as ExpensifyCardSettings;
+ const result = getIsTravelInvoicingEnabled(cardSettings);
+ expect(result).toBe(false);
+ });
+
+ it('Should return true when paymentBankAccountID is set to a valid value', () => {
+ const cardSettings = {paymentBankAccountID: 12345} as ExpensifyCardSettings;
+ const result = getIsTravelInvoicingEnabled(cardSettings);
+ expect(result).toBe(true);
+ });
+ });
+
+ describe('hasTravelInvoicingSettlementAccount', () => {
+ it('Should return false when cardSettings is undefined', () => {
+ const result = hasTravelInvoicingSettlementAccount(undefined);
+ expect(result).toBe(false);
+ });
+
+ it('Should return false when paymentBankAccountID is not set', () => {
+ const cardSettings = {} as ExpensifyCardSettings;
+ const result = hasTravelInvoicingSettlementAccount(cardSettings);
+ expect(result).toBe(false);
+ });
+
+ it('Should return false when paymentBankAccountID is DEFAULT_NUMBER_ID (0)', () => {
+ const cardSettings = {paymentBankAccountID: CONST.DEFAULT_NUMBER_ID} as ExpensifyCardSettings;
+ const result = hasTravelInvoicingSettlementAccount(cardSettings);
+ expect(result).toBe(false);
+ });
+
+ it('Should return true when paymentBankAccountID is a valid non-zero value', () => {
+ const cardSettings = {paymentBankAccountID: 67890} as ExpensifyCardSettings;
+ const result = hasTravelInvoicingSettlementAccount(cardSettings);
+ expect(result).toBe(true);
+ });
+ });
+
+ describe('getTravelLimit', () => {
+ it('Should return 0 when cardSettings is undefined', () => {
+ const result = getTravelLimit(undefined);
+ expect(result).toBe(0);
+ });
+
+ it('Should return 0 when remainingLimit is not set', () => {
+ const cardSettings = {} as ExpensifyCardSettings;
+ const result = getTravelLimit(cardSettings);
+ expect(result).toBe(0);
+ });
+
+ it('Should return the remainingLimit value when set', () => {
+ const cardSettings = {remainingLimit: 50000} as ExpensifyCardSettings;
+ const result = getTravelLimit(cardSettings);
+ expect(result).toBe(50000);
+ });
+ });
+
+ describe('getTravelSpend', () => {
+ it('Should return 0 when cardSettings is undefined', () => {
+ const result = getTravelSpend(undefined);
+ expect(result).toBe(0);
+ });
+
+ it('Should return 0 when currentBalance is not set', () => {
+ const cardSettings = {} as ExpensifyCardSettings;
+ const result = getTravelSpend(cardSettings);
+ expect(result).toBe(0);
+ });
+
+ it('Should return the currentBalance value when set', () => {
+ const cardSettings = {currentBalance: 25000} as ExpensifyCardSettings;
+ const result = getTravelSpend(cardSettings);
+ expect(result).toBe(25000);
+ });
+ });
+
+ describe('getTravelSettlementFrequency', () => {
+ it('Should return daily when cardSettings is undefined', () => {
+ const result = getTravelSettlementFrequency(undefined);
+ expect(result).toBe(CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.DAILY);
+ });
+
+ it('Should return daily when monthlySettlementDate is not set', () => {
+ const cardSettings = {} as ExpensifyCardSettings;
+ const result = getTravelSettlementFrequency(cardSettings);
+ expect(result).toBe(CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.DAILY);
+ });
+
+ it('Should return monthly when monthlySettlementDate is set', () => {
+ const cardSettings = {monthlySettlementDate: new Date('2024-01-15')} as ExpensifyCardSettings;
+ const result = getTravelSettlementFrequency(cardSettings);
+ expect(result).toBe(CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.MONTHLY);
+ });
+ });
+
+ describe('getTravelSettlementAccount', () => {
+ const mockBankAccountList: BankAccountList = {
+ bankAccountID: {
+ bankCurrency: 'USD',
+ bankCountry: 'US',
+ accountData: {
+ addressName: 'Test Company',
+ accountNumber: '****1234',
+ routingNumber: '123456789',
+ bankAccountID: 12345,
+ },
+ },
+ };
+
+ it('Should return undefined when cardSettings is undefined', () => {
+ const result = getTravelSettlementAccount(undefined, mockBankAccountList);
+ expect(result).toBeUndefined();
+ });
+
+ it('Should return undefined when paymentBankAccountID is not set', () => {
+ const cardSettings = {} as ExpensifyCardSettings;
+ const result = getTravelSettlementAccount(cardSettings, mockBankAccountList);
+ expect(result).toBeUndefined();
+ });
+
+ it('Should use paymentBankAccountAddressName when available', () => {
+ const cardSettings = {
+ paymentBankAccountID: 12345,
+ paymentBankAccountAddressName: 'Custom Name',
+ paymentBankAccountNumber: '****5678',
+ } as ExpensifyCardSettings;
+ const result = getTravelSettlementAccount(cardSettings, mockBankAccountList);
+ expect(result).toBeDefined();
+ expect(result?.displayName).toBe('Custom Name');
+ expect(result?.last4).toBe('5678');
+ });
+
+ it('Should fallback to bank account data when paymentBankAccountAddressName is not set', () => {
+ const cardSettings = {
+ paymentBankAccountID: 'bankAccountID' as unknown as number,
+ } as ExpensifyCardSettings;
+ const result = getTravelSettlementAccount(cardSettings, mockBankAccountList);
+ expect(result).toBeDefined();
+ expect(result?.displayName).toBe('Test Company');
+ expect(result?.last4).toBe('1234');
+ });
+
+ it('Should return bankAccountID in the result', () => {
+ const cardSettings = {
+ paymentBankAccountID: 12345,
+ } as ExpensifyCardSettings;
+ const result = getTravelSettlementAccount(cardSettings, mockBankAccountList);
+ expect(result).toBeDefined();
+ expect(result?.bankAccountID).toBe(12345);
+ });
+
+ it('Should handle missing bank account in list gracefully', () => {
+ const cardSettings = {
+ paymentBankAccountID: 99999,
+ } as ExpensifyCardSettings;
+ const result = getTravelSettlementAccount(cardSettings, mockBankAccountList);
+ expect(result).toBeDefined();
+ expect(result?.displayName).toBe('');
+ expect(result?.last4).toBe('');
+ });
+ });
+});