diff --git a/src/languages/de.ts b/src/languages/de.ts index 2b4c88ee9c30..4e027f285ef1 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -6071,6 +6071,8 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU subsections: { currentTravelSpendLabel: 'Aktuelle Reisekosten', currentTravelSpendPaymentQueued: (amount: string) => `Die Zahlung über ${amount} ist in der Warteschlange und wird in Kürze bearbeitet.`, + currentTravelSpendInvoiceQueued: 'Eine neue Rechnung für Ihre Reisekosten wird erstellt und Ihnen in Kürze zugesandt.', + currentTravelSpendInvoicePending: (amount: string) => `Eine Rechnung über ${amount} wurde gesendet und wartet auf Bezahlung.`, currentTravelSpendCta: 'Saldo bezahlen', currentTravelLimitLabel: 'Aktuelles Reisekontingent', settlementAccountLabel: 'Verrechnungskonto', @@ -6083,6 +6085,7 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU 'Wenn Sie das Limit reduzieren, können Mitglieder, die diesen Betrag bereits überschritten haben, bis zum nächsten Monat keine neuen Reisebuchungen vornehmen.', provisioningError: 'Wir konnten einige Mitglieder Ihres Arbeitsbereichs nicht für die Konsolidierte Reiseabrechnung einrichten. Bitte versuchen Sie es später erneut oder wenden Sie sich für Unterstützung an Concierge.', + sendInvoiceNowCta: 'Rechnung jetzt senden', }, }, disableModal: { @@ -6105,6 +6108,10 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU invalidDateRangeError: 'Das Startdatum muss vor dem Enddatum liegen', enabled: 'Zentrale Reiseabrechnung aktiviert!', enabledDescription: 'Alle Reisekosten in diesem Workspace werden nun in einer monatlichen Rechnung zentralisiert.', + sendInvoiceModal: { + title: (amount: string) => `Rechnung über ${amount} senden?`, + body: 'Wir erstellen eine Rechnung für Ihre aktuellen Reisekosten. Ihr Reiselimit wird wieder frei, sobald die Rechnung bezahlt ist.', + }, }, personalDetailsDescription: 'Um eine Reise zu buchen, gib bitte deinen amtlichen Namen genau so ein, wie er auf deinem amtlichen Ausweis steht.', }, diff --git a/src/languages/en.ts b/src/languages/en.ts index c0968a1c508f..1fa0f92a868b 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -6149,7 +6149,10 @@ const translations = { subsections: { currentTravelSpendLabel: 'Current travel spend', currentTravelSpendPaymentQueued: (amount: string) => `Payment of ${amount} is queued and will be processed soon.`, + currentTravelSpendInvoiceQueued: 'A new invoice for your travel spend will be created and sent to you soon.', + currentTravelSpendInvoicePending: (amount: string) => `An invoice for ${amount} has been sent and is awaiting payment.`, currentTravelSpendCta: 'Pay balance', + sendInvoiceNowCta: 'Send invoice now', currentTravelLimitLabel: 'Current travel limit', settlementAccountLabel: 'Settlement account', settlementFrequencyLabel: 'Settlement frequency', @@ -6176,6 +6179,10 @@ const translations = { title: (amount: string) => `Pay balance of ${amount}?`, body: 'The payment will be queued and processed shortly after. This action cannot be undone once started.', }, + sendInvoiceModal: { + title: (amount: string) => `Send invoice for ${amount}?`, + body: "We'll create an invoice for your current travel spend. Your travel limit is freed up once the invoice is paid.", + }, exportToPDF: 'Export to PDF', exportToCSV: 'Export to CSV', selectDateRangeError: 'Please select a date range to export', diff --git a/src/languages/es.ts b/src/languages/es.ts index 293750179f19..502dd9cc8145 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -5904,7 +5904,10 @@ ${amount} para ${merchant} - ${date}`, subsections: { currentTravelSpendLabel: 'Gasto actual en viajes', currentTravelSpendPaymentQueued: (amount: string) => `El pago de ${amount} está en cola y se procesará pronto.`, + currentTravelSpendInvoiceQueued: 'Pronto se creará y enviará una nueva factura por tus gastos de viaje.', + currentTravelSpendInvoicePending: (amount: string) => `Se ha enviado una factura por ${amount} y está a la espera de pago.`, currentTravelSpendCta: 'Pagar saldo', + sendInvoiceNowCta: 'Enviar factura ahora', currentTravelLimitLabel: 'Límite actual de viajes', settlementAccountLabel: 'Cuenta de liquidación', settlementFrequencyLabel: 'Frecuencia de liquidación', @@ -5932,6 +5935,10 @@ ${amount} para ${merchant} - ${date}`, title: (amount: string) => `¿Pagar el saldo de ${amount}?`, body: 'El pago se pondrá en cola y se procesará poco después. Esta acción no se puede deshacer una vez iniciada.', }, + sendInvoiceModal: { + title: (amount: string) => `¿Enviar factura por ${amount}?`, + body: 'Crearemos una factura por tus gastos de viaje actuales. Tu límite de viaje se libera una vez que se pague la factura.', + }, exportToPDF: 'Exportar a PDF', exportToCSV: 'Exportar a CSV', selectDateRangeError: 'Por favor, selecciona un rango de fechas para exportar', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 86ad445d9ff2..cd89be3d8c1c 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -6091,6 +6091,8 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. subsections: { currentTravelSpendLabel: 'Dépenses de voyage actuelles', currentTravelSpendPaymentQueued: (amount: string) => `Le paiement de ${amount} est en file d’attente et sera traité bientôt.`, + currentTravelSpendInvoiceQueued: 'Une nouvelle facture pour vos dépenses de voyage sera créée et vous sera envoyée bientôt.', + currentTravelSpendInvoicePending: (amount: string) => `Une facture de ${amount} a été envoyée et est en attente de paiement.`, currentTravelSpendCta: 'Payer le solde', currentTravelLimitLabel: 'Plafond de déplacement actuel', settlementAccountLabel: 'Compte de règlement', @@ -6104,6 +6106,7 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. 'Si vous réduisez la limite, les membres ayant déjà dépensé plus que ce montant ne pourront pas effectuer de nouvelles réservations de voyage avant le mois prochain.', provisioningError: 'Nous n’avons pas pu configurer certains membres de votre espace de travail pour la facturation de voyage consolidée. Veuillez réessayer plus tard ou contacter Concierge pour obtenir de l’aide.', + sendInvoiceNowCta: 'Envoyer la facture maintenant', }, }, disableModal: { @@ -6126,6 +6129,10 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. invalidDateRangeError: 'La date de début doit être antérieure à la date de fin', enabled: 'Facturation de voyage consolidée activée !', enabledDescription: 'Toutes les dépenses de voyage sur cet espace de travail seront désormais centralisées dans une facture mensuelle.', + sendInvoiceModal: { + title: (amount: string) => `Envoyer la facture de ${amount} ?`, + body: 'Nous créerons une facture pour vos dépenses de voyage actuelles. Votre plafond de voyage est à nouveau disponible une fois la facture payée.', + }, }, personalDetailsDescription: 'Pour pouvoir réserver un voyage, veuillez saisir votre nom légal tel qu’il apparaît sur votre pièce d’identité délivrée par le gouvernement.', }, diff --git a/src/languages/it.ts b/src/languages/it.ts index 1647e4c0e876..61900046b49c 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -6053,6 +6053,8 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. subsections: { currentTravelSpendLabel: 'Spesa di viaggio attuale', currentTravelSpendPaymentQueued: (amount: string) => `Il pagamento di ${amount} è in coda e verrà elaborato a breve.`, + currentTravelSpendInvoiceQueued: 'Una nuova fattura per le tue spese di viaggio verrà creata e ti sarà inviata a breve.', + currentTravelSpendInvoicePending: (amount: string) => `Una fattura di ${amount} è stata inviata ed è in attesa di pagamento.`, currentTravelSpendCta: 'Paga saldo', currentTravelLimitLabel: 'Limite di viaggio attuale', settlementAccountLabel: 'Conto di regolamento', @@ -6066,6 +6068,7 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. 'Se riduci il limite, i membri che hanno già speso più di questo importo non potranno effettuare nuove prenotazioni di viaggio fino al mese prossimo.', provisioningError: 'Non siamo riusciti a effettuare il provisioning di alcuni membri del tuo workspace per la Fatturazione Viaggi Consolidata. Riprova più tardi oppure contatta Concierge per assistenza.', + sendInvoiceNowCta: 'Invia fattura ora', }, }, disableModal: { @@ -6088,6 +6091,10 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. invalidDateRangeError: 'La data di inizio deve essere precedente alla data di fine', enabled: 'Fatturazione viaggio consolidata abilitata!', enabledDescription: 'Tutte le spese di viaggio in questo spazio di lavoro saranno ora accentrate in una fattura mensile.', + sendInvoiceModal: { + title: (amount: string) => `Inviare la fattura per ${amount}?`, + body: 'Creeremo una fattura per le tue spese di viaggio attuali. Il tuo limite di viaggio si libera una volta che la fattura è stata pagata.', + }, }, personalDetailsDescription: 'Per prenotare il viaggio, inserisci il tuo nome legale così come appare sul tuo documento d’identità rilasciato dal governo.', }, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 7a8dc0136b0a..207e8880d52f 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -5982,6 +5982,8 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO subsections: { currentTravelSpendLabel: '現在の出張費支出', currentTravelSpendPaymentQueued: (amount: string) => `${amount} の支払いはキューに登録されており、まもなく処理されます。`, + currentTravelSpendInvoiceQueued: '出張費用の新しい請求書が作成され、まもなくお客様に送信されます。', + currentTravelSpendInvoicePending: (amount: string) => `${amount} の請求書が送信され、支払い待ちです。`, currentTravelSpendCta: '残高を支払う', currentTravelLimitLabel: '現在の出張上限', settlementAccountLabel: '決済口座', @@ -5993,6 +5995,7 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO reduceLimitWarning: 'この上限を引き下げると、すでにこの金額を超えて支出しているメンバーは、翌月まで新しい出張予約ができなくなります。', provisioningError: 'ワークスペース内の一部メンバーに対して、Consolidated Travel Billing を有効化できませんでした。時間をおいてもう一度お試しいただくか、サポートが必要な場合は Concierge までお問い合わせください。', + sendInvoiceNowCta: '請求書を今すぐ送信', }, }, disableModal: { @@ -6011,6 +6014,10 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO invalidDateRangeError: '開始日は終了日より前でなければなりません', enabled: '一括出張請求が有効になりました!', enabledDescription: 'このワークスペースでの出張費用は、今後すべて月次の請求書に集約されます。', + sendInvoiceModal: { + title: (amount: string) => `${amount}の請求書を送信しますか?`, + body: '現在の出張費用について請求書を作成します。請求書が支払われると、出張の利用可能枠が再び使えるようになります。', + }, }, personalDetailsDescription: '旅行を予約するために、政府発行の身分証明書に記載されているとおりの正式な氏名を入力してください。', }, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 43980fc84722..fb5e18fa62b3 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -6042,6 +6042,8 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_ subsections: { currentTravelSpendLabel: 'Huidige reiskosten', currentTravelSpendPaymentQueued: (amount: string) => `Betaling van ${amount} staat in de wachtrij en wordt binnenkort verwerkt.`, + currentTravelSpendInvoiceQueued: 'Er wordt binnenkort een nieuwe factuur voor je reiskosten aangemaakt en naar je verzonden.', + currentTravelSpendInvoicePending: (amount: string) => `Er is een factuur voor ${amount} verzonden en deze wacht op betaling.`, currentTravelSpendCta: 'Saldo betalen', currentTravelLimitLabel: 'Huidige reisl imiet', settlementAccountLabel: 'Verrekeningsrekening', @@ -6053,6 +6055,7 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_ reduceLimitWarning: 'Als u het limiet verlaagt, kunnen leden die dit bedrag al hebben overschreden geen nieuwe reisboekingen maken tot volgende maand.', provisioningError: 'We konden voor sommige leden van je werkruimte geen toegang instellen tot Geconsolideerde Reisfacturering. Probeer het later opnieuw of neem contact op met Concierge voor ondersteuning.', + sendInvoiceNowCta: 'Verzend factuur nu', }, }, disableModal: { @@ -6075,6 +6078,10 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_ invalidDateRangeError: 'De begindatum moet vóór de einddatum liggen', enabled: 'Geconsolideerde reiskostenfacturatie ingeschakeld!', enabledDescription: 'Alle reiskosten in deze workspace worden nu gebundeld op één maandelijkse factuur.', + sendInvoiceModal: { + title: (amount: string) => `Factuur voor ${amount} versturen?`, + body: 'We maken een factuur aan voor je huidige reiskosten. Je reistegoed komt weer vrij zodra de factuur is betaald.', + }, }, personalDetailsDescription: 'Om een reis te boeken, voer je wettelijke naam in zoals deze op je door de overheid uitgegeven identiteitsbewijs staat.', }, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 9b4bcdc9d1d7..48a4c51a71fd 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -6021,6 +6021,8 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy subsections: { currentTravelSpendLabel: 'Aktualne wydatki na podróże', currentTravelSpendPaymentQueued: (amount: string) => `Płatność w wysokości ${amount} jest w kolejce i wkrótce zostanie przetworzona.`, + currentTravelSpendInvoiceQueued: 'Nowa faktura za twoje wydatki związane z podróżą zostanie wkrótce utworzona i wysłana do ciebie.', + currentTravelSpendInvoicePending: (amount: string) => `Faktura na kwotę ${amount} została wysłana i oczekuje na płatność.`, currentTravelSpendCta: 'Spłać saldo', currentTravelLimitLabel: 'Aktualny limit podróży', settlementAccountLabel: 'Konto rozliczeniowe', @@ -6033,6 +6035,7 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy 'Jeśli zmniejszysz limit, członkowie, którzy już wydali więcej niż ta kwota, nie będą mogli dokonywać nowych rezerwacji podróży do następnego miesiąca.', provisioningError: 'Nie udało nam się skonfigurować części członków twojego miejsca pracy do skonsolidowanego rozliczania podróży. Spróbuj ponownie później albo skontaktuj się z Concierge, żeby uzyskać pomoc.', + sendInvoiceNowCta: 'Wyślij fakturę teraz', }, }, disableModal: { @@ -6055,6 +6058,10 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy invalidDateRangeError: 'Data początkowa musi być wcześniejsza niż data końcowa', enabled: 'Włączono zbiorcze rozliczanie podróży!', enabledDescription: 'Wszystkie wydatki podróżne w tym obszarze roboczym będą teraz scentralizowane na miesięcznym rachunku.', + sendInvoiceModal: { + title: (amount: string) => `Wysłać fakturę na ${amount}?`, + body: 'Utworzymy fakturę za twoje bieżące wydatki na podróż. Twój limit podróży zostanie zwolniony, gdy faktura zostanie opłacona.', + }, }, personalDetailsDescription: 'Aby zarezerwować podróż, wpisz swoje imię i nazwisko dokładnie tak, jak widnieje w Twoim dokumencie tożsamości wydanym przez organ państwowy.', }, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 4f1ee2716224..a5373c4a60fb 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -6034,6 +6034,8 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS subsections: { currentTravelSpendLabel: 'Gasto atual com viagens', currentTravelSpendPaymentQueued: (amount: string) => `O pagamento de ${amount} está na fila e será processado em breve.`, + currentTravelSpendInvoiceQueued: 'Uma nova fatura dos seus gastos de viagem será criada e enviada para você em breve.', + currentTravelSpendInvoicePending: (amount: string) => `Uma fatura de ${amount} foi enviada e está aguardando pagamento.`, currentTravelSpendCta: 'Pagar saldo', currentTravelLimitLabel: 'Limite de viagem atual', settlementAccountLabel: 'Conta de liquidação', @@ -6046,6 +6048,7 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS reduceLimitWarning: 'Se você reduzir o limite, os membros que já gastaram mais do que esse valor não poderão fazer novas reservas de viagem até o próximo mês.', provisioningError: 'Não foi possível provisionar alguns membros do seu workspace para o Faturamento Consolidado de Viagens. Tente novamente mais tarde ou entre em contato com o Concierge para obter ajuda.', + sendInvoiceNowCta: 'Enviar fatura agora', }, }, disableModal: { @@ -6068,6 +6071,10 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS invalidDateRangeError: 'A data de início deve ser anterior à data de término', enabled: 'Faturamento de viagem consolidado ativado!', enabledDescription: 'Todos os gastos de viagem neste workspace agora serão centralizados em uma fatura mensal.', + sendInvoiceModal: { + title: (amount: string) => `Enviar fatura de ${amount}?`, + body: 'Vamos criar uma fatura para seus gastos atuais de viagem. Seu limite de viagem será liberado assim que a fatura for paga.', + }, }, personalDetailsDescription: 'Para reservar viagens, insira seu nome legal exatamente como consta no documento de identificação emitido pelo governo.', }, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index eed8b03bf3cf..e6510231cdd4 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -5854,6 +5854,8 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM subsections: { currentTravelSpendLabel: '当前差旅支出', currentTravelSpendPaymentQueued: (amount: string) => `金额为 ${amount} 的付款已排队,稍后将被处理。`, + currentTravelSpendInvoiceQueued: '您的差旅费用新发票将很快创建并发送给您。', + currentTravelSpendInvoicePending: (amount: string) => `金额为 ${amount} 的发票已发送,正在等待付款。`, currentTravelSpendCta: '支付余额', currentTravelLimitLabel: '当前出行限额', settlementAccountLabel: '结算账户', @@ -5864,6 +5866,7 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM reduceLimitTitle: '降低出差支出限额?', reduceLimitWarning: '如果您降低限额,已超出该金额的成员将无法进行新的出差预订,直至下个月。', provisioningError: '我们无法为您的工作区中部分成员开通合并差旅结算功能。请稍后重试,或联系 Concierge 获取帮助。', + sendInvoiceNowCta: '立即发送发票', }, }, disableModal: {title: '关闭合并差旅结算?', body: '即将到来的酒店和汽车租赁预订可能需要使用不同的付款方式重新预订,以避免被取消。', confirm: '关闭'}, @@ -5875,6 +5878,7 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM invalidDateRangeError: '开始日期必须早于结束日期', enabled: '已启用合并差旅账单!', enabledDescription: '此工作区的所有差旅支出现在将统一汇总到一份月度账单中。', + sendInvoiceModal: {title: (amount: string) => `要发送金额为 ${amount} 的发票吗?`, body: '我们会为您当前的差旅支出创建一张发票。发票付清后,您的差旅额度将被释放。'}, }, personalDetailsDescription: '为预订行程,请输入您在政府签发的身份证件上显示的法定姓名。', }, diff --git a/src/libs/TravelInvoicingUtils.ts b/src/libs/TravelInvoicingUtils.ts index c29645794b98..61918ae265cc 100644 --- a/src/libs/TravelInvoicingUtils.ts +++ b/src/libs/TravelInvoicingUtils.ts @@ -43,6 +43,13 @@ function getIsTravelInvoicingEnabled(cardSettings: ExpensifyCardSettingsBase | u return false; } +/** + * Checks whether the workspace pays its Travel Invoicing settlement by invoice (wire) instead of an ACH debit. + */ +function getIsTravelBillingPayByInvoice(cardSettings: ExpensifyCardSettingsBase | undefined): boolean { + return typeof cardSettings?.invoiceTo === 'string' && cardSettings.invoiceTo.length > 0; +} + /** * Checks if a settlement account is configured for Travel Invoicing. */ @@ -63,14 +70,24 @@ function getTravelLimit(cardSettings: ExpensifyCardSettingsBase | undefined): nu return cardSettings?.limit ?? cardSettings?.remainingLimit ?? 0; } +/** + * Gets the amount (in cents) awaiting payment on a sent Consolidated Travel Billing invoice. + * Non-zero for pay-by-invoice customers between the invoice being sent and paid. + * Returns 0 if no settings are available. + */ +function getPendingTravelInvoiceAmount(cardSettings: ExpensifyCardSettingsBase | undefined): number { + return cardSettings?.pendingInvoiceAmount ?? 0; +} + /** * Checks if the workspace has an outstanding Travel Invoicing balance. - * Returns true if there is unpaid travel spend, blocking disable. + * Returns true if there is unpaid travel spend, a queued settlement, or an unpaid invoice, blocking disable. */ function hasOutstandingTravelBalance(cardSettings: ExpensifyCardSettingsBase | undefined): boolean { const currentBalance = cardSettings?.currentBalance ?? 0; const pendingSettlementAmount = cardSettings?.pendingSettlementAmount ?? 0; - return currentBalance > 0 || pendingSettlementAmount > 0; + const pendingInvoiceAmount = cardSettings?.pendingInvoiceAmount ?? 0; + return currentBalance > 0 || pendingSettlementAmount > 0 || pendingInvoiceAmount > 0; } /** @@ -178,10 +195,12 @@ function isTravelCVVEligible(cardList: OnyxEntry): boolean { export { getIsTravelInvoicingEnabled, + getIsTravelBillingPayByInvoice, hasTravelInvoicingSettlementAccount, hasOutstandingTravelBalance, getTravelLimit, getTravelSpend, + getPendingTravelInvoiceAmount, getTravelSettlementAccount, getTravelSettlementFrequency, getTravelInvoicingCardSettingsKey, diff --git a/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx b/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx index bec0f8ddf074..12540498aa9f 100644 --- a/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx +++ b/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx @@ -33,7 +33,9 @@ import Navigation from '@libs/Navigation/Navigation'; import {areTravelPersonalDetailsMissing} from '@libs/PersonalDetailsUtils'; import {hasInProgressUSDVBBA} from '@libs/ReimbursementAccountUtils'; import { + getIsTravelBillingPayByInvoice, getIsTravelInvoicingEnabled, + getPendingTravelInvoiceAmount, getTravelInvoicingCardSettingsKey, getTravelLimit, getTravelSettlementAccount, @@ -105,17 +107,37 @@ function WorkspaceTravelInvoicingSection({policyID}: WorkspaceTravelInvoicingSec const pendingSettlementAmount = travelSettings?.pendingSettlementAmount ?? 0; const hasPendingSettlement = pendingSettlementAmount > 0; + + // Pay-by-invoice customers owe the sent invoice by wire, so it's surfaced separately from a queued ACH settlement + const pendingInvoiceAmount = getPendingTravelInvoiceAmount(travelSettings); + const hasPendingInvoice = pendingInvoiceAmount > 0; const travelLimit = getTravelLimit(travelSettings); const settlementAccount = getTravelSettlementAccount(travelSettings, bankAccountList); const settlementFrequency = getTravelSettlementFrequency(travelSettings); const isMonthlySettlementFrequency = settlementFrequency === CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.MONTHLY; const localizedFrequency = isMonthlySettlementFrequency ? translate('workspace.expensifyCard.frequency.monthly') : translate('workspace.expensifyCard.frequency.daily'); - const shouldShowPayButton = travelSpend > 0 && isMonthlySettlementFrequency && !hasPendingSettlement; + const shouldShowPayButton = travelSpend > 0 && travelSpend > pendingInvoiceAmount && isMonthlySettlementFrequency && !hasPendingSettlement; const formattedSpend = convertToDisplayString(travelSpend, CONST.CURRENCY.USD); + // Pay-by-invoice customers settle by wire against an invoice, so the pay CTA and modal use invoice copy + const isPayByInvoice = getIsTravelBillingPayByInvoice(travelSettings); + const payBalanceCtaText = translate( + isPayByInvoice + ? 'workspace.moreFeatures.travel.travelInvoicing.travelInvoicingSection.subsections.sendInvoiceNowCta' + : 'workspace.moreFeatures.travel.travelInvoicing.travelInvoicingSection.subsections.currentTravelSpendCta', + ); + const payBalanceModalTitle = isPayByInvoice + ? translate('workspace.moreFeatures.travel.travelInvoicing.sendInvoiceModal.title', formattedSpend) + : translate('workspace.moreFeatures.travel.travelInvoicing.payBalanceModal.title', formattedSpend); + const payBalanceModalBody = translate( + isPayByInvoice ? 'workspace.moreFeatures.travel.travelInvoicing.sendInvoiceModal.body' : 'workspace.moreFeatures.travel.travelInvoicing.payBalanceModal.body', + ); + // The pending settlement amount for the "payment queued" subtitle const formattedQueuedAmount = convertToDisplayString(pendingSettlementAmount, CONST.CURRENCY.USD); + // The outstanding invoice amount for the "awaiting payment" subtitle + const formattedPendingInvoiceAmount = convertToDisplayString(pendingInvoiceAmount, CONST.CURRENCY.USD); const formattedLimit = convertToDisplayString(travelLimit, CONST.CURRENCY.USD); // Settlement account display - show empty if no account is selected @@ -320,14 +342,21 @@ function WorkspaceTravelInvoicingSection({policyID}: WorkspaceTravelInvoicingSec {hasPendingSettlement && ( - {translate('workspace.moreFeatures.travel.travelInvoicing.travelInvoicingSection.subsections.currentTravelSpendPaymentQueued', formattedQueuedAmount)} + {isPayByInvoice + ? translate('workspace.moreFeatures.travel.travelInvoicing.travelInvoicingSection.subsections.currentTravelSpendInvoiceQueued') + : translate('workspace.moreFeatures.travel.travelInvoicing.travelInvoicingSection.subsections.currentTravelSpendPaymentQueued', formattedQueuedAmount)} + + )} + {hasPendingInvoice && ( + + {translate('workspace.moreFeatures.travel.travelInvoicing.travelInvoicingSection.subsections.currentTravelSpendInvoicePending', formattedPendingInvoiceAmount)} )} @@ -337,7 +366,7 @@ function WorkspaceTravelInvoicingSection({policyID}: WorkspaceTravelInvoicingSec isDisabled={isOffline} variant={CONST.BUTTON_VARIANT.SUCCESS} > - {translate('workspace.moreFeatures.travel.travelInvoicing.travelInvoicingSection.subsections.currentTravelSpendCta')} + {payBalanceCtaText} )} @@ -451,12 +480,12 @@ function WorkspaceTravelInvoicingSection({policyID}: WorkspaceTravelInvoicingSec /> setIsPayBalanceModalVisible(false)} - prompt={translate('workspace.moreFeatures.travel.travelInvoicing.payBalanceModal.body')} - confirmText={translate('workspace.moreFeatures.travel.travelInvoicing.travelInvoicingSection.subsections.currentTravelSpendCta')} + prompt={payBalanceModalBody} + confirmText={payBalanceCtaText} cancelText={translate('common.cancel')} success /> diff --git a/src/types/onyx/ExpensifyCardSettings.ts b/src/types/onyx/ExpensifyCardSettings.ts index 552b7c1bfdbe..298ad7aabd9d 100644 --- a/src/types/onyx/ExpensifyCardSettings.ts +++ b/src/types/onyx/ExpensifyCardSettings.ts @@ -83,6 +83,15 @@ type ExpensifyCardSettingsBase = { /** Amount (in cents) of in-flight settlement that has been billed but not yet settled at the bank */ pendingSettlementAmount?: number; + + /** Amount (in cents) held on a sent Consolidated Travel Billing invoice that is awaiting payment by the customer */ + pendingInvoiceAmount?: number; + + /** Recipient of the travel settlement invoice; non-empty when the workspace pays by invoice instead of an ACH debit */ + invoiceTo?: string; + + /** Additional recipients who receive a copy of the travel settlement invoice */ + shareWith?: string[]; }; /** Spend rule filter condition */ diff --git a/tests/unit/TravelInvoicingUtilsTest.ts b/tests/unit/TravelInvoicingUtilsTest.ts index bb7c39d34468..8dde8c2e9397 100644 --- a/tests/unit/TravelInvoicingUtilsTest.ts +++ b/tests/unit/TravelInvoicingUtilsTest.ts @@ -1,11 +1,14 @@ import CONST from '@src/CONST'; import { + getIsTravelBillingPayByInvoice, getIsTravelInvoicingEnabled, + getPendingTravelInvoiceAmount, getTravelInvoicingCard, getTravelLimit, getTravelSettlementAccount, getTravelSettlementFrequency, getTravelSpend, + hasOutstandingTravelBalance, hasTravelInvoicingSettlementAccount, isTravelCVVEligible, } from '@src/libs/TravelInvoicingUtils'; @@ -62,6 +65,31 @@ describe('TravelInvoicingUtils', () => { }); }); + describe('getIsTravelBillingPayByInvoice', () => { + it('Should return false when travelSettings is undefined', () => { + const result = getIsTravelBillingPayByInvoice(undefined); + expect(result).toBe(false); + }); + + it('Should return false when invoiceTo is not set', () => { + const travelSettings = {isEnabled: true} as ExpensifyCardSettingsBase; + const result = getIsTravelBillingPayByInvoice(travelSettings); + expect(result).toBe(false); + }); + + it('Should return false when invoiceTo is an empty string', () => { + const travelSettings = {invoiceTo: ''} as ExpensifyCardSettingsBase; + const result = getIsTravelBillingPayByInvoice(travelSettings); + expect(result).toBe(false); + }); + + it('Should return true when invoiceTo is a non-empty string', () => { + const travelSettings = {invoiceTo: 'billing@example.com'} as ExpensifyCardSettingsBase; + const result = getIsTravelBillingPayByInvoice(travelSettings); + expect(result).toBe(true); + }); + }); + describe('hasTravelInvoicingSettlementAccount', () => { it('Should return false when travelSettings is undefined', () => { const result = hasTravelInvoicingSettlementAccount(undefined); @@ -119,6 +147,56 @@ describe('TravelInvoicingUtils', () => { }); }); + describe('getPendingTravelInvoiceAmount', () => { + it('Should return 0 when travelSettings is undefined', () => { + const result = getPendingTravelInvoiceAmount(undefined); + expect(result).toBe(0); + }); + + it('Should return 0 when pendingInvoiceAmount is not set', () => { + const travelSettings = {currentBalance: 0} as ExpensifyCardSettingsBase; + const result = getPendingTravelInvoiceAmount(travelSettings); + expect(result).toBe(0); + }); + + it('Should return the pendingInvoiceAmount value when set', () => { + const travelSettings = {pendingInvoiceAmount: 45000} as ExpensifyCardSettingsBase; + const result = getPendingTravelInvoiceAmount(travelSettings); + expect(result).toBe(45000); + }); + }); + + describe('hasOutstandingTravelBalance', () => { + it('Should return false when travelSettings is undefined', () => { + const result = hasOutstandingTravelBalance(undefined); + expect(result).toBe(false); + }); + + it('Should return false when there is no balance, queued settlement, or pending invoice', () => { + const travelSettings = {currentBalance: 0, pendingSettlementAmount: 0, pendingInvoiceAmount: 0} as ExpensifyCardSettingsBase; + const result = hasOutstandingTravelBalance(travelSettings); + expect(result).toBe(false); + }); + + it('Should return true when there is unpaid travel spend', () => { + const travelSettings = {currentBalance: 25000} as ExpensifyCardSettingsBase; + const result = hasOutstandingTravelBalance(travelSettings); + expect(result).toBe(true); + }); + + it('Should return true when a settlement is queued', () => { + const travelSettings = {pendingSettlementAmount: 25000} as ExpensifyCardSettingsBase; + const result = hasOutstandingTravelBalance(travelSettings); + expect(result).toBe(true); + }); + + it('Should return true when an invoice is awaiting payment even if the balance is 0', () => { + const travelSettings = {currentBalance: 0, pendingSettlementAmount: 0, pendingInvoiceAmount: 45000} as ExpensifyCardSettingsBase; + const result = hasOutstandingTravelBalance(travelSettings); + expect(result).toBe(true); + }); + }); + describe('getTravelSettlementFrequency', () => { it('Should return monthly (default) when travelSettings is undefined', () => { const result = getTravelSettlementFrequency(undefined);