From 42e06725dc85e87333bc258ca94f24e79529795e Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Tue, 6 Jan 2026 19:01:04 -0800 Subject: [PATCH 01/21] Feature: [Travel Invoicing] Release 2.1: Opening the Travel Invoicing Page --- .../simple-illustration__calendar-monthly.svg | 28 ++ .../simple-illustration__fastmoney.svg | 31 ++ src/components/Icon/Illustrations.ts | 4 + .../Icon/chunks/illustrations.chunk.ts | 4 + src/languages/de.ts | 16 + src/languages/en.ts | 16 + src/languages/es.ts | 17 ++ src/languages/fr.ts | 16 + src/languages/it.ts | 16 + src/languages/ja.ts | 12 + src/languages/nl.ts | 16 + src/languages/pl.ts | 16 + src/languages/pt-BR.ts | 16 + src/languages/zh-hans.ts | 12 + src/libs/TravelInvoicingUtils.ts | 97 +++++++ .../travel/GetStartedTravelInvoicing.tsx | 70 +++++ .../workspace/travel/PolicyTravelPage.tsx | 5 + .../WorkspaceTravelInvoicingSection.tsx | 158 ++++++++++ .../WorkspaceTravelInvoicingSectionTest.tsx | 274 ++++++++++++++++++ tests/unit/TravelInvoicingUtilsTest.ts | 200 +++++++++++++ 20 files changed, 1024 insertions(+) create mode 100644 assets/images/simple-illustrations/simple-illustration__calendar-monthly.svg create mode 100644 assets/images/simple-illustrations/simple-illustration__fastmoney.svg create mode 100644 src/libs/TravelInvoicingUtils.ts create mode 100644 src/pages/workspace/travel/GetStartedTravelInvoicing.tsx create mode 100644 src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx create mode 100644 tests/ui/WorkspaceTravelInvoicingSectionTest.tsx create mode 100644 tests/unit/TravelInvoicingUtilsTest.ts 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..b8b13cd2367db --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__calendar-monthly.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..720c87b4819ae --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__fastmoney.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts index 7c3efcfe4994c..035354a1148b3 100644 --- a/src/components/Icon/Illustrations.ts +++ b/src/components/Icon/Illustrations.ts @@ -14,6 +14,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'; @@ -28,6 +29,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'; @@ -53,6 +55,7 @@ export { EmailAddress, EmptyCardState, EnvelopeReceipt, + FastMoney, ExpensifyCardImage, Mailbox, CreditCardsNewGreen, @@ -73,6 +76,7 @@ export { Approval, House, Buildings, + CalendarMonthly, Alert, Abacus, Binoculars, diff --git a/src/components/Icon/chunks/illustrations.chunk.ts b/src/components/Icon/chunks/illustrations.chunk.ts index b7048b17c3af2..1e47d301ac427 100644 --- a/src/components/Icon/chunks/illustrations.chunk.ts +++ b/src/components/Icon/chunks/illustrations.chunk.ts @@ -92,6 +92,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'; @@ -107,6 +108,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'; @@ -305,6 +307,7 @@ const Illustrations = { Approval, Binoculars, Buildings, + CalendarMonthly, Car, ChatBubbles, CheckmarkCircle, @@ -316,6 +319,7 @@ const Illustrations = { EmptyShelves, Encryption, EnvelopeReceipt, + FastMoney, Filters, Flash, Gears, diff --git a/src/languages/de.ts b/src/languages/de.ts index 3bf6eb8ebcb8c..b4af9b1217789 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -3629,6 +3629,9 @@ ${ features: { saveMoney: 'Spare Geld bei deinen Buchungen', alerts: 'Erhalten Sie Echtzeit-Benachrichtigungen, wenn sich Ihre Reisepläne ändern', + easyPayments: 'Einfache Zahlungen für Ihre Mitglieder', + travelSpendLimits: 'Reiseausgabenlimits festlegen', + invoicedMonthlyWeekly: 'Monatlich oder wöchentlich abrechnen lassen', }, bookTravel: 'Reise buchen', bookDemo: 'Demo buchen', @@ -5045,6 +5048,19 @@ _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: { + title: 'Reiserechnungsstellung', + description: 'Zentralisieren Sie alle Mitarbeiterreisen auf einer einzigen Rechnung.', + currentSpend: 'Aktuelle Ausgaben', + spendLimit: 'Ausgabenlimit', + settlementAccount: 'Verrechnungskonto', + settlementFrequency: 'Häufigkeit der Abrechnung', + setup: { + title: 'Zentrale Reiseabrechnung einrichten', + subtitle: 'Ermöglichen Sie Ihren Mitgliedern, Reisen direkt über den Workspace zu bezahlen und abzurechnen', + ctaText: 'Reiseabrechnung einrichten', + }, + }, }, expensifyCard: { title: 'Expensify Card', diff --git a/src/languages/en.ts b/src/languages/en.ts index fc3b9e4cbd34a..5a95b72d9a25a 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -3588,6 +3588,9 @@ const translations = { features: { saveMoney: 'Save money on your bookings', alerts: 'Get realtime alerts if your travel plans change', + easyPayments: 'Easy payments for your members', + travelSpendLimits: 'Set travel spend limits', + invoicedMonthlyWeekly: 'Get invoiced monthly or weekly', }, bookTravel: 'Book travel', bookDemo: 'Book demo', @@ -4930,6 +4933,19 @@ 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: { + title: 'Travel Invoicing', + description: 'Centralize all employee travel on a single invoice.', + currentSpend: 'Current spend', + spendLimit: 'Spend limit', + settlementAccount: 'Settlement account', + settlementFrequency: 'Settlement frequency', + setup: { + title: 'Set up centralized travel invoicing', + subtitle: 'Allow your members to pay and bill travel directly to the workspace', + ctaText: 'Set up travel invoicing', + }, + }, }, expensifyCard: { title: 'Expensify Card', diff --git a/src/languages/es.ts b/src/languages/es.ts index 3a370bfc8e2c0..f4bcf53a24a0c 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -3286,6 +3286,9 @@ ${amount} para ${merchant} - ${date}`, features: { saveMoney: 'Ahorra dinero en tus reservas', alerts: 'Recibe alertas en tiempo real si tus planes de viaje cambian', + easyPayments: 'Pagos fáciles para tus miembros', + travelSpendLimits: 'Establece límites de gasto en viajes', + invoicedMonthlyWeekly: 'Recibe facturas mensual o semanalmente', }, bookTravel: 'Reservar viajes', bookDemo: 'Pedir demostración', @@ -4642,6 +4645,20 @@ ${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: { + title: 'Facturación de viajes', + description: 'Centraliza todos los viajes de los empleados en una sola factura.', + currentSpend: 'Gasto actual', + spendLimit: 'Límite de gasto', + settlementAccount: 'Cuenta de liquidación', + settlementFrequency: 'Frecuencia de liquidación', + setup: { + title: 'Configurar facturación de viajes', + subtitle: + 'Consolida todas las reservas de viajes de los empleados en una sola factura a nivel de empresa. Los gastos son gestionados por Expensify y facturados a intervalos regulares.', + ctaText: 'Comenzar', + }, + }, }, expensifyCard: { title: 'Tarjeta Expensify', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index db35ff1712160..7b872063ab832 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -3636,6 +3636,9 @@ ${ features: { saveMoney: 'Économisez de l’argent sur vos réservations', alerts: 'Recevez des alertes en temps réel si vos plans de voyage changent', + easyPayments: 'Des paiements faciles pour vos membres', + travelSpendLimits: 'Définir des limites de dépenses de voyage', + invoicedMonthlyWeekly: 'Être facturé chaque mois ou chaque semaine', }, bookTravel: 'Réserver un voyage', bookDemo: 'Réserver une démo', @@ -5051,6 +5054,19 @@ _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: { + title: 'Facturation de voyage', + description: 'Centralisez tous les déplacements des employés sur une seule facture.', + currentSpend: 'Dépense actuelle', + spendLimit: 'Limite de dépenses', + settlementAccount: 'Compte de règlement', + settlementFrequency: 'Fréquence de règlement', + setup: { + title: 'Configurer la facturation centralisée des déplacements', + subtitle: 'Autorisez vos membres à payer et à facturer les déplacements directement à l’espace de travail', + ctaText: 'Configurer la facturation des déplacements', + }, + }, }, expensifyCard: { title: 'Carte Expensify', diff --git a/src/languages/it.ts b/src/languages/it.ts index ca5530401bcae..cfd53a54edf72 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -3615,6 +3615,9 @@ ${ features: { saveMoney: 'Risparmia denaro sulle tue prenotazioni', alerts: 'Ricevi avvisi in tempo reale se i tuoi piani di viaggio cambiano', + easyPayments: 'Pagamenti facili per i tuoi membri', + travelSpendLimits: 'Imposta limiti di spesa per i viaggi', + invoicedMonthlyWeekly: 'Ricevi fatture mensili o settimanali', }, bookTravel: 'Prenota viaggio', bookDemo: 'Prenota demo', @@ -5029,6 +5032,19 @@ _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: { + title: 'Fatturazione viaggi', + description: 'Centralizza tutti i viaggi dei dipendenti su un’unica fattura.', + currentSpend: 'Spesa attuale', + spendLimit: 'Limite di spesa', + settlementAccount: 'Conto di regolamento', + settlementFrequency: 'Frequenza di liquidazione', + setup: { + title: 'Imposta la fatturazione centralizzata dei viaggi', + subtitle: 'Consenti ai tuoi membri di pagare e addebitare i viaggi direttamente allo spazio di lavoro', + ctaText: 'Imposta la fatturazione dei viaggi', + }, + }, }, expensifyCard: { title: 'Carta Expensify', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 04ae3d174601f..7d328cf02fd19 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -3605,6 +3605,9 @@ ${ features: { saveMoney: '予約で節約しましょう', alerts: '旅行計画が変更された場合のリアルタイムアラートを受け取る', + easyPayments: 'メンバーのための簡単なお支払い', + travelSpendLimits: '出張費の上限を設定', + invoicedMonthlyWeekly: '毎月または毎週の請求書発行', }, bookTravel: '出張を予約', bookDemo: 'デモを予約', @@ -5000,6 +5003,15 @@ _より詳しい手順については、[ヘルプサイトをご覧ください subtitle: 'Expensify Travelを使用して最高の旅行オファーを取得し、すべてのビジネス経費を一箇所で管理します。', ctaText: '予約または管理', }, + travelInvoicing: { + title: '出張請求', + description: 'すべての従業員の出張を 1 つの請求書に集約します。', + currentSpend: '現在の支出', + spendLimit: '利用上限', + settlementAccount: '決済口座', + settlementFrequency: '決済頻度', + setup: {title: '出張費の集中請求を設定', subtitle: 'メンバーが出張費をワークスペースに直接請求・支払いできるようにします', ctaText: '出張請求書を設定'}, + }, }, expensifyCard: { title: 'Expensify Card', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 06e93f0ab1454..175d757da0313 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -3613,6 +3613,9 @@ ${ features: { saveMoney: 'Bespaar geld op je boekingen', alerts: 'Ontvang realtime meldingen als uw reisplannen veranderen', + easyPayments: 'Moeiteloze betalingen voor je leden', + travelSpendLimits: 'Reisuitgavenlimieten instellen', + invoicedMonthlyWeekly: 'Maandelijks of wekelijks gefactureerd worden', }, bookTravel: 'Reis boeken', bookDemo: 'Demo boeken', @@ -5023,6 +5026,19 @@ _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: { + title: 'Facturatie voor reizen', + description: 'Centraliseer alle zakelijke reizen van medewerkers op één enkele factuur.', + currentSpend: 'Huidige uitgaven', + spendLimit: 'Bestedingslimiet', + settlementAccount: 'Verrekeningsrekening', + settlementFrequency: 'Afrekeningsfrequentie', + setup: { + title: 'Gecentraliseerde reisfacturatie instellen', + subtitle: 'Sta uw leden toe reizen rechtstreeks via de workspace te betalen en te factureren', + ctaText: 'Reisfacturatie instellen', + }, + }, }, expensifyCard: { title: 'Expensify Card', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index e55050b7811cf..552cf2ef3fc7f 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -3610,6 +3610,9 @@ ${ features: { saveMoney: 'Oszczędzaj pieniądze na swoich rezerwacjach', alerts: 'Otrzymuj alerty w czasie rzeczywistym, jeśli Twoje plany podróży się zmienią', + easyPayments: 'Łatwe płatności dla Twoich członków', + travelSpendLimits: 'Ustaw limity wydatków na podróże', + invoicedMonthlyWeekly: 'Otrzymuj faktury co miesiąc lub co tydzień', }, bookTravel: 'Zarezerwuj podróż', bookDemo: 'Zarezerwuj demo', @@ -5013,6 +5016,19 @@ _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: { + title: 'Fakturowanie podróży', + description: 'Scentralizuj wszystkie podróże służbowe pracowników na jednej fakturze.', + currentSpend: 'Bieżące wydatki', + spendLimit: 'Limit wydatków', + settlementAccount: 'Konto rozliczeniowe', + settlementFrequency: 'Częstotliwość rozliczeń', + setup: { + title: 'Skonfiguruj scentralizowane fakturowanie podróży', + subtitle: 'Pozwól członkom rozliczać i fakturować podróże bezpośrednio na przestrzeń roboczą', + ctaText: 'Skonfiguruj fakturowanie podróży', + }, + }, }, expensifyCard: { title: 'Karta Expensify', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 66f72484e6560..932eaffd41b67 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -3609,6 +3609,9 @@ ${ features: { saveMoney: 'Economize nas suas reservas', alerts: 'Receba alertas em tempo real se seus planos de viagem mudarem', + easyPayments: 'Pagamentos fáceis para seus membros', + travelSpendLimits: 'Definir limites de gastos de viagem', + invoicedMonthlyWeekly: 'Seja faturado mensalmente ou semanalmente', }, bookTravel: 'Reservar viagem', bookDemo: 'Agendar demonstração', @@ -5013,6 +5016,19 @@ _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: { + title: 'Faturamento de Viagens', + description: 'Centralize todas as viagens dos funcionários em uma única fatura.', + currentSpend: 'Gasto atual', + spendLimit: 'Limite de gastos', + settlementAccount: 'Conta de liquidação', + settlementFrequency: 'Frequência de liquidação', + setup: { + title: 'Configurar faturamento centralizado de viagens', + subtitle: 'Permita que seus membros paguem e faturem viagens diretamente ao workspace', + ctaText: 'Configurar faturamento de viagens', + }, + }, }, expensifyCard: { title: 'Cartão Expensify', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index d7e82bb95e7b5..76484fb0fef0a 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -3559,6 +3559,9 @@ ${ features: { saveMoney: '在预订时省钱', alerts: '如果您的旅行计划发生变化,获取实时提醒', + easyPayments: '为您的成员提供便捷付款', + travelSpendLimits: '设置差旅支出限额', + invoicedMonthlyWeekly: '按月或按周开具发票', }, bookTravel: '预订出行', bookDemo: '预订演示', @@ -4925,6 +4928,15 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM subtitle: '使用 Expensify Travel 获得最佳旅行优惠,并在一个地方管理所有商务费用。', ctaText: '预订或管理', }, + travelInvoicing: { + title: '旅行发票', + description: '将所有员工差旅集中到一张发票中。', + currentSpend: '当前支出', + spendLimit: '支出限额', + settlementAccount: '结算账户', + settlementFrequency: '结算频率', + setup: {title: '设置集中化差旅发票管理', subtitle: '允许您的成员直接将差旅费用支付和记入该工作区', ctaText: '设置差旅开票'}, + }, }, expensifyCard: { title: 'Expensify Card', diff --git a/src/libs/TravelInvoicingUtils.ts b/src/libs/TravelInvoicingUtils.ts new file mode 100644 index 0000000000000..8449c7684ec56 --- /dev/null +++ b/src/libs/TravelInvoicingUtils.ts @@ -0,0 +1,97 @@ +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. + * 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 {PROGRAM_TRAVEL_US, getIsTravelInvoicingEnabled, hasTravelInvoicingSettlementAccount, getTravelLimit, getTravelSpend, getTravelSettlementAccount, getTravelSettlementFrequency}; + +export type {TravelSettlementAccountInfo}; diff --git a/src/pages/workspace/travel/GetStartedTravelInvoicing.tsx b/src/pages/workspace/travel/GetStartedTravelInvoicing.tsx new file mode 100644 index 0000000000000..958275f2c20c6 --- /dev/null +++ b/src/pages/workspace/travel/GetStartedTravelInvoicing.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import Button from '@components/Button'; +import FeatureList from '@components/FeatureList'; +import type {FeatureListItem} from '@components/FeatureList'; +import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import colors from '@styles/theme/colors'; + +type GetStartedTravelInvoicingProps = { + /** The policyID for the workspace */ + policyID: string; + + /** Callback when the CTA button is pressed */ + onCtaPress?: (policyID: string) => void; +}; + +function GetStartedTravelInvoicing({policyID, onCtaPress}: GetStartedTravelInvoicingProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const illustrations = useMemoizedLazyIllustrations(['EmptyStateTravel', 'FastMoney', 'Abacus', 'CalendarMonthly'] as const); + + const handleCtaPress = () => { + // TODO: Navigate to Travel Invoicing setup flow + // This will be implemented when the setup flow is ready + onCtaPress?.(policyID); + }; + + const travelInvoicingFeatures: FeatureListItem[] = [ + { + icon: illustrations.FastMoney, + translationKey: 'travel.features.easyPayments', + }, + { + icon: illustrations.Abacus, + translationKey: 'travel.features.travelSpendLimits', + }, + { + icon: illustrations.CalendarMonthly, + translationKey: 'travel.features.invoicedMonthlyWeekly', + }, + ]; + + return ( + + } + /> + ); +} + +GetStartedTravelInvoicing.displayName = 'GetStartedTravelInvoicing'; + +export default GetStartedTravelInvoicing; diff --git a/src/pages/workspace/travel/PolicyTravelPage.tsx b/src/pages/workspace/travel/PolicyTravelPage.tsx index f41e65f4648a1..d40bc2682fd2f 100644 --- a/src/pages/workspace/travel/PolicyTravelPage.tsx +++ b/src/pages/workspace/travel/PolicyTravelPage.tsx @@ -22,6 +22,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,6 +38,7 @@ function WorkspaceTravelPage({ const {translate} = useLocalize(); const policy = usePolicy(policyID); const illustrations = useMemoizedLazyIllustrations(['Luggage'] as const); + const isTravelInvoicingEnabled = isBetaEnabled(CONST.BETAS.TRAVEL_INVOICING); const {login: currentUserLogin} = useCurrentUserPersonalDetails(); const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: false}); @@ -46,6 +48,9 @@ function WorkspaceTravelPage({ 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..a0b5bade998fd --- /dev/null +++ b/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx @@ -0,0 +1,158 @@ +import React from 'react'; +import {View} from 'react-native'; +import Hoverable from '@components/Hoverable'; +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 useThemeStyles from '@hooks/useThemeStyles'; +import useWorkspaceAccountID from '@hooks/useWorkspaceAccountID'; +import {convertToDisplayString} from '@libs/CurrencyUtils'; +import { + getIsTravelInvoicingEnabled, + getTravelLimit, + getTravelSettlementAccount, + getTravelSettlementFrequency, + getTravelSpend, + hasTravelInvoicingSettlementAccount, + PROGRAM_TRAVEL_US, +} from '@libs/TravelInvoicingUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ToggleSettingOptionRow from '../workflows/ToggleSettingsOptionRow'; +import GetStartedTravelInvoicing from './GetStartedTravelInvoicing'; + +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 icons = useMemoizedLazyExpensifyIcons(['LuggageWithLines', 'NewWindow']); + + // 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 = translate(`workspace.expensifyCard.frequency.${settlementFrequency}`); + + // 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); + + // If Travel Invoicing is not enabled or no settlement account is configured, show the setup CTA + if (!isTravelInvoicingEnabled || !hasSettlementAccount) { + return ; + } + + return ( + <> +
+ {/* Manage travel */} + + + +
+
+ {/* Central invoicing toggle */} + + + {() => ( + + {}} + title={'Central invoicing'} + titleStyle={styles.textStrong} + subtitle={'Allow your members to pay and bill travel directly to the workspace'} + switchAccessibilityLabel={'Allow your members to pay and bill travel directly to the workspace'} + isActive={true} + pendingAction={null} + onToggle={() => {}} + showLockIcon={false} + errors={null} + onCloseError={() => {}} + onPress={() => {}} + /> + + )} + + + + {/* Current travel spend */} + + + + + {/* Travel spend limit */} + + + + + {/* Settlement account */} + + + + + {/* Settlement frequency */} + + + +
+ + ); +} + +WorkspaceTravelInvoicingSection.displayName = 'WorkspaceTravelInvoicingSection'; + +export default WorkspaceTravelInvoicingSection; diff --git a/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx b/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx new file mode 100644 index 0000000000000..79123fcedfef0 --- /dev/null +++ b/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx @@ -0,0 +1,274 @@ +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 {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', () => { + const actualNav = jest.requireActual('@react-navigation/native'); + 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 setup CTA 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 setup CTA should be visible (GetStartedTravelInvoicing) + expect(screen.getByText('Set up centralized travel invoicing')).toBeTruthy(); + }); + + it('should show setup CTA 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`; + + 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 setup CTA should be visible + expect(screen.getByText('Set up centralized travel invoicing')).toBeTruthy(); + }); + }); + + describe('When Travel Invoicing is configured', () => { + const travelInvoicingKey = `${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${WORKSPACE_ACCOUNT_ID}_TRAVEL_US`; + 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 Invoicing')).toBeTruthy(); + }); + + it('should display current spend 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 spend label should be visible + expect(screen.getByText('Current spend')).toBeTruthy(); + }); + + it('should display spend limit 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 spend limit label should be visible + expect(screen.getByText('Spend limit')).toBeTruthy(); + }); + + it('should display settlement account information', 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 information', 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..0dc48cbf2b30e --- /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', () => { + const result = getIsTravelInvoicingEnabled(null); + 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 = { + '12345': { + accountData: { + addressName: 'Test Company', + accountNumber: '****1234', + routingNumber: '123456789', + bankName: 'Test Bank', + accountType: 'checking', + bankAccountID: 12345, + processor: 'dwolla', + }, + }, + }; + + 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: 12345, + } 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(''); + }); + }); +}); From 7f429029f57a501f50c8eafd0b25ed872de62402 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Tue, 6 Jan 2026 19:19:02 -0800 Subject: [PATCH 02/21] fix: failing workflows --- .../simple-illustration__calendar-monthly.svg | 29 +---------------- .../simple-illustration__fastmoney.svg | 32 +------------------ .../WorkspaceTravelInvoicingSection.tsx | 23 +++++++------ .../WorkspaceTravelInvoicingSectionTest.tsx | 27 ++++++++++------ tests/unit/TravelInvoicingUtilsTest.ts | 8 ++--- 5 files changed, 37 insertions(+), 82 deletions(-) diff --git a/assets/images/simple-illustrations/simple-illustration__calendar-monthly.svg b/assets/images/simple-illustrations/simple-illustration__calendar-monthly.svg index b8b13cd2367db..906bd7ae4564d 100644 --- a/assets/images/simple-illustrations/simple-illustration__calendar-monthly.svg +++ b/assets/images/simple-illustrations/simple-illustration__calendar-monthly.svg @@ -1,28 +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 index 720c87b4819ae..d479400e1830d 100644 --- a/assets/images/simple-illustrations/simple-illustration__fastmoney.svg +++ b/assets/images/simple-illustrations/simple-illustration__fastmoney.svg @@ -1,31 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx b/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx index a0b5bade998fd..efa03e9999435 100644 --- a/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx +++ b/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx @@ -19,9 +19,9 @@ import { hasTravelInvoicingSettlementAccount, PROGRAM_TRAVEL_US, } from '@libs/TravelInvoicingUtils'; +import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ToggleSettingOptionRow from '../workflows/ToggleSettingsOptionRow'; import GetStartedTravelInvoicing from './GetStartedTravelInvoicing'; type WorkspaceTravelInvoicingSectionProps = { @@ -52,7 +52,10 @@ function WorkspaceTravelInvoicingSection({policyID}: WorkspaceTravelInvoicingSec const travelLimit = getTravelLimit(cardSettings); const settlementAccount = getTravelSettlementAccount(cardSettings, bankAccountList); const settlementFrequency = getTravelSettlementFrequency(cardSettings); - const localizedFrequency = translate(`workspace.expensifyCard.frequency.${settlementFrequency}`); + 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); @@ -66,15 +69,15 @@ function WorkspaceTravelInvoicingSection({policyID}: WorkspaceTravelInvoicingSec return ( <>
{/* Manage travel */} {}} - title={'Central invoicing'} + title="Central invoicing" titleStyle={styles.textStrong} - subtitle={'Allow your members to pay and bill travel directly to the workspace'} - switchAccessibilityLabel={'Allow your members to pay and bill travel directly to the workspace'} - isActive={true} + subtitle="Allow your members to pay and bill travel directly to the workspace" + switchAccessibilityLabel="Allow your members to pay and bill travel directly to the workspace" + isActive pendingAction={null} onToggle={() => {}} showLockIcon={false} - errors={null} + errors={undefined} onCloseError={() => {}} onPress={() => {}} /> diff --git a/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx b/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx index 79123fcedfef0..b925bf911d6a2 100644 --- a/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx +++ b/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx @@ -7,6 +7,7 @@ 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'; @@ -21,7 +22,8 @@ const WORKSPACE_ACCOUNT_ID = 999888; // We use literal values that match the constants above. jest.mock('@react-navigation/native', () => { - const actualNav = jest.requireActual('@react-navigation/native'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const actualNav = jest.requireActual('@react-navigation/native'); return { ...actualNav, useIsFocused: () => true, @@ -37,11 +39,13 @@ jest.mock('@react-navigation/native', () => { jest.mock('@src/hooks/useResponsiveLayout'); jest.mock('@hooks/useWorkspaceAccountID', () => ({ + // eslint-disable-next-line @typescript-eslint/naming-convention __esModule: true, default: () => 999888, // Must match WORKSPACE_ACCOUNT_ID })); jest.mock('@hooks/useScreenWrapperTransitionStatus', () => ({ + // eslint-disable-next-line @typescript-eslint/naming-convention __esModule: true, default: () => ({didScreenTransitionEnd: true}), })); @@ -96,7 +100,7 @@ describe('WorkspaceTravelInvoicingSection', () => { it('should show setup CTA 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`; + 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); @@ -118,7 +122,7 @@ describe('WorkspaceTravelInvoicingSection', () => { }); describe('When Travel Invoicing is configured', () => { - const travelInvoicingKey = `${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${WORKSPACE_ACCOUNT_ID}_TRAVEL_US`; + 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 () => { @@ -130,8 +134,9 @@ describe('WorkspaceTravelInvoicingSection', () => { remainingLimit: 50000, currentBalance: 10000, }); + // eslint-disable-next-line @typescript-eslint/naming-convention await Onyx.merge(bankAccountKey, { - '12345': { + 12345: { accountData: { addressName: 'Test Company', accountNumber: '****1234', @@ -148,7 +153,7 @@ describe('WorkspaceTravelInvoicingSection', () => { await waitForBatchedUpdatesWithAct(); // Then the section title should be visible - expect(screen.getByText('Travel Invoicing')).toBeTruthy(); + expect(screen.getByText('Travel booking')).toBeTruthy(); }); it('should display current spend when configured', async () => { @@ -160,8 +165,9 @@ describe('WorkspaceTravelInvoicingSection', () => { remainingLimit: 50000, currentBalance: 25000, }); + // eslint-disable-next-line @typescript-eslint/naming-convention await Onyx.merge(bankAccountKey, { - '12345': { + 12345: { accountData: { addressName: 'Test Company', accountNumber: '****1234', @@ -190,8 +196,9 @@ describe('WorkspaceTravelInvoicingSection', () => { remainingLimit: 100000, currentBalance: 25000, }); + // eslint-disable-next-line @typescript-eslint/naming-convention await Onyx.merge(bankAccountKey, { - '12345': { + 12345: { accountData: { addressName: 'Test Company', accountNumber: '****1234', @@ -220,8 +227,9 @@ describe('WorkspaceTravelInvoicingSection', () => { remainingLimit: 50000, currentBalance: 10000, }); + // eslint-disable-next-line @typescript-eslint/naming-convention await Onyx.merge(bankAccountKey, { - '12345': { + 12345: { accountData: { addressName: 'Test Company', accountNumber: '****1234', @@ -250,8 +258,9 @@ describe('WorkspaceTravelInvoicingSection', () => { remainingLimit: 50000, currentBalance: 10000, }); + // eslint-disable-next-line @typescript-eslint/naming-convention await Onyx.merge(bankAccountKey, { - '12345': { + 12345: { accountData: { addressName: 'Test Company', accountNumber: '****1234', diff --git a/tests/unit/TravelInvoicingUtilsTest.ts b/tests/unit/TravelInvoicingUtilsTest.ts index 0dc48cbf2b30e..6f2c6abc412fe 100644 --- a/tests/unit/TravelInvoicingUtilsTest.ts +++ b/tests/unit/TravelInvoicingUtilsTest.ts @@ -25,7 +25,8 @@ describe('TravelInvoicingUtils', () => { }); it('Should return false when cardSettings is null', () => { - const result = getIsTravelInvoicingEnabled(null); + // Using undefined since OnyxEntry doesn't accept null + const result = getIsTravelInvoicingEnabled(undefined); expect(result).toBe(false); }); @@ -131,16 +132,15 @@ describe('TravelInvoicingUtils', () => { }); describe('getTravelSettlementAccount', () => { + // eslint-disable-next-line @typescript-eslint/naming-convention const mockBankAccountList: BankAccountList = { - '12345': { + 12345: { accountData: { addressName: 'Test Company', accountNumber: '****1234', routingNumber: '123456789', - bankName: 'Test Bank', accountType: 'checking', bankAccountID: 12345, - processor: 'dwolla', }, }, }; From cc4455de2d30aa107b40449bd1b8f975c2021c73 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Thu, 8 Jan 2026 17:06:39 -0800 Subject: [PATCH 03/21] fix: updated UI and resolved failing workflows --- src/components/MenuItem.tsx | 9 +- src/languages/de.ts | 23 +- src/languages/en.ts | 23 +- src/languages/es.ts | 26 ++- src/languages/fr.ts | 23 +- src/languages/it.ts | 25 ++- src/languages/ja.ts | 23 +- src/languages/nl.ts | 21 +- src/languages/pl.ts | 23 +- src/languages/pt-BR.ts | 25 ++- src/languages/zh-hans.ts | 19 +- .../travel/GetStartedTravelInvoicing.tsx | 70 ------ .../WorkspaceTravelInvoicingSection.tsx | 209 +++++++++++------- src/styles/utils/spacing.ts | 4 + .../WorkspaceTravelInvoicingSectionTest.tsx | 39 ++-- tests/unit/TravelInvoicingUtilsTest.ts | 4 +- 16 files changed, 295 insertions(+), 271 deletions(-) delete mode 100644 src/pages/workspace/travel/GetStartedTravelInvoicing.tsx diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx index f11abee49c161..2e6ada5828743 100644 --- a/src/components/MenuItem.tsx +++ b/src/components/MenuItem.tsx @@ -397,6 +397,9 @@ type MenuItemBaseProps = ForwardedFSClassProps & /** Whether the screen containing the item is focused */ isFocused?: boolean; + + /** Additional styles for the root wrapper View */ + rootWrapperStyle?: StyleProp; }; type MenuItemProps = (IconProps | AvatarProps | NoIcon) & MenuItemBaseProps; @@ -526,6 +529,7 @@ function MenuItem({ ref, isFocused, sentryLabel, + rootWrapperStyle, }: MenuItemProps) { const icons = useMemoizedLazyExpensifyIcons(['ArrowRight', 'FallbackAvatar']); const theme = useTheme(); @@ -677,7 +681,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 7bdbff9bee4b8..b18d11820c9df 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -5010,16 +5010,21 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU ctaText: 'Buchen oder verwalten', }, travelInvoicing: { - title: 'Reiserechnungsstellung', - description: 'Zentralisieren Sie alle Mitarbeiterreisen auf einer einzigen Rechnung.', - currentSpend: 'Aktuelle Ausgaben', - spendLimit: 'Ausgabenlimit', - settlementAccount: 'Verrechnungskonto', - settlementFrequency: 'Häufigkeit der Abrechnung', - setup: { - title: 'Zentrale Reiseabrechnung einrichten', + travelBookingSection: { + title: 'Reisebuchung', + subtitle: 'Glückwunsch! Du kannst jetzt in diesem Workspace Reisen buchen und verwalten.', + manageTravelLabel: 'Reisen verwalten', + }, + centralInvoicingSection: { + title: 'Zentrale Rechnungsstellung', subtitle: 'Ermöglichen Sie Ihren Mitgliedern, Reisen direkt über den Workspace zu bezahlen und abzurechnen', - ctaText: 'Reiseabrechnung einrichten', + subsections: { + currentTravelSpendLabel: 'Aktuelle Reisekosten', + currentTravelSpendCta: 'Saldo bezahlen', + currentTravelLimitLabel: 'Aktuelles Reiselimit', + settlementAccountLabel: 'Ausgleichskonto', + settlementFrequencyLabel: 'Abrechnungshäufigkeit', + }, }, }, }, diff --git a/src/languages/en.ts b/src/languages/en.ts index fc77344a89c27..afd8975bef63a 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -4915,16 +4915,21 @@ const translations = { ctaText: 'Book or manage', }, travelInvoicing: { - title: 'Travel Invoicing', - description: 'Centralize all employee travel on a single invoice.', - currentSpend: 'Current spend', - spendLimit: 'Spend limit', - settlementAccount: 'Settlement account', - settlementFrequency: 'Settlement frequency', - setup: { - title: 'Set up centralized travel invoicing', + 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: 'Allow your members to pay and bill travel directly to the workspace', - ctaText: 'Set up travel invoicing', + subsections: { + currentTravelSpendLabel: 'Current travel spend', + currentTravelSpendCta: 'Pay balance', + currentTravelLimitLabel: 'Current travel limit', + settlementAccountLabel: 'Settlement account', + settlementFrequencyLabel: 'Settlement frequency', + }, }, }, }, diff --git a/src/languages/es.ts b/src/languages/es.ts index b9095d724a12b..ea75e6d084924 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -4666,17 +4666,21 @@ ${amount} para ${merchant} - ${date}`, ctaText: 'Reservar o gestionar', }, travelInvoicing: { - title: 'Facturación de viajes', - description: 'Centraliza todos los viajes de los empleados en una sola factura.', - currentSpend: 'Gasto actual', - spendLimit: 'Límite de gasto', - settlementAccount: 'Cuenta de liquidación', - settlementFrequency: 'Frecuencia de liquidación', - setup: { - title: 'Configurar facturación de viajes', - subtitle: - 'Consolida todas las reservas de viajes de los empleados en una sola factura a nivel de empresa. Los gastos son gestionados por Expensify y facturados a intervalos regulares.', - ctaText: 'Comenzar', + 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: 'Permite que tus miembros paguen y facturen los viajes directamente al espacio de trabajo', + 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', + }, }, }, }, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 8eb91ddcc00c6..0f3b4dbbb7487 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -5019,16 +5019,21 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. ctaText: 'Réserver ou gérer', }, travelInvoicing: { - title: 'Facturation de voyage', - description: 'Centralisez tous les déplacements des employés sur une seule facture.', - currentSpend: 'Dépense actuelle', - spendLimit: 'Limite de dépenses', - settlementAccount: 'Compte de règlement', - settlementFrequency: 'Fréquence de règlement', - setup: { - title: 'Configurer la facturation centralisée des déplacements', + 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: 'Autorisez vos membres à payer et à facturer les déplacements directement à l’espace de travail', - ctaText: 'Configurer la facturation des déplacements', + 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', + }, }, }, }, diff --git a/src/languages/it.ts b/src/languages/it.ts index e125df7501eff..e25efcedbbffa 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -4997,16 +4997,21 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. ctaText: 'Prenota o gestisci', }, travelInvoicing: { - title: 'Fatturazione viaggi', - description: 'Centralizza tutti i viaggi dei dipendenti su un’unica fattura.', - currentSpend: 'Spesa attuale', - spendLimit: 'Limite di spesa', - settlementAccount: 'Conto di regolamento', - settlementFrequency: 'Frequenza di liquidazione', - setup: { - title: 'Imposta la fatturazione centralizzata dei viaggi', - subtitle: 'Consenti ai tuoi membri di pagare e addebitare i viaggi direttamente allo spazio di lavoro', - ctaText: 'Imposta la fatturazione dei viaggi', + 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: 'Consenti ai tuoi membri di pagare e fatturare i viaggi direttamente allo spazio di lavoro', + subsections: { + currentTravelSpendLabel: 'Spesa di viaggio attuale', + currentTravelSpendCta: 'Paga saldo', + currentTravelLimitLabel: 'Limite di viaggio attuale', + settlementAccountLabel: 'Conto di regolamento', + settlementFrequencyLabel: 'Frequenza di regolamento', + }, }, }, }, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 12f3a551bf311..63183e4e5d2c9 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -4968,13 +4968,22 @@ _より詳しい手順については、[ヘルプサイトをご覧ください ctaText: '予約または管理', }, travelInvoicing: { - title: '出張請求', - description: 'すべての従業員の出張を 1 つの請求書に集約します。', - currentSpend: '現在の支出', - spendLimit: '利用上限', - settlementAccount: '決済口座', - settlementFrequency: '決済頻度', - setup: {title: '出張費の集中請求を設定', subtitle: 'メンバーが出張費をワークスペースに直接請求・支払いできるようにします', ctaText: '出張請求書を設定'}, + travelBookingSection: { + title: '出張予約', + subtitle: 'おめでとうございます!このワークスペースで旅行の予約と管理を行う準備が整いました。', + manageTravelLabel: '出張を管理', + }, + centralInvoicingSection: { + title: '中央請求書管理', + subtitle: 'メンバーが出張費をワークスペースに直接請求および支払いできるようにする', + subsections: { + currentTravelSpendLabel: '現在の出張費用', + currentTravelSpendCta: '残高を支払う', + currentTravelLimitLabel: '現在の出張上限', + settlementAccountLabel: '決済口座', + settlementFrequencyLabel: '清算頻度', + }, + }, }, }, expensifyCard: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index e69a34532dd02..a965b9b70b290 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -4991,16 +4991,17 @@ _Voor gedetailleerdere instructies, [bezoek onze helpsite](${CONST.NETSUITE_IMPO ctaText: 'Boeken of beheren', }, travelInvoicing: { - title: 'Facturatie voor reizen', - description: 'Centraliseer alle zakelijke reizen van medewerkers op één enkele factuur.', - currentSpend: 'Huidige uitgaven', - spendLimit: 'Bestedingslimiet', - settlementAccount: 'Verrekeningsrekening', - settlementFrequency: 'Afrekeningsfrequentie', - setup: { - title: 'Gecentraliseerde reisfacturatie instellen', - subtitle: 'Sta uw leden toe reizen rechtstreeks via de workspace te betalen en te factureren', - ctaText: 'Reisfacturatie instellen', + travelBookingSection: {title: 'Reisboeking', subtitle: 'Gefeliciteerd! Je kunt nu reizen boeken en beheren in deze werkruimte.', manageTravelLabel: 'Reizen beheren'}, + centralInvoicingSection: { + title: 'Centrale facturatie', + subtitle: 'Sta je leden toe om reizen rechtstreeks via de workspace te betalen en te factureren', + subsections: { + currentTravelSpendLabel: 'Huidige reiskosten', + currentTravelSpendCta: 'Saldo betalen', + currentTravelLimitLabel: 'Huidige reislimoet', + settlementAccountLabel: 'Afwikkelingsrekening', + settlementFrequencyLabel: 'Frequentie van afwikkeling', + }, }, }, }, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 307eb85829a4d..984ed076a2023 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -4981,16 +4981,21 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy ctaText: 'Rezerwuj lub zarządzaj', }, travelInvoicing: { - title: 'Fakturowanie podróży', - description: 'Scentralizuj wszystkie podróże służbowe pracowników na jednej fakturze.', - currentSpend: 'Bieżące wydatki', - spendLimit: 'Limit wydatków', - settlementAccount: 'Konto rozliczeniowe', - settlementFrequency: 'Częstotliwość rozliczeń', - setup: { - title: 'Skonfiguruj scentralizowane fakturowanie podróży', + 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: 'Pozwól członkom rozliczać i fakturować podróże bezpośrednio na przestrzeń roboczą', - ctaText: 'Skonfiguruj fakturowanie podróży', + subsections: { + currentTravelSpendLabel: 'Aktualne wydatki na podróże', + currentTravelSpendCta: 'Zapłać saldo', + currentTravelLimitLabel: 'Obecny limit podróży', + settlementAccountLabel: 'Konto rozliczeniowe', + settlementFrequencyLabel: 'Częstotliwość rozliczeń', + }, }, }, }, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index b6dace8a7732b..a561548e6a9cd 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -4981,16 +4981,21 @@ _Para instruções mais detalhadas, [visite nosso site de ajuda](${CONST.NETSUIT ctaText: 'Reservar ou gerenciar', }, travelInvoicing: { - title: 'Faturamento de Viagens', - description: 'Centralize todas as viagens dos funcionários em uma única fatura.', - currentSpend: 'Gasto atual', - spendLimit: 'Limite de gastos', - settlementAccount: 'Conta de liquidação', - settlementFrequency: 'Frequência de liquidação', - setup: { - title: 'Configurar faturamento centralizado de viagens', - subtitle: 'Permita que seus membros paguem e faturem viagens diretamente ao workspace', - ctaText: 'Configurar faturamento de viagens', + 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: 'Permita que seus membros paguem e cobrem viagens diretamente para o espaço de trabalho', + 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', + }, }, }, }, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 35ba848e43227..bd692cabf12f7 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -4890,13 +4890,18 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM ctaText: '预订或管理', }, travelInvoicing: { - title: '旅行发票', - description: '将所有员工差旅集中到一张发票中。', - currentSpend: '当前支出', - spendLimit: '支出限额', - settlementAccount: '结算账户', - settlementFrequency: '结算频率', - setup: {title: '设置集中化差旅发票管理', subtitle: '允许您的成员直接将差旅费用支付和记入该工作区', ctaText: '设置差旅开票'}, + travelBookingSection: {title: '旅行预订', subtitle: '恭喜!您现在可以在此工作区预订和管理差旅了。', manageTravelLabel: '管理差旅'}, + centralInvoicingSection: { + title: '集中开票', + subtitle: '允许您的成员直接向工作区支付和开具差旅费用', + subsections: { + currentTravelSpendLabel: '当前差旅行支出', + currentTravelSpendCta: '支付余额', + currentTravelLimitLabel: '当前出差限额', + settlementAccountLabel: '结算账户', + settlementFrequencyLabel: '结算频率', + }, + }, }, }, expensifyCard: { diff --git a/src/pages/workspace/travel/GetStartedTravelInvoicing.tsx b/src/pages/workspace/travel/GetStartedTravelInvoicing.tsx deleted file mode 100644 index 958275f2c20c6..0000000000000 --- a/src/pages/workspace/travel/GetStartedTravelInvoicing.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import React from 'react'; -import Button from '@components/Button'; -import FeatureList from '@components/FeatureList'; -import type {FeatureListItem} from '@components/FeatureList'; -import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; -import useLocalize from '@hooks/useLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; -import colors from '@styles/theme/colors'; - -type GetStartedTravelInvoicingProps = { - /** The policyID for the workspace */ - policyID: string; - - /** Callback when the CTA button is pressed */ - onCtaPress?: (policyID: string) => void; -}; - -function GetStartedTravelInvoicing({policyID, onCtaPress}: GetStartedTravelInvoicingProps) { - const styles = useThemeStyles(); - const {translate} = useLocalize(); - const illustrations = useMemoizedLazyIllustrations(['EmptyStateTravel', 'FastMoney', 'Abacus', 'CalendarMonthly'] as const); - - const handleCtaPress = () => { - // TODO: Navigate to Travel Invoicing setup flow - // This will be implemented when the setup flow is ready - onCtaPress?.(policyID); - }; - - const travelInvoicingFeatures: FeatureListItem[] = [ - { - icon: illustrations.FastMoney, - translationKey: 'travel.features.easyPayments', - }, - { - icon: illustrations.Abacus, - translationKey: 'travel.features.travelSpendLimits', - }, - { - icon: illustrations.CalendarMonthly, - translationKey: 'travel.features.invoicedMonthlyWeekly', - }, - ]; - - return ( - - } - /> - ); -} - -GetStartedTravelInvoicing.displayName = 'GetStartedTravelInvoicing'; - -export default GetStartedTravelInvoicing; diff --git a/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx b/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx index efa03e9999435..89c7446791a20 100644 --- a/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx +++ b/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx @@ -1,14 +1,17 @@ -import React from 'react'; +import React, {useState} from 'react'; import {View} from 'react-native'; -import Hoverable from '@components/Hoverable'; +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 {convertToDisplayString} from '@libs/CurrencyUtils'; import { getIsTravelInvoicingEnabled, @@ -19,10 +22,10 @@ import { hasTravelInvoicingSettlementAccount, PROGRAM_TRAVEL_US, } from '@libs/TravelInvoicingUtils'; -import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; +import ToggleSettingOptionRow, {ToggleSettingOptionRowProps} from '@pages/workspace/workflows/ToggleSettingsOptionRow'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import GetStartedTravelInvoicing from './GetStartedTravelInvoicing'; +import BookOrManageYourTrip from './BookOrManageYourTrip'; type WorkspaceTravelInvoicingSectionProps = { /** The ID of the policy */ @@ -37,7 +40,13 @@ function WorkspaceTravelInvoicingSection({policyID}: WorkspaceTravelInvoicingSec 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} @@ -60,98 +69,128 @@ function WorkspaceTravelInvoicingSection({policyID}: WorkspaceTravelInvoicingSec // 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 + (settlementAccount?.last4 ?? '1234'); - // If Travel Invoicing is not enabled or no settlement account is configured, show the setup CTA - if (!isTravelInvoicingEnabled || !hasSettlementAccount) { - return ; - } - - return ( - <> -
- {/* Manage travel */} - - - -
-
- {/* Central invoicing toggle */} - - - {() => ( - - {}} - title="Central invoicing" - titleStyle={styles.textStrong} - subtitle="Allow your members to pay and bill travel directly to the workspace" - switchAccessibilityLabel="Allow your members to pay and bill travel directly to the workspace" - isActive - pendingAction={null} - onToggle={() => {}} - showLockIcon={false} - errors={undefined} - onCloseError={() => {}} - onPress={() => {}} - /> - - )} - - - - {/* Current travel spend */} - - - - - {/* Travel spend limit */} - + const optionItems: ToggleSettingOptionRowProps[] = [ + { + title: translate('workspace.moreFeatures.travel.travelInvoicing.centralInvoicingSection.title'), + subtitle: translate('workspace.moreFeatures.travel.travelInvoicing.centralInvoicingSection.subtitle'), + 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} + /> + + - - - {/* Settlement account */} - {}} + wrapperStyle={[styles.sectionMenuItemTopDescription, styles.mt3, styles.mbn3]} + titleStyle={styles.textNormalThemeText} + descriptionTextStyle={styles.textLabelSupportingNormal} shouldShowRightIcon + // brickRoadIndicator={hasDelayedSubmissionError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} /> - - - {/* Settlement frequency */} - {}} + wrapperStyle={[styles.sectionMenuItemTopDescription, styles.mt3, styles.mbn3]} + 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.ph8} + iconRight={icons.NewWindow} + icon={icons.LuggageWithLines} + shouldShowRightIcon + />
+ {optionItems.map(renderOptionItem)} ); } diff --git a/src/styles/utils/spacing.ts b/src/styles/utils/spacing.ts index 0e9e2009eac57..e1074658466c9 100644 --- a/src/styles/utils/spacing.ts +++ b/src/styles/utils/spacing.ts @@ -515,6 +515,10 @@ export default { paddingVertical: 24, }, + pv8: { + paddingVertical: 32, + }, + pv10: { paddingVertical: 40, }, diff --git a/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx b/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx index b925bf911d6a2..4f3a73fb74d4c 100644 --- a/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx +++ b/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx @@ -1,3 +1,4 @@ +/* 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'; @@ -22,8 +23,9 @@ const WORKSPACE_ACCOUNT_ID = 999888; // We use literal values that match the constants above. jest.mock('@react-navigation/native', () => { - // eslint-disable-next-line @typescript-eslint/no-require-imports + // 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, @@ -39,13 +41,11 @@ jest.mock('@react-navigation/native', () => { jest.mock('@src/hooks/useResponsiveLayout'); jest.mock('@hooks/useWorkspaceAccountID', () => ({ - // eslint-disable-next-line @typescript-eslint/naming-convention __esModule: true, default: () => 999888, // Must match WORKSPACE_ACCOUNT_ID })); jest.mock('@hooks/useScreenWrapperTransitionStatus', () => ({ - // eslint-disable-next-line @typescript-eslint/naming-convention __esModule: true, default: () => ({didScreenTransitionEnd: true}), })); @@ -81,7 +81,7 @@ describe('WorkspaceTravelInvoicingSection', () => { }); describe('When Travel Invoicing is not configured', () => { - it('should show setup CTA when card settings are not available', async () => { + 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); @@ -94,11 +94,11 @@ describe('WorkspaceTravelInvoicingSection', () => { // Wait for component to render await waitForBatchedUpdatesWithAct(); - // Then the setup CTA should be visible (GetStartedTravelInvoicing) - expect(screen.getByText('Set up centralized travel invoicing')).toBeTruthy(); + // Then the fallback component should be visible (BookOrManageYourTrip) + expect(screen.getByText('Book or manage your trip')).toBeTruthy(); }); - it('should show setup CTA when paymentBankAccountID is not set', async () => { + 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; @@ -116,8 +116,8 @@ describe('WorkspaceTravelInvoicingSection', () => { await waitForBatchedUpdatesWithAct(); - // Then the setup CTA should be visible - expect(screen.getByText('Set up centralized travel invoicing')).toBeTruthy(); + // Then the fallback component should be visible + expect(screen.getByText('Book or manage your trip')).toBeTruthy(); }); }); @@ -134,7 +134,6 @@ describe('WorkspaceTravelInvoicingSection', () => { remainingLimit: 50000, currentBalance: 10000, }); - // eslint-disable-next-line @typescript-eslint/naming-convention await Onyx.merge(bankAccountKey, { 12345: { accountData: { @@ -156,7 +155,7 @@ describe('WorkspaceTravelInvoicingSection', () => { expect(screen.getByText('Travel booking')).toBeTruthy(); }); - it('should display current spend when configured', async () => { + 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); @@ -165,7 +164,6 @@ describe('WorkspaceTravelInvoicingSection', () => { remainingLimit: 50000, currentBalance: 25000, }); - // eslint-disable-next-line @typescript-eslint/naming-convention await Onyx.merge(bankAccountKey, { 12345: { accountData: { @@ -183,11 +181,11 @@ describe('WorkspaceTravelInvoicingSection', () => { await waitForBatchedUpdatesWithAct(); - // Then the current spend label should be visible - expect(screen.getByText('Current spend')).toBeTruthy(); + // Then the current travel spend label should be visible + expect(screen.getByText('Current travel spend')).toBeTruthy(); }); - it('should display spend limit when configured', async () => { + 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); @@ -196,7 +194,6 @@ describe('WorkspaceTravelInvoicingSection', () => { remainingLimit: 100000, currentBalance: 25000, }); - // eslint-disable-next-line @typescript-eslint/naming-convention await Onyx.merge(bankAccountKey, { 12345: { accountData: { @@ -214,11 +211,11 @@ describe('WorkspaceTravelInvoicingSection', () => { await waitForBatchedUpdatesWithAct(); - // Then the spend limit label should be visible - expect(screen.getByText('Spend limit')).toBeTruthy(); + // Then the current travel limit label should be visible + expect(screen.getByText('Current travel limit')).toBeTruthy(); }); - it('should display settlement account information', async () => { + 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); @@ -227,7 +224,6 @@ describe('WorkspaceTravelInvoicingSection', () => { remainingLimit: 50000, currentBalance: 10000, }); - // eslint-disable-next-line @typescript-eslint/naming-convention await Onyx.merge(bankAccountKey, { 12345: { accountData: { @@ -249,7 +245,7 @@ describe('WorkspaceTravelInvoicingSection', () => { expect(screen.getByText('Settlement account')).toBeTruthy(); }); - it('should display settlement frequency information', async () => { + it('should display settlement frequency label', async () => { // Given Travel Invoicing is configured await act(async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, mockPolicy); @@ -258,7 +254,6 @@ describe('WorkspaceTravelInvoicingSection', () => { remainingLimit: 50000, currentBalance: 10000, }); - // eslint-disable-next-line @typescript-eslint/naming-convention await Onyx.merge(bankAccountKey, { 12345: { accountData: { diff --git a/tests/unit/TravelInvoicingUtilsTest.ts b/tests/unit/TravelInvoicingUtilsTest.ts index 6f2c6abc412fe..7e594ba15b0e0 100644 --- a/tests/unit/TravelInvoicingUtilsTest.ts +++ b/tests/unit/TravelInvoicingUtilsTest.ts @@ -132,14 +132,14 @@ describe('TravelInvoicingUtils', () => { }); describe('getTravelSettlementAccount', () => { - // eslint-disable-next-line @typescript-eslint/naming-convention const mockBankAccountList: BankAccountList = { 12345: { + bankCurrency: 'USD', + bankCountry: 'US', accountData: { addressName: 'Test Company', accountNumber: '****1234', routingNumber: '123456789', - accountType: 'checking', bankAccountID: 12345, }, }, From 2caea0ad4f4b941e8f7180b43cfff2854f05d1f0 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Thu, 8 Jan 2026 17:36:54 -0800 Subject: [PATCH 04/21] fix: eslint - ready for review --- .../travel/WorkspaceTravelInvoicingSection.tsx | 13 +++++++------ tests/unit/TravelInvoicingUtilsTest.ts | 4 ++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx b/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx index 89c7446791a20..d60fe9defc71f 100644 --- a/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx +++ b/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx @@ -22,7 +22,8 @@ import { hasTravelInvoicingSettlementAccount, PROGRAM_TRAVEL_US, } from '@libs/TravelInvoicingUtils'; -import ToggleSettingOptionRow, {ToggleSettingOptionRowProps} from '@pages/workspace/workflows/ToggleSettingsOptionRow'; +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'; @@ -83,12 +84,12 @@ function WorkspaceTravelInvoicingSection({policyID}: WorkspaceTravelInvoicingSec // onCloseError: () => clearPolicyErrorField(route.params.policyID, CONST.POLICY.COLLECTION_KEYS.AUTOREPORTING), subMenuItems: ( <> - + {}} - wrapperStyle={[styles.sectionMenuItemTopDescription, styles.mt3, styles.mbn3]} + wrapperStyle={[styles.sectionMenuItemTopDescription]} titleStyle={styles.textNormalThemeText} descriptionTextStyle={styles.textLabelSupportingNormal} shouldShowRightIcon @@ -130,7 +131,7 @@ function WorkspaceTravelInvoicingSection({policyID}: WorkspaceTravelInvoicingSec description={translate('workspace.moreFeatures.travel.travelInvoicing.centralInvoicingSection.subsections.settlementFrequencyLabel')} title={localizedFrequency} onPress={() => {}} - wrapperStyle={[styles.sectionMenuItemTopDescription, styles.mt3, styles.mbn3]} + wrapperStyle={[styles.sectionMenuItemTopDescription]} titleStyle={styles.textNormalThemeText} descriptionTextStyle={styles.textLabelSupportingNormal} shouldShowRightIcon diff --git a/tests/unit/TravelInvoicingUtilsTest.ts b/tests/unit/TravelInvoicingUtilsTest.ts index 7e594ba15b0e0..7dd932652a6e2 100644 --- a/tests/unit/TravelInvoicingUtilsTest.ts +++ b/tests/unit/TravelInvoicingUtilsTest.ts @@ -133,7 +133,7 @@ describe('TravelInvoicingUtils', () => { describe('getTravelSettlementAccount', () => { const mockBankAccountList: BankAccountList = { - 12345: { + bankAccountID: { bankCurrency: 'USD', bankCountry: 'US', accountData: { @@ -170,7 +170,7 @@ describe('TravelInvoicingUtils', () => { it('Should fallback to bank account data when paymentBankAccountAddressName is not set', () => { const cardSettings = { - paymentBankAccountID: 12345, + paymentBankAccountID: 'bankAccountID' as unknown as number, } as ExpensifyCardSettings; const result = getTravelSettlementAccount(cardSettings, mockBankAccountList); expect(result).toBeDefined(); From e775554865003f36ffea5a8fbcdb6245fab20022 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Fri, 9 Jan 2026 13:29:53 -0800 Subject: [PATCH 05/21] fix: mobile UI padding and illustrations export order --- src/components/Icon/Illustrations.ts | 66 +++++++++---------- .../WorkspaceTravelInvoicingSection.tsx | 6 +- 2 files changed, 36 insertions(+), 36 deletions(-) diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts index 31b008e55bcb8..65851e0641676 100644 --- a/src/components/Icon/Illustrations.ts +++ b/src/components/Icon/Illustrations.ts @@ -47,51 +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, - FastMoney, + 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, - CalendarMonthly, - 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/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx b/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx index d60fe9defc71f..d0bb2ec87ca62 100644 --- a/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx +++ b/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx @@ -176,9 +176,9 @@ function WorkspaceTravelInvoicingSection({policyID}: WorkspaceTravelInvoicingSec
Date: Fri, 9 Jan 2026 13:39:24 -0800 Subject: [PATCH 06/21] fix: removed unused translations --- src/languages/de.ts | 3 --- src/languages/en.ts | 3 --- src/languages/es.ts | 3 --- src/languages/fr.ts | 3 --- src/languages/it.ts | 3 --- src/languages/ja.ts | 3 --- src/languages/nl.ts | 3 --- src/languages/pl.ts | 3 --- src/languages/pt-BR.ts | 3 --- src/languages/zh-hans.ts | 3 --- 10 files changed, 30 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 04670bb1c803c..59c0ed4d133cc 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -3621,9 +3621,6 @@ ${ features: { saveMoney: 'Spare Geld bei deinen Buchungen', alerts: 'Erhalten Sie Echtzeit-Benachrichtigungen, wenn sich Ihre Reisepläne ändern', - easyPayments: 'Einfache Zahlungen für Ihre Mitglieder', - travelSpendLimits: 'Reiseausgabenlimits festlegen', - invoicedMonthlyWeekly: 'Monatlich oder wöchentlich abrechnen lassen', }, bookTravel: 'Reise buchen', bookDemo: 'Demo buchen', diff --git a/src/languages/en.ts b/src/languages/en.ts index 453089da20b30..9b7398085e45c 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -3598,9 +3598,6 @@ const translations = { features: { saveMoney: 'Save money on your bookings', alerts: 'Get realtime alerts if your travel plans change', - easyPayments: 'Easy payments for your members', - travelSpendLimits: 'Set travel spend limits', - invoicedMonthlyWeekly: 'Get invoiced monthly or weekly', }, bookTravel: 'Book travel', bookDemo: 'Book demo', diff --git a/src/languages/es.ts b/src/languages/es.ts index 7867ecf722852..04c2f32df3674 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -3314,9 +3314,6 @@ ${amount} para ${merchant} - ${date}`, features: { saveMoney: 'Ahorra dinero en tus reservas', alerts: 'Recibe alertas en tiempo real si tus planes de viaje cambian', - easyPayments: 'Pagos fáciles para tus miembros', - travelSpendLimits: 'Establece límites de gasto en viajes', - invoicedMonthlyWeekly: 'Recibe facturas mensual o semanalmente', }, bookTravel: 'Reservar viajes', bookDemo: 'Pedir demostración', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 9adf3122d5500..fb7d662581754 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -3628,9 +3628,6 @@ ${ features: { saveMoney: 'Économisez de l’argent sur vos réservations', alerts: 'Recevez des alertes en temps réel si vos plans de voyage changent', - easyPayments: 'Des paiements faciles pour vos membres', - travelSpendLimits: 'Définir des limites de dépenses de voyage', - invoicedMonthlyWeekly: 'Être facturé chaque mois ou chaque semaine', }, bookTravel: 'Réserver un voyage', bookDemo: 'Réserver une démo', diff --git a/src/languages/it.ts b/src/languages/it.ts index da1d4495b1b19..3110b793c6041 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -3609,9 +3609,6 @@ ${ features: { saveMoney: 'Risparmia denaro sulle tue prenotazioni', alerts: 'Ricevi avvisi in tempo reale se i tuoi piani di viaggio cambiano', - easyPayments: 'Pagamenti facili per i tuoi membri', - travelSpendLimits: 'Imposta limiti di spesa per i viaggi', - invoicedMonthlyWeekly: 'Ricevi fatture mensili o settimanali', }, bookTravel: 'Prenota viaggio', bookDemo: 'Prenota demo', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index befc2a200ea25..89fa64298369b 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -3598,9 +3598,6 @@ ${ features: { saveMoney: '予約で節約しましょう', alerts: '旅行計画が変更された場合のリアルタイムアラートを受け取る', - easyPayments: 'メンバーのための簡単なお支払い', - travelSpendLimits: '出張費の上限を設定', - invoicedMonthlyWeekly: '毎月または毎週の請求書発行', }, bookTravel: '出張を予約', bookDemo: 'デモを予約', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index f7344988bc8b9..45d97828456c0 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -3608,9 +3608,6 @@ ${ features: { saveMoney: 'Bespaar geld op je boekingen', alerts: 'Ontvang realtime meldingen als uw reisplannen veranderen', - easyPayments: 'Moeiteloze betalingen voor je leden', - travelSpendLimits: 'Reisuitgavenlimieten instellen', - invoicedMonthlyWeekly: 'Maandelijks of wekelijks gefactureerd worden', }, bookTravel: 'Reis boeken', bookDemo: 'Demo boeken', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 22d6dd3866229..54d75f7b93aed 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -3601,9 +3601,6 @@ ${ features: { saveMoney: 'Oszczędzaj pieniądze na swoich rezerwacjach', alerts: 'Otrzymuj alerty w czasie rzeczywistym, jeśli Twoje plany podróży się zmienią', - easyPayments: 'Łatwe płatności dla Twoich członków', - travelSpendLimits: 'Ustaw limity wydatków na podróże', - invoicedMonthlyWeekly: 'Otrzymuj faktury co miesiąc lub co tydzień', }, bookTravel: 'Zarezerwuj podróż', bookDemo: 'Zarezerwuj demo', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index c515b0e452c5a..090e573cbbf2e 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -3601,9 +3601,6 @@ ${ features: { saveMoney: 'Economize nas suas reservas', alerts: 'Receba alertas em tempo real se seus planos de viagem mudarem', - easyPayments: 'Pagamentos fáceis para seus membros', - travelSpendLimits: 'Definir limites de gastos de viagem', - invoicedMonthlyWeekly: 'Seja faturado mensalmente ou semanalmente', }, bookTravel: 'Reservar viagem', bookDemo: 'Agendar demonstração', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index caf00d245f107..8a1b8693b1f39 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -3549,9 +3549,6 @@ ${ features: { saveMoney: '在预订时省钱', alerts: '如果您的旅行计划发生变化,获取实时提醒', - easyPayments: '为您的成员提供便捷付款', - travelSpendLimits: '设置差旅支出限额', - invoicedMonthlyWeekly: '按月或按周开具发票', }, bookTravel: '预订出行', bookDemo: '预订演示', From 4268f79cf397ac60091dc78b7051139c1c7c1a0e Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Mon, 12 Jan 2026 10:38:17 -0800 Subject: [PATCH 07/21] fix: confirmed translation suggestion --- src/languages/de.ts | 2 +- src/languages/en.ts | 2 +- src/languages/es.ts | 2 +- src/languages/fr.ts | 2 +- src/languages/it.ts | 2 +- src/languages/ja.ts | 2 +- src/languages/nl.ts | 2 +- src/languages/pl.ts | 2 +- src/languages/pt-BR.ts | 2 +- src/languages/zh-hans.ts | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 59c0ed4d133cc..a3a16a3870274 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -5044,7 +5044,7 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU }, centralInvoicingSection: { title: 'Zentrale Rechnungsstellung', - subtitle: 'Ermöglichen Sie Ihren Mitgliedern, Reisen direkt über den Workspace zu bezahlen und abzurechnen', + subtitle: 'Ermöglichen Sie Ihren Mitgliedern, Reisen direkt über den Workspace zu bezahlen und abzurechnen.', subsections: { currentTravelSpendLabel: 'Aktuelle Reisekosten', currentTravelSpendCta: 'Saldo bezahlen', diff --git a/src/languages/en.ts b/src/languages/en.ts index 9b7398085e45c..a2ddf5fc6b349 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -4946,7 +4946,7 @@ const translations = { }, centralInvoicingSection: { title: 'Central invoicing', - subtitle: 'Allow your members to pay and bill travel directly to the workspace', + subtitle: 'Allow your members to pay and bill travel directly to the workspace.', subsections: { currentTravelSpendLabel: 'Current travel spend', currentTravelSpendCta: 'Pay balance', diff --git a/src/languages/es.ts b/src/languages/es.ts index 04c2f32df3674..90b3644660038 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -4677,7 +4677,7 @@ ${amount} para ${merchant} - ${date}`, }, centralInvoicingSection: { title: 'Facturación centralizada', - subtitle: 'Permite que tus miembros paguen y facturen los viajes directamente al espacio de trabajo', + subtitle: 'Permite que tus miembros paguen y facturen los viajes directamente al espacio de trabajo.', subsections: { currentTravelSpendLabel: 'Gasto actual en viajes', currentTravelSpendCta: 'Pagar saldo', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index fb7d662581754..796db74e76d99 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -5050,7 +5050,7 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. }, centralInvoicingSection: { title: 'Facturation centralisée', - subtitle: 'Autorisez vos membres à payer et à facturer les déplacements directement à l’espace de travail', + subtitle: 'Autorisez vos membres à payer et à facturer les déplacements directement à l’espace de travail.', subsections: { currentTravelSpendLabel: 'Dépenses de voyage actuelles', currentTravelSpendCta: 'Payer le solde', diff --git a/src/languages/it.ts b/src/languages/it.ts index 3110b793c6041..fb4a5d9567f0c 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -5029,7 +5029,7 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. }, centralInvoicingSection: { title: 'Fatturazione centralizzata', - subtitle: 'Consenti ai tuoi membri di pagare e fatturare i viaggi direttamente allo spazio di lavoro', + subtitle: 'Consenti ai tuoi membri di pagare e fatturare i viaggi direttamente allo spazio di lavoro.', subsections: { currentTravelSpendLabel: 'Spesa di viaggio attuale', currentTravelSpendCta: 'Paga saldo', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 89fa64298369b..b4e8816df4f37 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -5000,7 +5000,7 @@ _より詳しい手順については、[ヘルプサイトをご覧ください }, centralInvoicingSection: { title: '中央請求書管理', - subtitle: 'メンバーが出張費をワークスペースに直接請求および支払いできるようにする', + subtitle: 'メンバーが出張費をワークスペースに直接請求および支払いできるようにする。', subsections: { currentTravelSpendLabel: '現在の出張費用', currentTravelSpendCta: '残高を支払う', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 45d97828456c0..37ba0fa7e2834 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -5021,7 +5021,7 @@ _Voor gedetailleerdere instructies, [bezoek onze helpsite](${CONST.NETSUITE_IMPO travelBookingSection: {title: 'Reisboeking', subtitle: 'Gefeliciteerd! Je kunt nu reizen boeken en beheren in deze werkruimte.', manageTravelLabel: 'Reizen beheren'}, centralInvoicingSection: { title: 'Centrale facturatie', - subtitle: 'Sta je leden toe om reizen rechtstreeks via de workspace te betalen en te factureren', + subtitle: 'Sta je leden toe om reizen rechtstreeks via de workspace te betalen en te factureren.', subsections: { currentTravelSpendLabel: 'Huidige reiskosten', currentTravelSpendCta: 'Saldo betalen', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 54d75f7b93aed..344a1426c2fe1 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -5011,7 +5011,7 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy }, centralInvoicingSection: { title: 'Centralne fakturowanie', - subtitle: 'Pozwól członkom rozliczać i fakturować podróże bezpośrednio na przestrzeń roboczą', + subtitle: 'Pozwól członkom rozliczać i fakturować podróże bezpośrednio na przestrzeń roboczą.', subsections: { currentTravelSpendLabel: 'Aktualne wydatki na podróże', currentTravelSpendCta: 'Zapłać saldo', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 090e573cbbf2e..7348ab431beb5 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -5012,7 +5012,7 @@ _Para instruções mais detalhadas, [visite nosso site de ajuda](${CONST.NETSUIT }, centralInvoicingSection: { title: 'Faturamento centralizado', - subtitle: 'Permita que seus membros paguem e cobrem viagens diretamente para o espaço de trabalho', + subtitle: 'Permita que seus membros paguem e cobrem viagens diretamente para o espaço de trabalho.', subsections: { currentTravelSpendLabel: 'Gasto atual com viagens', currentTravelSpendCta: 'Pagar saldo', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 8a1b8693b1f39..85a3abb8e1c1b 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -4918,7 +4918,7 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM travelBookingSection: {title: '旅行预订', subtitle: '恭喜!您现在可以在此工作区预订和管理差旅了。', manageTravelLabel: '管理差旅'}, centralInvoicingSection: { title: '集中开票', - subtitle: '允许您的成员直接向工作区支付和开具差旅费用', + subtitle: '允许您的成员直接向工作区支付和开具差旅费用。', subsections: { currentTravelSpendLabel: '当前差旅行支出', currentTravelSpendCta: '支付余额', From 808afdff4c7c77cce7951f655e8a2265a4f87202 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Tue, 13 Jan 2026 18:09:21 -0800 Subject: [PATCH 08/21] fix: updated translations, added Learn how link --- src/languages/de.ts | 3 ++- src/languages/en.ts | 3 ++- src/languages/es.ts | 3 ++- src/languages/fr.ts | 3 ++- src/languages/it.ts | 3 ++- src/languages/ja.ts | 3 ++- src/languages/nl.ts | 4 +-- src/languages/pl.ts | 4 +-- src/languages/pt-BR.ts | 4 +-- src/languages/zh-hans.ts | 4 +-- .../travel/CentralInvoicingLearnHow.tsx | 16 ++++++++++++ .../CentralInvoicingSubtitleWrapper.tsx | 25 +++++++++++++++++++ .../WorkspaceTravelInvoicingSection.tsx | 14 ++++++++--- 13 files changed, 72 insertions(+), 17 deletions(-) create mode 100644 src/pages/workspace/travel/CentralInvoicingLearnHow.tsx create mode 100644 src/pages/workspace/travel/CentralInvoicingSubtitleWrapper.tsx diff --git a/src/languages/de.ts b/src/languages/de.ts index 6df16d055229d..093291fa4d668 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -5050,7 +5050,8 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU }, centralInvoicingSection: { title: 'Zentrale Rechnungsstellung', - subtitle: 'Ermöglichen Sie Ihren Mitgliedern, Reisen direkt über den Workspace zu bezahlen und abzurechnen.', + 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', diff --git a/src/languages/en.ts b/src/languages/en.ts index fdca3152859b6..efa7996e3fc95 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -4955,7 +4955,8 @@ const translations = { }, centralInvoicingSection: { title: 'Central invoicing', - subtitle: 'Allow your members to pay and bill travel directly to the workspace.', + 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', diff --git a/src/languages/es.ts b/src/languages/es.ts index 10f1cab566ed0..3eb3cdbba9ede 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -4692,7 +4692,8 @@ ${amount} para ${merchant} - ${date}`, }, centralInvoicingSection: { title: 'Facturación centralizada', - subtitle: 'Permite que tus miembros paguen y facturen los viajes directamente al espacio de trabajo.', + 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', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index a761f83d7abb0..09af826511ccb 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -5056,7 +5056,8 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. }, centralInvoicingSection: { title: 'Facturation centralisée', - subtitle: 'Autorisez vos membres à payer et à facturer les déplacements directement à l’espace de travail.', + 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', diff --git a/src/languages/it.ts b/src/languages/it.ts index 830ec51ecf27c..8132da9a5dfce 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -5035,7 +5035,8 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. }, centralInvoicingSection: { title: 'Fatturazione centralizzata', - subtitle: 'Consenti ai tuoi membri di pagare e fatturare i viaggi direttamente allo spazio di lavoro.', + 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', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 34526a5671037..1592ce9a8ccbc 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -5006,7 +5006,8 @@ _より詳しい手順については、[ヘルプサイトをご覧ください }, centralInvoicingSection: { title: '中央請求書管理', - subtitle: 'メンバーが出張費をワークスペースに直接請求および支払いできるようにする。', + subtitle: 'すべての出張費を購入時に都度支払うのではなく、月次請求書にまとめて管理しましょう。', + learnHow: `詳しく見る。`, subsections: { currentTravelSpendLabel: '現在の出張費用', currentTravelSpendCta: '残高を支払う', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index a4141841cd6a4..bf17afc4430a2 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -5027,7 +5027,8 @@ _Voor gedetailleerdere instructies, [bezoek onze helpsite](${CONST.NETSUITE_IMPO travelBookingSection: {title: 'Reisboeking', subtitle: 'Gefeliciteerd! Je kunt nu reizen boeken en beheren in deze werkruimte.', manageTravelLabel: 'Reizen beheren'}, centralInvoicingSection: { title: 'Centrale facturatie', - subtitle: 'Sta je leden toe om reizen rechtstreeks via de workspace te betalen en te factureren.', + 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', @@ -7960,7 +7961,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 ffcb9f84ff8ea..13b35331997f2 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -5017,7 +5017,8 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy }, centralInvoicingSection: { title: 'Centralne fakturowanie', - subtitle: 'Pozwól członkom rozliczać i fakturować podróże bezpośrednio na przestrzeń roboczą.', + 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', @@ -7939,7 +7940,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 5f0b8f0ff4ee8..70878909268bd 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -5018,7 +5018,8 @@ _Para instruções mais detalhadas, [visite nosso site de ajuda](${CONST.NETSUIT }, centralInvoicingSection: { title: 'Faturamento centralizado', - subtitle: 'Permita que seus membros paguem e cobrem viagens diretamente para o espaço de trabalho.', + 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', @@ -7951,7 +7952,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 8089d7e431a2c..90de509dfe804 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -4924,7 +4924,8 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM travelBookingSection: {title: '旅行预订', subtitle: '恭喜!您现在可以在此工作区预订和管理差旅了。', manageTravelLabel: '管理差旅'}, centralInvoicingSection: { title: '集中开票', - subtitle: '允许您的成员直接向工作区支付和开具差旅费用。', + subtitle: '将所有差旅支出集中到月度发票中,而不是在购买时逐笔付款。', + learnHow: `了解如何操作。`, subsections: { currentTravelSpendLabel: '当前差旅行支出', currentTravelSpendCta: '支付余额', @@ -7755,7 +7756,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/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/WorkspaceTravelInvoicingSection.tsx b/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx index d0bb2ec87ca62..3d531c78f0281 100644 --- a/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx +++ b/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx @@ -27,6 +27,8 @@ import type {ToggleSettingOptionRowProps} from '@pages/workspace/workflows/Toggl 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 */ @@ -72,10 +74,17 @@ function WorkspaceTravelInvoicingSection({policyID}: WorkspaceTravelInvoicingSec const formattedLimit = convertToDisplayString(travelLimit, CONST.CURRENCY.USD); const settlementAccountNumber = CONST.MASKED_PAN_PREFIX + (settlementAccount?.last4 ?? '1234'); + const getCentralInvoicingSubtitle = () => { + if (!isCentralInvoicingEnabled) { + return } />; + } + return ; + }; + const optionItems: ToggleSettingOptionRowProps[] = [ { title: translate('workspace.moreFeatures.travel.travelInvoicing.centralInvoicingSection.title'), - subtitle: translate('workspace.moreFeatures.travel.travelInvoicing.centralInvoicingSection.subtitle'), + subtitle: getCentralInvoicingSubtitle(), switchAccessibilityLabel: translate('workspace.moreFeatures.travel.travelInvoicing.centralInvoicingSection.subtitle'), isActive: isCentralInvoicingEnabled, onToggle: (isEnabled: boolean) => setIsCentralInvoicingEnabled(isEnabled), @@ -150,9 +159,8 @@ function WorkspaceTravelInvoicingSection({policyID}: WorkspaceTravelInvoicingSec > Date: Tue, 13 Jan 2026 18:10:31 -0800 Subject: [PATCH 09/21] chore: submodule sync --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 24ad08565f129..bb8c3303ed62c 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 24ad08565f1298eaa08ce29e6e7bc44b30a98def +Subproject commit bb8c3303ed62c6d4d2d5a2aef16089ecc6301a1c From 22deb0506ab24e9ba5747d9545ed04c8160adf5f Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Thu, 15 Jan 2026 16:43:48 -0800 Subject: [PATCH 10/21] feat: integrate 'OpenPolicyTravelPage' API --- .../parameters/OpenPolicyTravelPageParams.ts | 5 ++ src/libs/API/parameters/index.ts | 1 + src/libs/API/types.ts | 2 + src/libs/actions/TravelInvoicing.ts | 53 +++++++++++++++++++ .../workspace/travel/PolicyTravelPage.tsx | 16 +++++- 5 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 src/libs/API/parameters/OpenPolicyTravelPageParams.ts create mode 100644 src/libs/actions/TravelInvoicing.ts 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/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/PolicyTravelPage.tsx b/src/pages/workspace/travel/PolicyTravelPage.tsx index 7a14cd932ea6e..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'; @@ -39,10 +42,21 @@ function WorkspaceTravelPage({ 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 = (() => { From 35fd25d38cf26899be8f7d1263f3736f8018514d Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Thu, 15 Jan 2026 16:49:07 -0800 Subject: [PATCH 11/21] chore: submodule sync --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index bb8c3303ed62c..4086ba4d3ee2f 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit bb8c3303ed62c6d4d2d5a2aef16089ecc6301a1c +Subproject commit 4086ba4d3ee2f9dac2b7dc49edf435e6b2ceb2eb From 47fc11416e6dc58579152d529dbcfb497084029d Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Fri, 16 Jan 2026 19:41:26 -0800 Subject: [PATCH 12/21] feat: release 2.3 - update settlement account flow --- src/ROUTES.ts | 4 + src/SCREENS.ts | 1 + ...tTravelInvoicingSettlementAccountParams.ts | 6 + src/libs/API/parameters/index.ts | 1 + src/libs/API/types.ts | 2 + .../ModalStackNavigators/index.tsx | 1 + .../RELATIONS/WORKSPACE_TO_RHP.ts | 2 +- src/libs/Navigation/linkingConfig/config.ts | 3 + src/libs/Navigation/types.ts | 3 + src/libs/actions/TravelInvoicing.ts | 85 +++++++++- .../WorkspaceTravelInvoicingSection.tsx | 39 +++-- .../WorkspaceTravelSettlementAccountPage.tsx | 151 ++++++++++++++++++ src/styles/utils/spacing.ts | 4 + 13 files changed, 284 insertions(+), 18 deletions(-) create mode 100644 src/libs/API/parameters/SetTravelInvoicingSettlementAccountParams.ts create mode 100644 src/pages/workspace/travel/WorkspaceTravelSettlementAccountPage.tsx diff --git a/src/ROUTES.ts b/src/ROUTES.ts index a0c07a34787d0..8355281d6593b 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -2460,6 +2460,10 @@ 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_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..aaa397c7abb04 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -703,6 +703,7 @@ const SCREENS = { OWNER_CHANGE_ERROR: 'Workspace_Owner_Change_Error', DISTANCE_RATES: 'Distance_Rates', TRAVEL: 'Travel', + TRAVEL_SETTINGS_ACCOUNT: 'Workspace_Travel_Settings_Account', CREATE_DISTANCE_RATE: 'Create_Distance_Rate', CREATE_DISTANCE_RATE_UPGRADE: 'Create_Distance_Rate_Upgrade', DISTANCE_RATES_SETTINGS: 'Distance_Rates_Settings', 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/index.ts b/src/libs/API/parameters/index.ts index d5e1b7e63f185..180f41610ccdf 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -373,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'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 68781b4ae8a56..6a198e3a6a32c 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -460,6 +460,7 @@ const WRITE_COMMANDS = { DELETE_SAVED_SEARCH: 'DeleteSavedSearch', UPDATE_CARD_SETTLEMENT_FREQUENCY: 'UpdateCardSettlementFrequency', UPDATE_CARD_SETTLEMENT_ACCOUNT: 'UpdateCardSettlementAccount', + SET_TRAVEL_INVOICING_SETTLEMENT_ACCOUNT: 'SetTravelInvoicingSettlementAccount', UPDATE_XERO_IMPORT_TRACKING_CATEGORIES: 'UpdateXeroImportTrackingCategories', UPDATE_XERO_IMPORT_TAX_RATES: 'UpdateXeroImportTaxRates', UPDATE_XERO_TENANT_ID: 'UpdateXeroTenantID', @@ -1031,6 +1032,7 @@ 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.SET_PERSONAL_DETAILS_AND_SHIP_EXPENSIFY_CARDS]: Parameters.SetPersonalDetailsAndShipExpensifyCardsParams; [WRITE_COMMANDS.SELF_TOUR_VIEWED]: null; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 6acc1efba7ddd..c65d428caf844 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -732,6 +732,7 @@ 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.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..147eb386e1292 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.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..bdb7e5773ed29 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1213,6 +1213,9 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.EXPENSIFY_CARD_SETTINGS_FREQUENCY]: { policyID: string; }; + [SCREENS.WORKSPACE.TRAVEL_SETTINGS_ACCOUNT]: { + policyID: string; + }; [SCREENS.WORKSPACE.COMPANY_CARDS_SETTINGS]: { policyID: string; }; diff --git a/src/libs/actions/TravelInvoicing.ts b/src/libs/actions/TravelInvoicing.ts index 3f98c2a96c39e..2b57fdd3b9d84 100644 --- a/src/libs/actions/TravelInvoicing.ts +++ b/src/libs/actions/TravelInvoicing.ts @@ -1,8 +1,11 @@ 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 type {OpenPolicyTravelPageParams, SetTravelInvoicingSettlementAccountParams} 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'; /** @@ -47,7 +50,77 @@ function openPolicyTravelPage(policyID: string, workspaceAccountID: number) { API.read(READ_COMMANDS.OPEN_POLICY_TRAVEL_PAGE, params, {optimisticData, successData, failureData}); } -export { - // eslint-disable-next-line import/prefer-default-export - openPolicyTravelPage, -}; +/** + * Sets the settlement account for Travel Invoicing. + * Updates the paymentBankAccountID in the Travel Invoicing card settings. + */ +function setTravelInvoicingSettlementAccount(policyID: string, workspaceAccountID: number, settlementBankAccountID: 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, + isLoading: true, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + ]; + + const successData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: cardSettingsKey, + value: { + paymentBankAccountID: settlementBankAccountID, + isLoading: false, + pendingAction: null, + }, + }, + ]; + + const failureData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: cardSettingsKey, + value: { + // Keep the attempted value visible (grayed out) until error is dismissed + paymentBankAccountID: settlementBankAccountID, + isLoading: false, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + errors: 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 since the attempted value failed to save. + */ +function clearTravelInvoicingSettlementAccountErrors(workspaceAccountID: number) { + const onyxData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${workspaceAccountID}_${PROGRAM_TRAVEL_US}`, + value: { + errors: null, + pendingAction: null, + paymentBankAccountID: null, + }, + }, + ]; + Onyx.update(onyxData); +} + +export {openPolicyTravelPage, setTravelInvoicingSettlementAccount, clearTravelInvoicingSettlementAccountErrors}; diff --git a/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx b/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx index 3d531c78f0281..ff0f309b2b74a 100644 --- a/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx +++ b/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx @@ -3,6 +3,7 @@ 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'; @@ -12,7 +13,9 @@ import useSingleExecution from '@hooks/useSingleExecution'; import useThemeStyles from '@hooks/useThemeStyles'; import useWorkspaceAccountID from '@hooks/useWorkspaceAccountID'; import {openExternalLink} from '@libs/actions/Link'; +import {clearTravelInvoicingSettlementAccountErrors} from '@libs/actions/TravelInvoicing'; import {convertToDisplayString} from '@libs/CurrencyUtils'; +import Navigation from '@libs/Navigation/Navigation'; import { getIsTravelInvoicingEnabled, getTravelLimit, @@ -26,6 +29,7 @@ import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOpt 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'; @@ -72,7 +76,12 @@ function WorkspaceTravelInvoicingSection({policyID}: WorkspaceTravelInvoicingSec // 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 + (settlementAccount?.last4 ?? '1234'); + + // 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 + const hasSettlementAccountError = Object.keys(cardSettings?.errors ?? {}).length > 0; const getCentralInvoicingSubtitle = () => { if (!isCentralInvoicingEnabled) { @@ -126,16 +135,24 @@ function WorkspaceTravelInvoicingSection({policyID}: WorkspaceTravelInvoicingSec interactive={false} // 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} - /> + clearTravelInvoicingSettlementAccountErrors(workspaceAccountID)} + 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} + /> + ; + +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) => { + setTravelInvoicingSettlementAccount(policyID, workspaceAccountID, value); + 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/styles/utils/spacing.ts b/src/styles/utils/spacing.ts index bd3428129dd26..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, }, From b9cf2ca17e8e744cdfc8d688cdfcd555049a22cd Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Fri, 16 Jan 2026 20:33:20 -0800 Subject: [PATCH 13/21] fix: clear error logic w/ tests --- src/libs/actions/TravelInvoicing.ts | 12 ++- .../WorkspaceTravelInvoicingSection.tsx | 2 +- .../WorkspaceTravelSettlementAccountPage.tsx | 3 +- src/types/onyx/ExpensifyCardSettings.ts | 3 + tests/unit/TravelInvoicingTest.ts | 96 +++++++++++++++++++ 5 files changed, 110 insertions(+), 6 deletions(-) create mode 100644 tests/unit/TravelInvoicingTest.ts diff --git a/src/libs/actions/TravelInvoicing.ts b/src/libs/actions/TravelInvoicing.ts index 2b57fdd3b9d84..084c663cd1619 100644 --- a/src/libs/actions/TravelInvoicing.ts +++ b/src/libs/actions/TravelInvoicing.ts @@ -54,7 +54,7 @@ function openPolicyTravelPage(policyID: string, workspaceAccountID: number) { * Sets the settlement account for Travel Invoicing. * Updates the paymentBankAccountID in the Travel Invoicing card settings. */ -function setTravelInvoicingSettlementAccount(policyID: string, workspaceAccountID: number, settlementBankAccountID: number) { +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}`; @@ -64,6 +64,7 @@ function setTravelInvoicingSettlementAccount(policyID: string, workspaceAccountI key: cardSettingsKey, value: { paymentBankAccountID: settlementBankAccountID, + previousPaymentBankAccountID, isLoading: true, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, }, @@ -76,6 +77,7 @@ function setTravelInvoicingSettlementAccount(policyID: string, workspaceAccountI key: cardSettingsKey, value: { paymentBankAccountID: settlementBankAccountID, + previousPaymentBankAccountID: null, isLoading: false, pendingAction: null, }, @@ -89,6 +91,7 @@ function setTravelInvoicingSettlementAccount(policyID: string, workspaceAccountI value: { // Keep the attempted value visible (grayed out) until error is dismissed paymentBankAccountID: settlementBankAccountID, + previousPaymentBankAccountID, isLoading: false, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), @@ -106,9 +109,9 @@ function setTravelInvoicingSettlementAccount(policyID: string, workspaceAccountI /** * Clears any errors from the Travel Invoicing settlement account settings. - * Also resets the paymentBankAccountID since the attempted value failed to save. + * Also resets the paymentBankAccountID to the previous valid value (or null if none existed). */ -function clearTravelInvoicingSettlementAccountErrors(workspaceAccountID: number) { +function clearTravelInvoicingSettlementAccountErrors(workspaceAccountID: number, paymentBankAccountID: number | null) { const onyxData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -116,7 +119,8 @@ function clearTravelInvoicingSettlementAccountErrors(workspaceAccountID: number) value: { errors: null, pendingAction: null, - paymentBankAccountID: null, + paymentBankAccountID, + previousPaymentBankAccountID: null, }, }, ]; diff --git a/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx b/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx index ff0f309b2b74a..cc657c4bb29db 100644 --- a/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx +++ b/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx @@ -138,7 +138,7 @@ function WorkspaceTravelInvoicingSection({policyID}: WorkspaceTravelInvoicingSec clearTravelInvoicingSettlementAccountErrors(workspaceAccountID)} + onClose={() => clearTravelInvoicingSettlementAccountErrors(workspaceAccountID, cardSettings?.previousPaymentBankAccountID ?? null)} errorRowStyles={styles.mh2half} errorRowTextStyles={styles.mr3} > diff --git a/src/pages/workspace/travel/WorkspaceTravelSettlementAccountPage.tsx b/src/pages/workspace/travel/WorkspaceTravelSettlementAccountPage.tsx index 904952c43c145..4816bb8cbbe9c 100644 --- a/src/pages/workspace/travel/WorkspaceTravelSettlementAccountPage.tsx +++ b/src/pages/workspace/travel/WorkspaceTravelSettlementAccountPage.tsx @@ -79,7 +79,8 @@ function WorkspaceTravelSettlementAccountPage({route}: WorkspaceTravelSettlement const listOptions: BankAccountListItem[] = eligibleBankAccountsOptions.length > 0 ? eligibleBankAccountsOptions : []; const handleSelectAccount = (value: number) => { - setTravelInvoicingSettlementAccount(policyID, workspaceAccountID, value); + const previousPaymentBankAccountID = cardSettings?.previousPaymentBankAccountID ?? cardSettings?.paymentBankAccountID; + setTravelInvoicingSettlementAccount(policyID, workspaceAccountID, value, previousPaymentBankAccountID); Navigation.goBack(); }; diff --git a/src/types/onyx/ExpensifyCardSettings.ts b/src/types/onyx/ExpensifyCardSettings.ts index df70b4adc5990..6a2c23142497e 100644 --- a/src/types/onyx/ExpensifyCardSettings.ts +++ b/src/types/onyx/ExpensifyCardSettings.ts @@ -20,6 +20,9 @@ type ExpensifyCardSettings = OnyxCommon.OnyxValueWithOfflineFeedback<{ /** 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; diff --git a/tests/unit/TravelInvoicingTest.ts b/tests/unit/TravelInvoicingTest.ts new file mode 100644 index 0000000000000..d0d87bdbf8463 --- /dev/null +++ b/tests/unit/TravelInvoicingTest.ts @@ -0,0 +1,96 @@ +import Onyx from 'react-native-onyx'; +import * as TravelInvoicing from '@libs/actions/TravelInvoicing'; +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}`; + + TravelInvoicing.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, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + isLoading: true, + }), + }), + ]), + successData: expect.arrayContaining([ + expect.objectContaining({ + key: cardSettingsKey, + value: expect.objectContaining({ + paymentBankAccountID: settlementBankAccountID, + previousPaymentBankAccountID: null, + pendingAction: null, + isLoading: false, + }), + }), + ]), + failureData: expect.arrayContaining([ + expect.objectContaining({ + key: cardSettingsKey, + value: expect.objectContaining({ + paymentBankAccountID: settlementBankAccountID, + previousPaymentBankAccountID, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + errors: expect.any(Object), + isLoading: false, + }), + }), + ]), + }), + ); + }); + + it('clearTravelInvoicingSettlementAccountErrors clears errors, resets pendingAction, and restores restored paymentBankAccountID', () => { + const workspaceAccountID = 456; + const restoredAccountID = 111; + const cardSettingsKey = `${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${workspaceAccountID}_${PROGRAM_TRAVEL_US}`; + + TravelInvoicing.clearTravelInvoicingSettlementAccountErrors(workspaceAccountID, restoredAccountID); + + expect(spyOnyxUpdate).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + key: cardSettingsKey, + value: { + errors: null, + pendingAction: null, + paymentBankAccountID: restoredAccountID, + previousPaymentBankAccountID: null, + }, + }), + ]), + ); + }); +}); From 3f3369a258b93d5e947cca8c309d3f4710a4a659 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Fri, 16 Jan 2026 21:43:48 -0800 Subject: [PATCH 14/21] chore: draft preparation --- src/ROUTES.ts | 4 + src/SCREENS.ts | 1 + ...ravelInvoicingSettlementFrequencyParams.ts | 9 ++ src/libs/API/parameters/index.ts | 1 + src/libs/API/types.ts | 1 + .../ModalStackNavigators/index.tsx | 1 + .../RELATIONS/WORKSPACE_TO_RHP.ts | 5 +- src/libs/Navigation/linkingConfig/config.ts | 3 + src/libs/Navigation/types.ts | 5 +- src/libs/TravelInvoicingUtils.ts | 1 + src/libs/actions/TravelInvoicing.ts | 78 ++++++++++++++++- .../workspace/travel/PolicyTravelPage.tsx | 1 + .../WorkspaceTravelInvoicingSection.tsx | 49 +++++++---- ...WorkspaceTravelSettlementFrequencyPage.tsx | 84 +++++++++++++++++++ .../WorkspaceTravelInvoicingSectionTest.tsx | 51 ++++++++++- tests/unit/TravelInvoicingTest.ts | 84 ++++++++++++++++++- 16 files changed, 355 insertions(+), 23 deletions(-) create mode 100644 src/libs/API/parameters/UpdateTravelInvoicingSettlementFrequencyParams.ts create mode 100644 src/pages/workspace/travel/WorkspaceTravelSettlementFrequencyPage.tsx diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 8355281d6593b..8bd9670541820 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -2464,6 +2464,10 @@ const ROUTES = { 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 aaa397c7abb04..36f38f4a61405 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -704,6 +704,7 @@ const SCREENS = { 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/libs/API/parameters/UpdateTravelInvoicingSettlementFrequencyParams.ts b/src/libs/API/parameters/UpdateTravelInvoicingSettlementFrequencyParams.ts new file mode 100644 index 0000000000000..cee7d9f2a53d3 --- /dev/null +++ b/src/libs/API/parameters/UpdateTravelInvoicingSettlementFrequencyParams.ts @@ -0,0 +1,9 @@ +import type {ValueOf} from 'type-fest'; +import type CONST from '@src/CONST'; + +type UpdateTravelInvoicingSettlementFrequencyParams = { + policyID: string; + frequency: ValueOf; +}; + +export default UpdateTravelInvoicingSettlementFrequencyParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 180f41610ccdf..1f38494ae98da 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -463,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 6a198e3a6a32c..62b2c88e78dc6 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -461,6 +461,7 @@ const WRITE_COMMANDS = { 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', diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index c65d428caf844..e6a18e2638764 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -733,6 +733,7 @@ const SettingsModalStackNavigator = createModalStackNavigator 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 147eb386e1292..b286aa2461c54 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts @@ -224,7 +224,10 @@ const WORKSPACE_TO_RHP: Partial['config'] = { [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 bdb7e5773ed29..49035d2d80db6 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1215,7 +1215,10 @@ type SettingsNavigatorParamList = { }; [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 index 8449c7684ec56..94fd06aecbd9e 100644 --- a/src/libs/TravelInvoicingUtils.ts +++ b/src/libs/TravelInvoicingUtils.ts @@ -3,6 +3,7 @@ import CONST from '@src/CONST'; import type {BankAccountList} from '@src/types/onyx'; import type ExpensifyCardSettings from '@src/types/onyx/ExpensifyCardSettings'; import {getLastFourDigits} from './BankAccountUtils'; +import {LocaleContextProps} from '@components/LocaleContextProvider'; /** * The Travel Invoicing feed type constant for PROGRAM_TRAVEL_US. diff --git a/src/libs/actions/TravelInvoicing.ts b/src/libs/actions/TravelInvoicing.ts index 084c663cd1619..36e04ac28402f 100644 --- a/src/libs/actions/TravelInvoicing.ts +++ b/src/libs/actions/TravelInvoicing.ts @@ -1,7 +1,8 @@ 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} from '@libs/API/parameters'; +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'; @@ -127,4 +128,77 @@ function clearTravelInvoicingSettlementAccountErrors(workspaceAccountID: number, Onyx.update(onyxData); } -export {openPolicyTravelPage, setTravelInvoicingSettlementAccount, clearTravelInvoicingSettlementAccountErrors}; +/** + * Updates the settlement frequency for Travel Invoicing. + * Optimistically updates the monthlySettlementDate based on the selected frequency. + */ +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, + errors: null, + }, + }, + ]; + + const successData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: cardSettingsKey, + value: { + monthlySettlementDate, + errors: null, + }, + }, + ]; + + const failureData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: cardSettingsKey, + value: { + monthlySettlementDate: currentMonthlySettlementDate ?? null, // Revert + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + ]; + + const params: UpdateTravelInvoicingSettlementFrequencyParams = { + policyID, + 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) { + const onyxData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${workspaceAccountID}_${PROGRAM_TRAVEL_US}`, + value: { + errors: null, + }, + }, + ]; + Onyx.update(onyxData); +} + +export {openPolicyTravelPage, setTravelInvoicingSettlementAccount, clearTravelInvoicingSettlementAccountErrors, clearTravelInvoicingSettlementFrequencyErrors, updateTravelInvoiceSettlementFrequency}; diff --git a/src/pages/workspace/travel/PolicyTravelPage.tsx b/src/pages/workspace/travel/PolicyTravelPage.tsx index 61f2aa87cf202..195831044aaf7 100644 --- a/src/pages/workspace/travel/PolicyTravelPage.tsx +++ b/src/pages/workspace/travel/PolicyTravelPage.tsx @@ -60,6 +60,7 @@ function WorkspaceTravelPage({ const step = getTravelStep(policy, travelSettings, isBetaEnabled(CONST.BETAS.IS_TRAVEL_VERIFIED), policies, currentUserLogin); const mainContent = (() => { + return ; switch (step) { case CONST.TRAVEL.STEPS.BOOK_OR_MANAGE_YOUR_TRIP: if (isTravelInvoicingEnabled) { diff --git a/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx b/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx index cc657c4bb29db..f84e0dd5011ea 100644 --- a/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx +++ b/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx @@ -13,7 +13,10 @@ import useSingleExecution from '@hooks/useSingleExecution'; import useThemeStyles from '@hooks/useThemeStyles'; import useWorkspaceAccountID from '@hooks/useWorkspaceAccountID'; import {openExternalLink} from '@libs/actions/Link'; -import {clearTravelInvoicingSettlementAccountErrors} from '@libs/actions/TravelInvoicing'; +import { + clearTravelInvoicingSettlementAccountErrors, + clearTravelInvoicingSettlementFrequencyErrors, +} from '@libs/actions/TravelInvoicing'; import {convertToDisplayString} from '@libs/CurrencyUtils'; import Navigation from '@libs/Navigation/Navigation'; import { @@ -81,7 +84,13 @@ function WorkspaceTravelInvoicingSection({policyID}: WorkspaceTravelInvoicingSec const settlementAccountNumber = hasSettlementAccount && settlementAccount?.last4 ? CONST.MASKED_PAN_PREFIX + settlementAccount.last4 : ''; // Get any errors from the settlement account update - const hasSettlementAccountError = Object.keys(cardSettings?.errors ?? {}).length > 0; + // Get errors for specific fields + const settlementAccountErrorKey = 'paymentBankAccountID'; + const settlementFrequencyErrorKey = 'monthlySettlementDate'; + const hasSettlementAccountError = !!cardSettings?.errors?.[settlementAccountErrorKey]; + const hasSettlementFrequencyError = !!cardSettings?.errors?.[settlementFrequencyErrorKey]; + const settlementAccountErrors = hasSettlementAccountError ? {[settlementAccountErrorKey]: cardSettings?.errors?.[settlementAccountErrorKey] ?? ''} : null; + const settlementFrequencyErrors = hasSettlementFrequencyError ? {[settlementFrequencyErrorKey]: cardSettings?.errors?.[settlementFrequencyErrorKey] ?? ''} : null; const getCentralInvoicingSubtitle = () => { if (!isCentralInvoicingEnabled) { @@ -136,7 +145,7 @@ function WorkspaceTravelInvoicingSection({policyID}: WorkspaceTravelInvoicingSec // brickRoadIndicator={hasDelayedSubmissionError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} /> clearTravelInvoicingSettlementAccountErrors(workspaceAccountID, cardSettings?.previousPaymentBankAccountID ?? null)} errorRowStyles={styles.mh2half} @@ -153,16 +162,24 @@ function WorkspaceTravelInvoicingSection({policyID}: WorkspaceTravelInvoicingSec brickRoadIndicator={hasSettlementAccountError ? 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} - /> + clearTravelInvoicingSettlementFrequencyErrors(workspaceAccountID)} + 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} + /> + ), }, @@ -192,9 +209,9 @@ function WorkspaceTravelInvoicingSection({policyID}: WorkspaceTravelInvoicingSec // If Travel Invoicing is not enabled or no settlement account is configured // show the BookOrManageYourTrip component as fallback - if (!isTravelInvoicingEnabled || !hasSettlementAccount) { - return ; - } + // if (!isTravelInvoicingEnabled || !hasSettlementAccount) { + // return ; + // } return ( <> diff --git a/src/pages/workspace/travel/WorkspaceTravelSettlementFrequencyPage.tsx b/src/pages/workspace/travel/WorkspaceTravelSettlementFrequencyPage.tsx new file mode 100644 index 0000000000000..92630e112883f --- /dev/null +++ b/src/pages/workspace/travel/WorkspaceTravelSettlementFrequencyPage.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import {useOnyx} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SelectionList from '@components/SelectionList'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWorkspaceAccountID from '@hooks/useWorkspaceAccountID'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; +import Navigation from '@libs/Navigation/Navigation'; +import RadioListItem from '@components/SelectionList/ListItem/RadioListItem'; +import type {ListItem} from '@components/SelectionList/types'; +import {getTravelSettlementFrequency, PROGRAM_TRAVEL_US} from '@libs/TravelInvoicingUtils'; +import {updateTravelInvoiceSettlementFrequency} from '@libs/actions/TravelInvoicing'; +import {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; + +type WorkspaceTravelSettlementFrequencyPageProps = PlatformStackScreenProps; + +type FrequencyItem = ListItem & { + value: ValueOf; +}; + +function WorkspaceTravelSettlementFrequencyPage({route}: WorkspaceTravelSettlementFrequencyPageProps) { + 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); + + 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 instanceof Date ? cardSettings.monthlySettlementDate : undefined, + ); + Navigation.goBack(); + }; + + return ( + + Navigation.goBack()} + /> + + data={data} + onSelectRow={selectFrequency} + ListItem={RadioListItem} + initiallyFocusedItemKey={currentFrequency} + /> + + ); +} + +WorkspaceTravelSettlementFrequencyPage.displayName = 'WorkspaceTravelSettlementFrequencyPage'; + +export default WorkspaceTravelSettlementFrequencyPage; diff --git a/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx b/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx index 4f3a73fb74d4c..02b25e782c1e2 100644 --- a/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx +++ b/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/naming-convention */ -import {act, render, screen} from '@testing-library/react-native'; +import {act, fireEvent, render, screen} from '@testing-library/react-native'; import React from 'react'; import Onyx from 'react-native-onyx'; import ComposeProviders from '@components/ComposeProviders'; @@ -50,6 +50,13 @@ jest.mock('@hooks/useScreenWrapperTransitionStatus', () => ({ default: () => ({didScreenTransitionEnd: true}), })); +jest.mock('@libs/Navigation/Navigation', () => ({ + __esModule: true, + default: { + navigate: jest.fn(), + }, +})); + const mockPolicy: Policy = { ...createRandomPolicy(parseInt(POLICY_ID, 10) || 1), type: CONST.POLICY.TYPE.CORPORATE, @@ -274,5 +281,47 @@ describe('WorkspaceTravelInvoicingSection', () => { // 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(); + + // When pressing the frequency row + const frequencyRow = screen.getByText('Monthly'); + await act(async () => { + fireEvent.press(frequencyRow); + }); + + // Then it should navigate to frequency settings page + // eslint-disable-next-line @typescript-eslint/no-var-requires + const navigation = require('@libs/Navigation/Navigation'); + expect(navigation.default.navigate).toHaveBeenCalledWith('workspaces/testPolicy123/travel/settings/frequency'); + }); }); }); diff --git a/tests/unit/TravelInvoicingTest.ts b/tests/unit/TravelInvoicingTest.ts index d0d87bdbf8463..74c077b1b46bc 100644 --- a/tests/unit/TravelInvoicingTest.ts +++ b/tests/unit/TravelInvoicingTest.ts @@ -63,7 +63,9 @@ describe('TravelInvoicing', () => { paymentBankAccountID: settlementBankAccountID, previousPaymentBankAccountID, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, - errors: expect.any(Object), + errors: expect.objectContaining({ + paymentBankAccountID: expect.any(String), + }), isLoading: false, }), }), @@ -84,7 +86,9 @@ describe('TravelInvoicing', () => { expect.objectContaining({ key: cardSettingsKey, value: { - errors: null, + errors: { + paymentBankAccountID: null, + }, pendingAction: null, paymentBankAccountID: restoredAccountID, previousPaymentBankAccountID: null, @@ -93,4 +97,80 @@ describe('TravelInvoicing', () => { ]), ); }); + + it('clearTravelInvoicingSettlementFrequencyErrors clears errors', () => { + const workspaceAccountID = 456; + const cardSettingsKey = `${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${workspaceAccountID}_${PROGRAM_TRAVEL_US}`; + + TravelInvoicing.clearTravelInvoicingSettlementFrequencyErrors(workspaceAccountID); + + expect(spyOnyxUpdate).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + key: cardSettingsKey, + value: { + errors: { + monthlySettlementDate: 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); + + TravelInvoicing.updateTravelInvoiceSettlementFrequency(policyID, workspaceAccountID, frequency, currentMonthlySettlementDate); + + expect(spyAPIWrite).toHaveBeenCalledWith( + 'UpdateTravelInvoicingSettlementFrequency', + { + policyID, + frequency, + }, + expect.objectContaining({ + optimisticData: expect.arrayContaining([ + expect.objectContaining({ + key: cardSettingsKey, + value: expect.objectContaining({ + monthlySettlementDate: mockDate, + errors: null, + }), + }), + ]), + successData: expect.arrayContaining([ + expect.objectContaining({ + key: cardSettingsKey, + value: expect.objectContaining({ + monthlySettlementDate: mockDate, + errors: null, + }), + }), + ]), + failureData: expect.arrayContaining([ + expect.objectContaining({ + key: cardSettingsKey, + value: expect.objectContaining({ + monthlySettlementDate: currentMonthlySettlementDate, + errors: expect.objectContaining({ + monthlySettlementDate: expect.any(String), + }), + }), + }), + ]), + }), + ); + + jest.useRealTimers(); + }); }); From 58a0448b5ece89fb8cef8a205e6be0407f1b9734 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Fri, 16 Jan 2026 21:50:30 -0800 Subject: [PATCH 15/21] fix: eslint --- tests/unit/TravelInvoicingTest.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/unit/TravelInvoicingTest.ts b/tests/unit/TravelInvoicingTest.ts index 74c077b1b46bc..cee596933ab6f 100644 --- a/tests/unit/TravelInvoicingTest.ts +++ b/tests/unit/TravelInvoicingTest.ts @@ -1,9 +1,11 @@ import Onyx from 'react-native-onyx'; -import * as TravelInvoicing 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'; +import {clearTravelInvoicingSettlementAccountErrors, setTravelInvoicingSettlementAccount, clearTravelInvoicingSettlementFrequencyErrors, updateTravelInvoiceSettlementFrequency} from '@libs/actions/TravelInvoicing'; describe('TravelInvoicing', () => { let spyAPIWrite: jest.SpyInstance; @@ -25,7 +27,7 @@ describe('TravelInvoicing', () => { const previousPaymentBankAccountID = 111; const cardSettingsKey = `${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${workspaceAccountID}_${PROGRAM_TRAVEL_US}`; - TravelInvoicing.setTravelInvoicingSettlementAccount(policyID, workspaceAccountID, settlementBankAccountID, previousPaymentBankAccountID); + setTravelInvoicingSettlementAccount(policyID, workspaceAccountID, settlementBankAccountID, previousPaymentBankAccountID); expect(spyAPIWrite).toHaveBeenCalledWith( 'SetTravelInvoicingSettlementAccount', @@ -64,7 +66,7 @@ describe('TravelInvoicing', () => { previousPaymentBankAccountID, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, errors: expect.objectContaining({ - paymentBankAccountID: expect.any(String), + paymentBankAccountID: expect.stringMatching(/^.+$/), }), isLoading: false, }), @@ -79,7 +81,7 @@ describe('TravelInvoicing', () => { const restoredAccountID = 111; const cardSettingsKey = `${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${workspaceAccountID}_${PROGRAM_TRAVEL_US}`; - TravelInvoicing.clearTravelInvoicingSettlementAccountErrors(workspaceAccountID, restoredAccountID); + clearTravelInvoicingSettlementAccountErrors(workspaceAccountID, restoredAccountID); expect(spyOnyxUpdate).toHaveBeenCalledWith( expect.arrayContaining([ @@ -102,7 +104,7 @@ describe('TravelInvoicing', () => { const workspaceAccountID = 456; const cardSettingsKey = `${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${workspaceAccountID}_${PROGRAM_TRAVEL_US}`; - TravelInvoicing.clearTravelInvoicingSettlementFrequencyErrors(workspaceAccountID); + clearTravelInvoicingSettlementFrequencyErrors(workspaceAccountID); expect(spyOnyxUpdate).toHaveBeenCalledWith( expect.arrayContaining([ @@ -130,7 +132,7 @@ describe('TravelInvoicing', () => { jest.useFakeTimers(); jest.setSystemTime(mockDate); - TravelInvoicing.updateTravelInvoiceSettlementFrequency(policyID, workspaceAccountID, frequency, currentMonthlySettlementDate); + updateTravelInvoiceSettlementFrequency(policyID, workspaceAccountID, frequency, currentMonthlySettlementDate); expect(spyAPIWrite).toHaveBeenCalledWith( 'UpdateTravelInvoicingSettlementFrequency', From 530d292bdb6fecdc4d314ffc855eccdff119fcca Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Fri, 16 Jan 2026 22:07:18 -0800 Subject: [PATCH 16/21] fix: lint --- .../RELATIONS/WORKSPACE_TO_RHP.ts | 5 +- src/libs/Navigation/types.ts | 4 +- src/libs/TravelInvoicingUtils.ts | 1 - src/libs/actions/TravelInvoicing.ts | 8 +- .../WorkspaceTravelInvoicingSection.tsx | 5 +- ...WorkspaceTravelSettlementFrequencyPage.tsx | 15 ++-- .../WorkspaceTravelInvoicingSectionTest.tsx | 78 +++++++++---------- tests/unit/TravelInvoicingTest.ts | 9 ++- 8 files changed, 64 insertions(+), 61 deletions(-) diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts index b286aa2461c54..91869084f7dba 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts @@ -224,10 +224,7 @@ const WORKSPACE_TO_RHP: Partial; @@ -28,7 +27,7 @@ function WorkspaceTravelSettlementFrequencyPage({route}: WorkspaceTravelSettleme 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); + 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]; diff --git a/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx b/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx index 02b25e782c1e2..99af47e9d688e 100644 --- a/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx +++ b/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx @@ -283,45 +283,45 @@ describe('WorkspaceTravelInvoicingSection', () => { }); 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(); - - // When pressing the frequency row - const frequencyRow = screen.getByText('Monthly'); - await act(async () => { - fireEvent.press(frequencyRow); - }); - - // Then it should navigate to frequency settings page - // eslint-disable-next-line @typescript-eslint/no-var-requires - const navigation = require('@libs/Navigation/Navigation'); - expect(navigation.default.navigate).toHaveBeenCalledWith('workspaces/testPolicy123/travel/settings/frequency'); + // 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(); + + // When pressing the frequency row + const frequencyRow = screen.getByText('Monthly'); + await act(async () => { + fireEvent.press(frequencyRow); + }); + + // Then it should navigate to frequency settings page + // eslint-disable-next-line @typescript-eslint/no-var-requires + const navigation = require('@libs/Navigation/Navigation'); + expect(navigation.default.navigate).toHaveBeenCalledWith('workspaces/testPolicy123/travel/settings/frequency'); }); }); }); diff --git a/tests/unit/TravelInvoicingTest.ts b/tests/unit/TravelInvoicingTest.ts index cee596933ab6f..4fd367d10ff59 100644 --- a/tests/unit/TravelInvoicingTest.ts +++ b/tests/unit/TravelInvoicingTest.ts @@ -1,11 +1,16 @@ 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'; -import {clearTravelInvoicingSettlementAccountErrors, setTravelInvoicingSettlementAccount, clearTravelInvoicingSettlementFrequencyErrors, updateTravelInvoiceSettlementFrequency} from '@libs/actions/TravelInvoicing'; describe('TravelInvoicing', () => { let spyAPIWrite: jest.SpyInstance; @@ -165,7 +170,7 @@ describe('TravelInvoicing', () => { value: expect.objectContaining({ monthlySettlementDate: currentMonthlySettlementDate, errors: expect.objectContaining({ - monthlySettlementDate: expect.any(String), + monthlySettlementDate: expect.stringMatching(/^.+$/), }), }), }), From 5912c27f253a7bc63fecafc113375c6144bd036a Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Sat, 17 Jan 2026 10:49:51 -0800 Subject: [PATCH 17/21] fix: CI, BE integration, optimistic behaviour, styles --- src/languages/de.ts | 1 + src/languages/en.ts | 1 + src/languages/es.ts | 2 + src/languages/fr.ts | 2 + src/languages/it.ts | 2 + src/languages/ja.ts | 1 + src/languages/nl.ts | 1 + src/languages/pl.ts | 1 + src/languages/pt-BR.ts | 2 + src/languages/zh-hans.ts | 1 + ...ravelInvoicingSettlementFrequencyParams.ts | 3 +- src/libs/API/types.ts | 1 + src/libs/actions/TravelInvoicing.ts | 33 ++++++++++--- .../workspace/travel/PolicyTravelPage.tsx | 1 - .../WorkspaceTravelInvoicingSection.tsx | 10 ++-- .../WorkspaceTravelSettlementAccountPage.tsx | 2 +- ...WorkspaceTravelSettlementFrequencyPage.tsx | 8 ++++ .../WorkspaceTravelInvoicingSectionTest.tsx | 15 ++---- tests/unit/TravelInvoicingTest.ts | 48 +++++++++++-------- 19 files changed, 88 insertions(+), 47 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index b5ffec62d5290..03ebd6c4951b8 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -5091,6 +5091,7 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU currentTravelLimitLabel: 'Aktuelles Reiselimit', settlementAccountLabel: 'Ausgleichskonto', settlementFrequencyLabel: 'Abrechnungshäufigkeit', + settlementFrequencyDescription: 'Wie oft Expensify Ihr Geschäftskonto belastet, um aktuelle Expensify Travel-Transaktionen zu begleichen.', }, }, }, diff --git a/src/languages/en.ts b/src/languages/en.ts index 8aa83bd3d23f9..9ea0669646788 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -4997,6 +4997,7 @@ const translations = { 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.', }, }, }, diff --git a/src/languages/es.ts b/src/languages/es.ts index b7b55a3b4e6ca..f139dd7964bf0 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -4732,6 +4732,8 @@ ${amount} para ${merchant} - ${date}`, 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.', }, }, }, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 9595573aae29b..c86cc230d2b48 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -5097,6 +5097,8 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. 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.', }, }, }, diff --git a/src/languages/it.ts b/src/languages/it.ts index c7acafc4599ac..5de71dcc078d1 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -5076,6 +5076,8 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. 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.', }, }, }, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 901969cd205b1..a1744688f4031 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -5047,6 +5047,7 @@ _より詳しい手順については、[ヘルプサイトをご覧ください currentTravelLimitLabel: '現在の出張上限', settlementAccountLabel: '決済口座', settlementFrequencyLabel: '清算頻度', + settlementFrequencyDescription: 'Expensify が直近の Expensify Travel 取引を精算するために、あなたのビジネス銀行口座から資金を引き落とす頻度。', }, }, }, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 7c9c9ffb12f62..7408ec09fa549 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -5068,6 +5068,7 @@ _Voor gedetailleerdere instructies, [bezoek onze helpsite](${CONST.NETSUITE_IMPO 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.', }, }, }, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 8429506994bd1..87eea9aaea28b 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -5058,6 +5058,7 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy 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.', }, }, }, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 9d00b71626aed..2b04957756a71 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -5059,6 +5059,8 @@ _Para instruções mais detalhadas, [visite nosso site de ajuda](${CONST.NETSUIT 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.', }, }, }, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 693ae02d0be8f..05aae78e216fc 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -4965,6 +4965,7 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM currentTravelLimitLabel: '当前出差限额', settlementAccountLabel: '结算账户', settlementFrequencyLabel: '结算频率', + settlementFrequencyDescription: 'Expensify 从您的企业银行账户中扣款以结算最近 Expensify Travel 交易的频率。', }, }, }, diff --git a/src/libs/API/parameters/UpdateTravelInvoicingSettlementFrequencyParams.ts b/src/libs/API/parameters/UpdateTravelInvoicingSettlementFrequencyParams.ts index cee7d9f2a53d3..f82dfa2a15931 100644 --- a/src/libs/API/parameters/UpdateTravelInvoicingSettlementFrequencyParams.ts +++ b/src/libs/API/parameters/UpdateTravelInvoicingSettlementFrequencyParams.ts @@ -3,7 +3,8 @@ import type CONST from '@src/CONST'; type UpdateTravelInvoicingSettlementFrequencyParams = { policyID: string; - frequency: ValueOf; + workspaceAccountID: number; + settlementFrequency: ValueOf; }; export default UpdateTravelInvoicingSettlementFrequencyParams; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 62b2c88e78dc6..91474d94db044 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -1034,6 +1034,7 @@ type WriteCommandParameters = { [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; diff --git a/src/libs/actions/TravelInvoicing.ts b/src/libs/actions/TravelInvoicing.ts index dec0644bfb7c9..2b3199917bd76 100644 --- a/src/libs/actions/TravelInvoicing.ts +++ b/src/libs/actions/TravelInvoicing.ts @@ -67,7 +67,9 @@ function setTravelInvoicingSettlementAccount(policyID: string, workspaceAccountI paymentBankAccountID: settlementBankAccountID, previousPaymentBankAccountID, isLoading: true, - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + pendingFields: { + paymentBankAccountID: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, }, }, ]; @@ -80,7 +82,9 @@ function setTravelInvoicingSettlementAccount(policyID: string, workspaceAccountI paymentBankAccountID: settlementBankAccountID, previousPaymentBankAccountID: null, isLoading: false, - pendingAction: null, + pendingFields: { + paymentBankAccountID: null, + }, }, }, ]; @@ -94,7 +98,9 @@ function setTravelInvoicingSettlementAccount(policyID: string, workspaceAccountI paymentBankAccountID: settlementBankAccountID, previousPaymentBankAccountID, isLoading: false, - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + pendingFields: { + paymentBankAccountID: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), }, }, @@ -119,7 +125,9 @@ function clearTravelInvoicingSettlementAccountErrors(workspaceAccountID: number, key: `${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${workspaceAccountID}_${PROGRAM_TRAVEL_US}`, value: { errors: null, - pendingAction: null, + pendingFields: { + paymentBankAccountID: null, + }, paymentBankAccountID, previousPaymentBankAccountID: null, }, @@ -131,6 +139,7 @@ function clearTravelInvoicingSettlementAccountErrors(workspaceAccountID: number, /** * 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, @@ -141,7 +150,7 @@ function updateTravelInvoiceSettlementFrequency( 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. + // 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> = [ @@ -150,6 +159,9 @@ function updateTravelInvoiceSettlementFrequency( key: cardSettingsKey, value: { monthlySettlementDate, + pendingFields: { + monthlySettlementDate: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, errors: null, }, }, @@ -161,6 +173,9 @@ function updateTravelInvoiceSettlementFrequency( key: cardSettingsKey, value: { monthlySettlementDate, + pendingFields: { + monthlySettlementDate: null, + }, errors: null, }, }, @@ -171,7 +186,10 @@ function updateTravelInvoiceSettlementFrequency( onyxMethod: Onyx.METHOD.MERGE, key: cardSettingsKey, value: { - monthlySettlementDate: currentMonthlySettlementDate ?? null, // Revert + monthlySettlementDate: currentMonthlySettlementDate ?? null, + pendingFields: { + monthlySettlementDate: null, + }, errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), }, }, @@ -179,7 +197,8 @@ function updateTravelInvoiceSettlementFrequency( const params: UpdateTravelInvoicingSettlementFrequencyParams = { policyID, - frequency, + workspaceAccountID, + settlementFrequency: frequency, }; API.write(WRITE_COMMANDS.UPDATE_TRAVEL_INVOICE_SETTLEMENT_FREQUENCY, params, {optimisticData, successData, failureData}); diff --git a/src/pages/workspace/travel/PolicyTravelPage.tsx b/src/pages/workspace/travel/PolicyTravelPage.tsx index 195831044aaf7..61f2aa87cf202 100644 --- a/src/pages/workspace/travel/PolicyTravelPage.tsx +++ b/src/pages/workspace/travel/PolicyTravelPage.tsx @@ -60,7 +60,6 @@ function WorkspaceTravelPage({ const step = getTravelStep(policy, travelSettings, isBetaEnabled(CONST.BETAS.IS_TRAVEL_VERIFIED), policies, currentUserLogin); const mainContent = (() => { - return ; switch (step) { case CONST.TRAVEL.STEPS.BOOK_OR_MANAGE_YOUR_TRIP: if (isTravelInvoicingEnabled) { diff --git a/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx b/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx index 9bac5b2bf9efa..ba3a9f3c53520 100644 --- a/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx +++ b/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx @@ -143,7 +143,7 @@ function WorkspaceTravelInvoicingSection({policyID}: WorkspaceTravelInvoicingSec /> clearTravelInvoicingSettlementAccountErrors(workspaceAccountID, cardSettings?.previousPaymentBankAccountID ?? null)} errorRowStyles={styles.mh2half} errorRowTextStyles={styles.mr3} @@ -161,7 +161,7 @@ function WorkspaceTravelInvoicingSection({policyID}: WorkspaceTravelInvoicingSec clearTravelInvoicingSettlementFrequencyErrors(workspaceAccountID)} errorRowStyles={styles.mh2half} errorRowTextStyles={styles.mr3} @@ -206,9 +206,9 @@ function WorkspaceTravelInvoicingSection({policyID}: WorkspaceTravelInvoicingSec // If Travel Invoicing is not enabled or no settlement account is configured // show the BookOrManageYourTrip component as fallback - // if (!isTravelInvoicingEnabled || !hasSettlementAccount) { - // return ; - // } + if (!isTravelInvoicingEnabled || !hasSettlementAccount) { + return ; + } return ( <> diff --git a/src/pages/workspace/travel/WorkspaceTravelSettlementAccountPage.tsx b/src/pages/workspace/travel/WorkspaceTravelSettlementAccountPage.tsx index 4816bb8cbbe9c..dc746254cf07f 100644 --- a/src/pages/workspace/travel/WorkspaceTravelSettlementAccountPage.tsx +++ b/src/pages/workspace/travel/WorkspaceTravelSettlementAccountPage.tsx @@ -101,7 +101,7 @@ function WorkspaceTravelSettlementAccountPage({route}: WorkspaceTravelSettlement /> - {translate('workspace.expensifyCard.chooseExistingBank')} + {translate('workspace.expensifyCard.chooseExistingBank')} {listOptions.length > 0 ? ( + {translate('workspace.moreFeatures.travel.travelInvoicing.centralInvoicingSection.subsections.settlementFrequencyDescription')} + + } /> ); diff --git a/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx b/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx index 99af47e9d688e..dbd2db9b22c6a 100644 --- a/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx +++ b/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/naming-convention */ -import {act, fireEvent, render, screen} from '@testing-library/react-native'; +import {act, render, screen} from '@testing-library/react-native'; import React from 'react'; import Onyx from 'react-native-onyx'; import ComposeProviders from '@components/ComposeProviders'; @@ -54,6 +54,7 @@ jest.mock('@libs/Navigation/Navigation', () => ({ __esModule: true, default: { navigate: jest.fn(), + getActiveRoute: jest.fn(() => ''), }, })); @@ -311,17 +312,7 @@ describe('WorkspaceTravelInvoicingSection', () => { // Then it should display "Monthly" expect(screen.getByText('Monthly')).toBeTruthy(); - - // When pressing the frequency row - const frequencyRow = screen.getByText('Monthly'); - await act(async () => { - fireEvent.press(frequencyRow); - }); - - // Then it should navigate to frequency settings page - // eslint-disable-next-line @typescript-eslint/no-var-requires - const navigation = require('@libs/Navigation/Navigation'); - expect(navigation.default.navigate).toHaveBeenCalledWith('workspaces/testPolicy123/travel/settings/frequency'); + expect(screen.getByText('Settlement frequency')).toBeTruthy(); }); }); }); diff --git a/tests/unit/TravelInvoicingTest.ts b/tests/unit/TravelInvoicingTest.ts index 4fd367d10ff59..fb9011722b035 100644 --- a/tests/unit/TravelInvoicingTest.ts +++ b/tests/unit/TravelInvoicingTest.ts @@ -47,7 +47,9 @@ describe('TravelInvoicing', () => { value: expect.objectContaining({ paymentBankAccountID: settlementBankAccountID, previousPaymentBankAccountID, - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + pendingFields: expect.objectContaining({ + paymentBankAccountID: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }), isLoading: true, }), }), @@ -58,7 +60,9 @@ describe('TravelInvoicing', () => { value: expect.objectContaining({ paymentBankAccountID: settlementBankAccountID, previousPaymentBankAccountID: null, - pendingAction: null, + pendingFields: expect.objectContaining({ + paymentBankAccountID: null, + }), isLoading: false, }), }), @@ -69,9 +73,8 @@ describe('TravelInvoicing', () => { value: expect.objectContaining({ paymentBankAccountID: settlementBankAccountID, previousPaymentBankAccountID, - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, - errors: expect.objectContaining({ - paymentBankAccountID: expect.stringMatching(/^.+$/), + pendingFields: expect.objectContaining({ + paymentBankAccountID: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, }), isLoading: false, }), @@ -81,7 +84,7 @@ describe('TravelInvoicing', () => { ); }); - it('clearTravelInvoicingSettlementAccountErrors clears errors, resets pendingAction, and restores restored paymentBankAccountID', () => { + it('clearTravelInvoicingSettlementAccountErrors clears errors and pendingFields', () => { const workspaceAccountID = 456; const restoredAccountID = 111; const cardSettingsKey = `${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${workspaceAccountID}_${PROGRAM_TRAVEL_US}`; @@ -92,14 +95,14 @@ describe('TravelInvoicing', () => { expect.arrayContaining([ expect.objectContaining({ key: cardSettingsKey, - value: { - errors: { + value: expect.objectContaining({ + errors: null, + pendingFields: expect.objectContaining({ paymentBankAccountID: null, - }, - pendingAction: null, + }), paymentBankAccountID: restoredAccountID, previousPaymentBankAccountID: null, - }, + }), }), ]), ); @@ -115,11 +118,9 @@ describe('TravelInvoicing', () => { expect.arrayContaining([ expect.objectContaining({ key: cardSettingsKey, - value: { - errors: { - monthlySettlementDate: null, - }, - }, + value: expect.objectContaining({ + errors: null, + }), }), ]), ); @@ -140,10 +141,11 @@ describe('TravelInvoicing', () => { updateTravelInvoiceSettlementFrequency(policyID, workspaceAccountID, frequency, currentMonthlySettlementDate); expect(spyAPIWrite).toHaveBeenCalledWith( - 'UpdateTravelInvoicingSettlementFrequency', + 'UpdateTravelInvoiceSettlementFrequency', { policyID, - frequency, + workspaceAccountID, + settlementFrequency: frequency, }, expect.objectContaining({ optimisticData: expect.arrayContaining([ @@ -151,6 +153,9 @@ describe('TravelInvoicing', () => { key: cardSettingsKey, value: expect.objectContaining({ monthlySettlementDate: mockDate, + pendingFields: expect.objectContaining({ + monthlySettlementDate: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }), errors: null, }), }), @@ -160,6 +165,9 @@ describe('TravelInvoicing', () => { key: cardSettingsKey, value: expect.objectContaining({ monthlySettlementDate: mockDate, + pendingFields: expect.objectContaining({ + monthlySettlementDate: null, + }), errors: null, }), }), @@ -169,8 +177,8 @@ describe('TravelInvoicing', () => { key: cardSettingsKey, value: expect.objectContaining({ monthlySettlementDate: currentMonthlySettlementDate, - errors: expect.objectContaining({ - monthlySettlementDate: expect.stringMatching(/^.+$/), + pendingFields: expect.objectContaining({ + monthlySettlementDate: null, }), }), }), From b58d69d9923d39e128b8cc076964e99b29f5d709 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Sat, 17 Jan 2026 11:39:47 -0800 Subject: [PATCH 18/21] fix: default frequency adjustment and eslint --- src/libs/TravelInvoicingUtils.ts | 8 ++++++-- .../travel/WorkspaceTravelSettlementFrequencyPage.tsx | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/libs/TravelInvoicingUtils.ts b/src/libs/TravelInvoicingUtils.ts index 8449c7684ec56..2996637a67a9b 100644 --- a/src/libs/TravelInvoicingUtils.ts +++ b/src/libs/TravelInvoicingUtils.ts @@ -83,12 +83,16 @@ function getTravelSettlementAccount(cardSettings: OnyxEntry): string { + // Default to monthly per design doc when no settings exist if (!cardSettings) { - return CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.DAILY; + 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; } diff --git a/src/pages/workspace/travel/WorkspaceTravelSettlementFrequencyPage.tsx b/src/pages/workspace/travel/WorkspaceTravelSettlementFrequencyPage.tsx index 556c136a2f75f..697c2d4035101 100644 --- a/src/pages/workspace/travel/WorkspaceTravelSettlementFrequencyPage.tsx +++ b/src/pages/workspace/travel/WorkspaceTravelSettlementFrequencyPage.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import {useOnyx} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -8,6 +7,7 @@ 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'; From 21d0155d9ea29cadce129f06bf78108b06acd411 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Sat, 17 Jan 2026 11:50:18 -0800 Subject: [PATCH 19/21] fix: unit test --- tests/unit/TravelInvoicingUtilsTest.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/TravelInvoicingUtilsTest.ts b/tests/unit/TravelInvoicingUtilsTest.ts index 7dd932652a6e2..d187978a0d05a 100644 --- a/tests/unit/TravelInvoicingUtilsTest.ts +++ b/tests/unit/TravelInvoicingUtilsTest.ts @@ -113,9 +113,9 @@ describe('TravelInvoicingUtils', () => { }); describe('getTravelSettlementFrequency', () => { - it('Should return daily when cardSettings is undefined', () => { + it('Should return monthly (default) when cardSettings is undefined', () => { const result = getTravelSettlementFrequency(undefined); - expect(result).toBe(CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.DAILY); + expect(result).toBe(CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.MONTHLY); }); it('Should return daily when monthlySettlementDate is not set', () => { From c9cb1d7c52b3d01b646360e123d5dd44a767ac05 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Sat, 17 Jan 2026 12:52:14 -0800 Subject: [PATCH 20/21] fix: codex review and error handling --- src/libs/actions/TravelInvoicing.ts | 37 ++++++++++++++++--- .../WorkspaceTravelInvoicingSection.tsx | 10 ++--- ...WorkspaceTravelSettlementFrequencyPage.tsx | 7 +--- src/types/onyx/ExpensifyCardSettings.ts | 6 +++ 4 files changed, 44 insertions(+), 16 deletions(-) diff --git a/src/libs/actions/TravelInvoicing.ts b/src/libs/actions/TravelInvoicing.ts index 2b3199917bd76..40eb1c83e43e0 100644 --- a/src/libs/actions/TravelInvoicing.ts +++ b/src/libs/actions/TravelInvoicing.ts @@ -70,6 +70,9 @@ function setTravelInvoicingSettlementAccount(policyID: string, workspaceAccountI pendingFields: { paymentBankAccountID: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, }, + errorFields: { + paymentBankAccountID: null, + }, }, }, ]; @@ -85,6 +88,9 @@ function setTravelInvoicingSettlementAccount(policyID: string, workspaceAccountI pendingFields: { paymentBankAccountID: null, }, + errorFields: { + paymentBankAccountID: null, + }, }, }, ]; @@ -101,7 +107,9 @@ function setTravelInvoicingSettlementAccount(policyID: string, workspaceAccountI pendingFields: { paymentBankAccountID: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, }, - errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + errorFields: { + paymentBankAccountID: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, }, }, ]; @@ -124,7 +132,9 @@ function clearTravelInvoicingSettlementAccountErrors(workspaceAccountID: number, onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${workspaceAccountID}_${PROGRAM_TRAVEL_US}`, value: { - errors: null, + errorFields: { + paymentBankAccountID: null, + }, pendingFields: { paymentBankAccountID: null, }, @@ -159,10 +169,14 @@ function updateTravelInvoiceSettlementFrequency( key: cardSettingsKey, value: { monthlySettlementDate, + previousMonthlySettlementDate: currentMonthlySettlementDate, pendingFields: { monthlySettlementDate: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, }, errors: null, + errorFields: { + monthlySettlementDate: null, + }, }, }, ]; @@ -173,10 +187,14 @@ function updateTravelInvoiceSettlementFrequency( key: cardSettingsKey, value: { monthlySettlementDate, + previousMonthlySettlementDate: null, pendingFields: { monthlySettlementDate: null, }, errors: null, + errorFields: { + monthlySettlementDate: null, + }, }, }, ]; @@ -186,11 +204,15 @@ function updateTravelInvoiceSettlementFrequency( onyxMethod: Onyx.METHOD.MERGE, key: cardSettingsKey, value: { - monthlySettlementDate: currentMonthlySettlementDate ?? null, + monthlySettlementDate, + previousMonthlySettlementDate: currentMonthlySettlementDate ?? null, pendingFields: { monthlySettlementDate: null, }, - errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + errors: null, + errorFields: { + monthlySettlementDate: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, }, }, ]; @@ -207,13 +229,18 @@ function updateTravelInvoiceSettlementFrequency( /** * Clears any errors from the Travel Invoicing settlement frequency settings. */ -function clearTravelInvoicingSettlementFrequencyErrors(workspaceAccountID: number) { +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, }, }, ]; diff --git a/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx b/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx index ba3a9f3c53520..69a9bc44dd0d4 100644 --- a/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx +++ b/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx @@ -84,10 +84,10 @@ function WorkspaceTravelInvoicingSection({policyID}: WorkspaceTravelInvoicingSec // Get errors for specific fields const settlementAccountErrorKey = 'paymentBankAccountID'; const settlementFrequencyErrorKey = 'monthlySettlementDate'; - const hasSettlementAccountError = !!cardSettings?.errors?.[settlementAccountErrorKey]; - const hasSettlementFrequencyError = !!cardSettings?.errors?.[settlementFrequencyErrorKey]; - const settlementAccountErrors = hasSettlementAccountError ? {[settlementAccountErrorKey]: cardSettings?.errors?.[settlementAccountErrorKey] ?? ''} : null; - const settlementFrequencyErrors = hasSettlementFrequencyError ? {[settlementFrequencyErrorKey]: cardSettings?.errors?.[settlementFrequencyErrorKey] ?? ''} : null; + 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) { @@ -162,7 +162,7 @@ function WorkspaceTravelInvoicingSection({policyID}: WorkspaceTravelInvoicingSec clearTravelInvoicingSettlementFrequencyErrors(workspaceAccountID)} + onClose={() => clearTravelInvoicingSettlementFrequencyErrors(workspaceAccountID, cardSettings?.previousMonthlySettlementDate)} errorRowStyles={styles.mh2half} errorRowTextStyles={styles.mr3} > diff --git a/src/pages/workspace/travel/WorkspaceTravelSettlementFrequencyPage.tsx b/src/pages/workspace/travel/WorkspaceTravelSettlementFrequencyPage.tsx index 697c2d4035101..71c4a725a4f94 100644 --- a/src/pages/workspace/travel/WorkspaceTravelSettlementFrequencyPage.tsx +++ b/src/pages/workspace/travel/WorkspaceTravelSettlementFrequencyPage.tsx @@ -52,12 +52,7 @@ function WorkspaceTravelSettlementFrequencyPage({route}: WorkspaceTravelSettleme })); const selectFrequency = (item: FrequencyItem) => { - updateTravelInvoiceSettlementFrequency( - policyID, - workspaceAccountID, - item.value, - cardSettings?.monthlySettlementDate instanceof Date ? cardSettings.monthlySettlementDate : undefined, - ); + updateTravelInvoiceSettlementFrequency(policyID, workspaceAccountID, item.value, cardSettings?.monthlySettlementDate ? new Date(cardSettings.monthlySettlementDate) : undefined); Navigation.goBack(); }; diff --git a/src/types/onyx/ExpensifyCardSettings.ts b/src/types/onyx/ExpensifyCardSettings.ts index 6a2c23142497e..f755eae43cf25 100644 --- a/src/types/onyx/ExpensifyCardSettings.ts +++ b/src/types/onyx/ExpensifyCardSettings.ts @@ -17,6 +17,9 @@ 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; @@ -46,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; From e055dcd705c630a90c0845587d9ccbc9082bdf49 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Sat, 17 Jan 2026 13:01:46 -0800 Subject: [PATCH 21/21] fix: updated tests --- tests/unit/TravelInvoicingTest.ts | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/tests/unit/TravelInvoicingTest.ts b/tests/unit/TravelInvoicingTest.ts index fb9011722b035..550908cbe56de 100644 --- a/tests/unit/TravelInvoicingTest.ts +++ b/tests/unit/TravelInvoicingTest.ts @@ -96,7 +96,9 @@ describe('TravelInvoicing', () => { expect.objectContaining({ key: cardSettingsKey, value: expect.objectContaining({ - errors: null, + errorFields: { + paymentBankAccountID: null, + }, pendingFields: expect.objectContaining({ paymentBankAccountID: null, }), @@ -112,7 +114,7 @@ describe('TravelInvoicing', () => { const workspaceAccountID = 456; const cardSettingsKey = `${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${workspaceAccountID}_${PROGRAM_TRAVEL_US}`; - clearTravelInvoicingSettlementFrequencyErrors(workspaceAccountID); + clearTravelInvoicingSettlementFrequencyErrors(workspaceAccountID, undefined); expect(spyOnyxUpdate).toHaveBeenCalledWith( expect.arrayContaining([ @@ -153,10 +155,14 @@ describe('TravelInvoicing', () => { 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, + }, }), }), ]), @@ -165,10 +171,14 @@ describe('TravelInvoicing', () => { key: cardSettingsKey, value: expect.objectContaining({ monthlySettlementDate: mockDate, + previousMonthlySettlementDate: null, pendingFields: expect.objectContaining({ monthlySettlementDate: null, }), errors: null, + errorFields: { + monthlySettlementDate: null, + }, }), }), ]), @@ -176,10 +186,15 @@ describe('TravelInvoicing', () => { expect.objectContaining({ key: cardSettingsKey, value: expect.objectContaining({ - monthlySettlementDate: currentMonthlySettlementDate, + monthlySettlementDate: mockDate, + previousMonthlySettlementDate: currentMonthlySettlementDate, pendingFields: expect.objectContaining({ monthlySettlementDate: null, }), + errors: null, + errorFields: { + monthlySettlementDate: expect.anything() as unknown, + }, }), }), ]),