diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 197bb84fa302..d80a12504eed 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -3110,6 +3110,7 @@ const CONST = { ENABLED: 'enabled', REIMBURSEMENT_ACCOUNT_ID: 'reimbursementAccountID', INVOICE_COLLECTIONS_ACCOUNT_ID: 'invoiceCollectionsAccountID', + FX_EXPENSE_ACCOUNT: 'fxExpenseAccount', SYNC_REIMBURSED_REPORTS: 'syncReimbursedReports', ENABLE_NEW_CATEGORIES: 'enableNewCategories', EXPORTER: 'exporter', diff --git a/src/ROUTES.ts b/src/ROUTES.ts index a7c2717714c9..1ac85460f6e1 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -4317,6 +4317,15 @@ const ROUTES = { return `workspaces/${policyID}/accounting/xero/advanced/invoice-account-selector` as const; }, }, + POLICY_ACCOUNTING_XERO_FX_EXPENSE_ACCOUNT_SELECTOR: { + route: 'workspaces/:policyID/accounting/xero/advanced/fx-expense-account-selector', + getRoute: (policyID: string | undefined) => { + if (!policyID) { + Log.warn('Invalid policyID is used to build the POLICY_ACCOUNTING_XERO_FX_EXPENSE_ACCOUNT_SELECTOR route'); + } + return `workspaces/${policyID}/accounting/xero/advanced/fx-expense-account-selector` as const; + }, + }, POLICY_ACCOUNTING_XERO_BILL_PAYMENT_ACCOUNT_SELECTOR: { route: 'workspaces/:policyID/accounting/xero/advanced/bill-payment-account-selector', getRoute: (policyID: string | undefined) => { diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 5121131fb896..5f5ed33af1b7 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -601,6 +601,7 @@ const SCREENS = { XERO_INVOICE_ACCOUNT_SELECTOR: 'Policy_Accounting_Xero_Invoice_Account_Selector', DYNAMIC_XERO_EXPORT_PREFERRED_EXPORTER_SELECT: 'Dynamic_Workspace_Accounting_Xero_Export_Preferred_Exporter_Select', XERO_BILL_PAYMENT_ACCOUNT_SELECTOR: 'Policy_Accounting_Xero_Bill_Payment_Account_Selector', + XERO_FX_EXPENSE_ACCOUNT_SELECTOR: 'Policy_Accounting_Xero_Fx_Expense_Account_Selector', DYNAMIC_XERO_EXPORT_BANK_ACCOUNT_SELECT: 'Dynamic_Policy_Accounting_Xero_Export_Bank_Account_Select', DYNAMIC_XERO_NON_REIMBURSABLE_DEFAULT_CONTACT_SELECT: 'Dynamic_Policy_Accounting_Xero_Non_Reimbursable_Default_Contact_Select', NETSUITE_IMPORT_MAPPING: 'Policy_Accounting_NetSuite_Import_Mapping', diff --git a/src/hooks/useCanConfigureCurrencyConversionFees.ts b/src/hooks/useCanConfigureCurrencyConversionFees.ts new file mode 100644 index 000000000000..64b2f4622e79 --- /dev/null +++ b/src/hooks/useCanConfigureCurrencyConversionFees.ts @@ -0,0 +1,18 @@ +import CONST from '@src/CONST'; +import type {Policy} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import usePermissions from './usePermissions'; + +/** + * Whether the currency conversion cost settings are available for a workspace. The cost only arises when + * the workspace reimburses through Expensify, so every surface that exposes these settings gates on this. + */ +function useCanConfigureCurrencyConversionFees(policy: OnyxEntry): boolean { + const {isBetaEnabled} = usePermissions(); + + return isBetaEnabled(CONST.BETAS.GLOBAL_REIMBURSEMENT_FX) && policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES; +} + +export default useCanConfigureCurrencyConversionFees; diff --git a/src/hooks/useIsGlobalReimbursementFXEnabled.ts b/src/hooks/useIsGlobalReimbursementFXEnabled.ts deleted file mode 100644 index c000b2a507d3..000000000000 --- a/src/hooks/useIsGlobalReimbursementFXEnabled.ts +++ /dev/null @@ -1,15 +0,0 @@ -import CONST from '@src/CONST'; - -import usePermissions from './usePermissions'; - -/** - * Whether the currency conversion cost settings are available. They sit on top of global reimbursements, so both betas - * have to be on before any of them is shown or reachable. - */ -function useIsGlobalReimbursementFXEnabled(): boolean { - const {isBetaEnabled} = usePermissions(); - - return isBetaEnabled(CONST.BETAS.GLOBAL_REIMBURSEMENTS) && isBetaEnabled(CONST.BETAS.GLOBAL_REIMBURSEMENT_FX); -} - -export default useIsGlobalReimbursementFXEnabled; diff --git a/src/languages/de.ts b/src/languages/de.ts index 8a9bea443304..22d4ced20b71 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -5135,6 +5135,9 @@ ${amount} für ${merchant} – ${date}`, xeroInvoiceCollectionAccount: 'Xero-Forderungskonto für Rechnungen', xeroBillPaymentAccountDescription: 'Wählen Sie, von wo aus Sie Rechnungen bezahlen möchten, und wir erstellen die Zahlung in Xero.', invoiceAccountSelectorDescription: 'Wählen Sie aus, wohin Rechnungzahlungen eingehen sollen, und wir erstellen die Zahlung in Xero.', + xeroFxExpenseAccount: 'Xero-Konto für Währungsumrechnungsgebühren', + fxExpenseAccountDescription: + 'Wenn Ihr Unternehmen die Kosten für die Währungsumrechnung bei einer im Ausland getätigten Zahlung übernimmt, buchen wir diese Kosten in Xero auf dieses Konto als „Geldausgabe“-Transaktion.', }, exportDate: { label: 'Rechnungsdatum des Einkaufs', diff --git a/src/languages/el.ts b/src/languages/el.ts index 770d8319d675..c04df0dad297 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -5213,6 +5213,9 @@ ${amount} για ${merchant} - ${date}`, xeroInvoiceCollectionAccount: 'Λογαριασμός εισπράξεων τιμολογίων Xero', xeroBillPaymentAccountDescription: 'Επιλέξτε από πού θα πληρώνονται οι λογαριασμοί και θα δημιουργήσουμε την πληρωμή στο Xero.', invoiceAccountSelectorDescription: 'Επιλέξτε πού θέλετε να λαμβάνετε τις πληρωμές τιμολογίων και θα δημιουργήσουμε την πληρωμή στο Xero.', + xeroFxExpenseAccount: 'λογαριασμός προμήθειας μετατροπής συναλλάγματος Xero', + fxExpenseAccountDescription: + 'Όταν η εταιρεία σας καλύπτει το κόστος μετατροπής νομίσματος για μια πληρωμή που έγινε στο εξωτερικό, θα καταχωρούμε αυτό το κόστος σε αυτόν τον λογαριασμό στο Xero ως συναλλαγή «εξόδου χρημάτων».', }, exportDate: { label: 'Ημερομηνία τιμολογίου αγοράς', diff --git a/src/languages/en.ts b/src/languages/en.ts index f0b90116e5e0..b502e60593cc 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -5252,6 +5252,9 @@ const translations = { xeroInvoiceCollectionAccount: 'Xero invoice collections account', xeroBillPaymentAccountDescription: "Choose where to pay bills from and we'll create the payment in Xero.", invoiceAccountSelectorDescription: "Choose where to receive invoice payments and we'll create the payment in Xero.", + xeroFxExpenseAccount: 'Xero currency conversion fee account', + fxExpenseAccountDescription: + "When your company covers the currency conversion cost on a payment made abroad, we'll post that cost to this account in Xero as a spend money transaction.", }, exportDate: { label: 'Purchase bill date', diff --git a/src/languages/es.ts b/src/languages/es.ts index 2135f39b2c5c..551bd69c8c77 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -5068,6 +5068,9 @@ ${amount} para ${merchant} - ${date}`, xeroInvoiceCollectionAccount: 'Cuenta de cobro de las facturas Xero', xeroBillPaymentAccountDescription: 'Elige desde dónde pagar las facturas y crearemos el pago en Xero.', invoiceAccountSelectorDescription: 'Elige dónde recibir los pagos de facturas y crearemos el pago en Xero.', + xeroFxExpenseAccount: 'Cuenta de comisión por conversión de divisa de Xero', + fxExpenseAccountDescription: + 'Cuando tu empresa cubra el coste de conversión de moneda en un pago realizado en el extranjero, registraremos ese coste en esta cuenta en Xero como una transacción de gasto de dinero.', }, exportDate: { label: 'Fecha de la factura de compra', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 54614b926213..9e49cc69e654 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -5142,6 +5142,9 @@ ${amount} pour ${merchant} - ${date}`, xeroInvoiceCollectionAccount: 'Compte d’encaissement des factures Xero', xeroBillPaymentAccountDescription: 'Choisissez d’où payer les factures et nous créerons le paiement dans Xero.', invoiceAccountSelectorDescription: 'Choisissez où recevoir les paiements de factures et nous créerons le paiement dans Xero.', + xeroFxExpenseAccount: 'Compte de frais de conversion de devise Xero', + fxExpenseAccountDescription: + 'Lorsque votre entreprise prend en charge le coût de conversion de devise sur un paiement effectué à l’étranger, nous comptabiliserons ce coût sur ce compte dans Xero en tant qu’opération de décaissement.', }, exportDate: { label: 'Date de facture d’achat', diff --git a/src/languages/it.ts b/src/languages/it.ts index 23ae5c1feb7b..8bdb9c107f24 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -5102,6 +5102,9 @@ ${amount} per ${merchant} - ${date}`, xeroInvoiceCollectionAccount: 'Conto incassi fatture Xero', xeroBillPaymentAccountDescription: 'Scegli da dove pagare le fatture e creeremo il pagamento in Xero.', invoiceAccountSelectorDescription: 'Scegli dove ricevere i pagamenti delle fatture e creeremo il pagamento in Xero.', + xeroFxExpenseAccount: 'Conto commissioni conversione valuta Xero', + fxExpenseAccountDescription: + 'Quando la tua azienda copre il costo di conversione valutaria su un pagamento effettuato all’estero, registreremo quel costo su questo conto in Xero come transazione di tipo “spesa di denaro”.', }, exportDate: { label: 'Data fattura di acquisto', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 52f9476ce149..27b3e2bc83d9 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -5060,6 +5060,8 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' xeroInvoiceCollectionAccount: 'Xero請求書回収勘定', xeroBillPaymentAccountDescription: '請求書の支払元を選択すると、Xero 内に支払処理を作成します。', invoiceAccountSelectorDescription: '請求書の入金先を選択すると、Xero に支払いが作成されます。', + xeroFxExpenseAccount: 'Xero 通貨換算手数料勘定', + fxExpenseAccountDescription: '海外で行われた支払いに対する通貨換算コストを御社が負担する場合、そのコストは「支出入金取引」として Xero のこの勘定科目に計上します。', }, exportDate: { label: '仕入請求書の日付', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index eb530a0e145b..8ee5d257622f 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -5099,6 +5099,9 @@ ${amount} voor ${merchant} - ${date}`, xeroInvoiceCollectionAccount: 'Xero-incasso rekening voor facturen', xeroBillPaymentAccountDescription: 'Kies vanwaar je rekeningen wilt betalen en wij maken de betaling aan in Xero.', invoiceAccountSelectorDescription: 'Kies waar je factuurbetalingen wilt ontvangen en wij maken de betaling aan in Xero.', + xeroFxExpenseAccount: 'Xero-account voor valutaconversiekosten', + fxExpenseAccountDescription: + 'Wanneer je bedrijf de kosten voor valutaconversie dekt voor een betaling in het buitenland, boeken we die kosten op deze rekening in Xero als een ‘spend money’-transactie.', }, exportDate: { label: 'Aankoopfactuurdatum', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index de72057d9b07..3e602d6e104d 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -5127,6 +5127,9 @@ ${amount} dla ${merchant} - ${date}`, xeroInvoiceCollectionAccount: 'Konto rozliczeniowe faktur Xero', xeroBillPaymentAccountDescription: 'Wybierz, z jakiego konta opłacać rachunki, a my utworzymy płatność w Xero.', invoiceAccountSelectorDescription: 'Wybierz, gdzie chcesz otrzymywać płatności za faktury, a my utworzymy płatność w Xero.', + xeroFxExpenseAccount: 'Konto opłat za przewalutowanie Xero', + fxExpenseAccountDescription: + 'Gdy twoja firma pokrywa koszt przewalutowania przy płatności dokonanej za granicą, zaksięgujemy ten koszt na tym koncie w Xero jako transakcję „wydanie pieniędzy”.', }, exportDate: { label: 'Data rachunku zakupu', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 263838372c37..6b969aada42c 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -5095,6 +5095,9 @@ ${amount} para ${merchant} - ${date}`, xeroInvoiceCollectionAccount: 'Conta de recebimentos de faturas do Xero', xeroBillPaymentAccountDescription: 'Escolha de onde pagar as contas e nós criaremos o pagamento no Xero.', invoiceAccountSelectorDescription: 'Escolha onde receber os pagamentos de faturas e nós criaremos o pagamento no Xero.', + xeroFxExpenseAccount: 'Conta de taxa de conversão de moeda do Xero', + fxExpenseAccountDescription: + 'Quando sua empresa cobrir o custo de conversão de moeda em um pagamento feito no exterior, nós lançaremos esse custo nesta conta no Xero como uma transação de saída de dinheiro.', }, exportDate: { label: 'Data da fatura de compra', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 2459b6c611bf..53d65a44bb8f 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -4930,6 +4930,8 @@ ${amount},商户:${merchant} - 日期:${date}`, xeroInvoiceCollectionAccount: 'Xero 发票收款账户', xeroBillPaymentAccountDescription: '选择从哪里支付账单,我们会在 Xero 中创建相应的付款。', invoiceAccountSelectorDescription: '选择接收发票付款的账户,我们会在 Xero 中创建这笔付款。', + xeroFxExpenseAccount: 'Xero 货币转换手续费科目', + fxExpenseAccountDescription: '当您的公司承担境外付款的货币兑换成本时,我们会在 Xero 中将该成本记入此科目,作为一笔“支出款项”交易。', }, exportDate: { label: '采购账单日期', diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 7b9bafe2f1f7..569b0e58ff11 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -622,6 +622,7 @@ const WRITE_COMMANDS = { UPDATE_XERO_SYNC_INVOICE_COLLECTIONS_ACCOUNT_ID: 'UpdateXeroSyncInvoiceCollectionsAccountID', UPDATE_XERO_SYNC_SYNC_REIMBURSED_REPORTS: 'UpdateXeroSyncSyncReimbursedReports', UPDATE_XERO_SYNC_REIMBURSEMENT_ACCOUNT_ID: 'UpdateXeroSyncReimbursementAccountID', + UPDATE_XERO_FX_EXPENSE_ACCOUNT: 'UpdateXeroFxExpenseAccount', REQUEST_FEED_SETUP: 'RequestFeedSetup', SET_COMPANY_CARD_FEED_NAME: 'SetFeedName', DELETE_COMPANY_CARD_FEED: 'RemoveFeed', @@ -1375,6 +1376,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.UPDATE_XERO_SYNC_INVOICE_COLLECTIONS_ACCOUNT_ID]: Parameters.UpdateXeroGenericTypeParams; [WRITE_COMMANDS.UPDATE_XERO_SYNC_SYNC_REIMBURSED_REPORTS]: Parameters.UpdateXeroGenericTypeParams; [WRITE_COMMANDS.UPDATE_XERO_SYNC_REIMBURSEMENT_ACCOUNT_ID]: Parameters.UpdateXeroGenericTypeParams; + [WRITE_COMMANDS.UPDATE_XERO_FX_EXPENSE_ACCOUNT]: Parameters.UpdateXeroGenericTypeParams; // Invoice API [WRITE_COMMANDS.SET_INVOICING_TRANSFER_BANK_ACCOUNT]: Parameters.SetInvoicingTransferBankAccountParams; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 231be7080068..b1f912653ce9 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -790,6 +790,8 @@ const SettingsModalStackNavigator = createModalStackNavigator('../../../../pages/workspace/accounting/xero/export/DynamicXeroPreferredExporterSelectPage').default, [SCREENS.WORKSPACE.ACCOUNTING.XERO_BILL_PAYMENT_ACCOUNT_SELECTOR]: () => require('../../../../pages/workspace/accounting/xero/advanced/XeroBillPaymentAccountSelectorPage').default, + [SCREENS.WORKSPACE.ACCOUNTING.XERO_FX_EXPENSE_ACCOUNT_SELECTOR]: () => + require('../../../../pages/workspace/accounting/xero/advanced/XeroFxExpenseAccountSelectorPage').default, [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_SUBSIDIARY_SELECTOR]: () => require('../../../../pages/workspace/accounting/netsuite/NetSuiteSubsidiarySelector').default, [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_REUSE_EXISTING_CONNECTIONS]: () => diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts index 6cfff7bd9e22..54c360344731 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts @@ -125,6 +125,7 @@ const WORKSPACE_TO_RHP: Partial['config'] = { [SCREENS.WORKSPACE.ACCOUNTING.XERO_INVOICE_ACCOUNT_SELECTOR]: {path: ROUTES.POLICY_ACCOUNTING_XERO_INVOICE_SELECTOR.route}, [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_EXPORT_PREFERRED_EXPORTER_SELECT]: DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_PREFERRED_EXPORTER_SELECT.path, [SCREENS.WORKSPACE.ACCOUNTING.XERO_BILL_PAYMENT_ACCOUNT_SELECTOR]: {path: ROUTES.POLICY_ACCOUNTING_XERO_BILL_PAYMENT_ACCOUNT_SELECTOR.route}, + [SCREENS.WORKSPACE.ACCOUNTING.XERO_FX_EXPENSE_ACCOUNT_SELECTOR]: {path: ROUTES.POLICY_ACCOUNTING_XERO_FX_EXPENSE_ACCOUNT_SELECTOR.route}, [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_SUBSIDIARY_SELECTOR]: {path: ROUTES.POLICY_ACCOUNTING_NETSUITE_SUBSIDIARY_SELECTOR.route}, [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_TOKEN_INPUT]: {path: ROUTES.POLICY_ACCOUNTING_NETSUITE_TOKEN_INPUT.route}, [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_SETUP]: {path: ROUTES.POLICY_ACCOUNTING_NETSUITE_SETUP.route}, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 729e1ae0c451..76a87611b8db 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1013,6 +1013,9 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.ACCOUNTING.XERO_BILL_PAYMENT_ACCOUNT_SELECTOR]: { policyID: string; }; + [SCREENS.WORKSPACE.ACCOUNTING.XERO_FX_EXPENSE_ACCOUNT_SELECTOR]: { + policyID: string; + }; [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_SAGE_INTACCT_PREREQUISITES]: { policyID: string; }; diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 2cc786ed8b58..1ffe5e5adc64 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -21,6 +21,7 @@ import type { } from '@src/types/onyx'; import type {ErrorFields, PendingAction, PendingFields} from '@src/types/onyx/OnyxCommon'; import type { + Account, ApprovalRule, ConnectionLastSync, ConnectionName, @@ -2135,6 +2136,16 @@ function getXeroBankAccounts(policy: Policy | undefined, selectedBankAccountId: })); } +/** Only profit and loss accounts can take a currency conversion cost, so these are kept apart from the bank accounts. */ +function getXeroExpenseAccounts(expenseAccounts: Account[] | undefined, selectedExpenseAccountID: string | undefined): SelectorType[] { + return (expenseAccounts ?? []).map(({id, name}) => ({ + value: id, + text: name, + keyForList: id, + isSelected: selectedExpenseAccountID === id, + })); +} + function areSettingsInErrorFields(settings?: string[], errorFields?: ErrorFields) { if (settings === undefined || errorFields === undefined) { return false; @@ -3363,6 +3374,7 @@ export { findCurrentXeroOrganization, getCurrentXeroOrganizationName, getXeroBankAccounts, + getXeroExpenseAccounts, hasPolicyWithXeroConnection, getNetSuiteVendorOptions, canUseTaxNetSuite, diff --git a/src/libs/actions/connections/Xero.ts b/src/libs/actions/connections/Xero.ts index 9a2fb93f5028..71ccd27bba58 100644 --- a/src/libs/actions/connections/Xero.ts +++ b/src/libs/actions/connections/Xero.ts @@ -547,6 +547,22 @@ function updateXeroSyncReimbursementAccountID( API.write(WRITE_COMMANDS.UPDATE_XERO_SYNC_REIMBURSEMENT_ACCOUNT_ID, parameters, {optimisticData, failureData, successData}); } +function updateXeroFxExpenseAccount(policyID: string | undefined, settingValue: string, oldSettingValue?: string) { + if (!policyID || settingValue === oldSettingValue) { + return; + } + + const parameters: UpdateXeroGenericTypeParams = { + policyID, + settingValue: JSON.stringify(settingValue), + idempotencyKey: String(CONST.XERO_CONFIG.FX_EXPENSE_ACCOUNT), + }; + + const {optimisticData, failureData, successData} = prepareXeroOptimisticData(policyID, CONST.XERO_CONFIG.FX_EXPENSE_ACCOUNT, settingValue, oldSettingValue); + + API.write(WRITE_COMMANDS.UPDATE_XERO_FX_EXPENSE_ACCOUNT, parameters, {optimisticData, failureData, successData}); +} + function updateXeroSyncSyncReimbursedReports( policyID: string | undefined, syncReimbursedReports: Partial, @@ -605,4 +621,5 @@ export { updateXeroSyncInvoiceCollectionsAccountID, updateXeroSyncSyncReimbursedReports, updateXeroSyncReimbursementAccountID, + updateXeroFxExpenseAccount, }; diff --git a/src/pages/workspace/accounting/qbo/advanced/QuickbooksAdvancedPage.tsx b/src/pages/workspace/accounting/qbo/advanced/QuickbooksAdvancedPage.tsx index 544771ec742d..56b1b46f7b6a 100644 --- a/src/pages/workspace/accounting/qbo/advanced/QuickbooksAdvancedPage.tsx +++ b/src/pages/workspace/accounting/qbo/advanced/QuickbooksAdvancedPage.tsx @@ -4,7 +4,7 @@ import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import useAccordionAnimation from '@hooks/useAccordionAnimation'; -import useIsGlobalReimbursementFXEnabled from '@hooks/useIsGlobalReimbursementFXEnabled'; +import useCanConfigureCurrencyConversionFees from '@hooks/useCanConfigureCurrencyConversionFees'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import useWaitForNavigation from '@hooks/useWaitForNavigation'; @@ -38,7 +38,7 @@ function QuickbooksAdvancedPage({policy}: WithPolicyConnectionsProps) { const styles = useThemeStyles(); const waitForNavigate = useWaitForNavigation(); const {translate} = useLocalize(); - const isGlobalReimbursementFXEnabled = useIsGlobalReimbursementFXEnabled(); + const canConfigureCurrencyConversionFees = useCanConfigureCurrencyConversionFees(policy); const integrationName = getQuickbooksOnlineIntegrationName(policy, translate); const policyID = policy?.id; @@ -90,7 +90,7 @@ function QuickbooksAdvancedPage({policy}: WithPolicyConnectionsProps) { brickRoadIndicator: areSettingsInErrorFields(collectionAccountIDs, qboConfig?.errorFields) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, pendingAction: settingsPendingAction(collectionAccountIDs, qboConfig?.pendingFields), }, - ...(isGlobalReimbursementFXEnabled + ...(canConfigureCurrencyConversionFees ? [ { key: 'qboFxExpenseAccount', diff --git a/src/pages/workspace/accounting/qbo/advanced/QuickbooksFxExpenseAccountSelectPage.tsx b/src/pages/workspace/accounting/qbo/advanced/QuickbooksFxExpenseAccountSelectPage.tsx index 58d5e4077ec9..be31e3ede855 100644 --- a/src/pages/workspace/accounting/qbo/advanced/QuickbooksFxExpenseAccountSelectPage.tsx +++ b/src/pages/workspace/accounting/qbo/advanced/QuickbooksFxExpenseAccountSelectPage.tsx @@ -3,9 +3,10 @@ import type {ListItem} from '@components/SelectionList/types'; import SelectionScreen from '@components/SelectionScreen'; import Text from '@components/Text'; -import useIsGlobalReimbursementFXEnabled from '@hooks/useIsGlobalReimbursementFXEnabled'; +import useCanConfigureCurrencyConversionFees from '@hooks/useCanConfigureCurrencyConversionFees'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useSelectionListSearch from '@hooks/useSelectionListSearch'; import useThemeStyles from '@hooks/useThemeStyles'; import {updateQuickbooksOnlineFxExpenseAccount} from '@libs/actions/connections/QuickbooksOnline'; @@ -34,7 +35,7 @@ type SelectorType = ListItem & { function QuickbooksFxExpenseAccountSelectPage({policy}: WithPolicyConnectionsProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); - const isGlobalReimbursementFXEnabled = useIsGlobalReimbursementFXEnabled(); + const canConfigureCurrencyConversionFees = useCanConfigureCurrencyConversionFees(policy); const integrationName = getQuickbooksOnlineIntegrationName(policy, translate); const illustrations = useMemoizedLazyIllustrations(['Telescope']); @@ -52,6 +53,7 @@ function QuickbooksFxExpenseAccountSelectPage({policy}: WithPolicyConnectionsPro })), [qboConfig?.fxExpenseAccount, expenseAccounts], ); + const {filteredData, textInputOptions} = useSelectionListSearch(qboOnlineSelectorOptions); const listHeaderComponent = useMemo( () => ( @@ -91,9 +93,10 @@ function QuickbooksFxExpenseAccountSelectPage({policy}: WithPolicyConnectionsPro policyID={policyID} accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN, CONST.POLICY.ACCESS_VARIANTS.PAID]} featureName={CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED} - shouldBeBlocked={!isGlobalReimbursementFXEnabled} + shouldBeBlocked={!canConfigureCurrencyConversionFees} displayName="QuickbooksFxExpenseAccountSelectPage" - data={qboOnlineSelectorOptions} + data={filteredData} + textInputOptions={textInputOptions} headerContent={listHeaderComponent} onSelectRow={updateAccount} shouldSingleExecuteRowSelect diff --git a/src/pages/workspace/accounting/utils.tsx b/src/pages/workspace/accounting/utils.tsx index 90a8ae26ced0..3dd5a1d037d3 100644 --- a/src/pages/workspace/accounting/utils.tsx +++ b/src/pages/workspace/accounting/utils.tsx @@ -188,6 +188,7 @@ function getAccountingIntegrationData( CONST.QUICKBOOKS_CONFIG.AUTO_SYNC, CONST.QUICKBOOKS_CONFIG.SYNC_PEOPLE, CONST.QUICKBOOKS_CONFIG.AUTO_CREATE_VENDOR, + CONST.QUICKBOOKS_CONFIG.FX_EXPENSE_ACCOUNT, ...(qboConfig?.collectionAccountID ? [CONST.QUICKBOOKS_CONFIG.REIMBURSEMENT_ACCOUNT_ID, CONST.QUICKBOOKS_CONFIG.COLLECTION_ACCOUNT_ID] : []), ], pendingFields: {...qboConfig?.pendingFields, ...policy?.connections?.quickbooksOnline?.config?.pendingFields}, @@ -241,6 +242,7 @@ function getAccountingIntegrationData( CONST.XERO_CONFIG.SYNC_REIMBURSED_REPORTS, CONST.XERO_CONFIG.REIMBURSEMENT_ACCOUNT_ID, CONST.XERO_CONFIG.INVOICE_COLLECTIONS_ACCOUNT_ID, + CONST.XERO_CONFIG.FX_EXPENSE_ACCOUNT, ], pendingFields: policy?.connections?.xero?.config?.pendingFields, errorFields: policy?.connections?.xero?.config?.errorFields, diff --git a/src/pages/workspace/accounting/xero/advanced/XeroAdvancedPage.tsx b/src/pages/workspace/accounting/xero/advanced/XeroAdvancedPage.tsx index 43c78be1b1c7..4131172215de 100644 --- a/src/pages/workspace/accounting/xero/advanced/XeroAdvancedPage.tsx +++ b/src/pages/workspace/accounting/xero/advanced/XeroAdvancedPage.tsx @@ -4,6 +4,7 @@ import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import useAccordionAnimation from '@hooks/useAccordionAnimation'; +import useCanConfigureCurrencyConversionFees from '@hooks/useCanConfigureCurrencyConversionFees'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -29,11 +30,12 @@ import React, {useMemo} from 'react'; function XeroAdvancedPage({policy}: WithPolicyConnectionsProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); + const canConfigureCurrencyConversionFees = useCanConfigureCurrencyConversionFees(policy); const policyID = policy?.id; const xeroConfig = policy?.connections?.xero?.config; const {pendingFields, errorFields, sync} = xeroConfig ?? {}; - const {bankAccounts} = policy?.connections?.xero?.data ?? {}; + const {bankAccounts, expenseAccounts} = policy?.connections?.xero?.data ?? {}; const {invoiceCollectionsAccountID, reimbursementAccountID} = sync ?? {}; const accountingMethod = xeroConfig?.export?.accountingMethod ?? COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH; @@ -51,6 +53,7 @@ function XeroAdvancedPage({policy}: WithPolicyConnectionsProps) { const selectedBankAccountName = getSelectedAccountName(invoiceCollectionsAccountID); const selectedBillPaymentAccountName = getSelectedAccountName(reimbursementAccountID); + const selectedFxExpenseAccountName = (expenseAccounts ?? []).find((account) => account.id === xeroConfig?.fxExpenseAccount)?.name; const currentXeroOrganizationName = useMemo(() => getCurrentXeroOrganizationName(policy ?? undefined), [policy]); @@ -116,6 +119,19 @@ function XeroAdvancedPage({policy}: WithPolicyConnectionsProps) { brickRoadIndicator={areSettingsInErrorFields([CONST.XERO_CONFIG.REIMBURSEMENT_ACCOUNT_ID], errorFields) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} /> + {canConfigureCurrencyConversionFees && ( + + Navigation.navigate(ROUTES.POLICY_ACCOUNTING_XERO_FX_EXPENSE_ACCOUNT_SELECTOR.getRoute(policyID))} + brickRoadIndicator={areSettingsInErrorFields([CONST.XERO_CONFIG.FX_EXPENSE_ACCOUNT], errorFields) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + /> + + )} + {translate('workspace.xero.advancedConfig.fxExpenseAccountDescription')} + + ); + + const initiallyFocusedOptionKey = xeroSelectorOptions.find((option) => option.isSelected)?.keyForList; + + const updateAccount = ({value}: SelectorType) => { + updateXeroFxExpenseAccount(policyID, value, fxExpenseAccount); + Navigation.goBack(ROUTES.POLICY_ACCOUNTING_XERO_ADVANCED.getRoute(policyID)); + }; + + const listEmptyContent = ( + + ); + + return ( + Navigation.goBack(ROUTES.POLICY_ACCOUNTING_XERO_ADVANCED.getRoute(policyID))} + title="workspace.xero.advancedConfig.xeroFxExpenseAccount" + listEmptyContent={listEmptyContent} + pendingAction={settingsPendingAction([CONST.XERO_CONFIG.FX_EXPENSE_ACCOUNT], config?.pendingFields)} + errors={getLatestErrorField(config ?? {}, CONST.XERO_CONFIG.FX_EXPENSE_ACCOUNT)} + errorRowStyles={[styles.ph5, styles.pv3]} + onClose={() => clearXeroErrorField(policyID, CONST.XERO_CONFIG.FX_EXPENSE_ACCOUNT)} + /> + ); +} + +export default withPolicyConnections(XeroFxExpenseAccountSelectorPage); diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsCurrencyConversionFeesPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsCurrencyConversionFeesPage.tsx index 722d0e62a9eb..b0b698f59ad9 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsCurrencyConversionFeesPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsCurrencyConversionFeesPage.tsx @@ -6,7 +6,7 @@ import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelec import Text from '@components/Text'; import TextLink from '@components/TextLink'; -import useIsGlobalReimbursementFXEnabled from '@hooks/useIsGlobalReimbursementFXEnabled'; +import useCanConfigureCurrencyConversionFees from '@hooks/useCanConfigureCurrencyConversionFees'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -40,7 +40,7 @@ type CurrencyConversionFeesItem = { function WorkspaceWorkflowsCurrencyConversionFeesPage({policy, route}: WorkspaceWorkflowsCurrencyConversionFeesPageProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); - const isGlobalReimbursementFXEnabled = useIsGlobalReimbursementFXEnabled(); + const canConfigureCurrencyConversionFees = useCanConfigureCurrencyConversionFees(policy); const selectedPreference = policy?.globalReimbursementFXPreferCompany ? CONST.POLICY.GLOBAL_REIMBURSEMENT_FX_PREFERENCE.COMPANY @@ -87,7 +87,7 @@ function WorkspaceWorkflowsCurrencyConversionFeesPage({policy, route}: Workspace featureName={CONST.POLICY.MORE_FEATURES.ARE_WORKFLOWS_ENABLED} policyFeature={CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS} policyFeatureAccess={CONST.POLICY.POLICY_FEATURE_ACCESS.WRITE} - shouldBeBlocked={!isGlobalReimbursementFXEnabled || policy?.reimbursementChoice !== CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES} + shouldBeBlocked={!canConfigureCurrencyConversionFees} > )} - {policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES && canWritePayments && isGlobalReimbursementFXEnabled && ( + {canWritePayments && canConfigureCurrencyConversionFees && ( ; @@ -828,6 +831,9 @@ type XeroConnectionConfig = OnyxCommon.OnyxValueWithOfflineFeedback< /** Default supplier contact used as a fallback when a non-reimbursable card transaction has no contact set. */ defaultVendor?: string; + /** ID of the account cross-border currency conversion costs are charged to. Unset means the cost is not exported. */ + fxExpenseAccount?: string; + /** TODO: Will be handled in another issue */ errors?: OnyxCommon.Errors; diff --git a/tests/actions/connections/Xero.test.ts b/tests/actions/connections/Xero.test.ts index 0d572cd0abcd..898f1f00a607 100644 --- a/tests/actions/connections/Xero.test.ts +++ b/tests/actions/connections/Xero.test.ts @@ -1,4 +1,4 @@ -import {updateXeroTravelBillingPayableAccount} from '@libs/actions/connections/Xero'; +import {updateXeroFxExpenseAccount, updateXeroTravelBillingPayableAccount} from '@libs/actions/connections/Xero'; import * as API from '@libs/API'; import type {ApiRequestCommandParameters} from '@libs/API/types'; import {WRITE_COMMANDS} from '@libs/API/types'; @@ -78,4 +78,85 @@ describe('actions/connections/Xero', () => { ); }); }); + + describe('updateXeroFxExpenseAccount', () => { + it('writes the UpdateXeroFxExpenseAccount command with the selected account', () => { + updateXeroFxExpenseAccount(MOCK_POLICY_ID, 'account-123', 'old-account'); + + const call = writeSpy.mock.calls.at(0); + if (!call) { + throw new Error('API.write was not called'); + } + const [command, parameters] = call; + expect(command).toBe(WRITE_COMMANDS.UPDATE_XERO_FX_EXPENSE_ACCOUNT); + + const expectedParameters = { + policyID: MOCK_POLICY_ID, + settingValue: JSON.stringify('account-123'), + idempotencyKey: CONST.XERO_CONFIG.FX_EXPENSE_ACCOUNT, + } satisfies ApiRequestCommandParameters[typeof WRITE_COMMANDS.UPDATE_XERO_FX_EXPENSE_ACCOUNT]; + expect(parameters).toEqual(expectedParameters); + }); + + it('merges fxExpenseAccount optimistically onto the Xero config', () => { + updateXeroFxExpenseAccount(MOCK_POLICY_ID, 'account-123', 'old-account'); + + const call = writeSpy.mock.calls.at(0); + if (!call) { + throw new Error('API.write was not called'); + } + const [, , onyxData] = call; + const optimisticUpdate = onyxData?.optimisticData?.at(0); + expect(optimisticUpdate?.key).toBe(`${ONYXKEYS.COLLECTION.POLICY}${MOCK_POLICY_ID}`); + + expect(optimisticUpdate?.value).toEqual( + expect.objectContaining({ + connections: expect.objectContaining({ + xero: expect.objectContaining({ + config: expect.objectContaining({ + [CONST.XERO_CONFIG.FX_EXPENSE_ACCOUNT]: 'account-123', + pendingFields: {[CONST.XERO_CONFIG.FX_EXPENSE_ACCOUNT]: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + }), + }), + }), + }), + ); + }); + + it('reverts fxExpenseAccount to the old value on failure', () => { + updateXeroFxExpenseAccount(MOCK_POLICY_ID, 'account-123', 'old-account'); + + const call = writeSpy.mock.calls.at(0); + if (!call) { + throw new Error('API.write was not called'); + } + const [, , onyxData] = call; + const failureUpdate = onyxData?.failureData?.at(0); + expect(failureUpdate?.key).toBe(`${ONYXKEYS.COLLECTION.POLICY}${MOCK_POLICY_ID}`); + + expect(failureUpdate?.value).toEqual( + expect.objectContaining({ + connections: expect.objectContaining({ + xero: expect.objectContaining({ + config: expect.objectContaining({ + [CONST.XERO_CONFIG.FX_EXPENSE_ACCOUNT]: 'old-account', + }), + }), + }), + }), + ); + }); + + it('does not write when the account did not change', () => { + updateXeroFxExpenseAccount(MOCK_POLICY_ID, 'account-123', 'account-123'); + + expect(writeSpy).not.toHaveBeenCalled(); + }); + + it('does not write when policyID is missing', () => { + updateXeroFxExpenseAccount(undefined, 'account-123', 'old-account'); + + expect(writeSpy).not.toHaveBeenCalled(); + }); + }); }); diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index f9be99849daa..0def70cdcdb8 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -52,6 +52,7 @@ import { getTagListByOrderWeight, getUberConnectionErrorDirectlyFromPolicy, getUnitRateValue, + getXeroExpenseAccounts, getXeroSupplierByID, getXeroSuppliers, hasConfiguredRules, @@ -4406,6 +4407,36 @@ describe('PolicyUtils', () => { }); }); + describe('getXeroExpenseAccounts', () => { + const XERO_EXPENSE_ACCOUNTS = [ + {id: 'acc1', name: 'Travel Expenses', currency: 'USD'}, + {id: 'acc2', name: 'Bank Fees', currency: 'USD'}, + ]; + + it('maps the expense accounts to selector options', () => { + expect(getXeroExpenseAccounts(XERO_EXPENSE_ACCOUNTS, undefined)).toEqual([ + {value: 'acc1', text: 'Travel Expenses', keyForList: 'acc1', isSelected: false}, + {value: 'acc2', text: 'Bank Fees', keyForList: 'acc2', isSelected: false}, + ]); + }); + + it('marks only the selected account as selected', () => { + const options = getXeroExpenseAccounts(XERO_EXPENSE_ACCOUNTS, 'acc2'); + expect(options.map(({keyForList, isSelected}) => ({keyForList, isSelected}))).toEqual([ + {keyForList: 'acc1', isSelected: false}, + {keyForList: 'acc2', isSelected: true}, + ]); + }); + + it('selects nothing when the stored account is no longer in the synced list', () => { + expect(getXeroExpenseAccounts(XERO_EXPENSE_ACCOUNTS, 'acc-archived').every(({isSelected}) => !isSelected)).toBe(true); + }); + + it('returns an empty array when Xero expense accounts have not synced yet', () => { + expect(getXeroExpenseAccounts(undefined, 'acc1')).toEqual([]); + }); + }); + describe('isXeroActiveMatchingSource (R4)', () => { it('returns true when only Xero is connected', () => { expect(isXeroActiveMatchingSource(buildXeroPolicy())).toBe(true);