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 ( + <> +
+ openExternalLink(CONST.FOOTER.TRAVEL_URL))} + disabled={isExecuting} + wrapperStyle={styles.sectionMenuItemTopDescription} + iconRight={icons.NewWindow} + icon={icons.LuggageWithLines} + shouldShowRightIcon + /> +
+ {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(''); + }); + }); +});