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/ROUTES.ts b/src/ROUTES.ts
index a0c07a34787d0..8bd9670541820 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -2460,6 +2460,14 @@ const ROUTES = {
return `workspaces/${policyID}/travel` as const;
},
},
+ WORKSPACE_TRAVEL_SETTINGS_ACCOUNT: {
+ route: 'workspaces/:policyID/travel/settings/account',
+ getRoute: (policyID: string) => `workspaces/${policyID}/travel/settings/account` as const,
+ },
+ WORKSPACE_TRAVEL_SETTINGS_FREQUENCY: {
+ route: 'workspaces/:policyID/travel/settings/frequency',
+ getRoute: (policyID: string) => `workspaces/${policyID}/travel/settings/frequency` as const,
+ },
WORKSPACE_CREATE_DISTANCE_RATE: {
route: 'workspaces/:policyID/distance-rates/new',
getRoute: (policyID: string, transactionID?: string, reportID?: string) =>
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index 9f0cd88b172b0..36f38f4a61405 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -703,6 +703,8 @@ const SCREENS = {
OWNER_CHANGE_ERROR: 'Workspace_Owner_Change_Error',
DISTANCE_RATES: 'Distance_Rates',
TRAVEL: 'Travel',
+ TRAVEL_SETTINGS_ACCOUNT: 'Workspace_Travel_Settings_Account',
+ TRAVEL_SETTINGS_FREQUENCY: 'Workspace_Travel_Settings_Frequency',
CREATE_DISTANCE_RATE: 'Create_Distance_Rate',
CREATE_DISTANCE_RATE_UPGRADE: 'Create_Distance_Rate_Upgrade',
DISTANCE_RATES_SETTINGS: 'Distance_Rates_Settings',
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 8de411422637b..3a904c9f4331f 100644
--- a/src/components/MenuItem.tsx
+++ b/src/components/MenuItem.tsx
@@ -401,6 +401,9 @@ type MenuItemBaseProps = ForwardedFSClassProps &
/** Whether the screen containing the item is focused */
isFocused?: boolean;
+ /** Additional styles for the root wrapper View */
+ rootWrapperStyle?: StyleProp;
+
/** Whether to show the badge in a separate row */
shouldShowBadgeInSeparateRow?: boolean;
@@ -544,6 +547,7 @@ function MenuItem({
ref,
isFocused,
sentryLabel,
+ rootWrapperStyle,
shouldBeAccessible = true,
tabIndex = 0,
}: MenuItemProps) {
@@ -697,7 +701,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 84d6ea89131cc..03ebd6c4951b8 100644
--- a/src/languages/de.ts
+++ b/src/languages/de.ts
@@ -5075,6 +5075,26 @@ _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',
+ settlementFrequencyDescription: 'Wie oft Expensify Ihr Geschäftskonto belastet, um aktuelle Expensify Travel-Transaktionen zu begleichen.',
+ },
+ },
+ },
},
expensifyCard: {
title: 'Expensify Card',
diff --git a/src/languages/en.ts b/src/languages/en.ts
index ee67f27a85f53..9ea0669646788 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -4981,6 +4981,26 @@ 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',
+ settlementFrequencyDescription: 'How often Expensify will pull from your business bank account to settle recent Expensify Travel transactions.',
+ },
+ },
+ },
},
expensifyCard: {
title: 'Expensify Card',
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 7ede5aa91f985..f139dd7964bf0 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -4716,6 +4716,27 @@ ${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',
+ settlementFrequencyDescription:
+ 'Con qué frecuencia Expensify retirará fondos de la cuenta bancaria de tu empresa para liquidar transacciones recientes de Expensify Travel.',
+ },
+ },
+ },
},
expensifyCard: {
title: 'Tarjeta Expensify',
diff --git a/src/languages/fr.ts b/src/languages/fr.ts
index 8eb300d4e3a23..c86cc230d2b48 100644
--- a/src/languages/fr.ts
+++ b/src/languages/fr.ts
@@ -5081,6 +5081,27 @@ _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',
+ settlementFrequencyDescription:
+ 'Fréquence à laquelle Expensify prélèvera sur votre compte bancaire professionnel pour régler les transactions récentes d’Expensify Travel.',
+ },
+ },
+ },
},
expensifyCard: {
title: 'Carte Expensify',
diff --git a/src/languages/it.ts b/src/languages/it.ts
index ee9dfe181b75e..5de71dcc078d1 100644
--- a/src/languages/it.ts
+++ b/src/languages/it.ts
@@ -5060,6 +5060,27 @@ _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',
+ settlementFrequencyDescription:
+ 'Con quale frequenza Expensify preleverà dal tuo conto bancario aziendale per saldare le recenti transazioni di Expensify Travel.',
+ },
+ },
+ },
},
expensifyCard: {
title: 'Carta Expensify',
diff --git a/src/languages/ja.ts b/src/languages/ja.ts
index b40dfa35c3ab3..a1744688f4031 100644
--- a/src/languages/ja.ts
+++ b/src/languages/ja.ts
@@ -5031,6 +5031,26 @@ _より詳しい手順については、[ヘルプサイトをご覧ください
subtitle: 'Expensify Travelを使用して最高の旅行オファーを取得し、すべてのビジネス経費を一箇所で管理します。',
ctaText: '予約または管理',
},
+ travelInvoicing: {
+ travelBookingSection: {
+ title: '出張予約',
+ subtitle: 'おめでとうございます!このワークスペースで旅行の予約と管理を行う準備が整いました。',
+ manageTravelLabel: '出張を管理',
+ },
+ centralInvoicingSection: {
+ title: '中央請求書管理',
+ subtitle: 'すべての出張費を購入時に都度支払うのではなく、月次請求書にまとめて管理しましょう。',
+ learnHow: `詳しく見る。`,
+ subsections: {
+ currentTravelSpendLabel: '現在の出張費用',
+ currentTravelSpendCta: '残高を支払う',
+ currentTravelLimitLabel: '現在の出張上限',
+ settlementAccountLabel: '決済口座',
+ settlementFrequencyLabel: '清算頻度',
+ settlementFrequencyDescription: 'Expensify が直近の Expensify Travel 取引を精算するために、あなたのビジネス銀行口座から資金を引き落とす頻度。',
+ },
+ },
+ },
},
expensifyCard: {
title: 'Expensify Card',
diff --git a/src/languages/nl.ts b/src/languages/nl.ts
index 6139f13de42b9..7408ec09fa549 100644
--- a/src/languages/nl.ts
+++ b/src/languages/nl.ts
@@ -5056,6 +5056,22 @@ _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',
+ settlementFrequencyDescription: 'Hoe vaak Expensify geld van uw zakelijke bankrekening zal incasseren om recente Expensify Travel-transacties te vereffenen.',
+ },
+ },
+ },
},
expensifyCard: {
title: 'Expensify Card',
@@ -7999,7 +8015,6 @@ Hier is een *testbon* om je te laten zien hoe het werkt:`,
confirm: 'Afstandstracking negeren',
},
zeroDistanceTripModal: {title: 'Kan geen uitgave aanmaken', prompt: 'Je kunt geen uitgave aanmaken met dezelfde begin- en eindlocatie.'},
-
locationRequiredModal: {
title: 'Locatietoegang vereist',
prompt: 'Sta locatietoegang toe in de instellingen van je apparaat om GPS-afstandsregistratie te starten.',
diff --git a/src/languages/pl.ts b/src/languages/pl.ts
index 3fb33efbe9257..87eea9aaea28b 100644
--- a/src/languages/pl.ts
+++ b/src/languages/pl.ts
@@ -5042,6 +5042,26 @@ _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ń',
+ settlementFrequencyDescription: 'Jak często Expensify będzie pobierać środki z firmowego konta bankowego, aby rozliczyć ostatnie transakcje Expensify Travel.',
+ },
+ },
+ },
},
expensifyCard: {
title: 'Karta Expensify',
@@ -7974,7 +7994,6 @@ Oto *paragon testowy*, który pokazuje, jak to działa:`,
confirm: 'Odrzuć śledzenie dystansu',
},
zeroDistanceTripModal: {title: 'Nie można utworzyć wydatku', prompt: 'Nie możesz utworzyć wydatku z tym samym miejscem początkowym i końcowym.'},
-
locationRequiredModal: {
title: 'Wymagany dostęp do lokalizacji',
prompt: 'Aby rozpocząć śledzenie dystansu GPS, zezwól na dostęp do lokalizacji w ustawieniach swojego urządzenia.',
diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts
index 4b17be3565d85..2b04957756a71 100644
--- a/src/languages/pt-BR.ts
+++ b/src/languages/pt-BR.ts
@@ -5043,6 +5043,27 @@ _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',
+ settlementFrequencyDescription:
+ 'Com que frequência o Expensify vai debitar da sua conta bancária empresarial para liquidar as transações recentes do Expensify Travel.',
+ },
+ },
+ },
},
expensifyCard: {
title: 'Cartão Expensify',
@@ -7984,7 +8005,6 @@ Aqui está um *recibo de teste* para mostrar como funciona:`,
confirm: 'Descartar rastreamento de distância',
},
zeroDistanceTripModal: {title: 'Não é possível criar a despesa', prompt: 'Você não pode criar uma despesa com o mesmo local de partida e de chegada.'},
-
locationRequiredModal: {
title: 'Acesso à localização necessário',
prompt: 'Permita o acesso à localização nas configurações do seu dispositivo para iniciar o rastreamento de distância por GPS.',
diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts
index 5db7cd4b0d3f5..05aae78e216fc 100644
--- a/src/languages/zh-hans.ts
+++ b/src/languages/zh-hans.ts
@@ -4953,6 +4953,22 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM
subtitle: '使用 Expensify Travel 获得最佳旅行优惠,并在一个地方管理所有商务费用。',
ctaText: '预订或管理',
},
+ travelInvoicing: {
+ travelBookingSection: {title: '旅行预订', subtitle: '恭喜!您现在可以在此工作区预订和管理差旅了。', manageTravelLabel: '管理差旅'},
+ centralInvoicingSection: {
+ title: '集中开票',
+ subtitle: '将所有差旅支出集中到月度发票中,而不是在购买时逐笔付款。',
+ learnHow: `了解如何操作。`,
+ subsections: {
+ currentTravelSpendLabel: '当前差旅行支出',
+ currentTravelSpendCta: '支付余额',
+ currentTravelLimitLabel: '当前出差限额',
+ settlementAccountLabel: '结算账户',
+ settlementFrequencyLabel: '结算频率',
+ settlementFrequencyDescription: 'Expensify 从您的企业银行账户中扣款以结算最近 Expensify Travel 交易的频率。',
+ },
+ },
+ },
},
expensifyCard: {
title: 'Expensify Card',
@@ -7790,7 +7806,6 @@ ${reportName}
stopGpsTrackingModal: {title: '停止 GPS 追踪', prompt: '你确定吗?这将结束你当前的旅程。', cancel: '恢复追踪', confirm: '停止 GPS 追踪'},
discardDistanceTrackingModal: {title: '丢弃距离跟踪', prompt: '您确定吗?这将放弃您当前的流程,且无法撤销。', confirm: '丢弃距离跟踪'},
zeroDistanceTripModal: {title: '无法创建报销', prompt: '你不能创建起点和终点相同的报销。'},
-
locationRequiredModal: {title: '需要访问位置信息', prompt: '请在设备设置中允许位置访问以开始 GPS 距离跟踪。', allow: '允许'},
androidBackgroundLocationRequiredModal: {title: '需要后台位置访问权限', prompt: '请在设备设置中允许应用使用“始终允许”位置访问权限,以开始 GPS 距离跟踪。'},
preciseLocationRequiredModal: {title: '需要精确位置', prompt: '请在设备设置中启用“精确位置”以开始 GPS 距离跟踪。'},
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/SetTravelInvoicingSettlementAccountParams.ts b/src/libs/API/parameters/SetTravelInvoicingSettlementAccountParams.ts
new file mode 100644
index 0000000000000..af0f1db5f1398
--- /dev/null
+++ b/src/libs/API/parameters/SetTravelInvoicingSettlementAccountParams.ts
@@ -0,0 +1,6 @@
+type SetTravelInvoicingSettlementAccountParams = {
+ policyID: string;
+ settlementBankAccountID: number;
+};
+
+export default SetTravelInvoicingSettlementAccountParams;
diff --git a/src/libs/API/parameters/UpdateTravelInvoicingSettlementFrequencyParams.ts b/src/libs/API/parameters/UpdateTravelInvoicingSettlementFrequencyParams.ts
new file mode 100644
index 0000000000000..f82dfa2a15931
--- /dev/null
+++ b/src/libs/API/parameters/UpdateTravelInvoicingSettlementFrequencyParams.ts
@@ -0,0 +1,10 @@
+import type {ValueOf} from 'type-fest';
+import type CONST from '@src/CONST';
+
+type UpdateTravelInvoicingSettlementFrequencyParams = {
+ policyID: string;
+ workspaceAccountID: number;
+ settlementFrequency: ValueOf;
+};
+
+export default UpdateTravelInvoicingSettlementFrequencyParams;
diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts
index b65ee965c5255..1f38494ae98da 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';
@@ -372,6 +373,7 @@ export type {default as ExportTagsSpreadsheetParams} from './ExportTagsSpreadshe
export type {default as UpdateXeroGenericTypeParams} from './UpdateXeroGenericTypeParams';
export type {default as UpdateCardSettlementFrequencyParams} from './UpdateCardSettlementFrequencyParams';
export type {default as UpdateCardSettlementAccountParams} from './UpdateCardSettlementAccountParams';
+export type {default as SetTravelInvoicingSettlementAccountParams} from './SetTravelInvoicingSettlementAccountParams';
export type {default as SetCompanyCardFeedName} from './SetCompanyCardFeedName';
export type {default as DeleteCompanyCardFeed} from './DeleteCompanyCardFeed';
export type {default as SetCompanyCardTransactionLiability} from './SetCompanyCardTransactionLiability';
@@ -461,3 +463,4 @@ export type {default as ToggleConsolidatedDomainBillingParams} from './ToggleCon
export type {default as RemoveDomainAdminParams} from './RemoveDomainAdminParams';
export type {default as DeleteDomainParams} from './DeleteDomainParams';
export type {default as GetDuplicateTransactionDetailsParams} from './GetDuplicateTransactionDetailsParams';
+export type {default as UpdateTravelInvoicingSettlementFrequencyParams} from './UpdateTravelInvoicingSettlementFrequencyParams';
diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts
index fc338163b81fc..91474d94db044 100644
--- a/src/libs/API/types.ts
+++ b/src/libs/API/types.ts
@@ -460,6 +460,8 @@ const WRITE_COMMANDS = {
DELETE_SAVED_SEARCH: 'DeleteSavedSearch',
UPDATE_CARD_SETTLEMENT_FREQUENCY: 'UpdateCardSettlementFrequency',
UPDATE_CARD_SETTLEMENT_ACCOUNT: 'UpdateCardSettlementAccount',
+ SET_TRAVEL_INVOICING_SETTLEMENT_ACCOUNT: 'SetTravelInvoicingSettlementAccount',
+ UPDATE_TRAVEL_INVOICE_SETTLEMENT_FREQUENCY: 'UpdateTravelInvoiceSettlementFrequency',
UPDATE_XERO_IMPORT_TRACKING_CATEGORIES: 'UpdateXeroImportTrackingCategories',
UPDATE_XERO_IMPORT_TAX_RATES: 'UpdateXeroImportTaxRates',
UPDATE_XERO_TENANT_ID: 'UpdateXeroTenantID',
@@ -1031,6 +1033,8 @@ type WriteCommandParameters = {
[WRITE_COMMANDS.DELETE_SAVED_SEARCH]: Parameters.DeleteSavedSearchParams;
[WRITE_COMMANDS.UPDATE_CARD_SETTLEMENT_FREQUENCY]: Parameters.UpdateCardSettlementFrequencyParams;
[WRITE_COMMANDS.UPDATE_CARD_SETTLEMENT_ACCOUNT]: Parameters.UpdateCardSettlementAccountParams;
+ [WRITE_COMMANDS.SET_TRAVEL_INVOICING_SETTLEMENT_ACCOUNT]: Parameters.SetTravelInvoicingSettlementAccountParams;
+ [WRITE_COMMANDS.UPDATE_TRAVEL_INVOICE_SETTLEMENT_FREQUENCY]: Parameters.UpdateTravelInvoicingSettlementFrequencyParams;
[WRITE_COMMANDS.SET_PERSONAL_DETAILS_AND_SHIP_EXPENSIFY_CARDS]: Parameters.SetPersonalDetailsAndShipExpensifyCardsParams;
[WRITE_COMMANDS.SELF_TOUR_VIEWED]: null;
@@ -1145,6 +1149,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 +1242,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/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
index 6acc1efba7ddd..e6a18e2638764 100644
--- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
+++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
@@ -732,6 +732,8 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/expensifyCard/WorkspaceCardSettingsPage').default,
[SCREENS.WORKSPACE.EXPENSIFY_CARD_SETTINGS_ACCOUNT]: () => require('../../../../pages/workspace/expensifyCard/WorkspaceSettlementAccountPage').default,
[SCREENS.WORKSPACE.EXPENSIFY_CARD_SETTINGS_FREQUENCY]: () => require('../../../../pages/workspace/expensifyCard/WorkspaceSettlementFrequencyPage').default,
+ [SCREENS.WORKSPACE.TRAVEL_SETTINGS_ACCOUNT]: () => require('../../../../pages/workspace/travel/WorkspaceTravelSettlementAccountPage').default,
+ [SCREENS.WORKSPACE.TRAVEL_SETTINGS_FREQUENCY]: () => require('../../../../pages/workspace/travel/WorkspaceTravelSettlementFrequencyPage').default,
[SCREENS.WORKSPACE.EXPENSIFY_CARD_SELECT_FEED]: () => require('../../../../pages/workspace/expensifyCard/WorkspaceExpensifyCardSelectorPage').default,
[SCREENS.WORKSPACE.EXPENSIFY_CARD_BANK_ACCOUNT]: () => require('../../../../pages/workspace/expensifyCard/WorkspaceExpensifyCardBankAccounts').default,
[SCREENS.WORKSPACE.EXPENSIFY_CARD_DETAILS]: () => require('../../../../pages/workspace/expensifyCard/WorkspaceExpensifyCardDetailsPage').default,
diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts
index 0beb9e8ba14f1..91869084f7dba 100755
--- a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts
+++ b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts
@@ -224,7 +224,7 @@ const WORKSPACE_TO_RHP: Partial['config'] = {
[SCREENS.WORKSPACE.EXPENSIFY_CARD_SELECT_FEED]: {
path: ROUTES.WORKSPACE_EXPENSIFY_CARD_SELECT_FEED.route,
},
+ [SCREENS.WORKSPACE.TRAVEL_SETTINGS_ACCOUNT]: {
+ path: ROUTES.WORKSPACE_TRAVEL_SETTINGS_ACCOUNT.route,
+ },
+ [SCREENS.WORKSPACE.TRAVEL_SETTINGS_FREQUENCY]: {
+ path: ROUTES.WORKSPACE_TRAVEL_SETTINGS_FREQUENCY.route,
+ },
[SCREENS.WORKSPACE.COMPANY_CARDS_SETTINGS]: {
path: ROUTES.WORKSPACE_COMPANY_CARDS_SETTINGS.route,
},
diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts
index 4ef171b360e9d..8589f9e83b96c 100644
--- a/src/libs/Navigation/types.ts
+++ b/src/libs/Navigation/types.ts
@@ -1213,6 +1213,12 @@ type SettingsNavigatorParamList = {
[SCREENS.WORKSPACE.EXPENSIFY_CARD_SETTINGS_FREQUENCY]: {
policyID: string;
};
+ [SCREENS.WORKSPACE.TRAVEL_SETTINGS_ACCOUNT]: {
+ policyID: string;
+ };
+ [SCREENS.WORKSPACE.TRAVEL_SETTINGS_FREQUENCY]: {
+ policyID: string;
+ };
[SCREENS.WORKSPACE.COMPANY_CARDS_SETTINGS]: {
policyID: string;
};
diff --git a/src/libs/TravelInvoicingUtils.ts b/src/libs/TravelInvoicingUtils.ts
new file mode 100644
index 0000000000000..2996637a67a9b
--- /dev/null
+++ b/src/libs/TravelInvoicingUtils.ts
@@ -0,0 +1,101 @@
+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';
+
+/**
+ * The Travel Invoicing feed type constant for PROGRAM_TRAVEL_US.
+ * This feed is used for Travel Invoicing cards which are separate from regular Expensify Cards.
+ */
+const PROGRAM_TRAVEL_US = 'TRAVEL_US';
+
+/**
+ * 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.
+ * - If monthlySettlementDate is truthy (a Date), frequency is Monthly.
+ * - If monthlySettlementDate is falsy (null/undefined), frequency is Daily.
+ * - If cardSettings is missing, default to Monthly per design doc.
+ */
+function getTravelSettlementFrequency(cardSettings: OnyxEntry): string {
+ // Default to monthly per design doc when no settings exist
+ if (!cardSettings) {
+ return CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.MONTHLY;
+ }
+ // If monthlySettlementDate is set, it's monthly; otherwise it's daily
+ return cardSettings.monthlySettlementDate ? CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.MONTHLY : CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.DAILY;
+}
+
+export {PROGRAM_TRAVEL_US, 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..40eb1c83e43e0
--- /dev/null
+++ b/src/libs/actions/TravelInvoicing.ts
@@ -0,0 +1,256 @@
+import Onyx from 'react-native-onyx';
+import type {OnyxUpdate} from 'react-native-onyx';
+import type {ValueOf} from 'type-fest';
+import * as API from '@libs/API';
+import type {OpenPolicyTravelPageParams, SetTravelInvoicingSettlementAccountParams, UpdateTravelInvoicingSettlementFrequencyParams} from '@libs/API/parameters';
+import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types';
+import * as ErrorUtils from '@libs/ErrorUtils';
+import {PROGRAM_TRAVEL_US} from '@libs/TravelInvoicingUtils';
+import CONST from '@src/CONST';
+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});
+}
+
+/**
+ * Sets the settlement account for Travel Invoicing.
+ * Updates the paymentBankAccountID in the Travel Invoicing card settings.
+ */
+function setTravelInvoicingSettlementAccount(policyID: string, workspaceAccountID: number, settlementBankAccountID: number, previousPaymentBankAccountID?: number) {
+ const cardSettingsKey =
+ `${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${workspaceAccountID}_${PROGRAM_TRAVEL_US}` as `${typeof ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${string}`;
+
+ const optimisticData: Array> = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: cardSettingsKey,
+ value: {
+ paymentBankAccountID: settlementBankAccountID,
+ previousPaymentBankAccountID,
+ isLoading: true,
+ pendingFields: {
+ paymentBankAccountID: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
+ },
+ errorFields: {
+ paymentBankAccountID: null,
+ },
+ },
+ },
+ ];
+
+ const successData: Array> = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: cardSettingsKey,
+ value: {
+ paymentBankAccountID: settlementBankAccountID,
+ previousPaymentBankAccountID: null,
+ isLoading: false,
+ pendingFields: {
+ paymentBankAccountID: null,
+ },
+ errorFields: {
+ paymentBankAccountID: null,
+ },
+ },
+ },
+ ];
+
+ const failureData: Array> = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: cardSettingsKey,
+ value: {
+ // Keep the attempted value visible (grayed out) until error is dismissed
+ paymentBankAccountID: settlementBankAccountID,
+ previousPaymentBankAccountID,
+ isLoading: false,
+ pendingFields: {
+ paymentBankAccountID: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
+ },
+ errorFields: {
+ paymentBankAccountID: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'),
+ },
+ },
+ },
+ ];
+
+ const params: SetTravelInvoicingSettlementAccountParams = {
+ policyID,
+ settlementBankAccountID,
+ };
+
+ API.write(WRITE_COMMANDS.SET_TRAVEL_INVOICING_SETTLEMENT_ACCOUNT, params, {optimisticData, successData, failureData});
+}
+
+/**
+ * Clears any errors from the Travel Invoicing settlement account settings.
+ * Also resets the paymentBankAccountID to the previous valid value (or null if none existed).
+ */
+function clearTravelInvoicingSettlementAccountErrors(workspaceAccountID: number, paymentBankAccountID: number | null) {
+ const onyxData: Array> = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${workspaceAccountID}_${PROGRAM_TRAVEL_US}`,
+ value: {
+ errorFields: {
+ paymentBankAccountID: null,
+ },
+ pendingFields: {
+ paymentBankAccountID: null,
+ },
+ paymentBankAccountID,
+ previousPaymentBankAccountID: null,
+ },
+ },
+ ];
+ Onyx.update(onyxData);
+}
+
+/**
+ * Updates the settlement frequency for Travel Invoicing.
+ * Optimistically updates the monthlySettlementDate based on the selected frequency.
+ * Supports offline behavior - changes are queued and synced when back online.
+ */
+function updateTravelInvoiceSettlementFrequency(
+ policyID: string,
+ workspaceAccountID: number,
+ frequency: ValueOf,
+ currentMonthlySettlementDate?: Date,
+) {
+ const cardSettingsKey =
+ `${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${workspaceAccountID}_${PROGRAM_TRAVEL_US}` as `${typeof ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${string}`;
+
+ // If Monthly, set date (optimistically today). If Daily, set null.
+ const monthlySettlementDate = frequency === CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.MONTHLY ? new Date() : null;
+
+ const optimisticData: Array> = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: cardSettingsKey,
+ value: {
+ monthlySettlementDate,
+ previousMonthlySettlementDate: currentMonthlySettlementDate,
+ pendingFields: {
+ monthlySettlementDate: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
+ },
+ errors: null,
+ errorFields: {
+ monthlySettlementDate: null,
+ },
+ },
+ },
+ ];
+
+ const successData: Array> = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: cardSettingsKey,
+ value: {
+ monthlySettlementDate,
+ previousMonthlySettlementDate: null,
+ pendingFields: {
+ monthlySettlementDate: null,
+ },
+ errors: null,
+ errorFields: {
+ monthlySettlementDate: null,
+ },
+ },
+ },
+ ];
+
+ const failureData: Array> = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: cardSettingsKey,
+ value: {
+ monthlySettlementDate,
+ previousMonthlySettlementDate: currentMonthlySettlementDate ?? null,
+ pendingFields: {
+ monthlySettlementDate: null,
+ },
+ errors: null,
+ errorFields: {
+ monthlySettlementDate: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'),
+ },
+ },
+ },
+ ];
+
+ const params: UpdateTravelInvoicingSettlementFrequencyParams = {
+ policyID,
+ workspaceAccountID,
+ settlementFrequency: frequency,
+ };
+
+ API.write(WRITE_COMMANDS.UPDATE_TRAVEL_INVOICE_SETTLEMENT_FREQUENCY, params, {optimisticData, successData, failureData});
+}
+
+/**
+ * Clears any errors from the Travel Invoicing settlement frequency settings.
+ */
+function clearTravelInvoicingSettlementFrequencyErrors(workspaceAccountID: number, monthlySettlementDate: Date | null | undefined) {
+ const onyxData: Array> = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${workspaceAccountID}_${PROGRAM_TRAVEL_US}`,
+ value: {
+ errors: null,
+ errorFields: {
+ monthlySettlementDate: null,
+ },
+ monthlySettlementDate: monthlySettlementDate ?? null,
+ previousMonthlySettlementDate: null,
+ },
+ },
+ ];
+ Onyx.update(onyxData);
+}
+
+export {
+ openPolicyTravelPage,
+ setTravelInvoicingSettlementAccount,
+ clearTravelInvoicingSettlementAccountErrors,
+ clearTravelInvoicingSettlementFrequencyErrors,
+ updateTravelInvoiceSettlementFrequency,
+};
diff --git a/src/pages/workspace/travel/CentralInvoicingLearnHow.tsx b/src/pages/workspace/travel/CentralInvoicingLearnHow.tsx
new file mode 100644
index 0000000000000..308c6cca3a2a3
--- /dev/null
+++ b/src/pages/workspace/travel/CentralInvoicingLearnHow.tsx
@@ -0,0 +1,16 @@
+import React from 'react';
+import RenderHTML from '@components/RenderHTML';
+import useLocalize from '@hooks/useLocalize';
+
+function CentralInvoicingLearnHow() {
+ const {translate} = useLocalize();
+
+ return (
+ <>
+ {' '}
+
+ >
+ );
+}
+
+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..4e325f8145fd4
--- /dev/null
+++ b/src/pages/workspace/travel/CentralInvoicingSubtitleWrapper.tsx
@@ -0,0 +1,25 @@
+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..61f2aa87cf202 100644
--- a/src/pages/workspace/travel/PolicyTravelPage.tsx
+++ b/src/pages/workspace/travel/PolicyTravelPage.tsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, {useCallback, useEffect} from 'react';
import {View} from 'react-native';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
@@ -6,11 +6,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 +25,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 +41,30 @@ 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]);
+
+ useNetwork({onReconnect: fetchTravelData});
+
+ useEffect(() => {
+ 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..69a9bc44dd0d4
--- /dev/null
+++ b/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx
@@ -0,0 +1,240 @@
+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 OfflineWithFeedback from '@components/OfflineWithFeedback';
+import Section from '@components/Section';
+import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
+import useLocalize from '@hooks/useLocalize';
+import useOnyx from '@hooks/useOnyx';
+import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useSingleExecution from '@hooks/useSingleExecution';
+import useThemeStyles from '@hooks/useThemeStyles';
+import useWorkspaceAccountID from '@hooks/useWorkspaceAccountID';
+import {openExternalLink} from '@libs/actions/Link';
+import {clearTravelInvoicingSettlementAccountErrors, clearTravelInvoicingSettlementFrequencyErrors} from '@libs/actions/TravelInvoicing';
+import {convertToDisplayString} from '@libs/CurrencyUtils';
+import Navigation from '@libs/Navigation/Navigation';
+import {
+ getIsTravelInvoicingEnabled,
+ getTravelLimit,
+ getTravelSettlementAccount,
+ getTravelSettlementFrequency,
+ getTravelSpend,
+ hasTravelInvoicingSettlementAccount,
+ PROGRAM_TRAVEL_US,
+} 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 ROUTES from '@src/ROUTES';
+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']);
+ // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to apply a correct padding style
+ // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
+ const {isSmallScreenWidth} = useResponsiveLayout();
+
+ 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}_${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);
+
+ // Settlement account display - show empty if no account is selected
+ const settlementAccountNumber = hasSettlementAccount && settlementAccount?.last4 ? CONST.MASKED_PAN_PREFIX + settlementAccount.last4 : '';
+
+ // Get any errors from the settlement account update
+ // Get errors for specific fields
+ const settlementAccountErrorKey = 'paymentBankAccountID';
+ const settlementFrequencyErrorKey = 'monthlySettlementDate';
+ const hasSettlementAccountError = !!cardSettings?.errorFields?.[settlementAccountErrorKey];
+ const hasSettlementFrequencyError = !!cardSettings?.errorFields?.[settlementFrequencyErrorKey];
+ const settlementAccountErrors = hasSettlementAccountError ? cardSettings?.errorFields?.[settlementAccountErrorKey] : null;
+ const settlementFrequencyErrors = hasSettlementFrequencyError ? cardSettings?.errorFields?.[settlementFrequencyErrorKey] : null;
+
+ 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}
+ />
+
+
+
+ clearTravelInvoicingSettlementAccountErrors(workspaceAccountID, cardSettings?.previousPaymentBankAccountID ?? null)}
+ errorRowStyles={styles.mh2half}
+ errorRowTextStyles={styles.mr3}
+ >
+ Navigation.navigate(ROUTES.WORKSPACE_TRAVEL_SETTINGS_ACCOUNT.getRoute(policyID))}
+ wrapperStyle={[styles.sectionMenuItemTopDescription]}
+ titleStyle={settlementAccountNumber ? styles.textNormalThemeText : styles.colorMuted}
+ descriptionTextStyle={styles.textLabelSupportingNormal}
+ shouldShowRightIcon
+ brickRoadIndicator={hasSettlementAccountError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined}
+ />
+
+ clearTravelInvoicingSettlementFrequencyErrors(workspaceAccountID, cardSettings?.previousMonthlySettlementDate)}
+ errorRowStyles={styles.mh2half}
+ errorRowTextStyles={styles.mr3}
+ >
+ Navigation.navigate(ROUTES.WORKSPACE_TRAVEL_SETTINGS_FREQUENCY.getRoute(policyID))}
+ wrapperStyle={[styles.sectionMenuItemTopDescription]}
+ titleStyle={styles.textNormalThemeText}
+ descriptionTextStyle={styles.textLabelSupportingNormal}
+ shouldShowRightIcon
+ brickRoadIndicator={hasSettlementFrequencyError ? 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)}
+ >
+ );
+}
+
+WorkspaceTravelInvoicingSection.displayName = 'WorkspaceTravelInvoicingSection';
+
+export default WorkspaceTravelInvoicingSection;
diff --git a/src/pages/workspace/travel/WorkspaceTravelSettlementAccountPage.tsx b/src/pages/workspace/travel/WorkspaceTravelSettlementAccountPage.tsx
new file mode 100644
index 0000000000000..dc746254cf07f
--- /dev/null
+++ b/src/pages/workspace/travel/WorkspaceTravelSettlementAccountPage.tsx
@@ -0,0 +1,152 @@
+import React from 'react';
+import {View} from 'react-native';
+import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import Icon from '@components/Icon';
+import getBankIcon from '@components/Icon/BankIcons';
+import * as Expensicons from '@components/Icon/Expensicons';
+import MenuItem from '@components/MenuItem';
+import ScreenWrapper from '@components/ScreenWrapper';
+import SelectionList from '@components/SelectionList';
+import RadioListItem from '@components/SelectionList/ListItem/RadioListItem';
+import type {ListItem} from '@components/SelectionList/types';
+import Text from '@components/Text';
+import useLocalize from '@hooks/useLocalize';
+import useOnyx from '@hooks/useOnyx';
+import useThemeStyles from '@hooks/useThemeStyles';
+import useWorkspaceAccountID from '@hooks/useWorkspaceAccountID';
+import {setTravelInvoicingSettlementAccount} from '@libs/actions/TravelInvoicing';
+import {getLastFourDigits} from '@libs/BankAccountUtils';
+import {getEligibleBankAccountsForCard} from '@libs/CardUtils';
+import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
+import {REIMBURSEMENT_ACCOUNT_ROUTE_NAMES} from '@libs/ReimbursementAccountUtils';
+import {PROGRAM_TRAVEL_US} from '@libs/TravelInvoicingUtils';
+import Navigation from '@navigation/Navigation';
+import type {SettingsNavigatorParamList} from '@navigation/types';
+import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+import type SCREENS from '@src/SCREENS';
+import type {BankName} from '@src/types/onyx/Bank';
+
+type BankAccountListItem = ListItem & {value: number | undefined};
+
+type WorkspaceTravelSettlementAccountPageProps = PlatformStackScreenProps;
+
+function BankAccountListItemLeftElement({bankName}: {bankName: BankName}) {
+ const styles = useThemeStyles();
+ const {icon, iconSize, iconStyles} = getBankIcon({bankName, styles});
+
+ return (
+
+
+
+ );
+}
+
+function WorkspaceTravelSettlementAccountPage({route}: WorkspaceTravelSettlementAccountPageProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ const policyID = route.params?.policyID;
+ const workspaceAccountID = useWorkspaceAccountID(policyID);
+ const [bankAccountsList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST, {canBeMissing: true});
+ const [cardSettings] = useOnyx(`${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${workspaceAccountID}_${PROGRAM_TRAVEL_US}`, {canBeMissing: true});
+
+ const paymentBankAccountID = cardSettings?.paymentBankAccountID;
+ const eligibleBankAccounts = getEligibleBankAccountsForCard(bankAccountsList);
+
+ const eligibleBankAccountsOptions: BankAccountListItem[] = eligibleBankAccounts?.map((bankAccount) => {
+ const bankName = (bankAccount.accountData?.addressName ?? '') as BankName;
+ const bankAccountNumber = bankAccount.accountData?.accountNumber ?? '';
+ const bankAccountID = bankAccount.accountData?.bankAccountID ?? bankAccount.methodID;
+
+ return {
+ value: bankAccountID,
+ text: bankAccount.title,
+ leftElement: ,
+ alternateText: `${translate('workspace.expensifyCard.accountEndingIn')} ${getLastFourDigits(bankAccountNumber)}`,
+ keyForList: bankAccountID?.toString() ?? '',
+ isSelected: bankAccountID === paymentBankAccountID,
+ };
+ });
+
+ const listOptions: BankAccountListItem[] = eligibleBankAccountsOptions.length > 0 ? eligibleBankAccountsOptions : [];
+
+ const handleSelectAccount = (value: number) => {
+ const previousPaymentBankAccountID = cardSettings?.previousPaymentBankAccountID ?? cardSettings?.paymentBankAccountID;
+ setTravelInvoicingSettlementAccount(policyID, workspaceAccountID, value, previousPaymentBankAccountID);
+ Navigation.goBack();
+ };
+
+ return (
+
+
+ Navigation.goBack()}
+ />
+
+
+ {translate('workspace.expensifyCard.chooseExistingBank')}
+ {listOptions.length > 0 ? (
+ handleSelectAccount(value ?? 0)}
+ shouldSingleExecuteRowSelect
+ initiallyFocusedItemKey={paymentBankAccountID?.toString()}
+ listFooterContent={
+
+
+
+
+ );
+}
+
+WorkspaceTravelSettlementAccountPage.displayName = 'WorkspaceTravelSettlementAccountPage';
+
+export default WorkspaceTravelSettlementAccountPage;
diff --git a/src/pages/workspace/travel/WorkspaceTravelSettlementFrequencyPage.tsx b/src/pages/workspace/travel/WorkspaceTravelSettlementFrequencyPage.tsx
new file mode 100644
index 0000000000000..71c4a725a4f94
--- /dev/null
+++ b/src/pages/workspace/travel/WorkspaceTravelSettlementFrequencyPage.tsx
@@ -0,0 +1,86 @@
+import React from 'react';
+import type {ValueOf} from 'type-fest';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import ScreenWrapper from '@components/ScreenWrapper';
+import SelectionList from '@components/SelectionList';
+import RadioListItem from '@components/SelectionList/ListItem/RadioListItem';
+import type {ListItem} from '@components/SelectionList/types';
+import Text from '@components/Text';
+import useLocalize from '@hooks/useLocalize';
+import useOnyx from '@hooks/useOnyx';
+import useThemeStyles from '@hooks/useThemeStyles';
+import useWorkspaceAccountID from '@hooks/useWorkspaceAccountID';
+import {updateTravelInvoiceSettlementFrequency} from '@libs/actions/TravelInvoicing';
+import Navigation from '@libs/Navigation/Navigation';
+import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
+import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
+import {getTravelSettlementFrequency, PROGRAM_TRAVEL_US} from '@libs/TravelInvoicingUtils';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type SCREENS from '@src/SCREENS';
+
+type WorkspaceTravelSettlementFrequencyPageProps = PlatformStackScreenProps;
+
+type FrequencyItem = ListItem & {
+ value: ValueOf;
+};
+
+function WorkspaceTravelSettlementFrequencyPage({route}: WorkspaceTravelSettlementFrequencyPageProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ const policyID = route.params?.policyID;
+ const workspaceAccountID = useWorkspaceAccountID(policyID);
+ const [cardSettings] = useOnyx(`${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${workspaceAccountID}_${PROGRAM_TRAVEL_US}` as const, {canBeMissing: true});
+
+ const currentFrequency = getTravelSettlementFrequency(cardSettings);
+ const frequencies = [CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.MONTHLY, CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.DAILY];
+
+ function getSettlementFrequencyLabel(frequency: ValueOf) {
+ if (frequency === CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.MONTHLY) {
+ return translate('workspace.common.frequency.monthly');
+ }
+ if (frequency === CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.DAILY) {
+ return translate('workspace.common.frequency.immediate');
+ }
+ }
+
+ const data = frequencies?.map((frequency) => ({
+ text: getSettlementFrequencyLabel(frequency),
+ value: frequency,
+ keyForList: frequency,
+ isSelected: frequency === currentFrequency,
+ }));
+
+ const selectFrequency = (item: FrequencyItem) => {
+ updateTravelInvoiceSettlementFrequency(policyID, workspaceAccountID, item.value, cardSettings?.monthlySettlementDate ? new Date(cardSettings.monthlySettlementDate) : undefined);
+ Navigation.goBack();
+ };
+
+ return (
+
+ Navigation.goBack()}
+ />
+
+ data={data}
+ onSelectRow={selectFrequency}
+ ListItem={RadioListItem}
+ initiallyFocusedItemKey={currentFrequency}
+ customListHeaderContent={
+
+ {translate('workspace.moreFeatures.travel.travelInvoicing.centralInvoicingSection.subsections.settlementFrequencyDescription')}
+
+ }
+ />
+
+ );
+}
+
+WorkspaceTravelSettlementFrequencyPage.displayName = 'WorkspaceTravelSettlementFrequencyPage';
+
+export default WorkspaceTravelSettlementFrequencyPage;
diff --git a/src/styles/utils/spacing.ts b/src/styles/utils/spacing.ts
index 152b4de5ab5eb..96e8d53f3de6b 100644
--- a/src/styles/utils/spacing.ts
+++ b/src/styles/utils/spacing.ts
@@ -47,6 +47,10 @@ export default {
marginHorizontal: 8,
},
+ mh2half: {
+ marginHorizontal: 10,
+ },
+
mh3: {
marginHorizontal: 12,
},
@@ -519,6 +523,10 @@ export default {
paddingVertical: 24,
},
+ pv8: {
+ paddingVertical: 32,
+ },
+
pv10: {
paddingVertical: 40,
},
diff --git a/src/types/onyx/ExpensifyCardSettings.ts b/src/types/onyx/ExpensifyCardSettings.ts
index df70b4adc5990..f755eae43cf25 100644
--- a/src/types/onyx/ExpensifyCardSettings.ts
+++ b/src/types/onyx/ExpensifyCardSettings.ts
@@ -17,9 +17,15 @@ type ExpensifyCardSettings = OnyxCommon.OnyxValueWithOfflineFeedback<{
/** Whether monthly option should appear in the settlement frequency settings */
isMonthlySettlementAllowed: boolean;
+ /** The previous monthly settlement date, used for reverting failed updates */
+ previousMonthlySettlementDate?: Date;
+
/** The bank account chosen for the card settlement */
paymentBankAccountID: number;
+ /** The previous bank account chosen for the card settlement, used for reverting failed updates */
+ previousPaymentBankAccountID?: number;
+
/** Whether we are loading the data via the API */
isLoading?: boolean;
@@ -43,6 +49,9 @@ type ExpensifyCardSettings = OnyxCommon.OnyxValueWithOfflineFeedback<{
/** Number of the bank account used for the card settlement */
paymentBankAccountNumber?: string;
+
+ /** Collections of form field errors */
+ errorFields?: OnyxCommon.ErrorFields;
}>;
export default ExpensifyCardSettings;
diff --git a/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx b/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx
new file mode 100644
index 0000000000000..dbd2db9b22c6a
--- /dev/null
+++ b/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx
@@ -0,0 +1,318 @@
+/* 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}),
+}));
+
+jest.mock('@libs/Navigation/Navigation', () => ({
+ __esModule: true,
+ default: {
+ navigate: jest.fn(),
+ getActiveRoute: jest.fn(() => ''),
+ },
+}));
+
+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();
+ });
+
+ it('should show correct frequency value and navigate on press', async () => {
+ // Given Travel Invoicing is configured with Monthly frequency (default if monthlySettlementDate exists)
+ await act(async () => {
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, mockPolicy);
+ await Onyx.merge(travelInvoicingKey, {
+ paymentBankAccountID: 12345,
+ remainingLimit: 50000,
+ currentBalance: 10000,
+ monthlySettlementDate: '2023-10-01',
+ });
+ await Onyx.merge(bankAccountKey, {
+ 12345: {
+ accountData: {
+ addressName: 'Test Company',
+ accountNumber: '****1234',
+ bankAccountID: 12345,
+ },
+ },
+ });
+ await waitForBatchedUpdatesWithAct();
+ });
+
+ // When rendering the component
+ renderWorkspaceTravelInvoicingSection();
+
+ await waitForBatchedUpdatesWithAct();
+
+ // Then it should display "Monthly"
+ expect(screen.getByText('Monthly')).toBeTruthy();
+ expect(screen.getByText('Settlement frequency')).toBeTruthy();
+ });
+ });
+});
diff --git a/tests/unit/TravelInvoicingTest.ts b/tests/unit/TravelInvoicingTest.ts
new file mode 100644
index 0000000000000..550908cbe56de
--- /dev/null
+++ b/tests/unit/TravelInvoicingTest.ts
@@ -0,0 +1,206 @@
+import Onyx from 'react-native-onyx';
+import {
+ clearTravelInvoicingSettlementAccountErrors,
+ clearTravelInvoicingSettlementFrequencyErrors,
+ setTravelInvoicingSettlementAccount,
+ updateTravelInvoiceSettlementFrequency,
+} from '@libs/actions/TravelInvoicing';
+// We need to import API because it is used in the tests
+// eslint-disable-next-line no-restricted-syntax
+import * as API from '@libs/API';
+import {PROGRAM_TRAVEL_US} from '@libs/TravelInvoicingUtils';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+
+describe('TravelInvoicing', () => {
+ let spyAPIWrite: jest.SpyInstance;
+ let spyOnyxUpdate: jest.SpyInstance;
+
+ beforeEach(() => {
+ spyAPIWrite = jest.spyOn(API, 'write');
+ spyOnyxUpdate = jest.spyOn(Onyx, 'update');
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('setTravelInvoicingSettlementAccount sends correct optimistic, success, and failure data', () => {
+ const policyID = '123';
+ const workspaceAccountID = 456;
+ const settlementBankAccountID = 789;
+ const previousPaymentBankAccountID = 111;
+ const cardSettingsKey = `${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${workspaceAccountID}_${PROGRAM_TRAVEL_US}`;
+
+ setTravelInvoicingSettlementAccount(policyID, workspaceAccountID, settlementBankAccountID, previousPaymentBankAccountID);
+
+ expect(spyAPIWrite).toHaveBeenCalledWith(
+ 'SetTravelInvoicingSettlementAccount',
+ {
+ policyID,
+ settlementBankAccountID,
+ },
+ expect.objectContaining({
+ optimisticData: expect.arrayContaining([
+ expect.objectContaining({
+ key: cardSettingsKey,
+ value: expect.objectContaining({
+ paymentBankAccountID: settlementBankAccountID,
+ previousPaymentBankAccountID,
+ pendingFields: expect.objectContaining({
+ paymentBankAccountID: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
+ }),
+ isLoading: true,
+ }),
+ }),
+ ]),
+ successData: expect.arrayContaining([
+ expect.objectContaining({
+ key: cardSettingsKey,
+ value: expect.objectContaining({
+ paymentBankAccountID: settlementBankAccountID,
+ previousPaymentBankAccountID: null,
+ pendingFields: expect.objectContaining({
+ paymentBankAccountID: null,
+ }),
+ isLoading: false,
+ }),
+ }),
+ ]),
+ failureData: expect.arrayContaining([
+ expect.objectContaining({
+ key: cardSettingsKey,
+ value: expect.objectContaining({
+ paymentBankAccountID: settlementBankAccountID,
+ previousPaymentBankAccountID,
+ pendingFields: expect.objectContaining({
+ paymentBankAccountID: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
+ }),
+ isLoading: false,
+ }),
+ }),
+ ]),
+ }),
+ );
+ });
+
+ it('clearTravelInvoicingSettlementAccountErrors clears errors and pendingFields', () => {
+ const workspaceAccountID = 456;
+ const restoredAccountID = 111;
+ const cardSettingsKey = `${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${workspaceAccountID}_${PROGRAM_TRAVEL_US}`;
+
+ clearTravelInvoicingSettlementAccountErrors(workspaceAccountID, restoredAccountID);
+
+ expect(spyOnyxUpdate).toHaveBeenCalledWith(
+ expect.arrayContaining([
+ expect.objectContaining({
+ key: cardSettingsKey,
+ value: expect.objectContaining({
+ errorFields: {
+ paymentBankAccountID: null,
+ },
+ pendingFields: expect.objectContaining({
+ paymentBankAccountID: null,
+ }),
+ paymentBankAccountID: restoredAccountID,
+ previousPaymentBankAccountID: null,
+ }),
+ }),
+ ]),
+ );
+ });
+
+ it('clearTravelInvoicingSettlementFrequencyErrors clears errors', () => {
+ const workspaceAccountID = 456;
+ const cardSettingsKey = `${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${workspaceAccountID}_${PROGRAM_TRAVEL_US}`;
+
+ clearTravelInvoicingSettlementFrequencyErrors(workspaceAccountID, undefined);
+
+ expect(spyOnyxUpdate).toHaveBeenCalledWith(
+ expect.arrayContaining([
+ expect.objectContaining({
+ key: cardSettingsKey,
+ value: expect.objectContaining({
+ errors: null,
+ }),
+ }),
+ ]),
+ );
+ });
+
+ it('updateTravelInvoiceSettlementFrequency sends correct optimistic, success, and failure data', () => {
+ const policyID = '123';
+ const workspaceAccountID = 456;
+ const frequency = CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.MONTHLY;
+ const currentMonthlySettlementDate = new Date('2024-01-01');
+ const cardSettingsKey = `${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${workspaceAccountID}_${PROGRAM_TRAVEL_US}`;
+
+ // Set fake time to ensure deterministic optimistic data
+ const mockDate = new Date('2024-05-20');
+ jest.useFakeTimers();
+ jest.setSystemTime(mockDate);
+
+ updateTravelInvoiceSettlementFrequency(policyID, workspaceAccountID, frequency, currentMonthlySettlementDate);
+
+ expect(spyAPIWrite).toHaveBeenCalledWith(
+ 'UpdateTravelInvoiceSettlementFrequency',
+ {
+ policyID,
+ workspaceAccountID,
+ settlementFrequency: frequency,
+ },
+ expect.objectContaining({
+ optimisticData: expect.arrayContaining([
+ expect.objectContaining({
+ key: cardSettingsKey,
+ value: expect.objectContaining({
+ monthlySettlementDate: mockDate,
+ previousMonthlySettlementDate: currentMonthlySettlementDate,
+ pendingFields: expect.objectContaining({
+ monthlySettlementDate: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
+ }),
+ errors: null,
+ errorFields: {
+ monthlySettlementDate: null,
+ },
+ }),
+ }),
+ ]),
+ successData: expect.arrayContaining([
+ expect.objectContaining({
+ key: cardSettingsKey,
+ value: expect.objectContaining({
+ monthlySettlementDate: mockDate,
+ previousMonthlySettlementDate: null,
+ pendingFields: expect.objectContaining({
+ monthlySettlementDate: null,
+ }),
+ errors: null,
+ errorFields: {
+ monthlySettlementDate: null,
+ },
+ }),
+ }),
+ ]),
+ failureData: expect.arrayContaining([
+ expect.objectContaining({
+ key: cardSettingsKey,
+ value: expect.objectContaining({
+ monthlySettlementDate: mockDate,
+ previousMonthlySettlementDate: currentMonthlySettlementDate,
+ pendingFields: expect.objectContaining({
+ monthlySettlementDate: null,
+ }),
+ errors: null,
+ errorFields: {
+ monthlySettlementDate: expect.anything() as unknown,
+ },
+ }),
+ }),
+ ]),
+ }),
+ );
+
+ jest.useRealTimers();
+ });
+});
diff --git a/tests/unit/TravelInvoicingUtilsTest.ts b/tests/unit/TravelInvoicingUtilsTest.ts
new file mode 100644
index 0000000000000..d187978a0d05a
--- /dev/null
+++ b/tests/unit/TravelInvoicingUtilsTest.ts
@@ -0,0 +1,200 @@
+import CONST from '@src/CONST';
+import {
+ getIsTravelInvoicingEnabled,
+ getTravelLimit,
+ getTravelSettlementAccount,
+ getTravelSettlementFrequency,
+ getTravelSpend,
+ hasTravelInvoicingSettlementAccount,
+ PROGRAM_TRAVEL_US,
+} 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(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 monthly (default) when cardSettings is undefined', () => {
+ const result = getTravelSettlementFrequency(undefined);
+ expect(result).toBe(CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.MONTHLY);
+ });
+
+ 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('');
+ });
+ });
+});