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 ( + <> +
+ openExternalLink(CONST.FOOTER.TRAVEL_URL))} + disabled={isExecuting} + wrapperStyle={styles.ph8} + iconRight={icons.NewWindow} + icon={icons.LuggageWithLines} + shouldShowRightIcon + /> +
+ {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={ + + Navigation.navigate( + ROUTES.BANK_ACCOUNT_WITH_STEP_TO_OPEN.getRoute( + policyID, + REIMBURSEMENT_ACCOUNT_ROUTE_NAMES.NEW, + ROUTES.WORKSPACE_TRAVEL_SETTINGS_ACCOUNT.getRoute(policyID), + ), + ) + } + /> + } + /> + ) : ( + + Navigation.navigate( + ROUTES.BANK_ACCOUNT_WITH_STEP_TO_OPEN.getRoute( + policyID, + REIMBURSEMENT_ACCOUNT_ROUTE_NAMES.NEW, + ROUTES.WORKSPACE_TRAVEL_SETTINGS_ACCOUNT.getRoute(policyID), + ), + ) + } + /> + )} + + + + + ); +} + +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(''); + }); + }); +});