From 8d1888c5fc629cee257bbdfab24629eb34d60269 Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Thu, 7 May 2026 20:32:39 +0100 Subject: [PATCH 01/17] Add workspace.copyPolicySettings.error translation key Adds the failure RBR message used when a bulk Copy Policy Settings request fails. Defined across all ten language files so the TranslationPaths type accepts it. --- 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 insertions(+) diff --git a/src/languages/de.ts b/src/languages/de.ts index a635269d3926..fb297faea9ae 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -5926,6 +5926,9 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU `Sie sind dabei, ${newWorkspaceName ?? ''} mit ${totalMembers ?? 0} Mitgliedern aus dem ursprünglichen Workspace zu erstellen und zu teilen.`, error: 'Beim Duplizieren deines neuen Workspace ist ein Fehler aufgetreten. Bitte versuche es erneut.', }, + copyPolicySettings: { + error: 'Beim Kopieren der Arbeitsbereich-Einstellungen ist ein Fehler aufgetreten. Bitte versuche es erneut.', + }, emptyWorkspace: { title: 'Du hast keine Arbeitsbereiche', subtitle: 'Belege erfassen, Auslagen erstatten, Reisen verwalten, Rechnungen versenden und mehr.', diff --git a/src/languages/en.ts b/src/languages/en.ts index 113077be6470..3bb3834362fa 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -5934,6 +5934,9 @@ const translations = { `You’re about to create and share ${newWorkspaceName ?? ''} with ${totalMembers ?? 0} members from the original workspace.`, error: 'An error occurred while duplicating your new workspace. Please try again.', }, + copyPolicySettings: { + error: 'An error occurred while copying workspace settings. Please try again.', + }, emptyWorkspace: { title: 'No workspaces yet', subtitle: 'Create a workspace to manage your expenses, reimbursements, and company cards.', diff --git a/src/languages/es.ts b/src/languages/es.ts index 684672e3ceac..98ac3aa1f053 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -5780,6 +5780,9 @@ ${amount} para ${merchant} - ${date}`, `Estás a punto de crear y compartir ${newWorkspaceName ?? ''} con ${totalMembers ?? 0} miembros del espacio de trabajo original.`, error: 'Se produjo un error al duplicar tu nuevo espacio de trabajo. Inténtalo de nuevo.', }, + copyPolicySettings: { + error: 'Se produjo un error al copiar la configuración del espacio de trabajo. Inténtalo de nuevo.', + }, emptyWorkspace: { title: 'Aún no hay espacios de trabajo', subtitle: 'Crea un espacio de trabajo para gestionar tus gastos, reembolsos y tarjetas de empresa.', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index fe8ce38694dc..cd8b29820f37 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -5946,6 +5946,9 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. `Vous êtes sur le point de créer et de partager ${newWorkspaceName ?? ''} avec ${totalMembers ?? 0} membres de l’espace de travail d’origine.`, error: 'Une erreur s’est produite lors de la duplication de votre nouvel espace de travail. Veuillez réessayer.', }, + copyPolicySettings: { + error: 'Une erreur s’est produite lors de la copie des paramètres de l’espace de travail. Veuillez réessayer.', + }, emptyWorkspace: { title: 'Vous n’avez aucun espace de travail', subtitle: 'Suivez les reçus, remboursez les dépenses, gérez les voyages, envoyez des factures, et plus encore.', diff --git a/src/languages/it.ts b/src/languages/it.ts index fbc6e64601dd..9bc019b15ba8 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -5915,6 +5915,9 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. `Stai per creare e condividere ${newWorkspaceName ?? ''} con ${totalMembers ?? 0} membri dello spazio di lavoro originale.`, error: 'Si è verificato un errore durante la duplicazione del tuo nuovo workspace. Riprova.', }, + copyPolicySettings: { + error: 'Si è verificato un errore durante la copia delle impostazioni del workspace. Riprova.', + }, emptyWorkspace: { title: 'Non hai nessuna area di lavoro', subtitle: 'Tieni traccia delle ricevute, rimborsa le spese, gestisci i viaggi, invia le fatture e altro ancora.', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 5f1f5c7a99bf..11937ef1f0a5 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -5848,6 +5848,9 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO `元のワークスペースから ${totalMembers ?? 0} 人のメンバーと一緒に、${newWorkspaceName ?? ''} を作成して共有しようとしています。`, error: '新しいワークスペースの複製中にエラーが発生しました。もう一度お試しください。', }, + copyPolicySettings: { + error: 'ワークスペース設定のコピー中にエラーが発生しました。もう一度お試しください。', + }, emptyWorkspace: { title: 'ワークスペースがありません', subtitle: '領収書を管理し、経費を精算し、出張を管理し、請求書を送信するなど、さまざまなことができます。', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 6ad319bdc44c..4deac133d57e 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -5896,6 +5896,9 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_ `Je staat op het punt ${newWorkspaceName ?? ''} te maken en te delen met ${totalMembers ?? 0} leden van de oorspronkelijke werkruimte.`, error: 'Er is een fout opgetreden bij het dupliceren van je nieuwe werkruimte. Probeer het opnieuw.', }, + copyPolicySettings: { + error: 'Er is een fout opgetreden bij het kopiëren van de werkruimte-instellingen. Probeer het opnieuw.', + }, emptyWorkspace: { title: 'Je hebt geen werkruimtes', subtitle: 'Volg bonnen, vergoed uitgaven, beheer reizen, verstuur facturen en meer.', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 289aabbdb253..dec6d60fcb29 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -5889,6 +5889,9 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy `Za chwilę utworzysz i udostępnisz ${newWorkspaceName ?? ''} ${totalMembers ?? 0} członkom oryginalnego obszaru roboczego.`, error: 'Wystąpił błąd podczas duplikowania Twojego nowego obszaru roboczego. Spróbuj ponownie.', }, + copyPolicySettings: { + error: 'Wystąpił błąd podczas kopiowania ustawień przestrzeni roboczej. Spróbuj ponownie.', + }, emptyWorkspace: { title: 'Nie masz żadnych przestrzeni roboczych', subtitle: 'Śledź paragony, rozliczaj wydatki, zarządzaj podróżami, wysyłaj faktury i wiele więcej.', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 8b0adb49517d..53d61fa1d71d 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -5895,6 +5895,9 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS `Você está prestes to criar e compartilhar ${newWorkspaceName ?? ''} com ${totalMembers ?? 0} membros do workspace original.`, error: 'Ocorreu um erro ao duplicar seu novo espaço de trabalho. Tente novamente.', }, + copyPolicySettings: { + error: 'Ocorreu um erro ao copiar as configurações do espaço de trabalho. Tente novamente.', + }, emptyWorkspace: { title: 'Você não tem nenhum workspace', subtitle: 'Controle recibos, reembolse despesas, gerencie viagens, envie faturas e muito mais.', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 8a6d58a492b4..78d193446e30 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -5750,6 +5750,9 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM `您即将创建并共享 ${newWorkspaceName ?? ''},其中包含来自原始工作区的 ${totalMembers ?? 0} 位成员。`, error: '复制您的新工作区时发生错误。请重试。', }, + copyPolicySettings: { + error: '复制工作区设置时发生错误。请重试。', + }, emptyWorkspace: { title: '你还没有工作区', subtitle: '跟踪收据、报销费用、管理差旅、发送发票等。', From 942429576d19bbc63c3a06a73af9326505a53c74 Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Thu, 7 May 2026 20:34:06 +0100 Subject: [PATCH 02/17] Add CopyPolicySettings action file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the Onyx + API logic for the bulk Copy Policy Settings flow. Exports: - setCopyPolicySettingsData / clearCopyPolicySettings — Onyx helpers - requestCopyPolicySettingsNotification — fires CopyPolicySettings_Notify - PARTS_TO_POLICY_FIELDS — part key → list of Policy field names; drives pendingFields expansion so OfflineWithFeedback wrappers grey out per-tab during the copy - buildCopyPolicySettingsData — produces optimistic/success/failure Onyx updates following the four-step algorithm in the design doc: field patch from source, per-target merge with snapshot rollback, SET-level overwrite of POLICY_CATEGORIES / POLICY_TAGS collection keys, and currentStep lifecycle on COPY_POLICY_SETTINGS - copyPolicySettings — entry point that calls API.write customUnits handling preserves each target's existing distance / per-diem unit ID and only generates a new hex ID when the target has no unit of that type yet. --- src/libs/actions/Policy/CopyPolicySettings.ts | 288 ++++++++++++++++++ 1 file changed, 288 insertions(+) create mode 100644 src/libs/actions/Policy/CopyPolicySettings.ts diff --git a/src/libs/actions/Policy/CopyPolicySettings.ts b/src/libs/actions/Policy/CopyPolicySettings.ts new file mode 100644 index 000000000000..6a4b3b216e8d --- /dev/null +++ b/src/libs/actions/Policy/CopyPolicySettings.ts @@ -0,0 +1,288 @@ +import type {OnyxCollection, OnyxUpdate} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; +import * as API from '@libs/API'; +import type {CopyPolicySettingsParams} from '@libs/API/parameters'; +import {WRITE_COMMANDS} from '@libs/API/types'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import * as NumberUtils from '@libs/NumberUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {CopyPolicySettings as CopyPolicySettingsState, Policy, PolicyCategories, PolicyTagLists} from '@src/types/onyx'; +import type {CustomUnit} from '@src/types/onyx/Policy'; + +type Part = 'overview' | 'members' | 'reports' | 'accounting' | 'categories' | 'tags' | 'taxes' | 'workflows' | 'rules' | 'distanceRates' | 'perDiem' | 'invoices' | 'travel'; + +const PARTS_TO_POLICY_FIELDS = { + overview: ['outputCurrency', 'address'], + members: ['employeeList'], + reports: ['fieldList', 'areReportFieldsEnabled'], + accounting: ['connections', 'areConnectionsEnabled'], + categories: ['areCategoriesEnabled'], + tags: ['areTagsEnabled'], + taxes: ['tax', 'taxRates'], + workflows: ['areWorkflowsEnabled', 'autoReportingFrequency', 'autoReporting', 'approvalMode', 'reimbursementChoice', 'achAccount'], + rules: [ + 'areRulesEnabled', + 'maxExpenseAmount', + 'maxExpenseAge', + 'maxExpenseAmountNoReceipt', + 'maxExpenseAmountNoItemizedReceipt', + 'defaultBillable', + 'prohibitedExpenses', + 'eReceipts', + 'isAttendeeTrackingEnabled', + 'preventSelfApproval', + 'shouldShowAutoApprovalOptions', + 'shouldShowAutoReimbursementLimitOption', + ], + distanceRates: ['areDistanceRatesEnabled', 'customUnits'], + perDiem: ['arePerDiemRatesEnabled', 'customUnits'], + invoices: ['areInvoicesEnabled', 'invoice'], + travel: ['isTravelEnabled', 'travelSettings'], +} as const satisfies Record>; + +type PolicyFieldsForPart = (typeof PARTS_TO_POLICY_FIELDS)[Part][number]; + +function setCopyPolicySettingsData(data: Partial): void { + Onyx.merge(ONYXKEYS.COPY_POLICY_SETTINGS, data); +} + +function clearCopyPolicySettings(): void { + Onyx.set(ONYXKEYS.COPY_POLICY_SETTINGS, {}); +} + +function requestCopyPolicySettingsNotification(): void { + API.write(WRITE_COMMANDS.COPY_POLICY_SETTINGS_NOTIFY, {}); +} + +function findCustomUnitByName(policy: Policy | undefined, unitName: string): CustomUnit | undefined { + if (!policy?.customUnits) { + return undefined; + } + return Object.values(policy.customUnits).find((unit) => unit.name === unitName); +} + +/** + * Returns the customUnits patch to merge into the target policy when distanceRates and/or perDiem are + * being copied. The source unit data is written under the target's existing unit ID — a new ID is + * generated only when the target has no unit of that type yet. + */ +function buildCustomUnitsPatch(sourcePolicy: Policy, targetPolicy: Policy, isDistanceSelected: boolean, isPerDiemSelected: boolean): {customUnits: Record} | undefined { + if (!isDistanceSelected && !isPerDiemSelected) { + return undefined; + } + + const patch: Record = {}; + + if (isDistanceSelected) { + const sourceDistance = findCustomUnitByName(sourcePolicy, CONST.CUSTOM_UNITS.NAME_DISTANCE); + if (sourceDistance) { + const targetDistance = findCustomUnitByName(targetPolicy, CONST.CUSTOM_UNITS.NAME_DISTANCE); + const targetUnitID = targetDistance?.customUnitID ?? NumberUtils.generateHexadecimalValue(13); + patch[targetUnitID] = {...sourceDistance, customUnitID: targetUnitID}; + } + } + + if (isPerDiemSelected) { + const sourcePerDiem = findCustomUnitByName(sourcePolicy, CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL); + if (sourcePerDiem) { + const targetPerDiem = findCustomUnitByName(targetPolicy, CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL); + const targetUnitID = targetPerDiem?.customUnitID ?? NumberUtils.generateHexadecimalValue(13); + patch[targetUnitID] = {...sourcePerDiem, customUnitID: targetUnitID}; + } + } + + if (Object.keys(patch).length === 0) { + return undefined; + } + return {customUnits: patch}; +} + +/** + * Returns the partial Policy patch derived from the selected `parts`, excluding fields whose + * mapping is handled separately (customUnits, categories, tags collection keys). + */ +function buildPolicyFieldPatch(sourcePolicy: Policy, parts: Part[]): Partial { + const patch: Partial = {}; + for (const part of parts) { + for (const field of PARTS_TO_POLICY_FIELDS[part]) { + if (field === 'customUnits') { + continue; + } + // The PARTS_TO_POLICY_FIELDS values are typed as keyof Policy, so this assignment is safe. + (patch as Record)[field] = sourcePolicy[field as keyof Policy]; + } + } + return patch; +} + +function buildExpandedPendingFields(parts: Part[]): Partial> { + const pendingFields: Partial> = {}; + for (const part of parts) { + for (const field of PARTS_TO_POLICY_FIELDS[part]) { + pendingFields[field] = CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE; + } + } + return pendingFields; +} + +function buildClearedPendingFields(parts: Part[]): Partial> { + const cleared: Partial> = {}; + for (const part of parts) { + for (const field of PARTS_TO_POLICY_FIELDS[part]) { + cleared[field] = null; + } + } + return cleared; +} + +function snapshotTargetFields(targetPolicy: Policy, parts: Part[]): Partial { + const snapshot: Partial = {}; + for (const part of parts) { + for (const field of PARTS_TO_POLICY_FIELDS[part]) { + (snapshot as Record)[field] = targetPolicy[field as keyof Policy]; + } + } + return snapshot; +} + +type CopyPolicySettingsOnyxKeys = + | typeof ONYXKEYS.COLLECTION.POLICY + | typeof ONYXKEYS.COLLECTION.POLICY_CATEGORIES + | typeof ONYXKEYS.COLLECTION.POLICY_TAGS + | typeof ONYXKEYS.COPY_POLICY_SETTINGS; + +function buildCopyPolicySettingsData( + sourcePolicy: Policy, + targetPolicies: Policy[], + parts: Part[], + allPolicyCategories: OnyxCollection, + allPolicyTags: OnyxCollection, +): { + optimisticData: Array>; + successData: Array>; + failureData: Array>; +} { + const optimisticData: Array> = []; + const successData: Array> = []; + const failureData: Array> = []; + + const policyFieldPatch = buildPolicyFieldPatch(sourcePolicy, parts); + const pendingFields = buildExpandedPendingFields(parts); + const clearedPendingFields = buildClearedPendingFields(parts); + + const isCategoriesSelected = parts.includes('categories'); + const isTagsSelected = parts.includes('tags'); + const isDistanceSelected = parts.includes('distanceRates'); + const isPerDiemSelected = parts.includes('perDiem'); + + const sourceCategoriesKey = `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${sourcePolicy.id}` as const; + const sourceTagsKey = `${ONYXKEYS.COLLECTION.POLICY_TAGS}${sourcePolicy.id}` as const; + const sourceCategories = allPolicyCategories?.[sourceCategoriesKey] ?? {}; + const sourceTags = allPolicyTags?.[sourceTagsKey] ?? {}; + + for (const targetPolicy of targetPolicies) { + const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${targetPolicy.id}` as const; + const customUnitsPatch = buildCustomUnitsPatch(sourcePolicy, targetPolicy, isDistanceSelected, isPerDiemSelected); + const snapshot = snapshotTargetFields(targetPolicy, parts); + + // Step 1+2: optimistic merge of patched fields + pending markers + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: { + ...policyFieldPatch, + ...customUnitsPatch, + pendingFields, + }, + }); + + // Success: clear the pending markers + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: { + pendingFields: clearedPendingFields, + }, + }); + + // Failure: restore snapshot, clear pending markers, surface RBR + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: { + ...snapshot, + ...(customUnitsPatch ? {customUnits: targetPolicy.customUnits} : {}), + pendingFields: clearedPendingFields, + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.copyPolicySettings.error'), + }, + }); + + // Step 3: collection keys (categories / tags) — SET-level overwrite with snapshot rollback + if (isCategoriesSelected) { + const targetCategoriesKey = `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${targetPolicy.id}` as const; + const previousCategories = allPolicyCategories?.[targetCategoriesKey] ?? {}; + optimisticData.push({ + onyxMethod: Onyx.METHOD.SET, + key: targetCategoriesKey, + value: sourceCategories, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.SET, + key: targetCategoriesKey, + value: previousCategories, + }); + } + + if (isTagsSelected) { + const targetTagsKey = `${ONYXKEYS.COLLECTION.POLICY_TAGS}${targetPolicy.id}` as const; + const previousTags = allPolicyTags?.[targetTagsKey] ?? {}; + optimisticData.push({ + onyxMethod: Onyx.METHOD.SET, + key: targetTagsKey, + value: sourceTags, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.SET, + key: targetTagsKey, + value: previousTags, + }); + } + } + + // Step 4: drive currentStep on the COPY_POLICY_SETTINGS key itself + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.COPY_POLICY_SETTINGS, + value: {currentStep: 'loading'}, + }); + + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.COPY_POLICY_SETTINGS, + value: {currentStep: undefined}, + }); + + return {optimisticData, successData, failureData}; +} + +function copyPolicySettings( + sourcePolicy: Policy, + targetPolicies: Policy[], + parts: Part[], + allPolicyCategories: OnyxCollection, + allPolicyTags: OnyxCollection, +): void { + const {optimisticData, successData, failureData} = buildCopyPolicySettingsData(sourcePolicy, targetPolicies, parts, allPolicyCategories, allPolicyTags); + + const params: CopyPolicySettingsParams = { + sourcePolicyID: sourcePolicy.id, + policyIDList: targetPolicies.map((policy) => policy.id).join(','), + parts: parts.join(','), + }; + + API.write(WRITE_COMMANDS.COPY_POLICY_SETTINGS, params, {optimisticData, successData, failureData}); +} + +export {PARTS_TO_POLICY_FIELDS, setCopyPolicySettingsData, clearCopyPolicySettings, requestCopyPolicySettingsNotification, buildCopyPolicySettingsData, copyPolicySettings}; +export type {Part}; From a48557e8bcc5e78252f494a2185385b572fd833e Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Thu, 7 May 2026 20:35:52 +0100 Subject: [PATCH 03/17] Add unit tests for buildCopyPolicySettingsData Covers the four behaviors required by the design doc: - each part key in isolation patches the right Policy fields and marks each one pending - SET-level overwrite of POLICY_CATEGORIES and POLICY_TAGS collection keys, with failure data restoring the target's snapshot - failure data restores the target's pre-copy field values - customUnits preservation: target's existing distance / per-diem unit ID is reused; a new ID is generated only when absent --- tests/actions/CopyPolicySettingsTest.ts | 326 ++++++++++++++++++++++++ 1 file changed, 326 insertions(+) create mode 100644 tests/actions/CopyPolicySettingsTest.ts diff --git a/tests/actions/CopyPolicySettingsTest.ts b/tests/actions/CopyPolicySettingsTest.ts new file mode 100644 index 000000000000..29603d05092a --- /dev/null +++ b/tests/actions/CopyPolicySettingsTest.ts @@ -0,0 +1,326 @@ +import type {OnyxCollection} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; +import CONST from '@src/CONST'; +import {buildCopyPolicySettingsData } from '@src/libs/actions/Policy/CopyPolicySettings'; +import type {Part} from '@src/libs/actions/Policy/CopyPolicySettings'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy, PolicyCategories, PolicyTagLists} from '@src/types/onyx'; +import type {CustomUnit} from '@src/types/onyx/Policy'; +import createRandomPolicy from '../utils/collections/policies'; + +const SOURCE_POLICY_ID = 'SOURCE000000000A'; +const TARGET_POLICY_ID = 'TARGET000000000B'; +const POLICY_KEY = `${ONYXKEYS.COLLECTION.POLICY}${TARGET_POLICY_ID}` as const; +const TARGET_CATEGORIES_KEY = `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${TARGET_POLICY_ID}` as const; +const SOURCE_CATEGORIES_KEY = `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${SOURCE_POLICY_ID}` as const; +const TARGET_TAGS_KEY = `${ONYXKEYS.COLLECTION.POLICY_TAGS}${TARGET_POLICY_ID}` as const; +const SOURCE_TAGS_KEY = `${ONYXKEYS.COLLECTION.POLICY_TAGS}${SOURCE_POLICY_ID}` as const; + +function makeSourcePolicy(overrides: Partial = {}): Policy { + const base = createRandomPolicy(0, CONST.POLICY.TYPE.CORPORATE); + return { + ...base, + id: SOURCE_POLICY_ID, + outputCurrency: 'USD', + address: { + addressStreet: '123 Source St', + city: 'San Francisco', + country: 'US', + state: 'CA', + zipCode: '94105', + }, + areCategoriesEnabled: true, + areTagsEnabled: true, + areReportFieldsEnabled: true, + areConnectionsEnabled: true, + areWorkflowsEnabled: true, + areRulesEnabled: true, + areDistanceRatesEnabled: true, + arePerDiemRatesEnabled: true, + areInvoicesEnabled: true, + isTravelEnabled: true, + autoReporting: true, + autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.IMMEDIATE, + approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC, + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, + maxExpenseAmount: 50000, + maxExpenseAge: 90, + defaultBillable: true, + eReceipts: true, + preventSelfApproval: true, + ...overrides, + }; +} + +function makeTargetPolicy(overrides: Partial = {}): Policy { + const base = createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE); + return { + ...base, + id: TARGET_POLICY_ID, + outputCurrency: 'EUR', + address: { + addressStreet: '99 Target Ave', + city: 'Berlin', + country: 'DE', + state: 'BE', + zipCode: '10115', + }, + areCategoriesEnabled: false, + areTagsEnabled: false, + areReportFieldsEnabled: false, + areConnectionsEnabled: false, + areWorkflowsEnabled: false, + areRulesEnabled: false, + areDistanceRatesEnabled: false, + arePerDiemRatesEnabled: false, + areInvoicesEnabled: false, + isTravelEnabled: false, + autoReporting: false, + autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.WEEKLY, + approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL, + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO, + maxExpenseAmount: 1000, + maxExpenseAge: 30, + defaultBillable: false, + eReceipts: false, + preventSelfApproval: false, + customUnits: {}, + ...overrides, + }; +} + +function findPolicyMerge(updates: ReturnType['optimisticData']) { + return updates.find((u) => u.key === POLICY_KEY && u.onyxMethod === Onyx.METHOD.MERGE); +} + +function findPolicyFailure(updates: ReturnType['failureData']) { + return updates.find((u) => u.key === POLICY_KEY && u.onyxMethod === Onyx.METHOD.MERGE); +} + +describe('actions/Policy/CopyPolicySettings', () => { + describe('buildCopyPolicySettingsData', () => { + describe('per-part field patches and pendingFields', () => { + it.each<[Part, readonly string[]]>([ + ['overview', ['outputCurrency', 'address']], + ['members', ['employeeList']], + ['reports', ['fieldList', 'areReportFieldsEnabled']], + ['accounting', ['connections', 'areConnectionsEnabled']], + ['categories', ['areCategoriesEnabled']], + ['tags', ['areTagsEnabled']], + ['taxes', ['tax', 'taxRates']], + ['workflows', ['areWorkflowsEnabled', 'autoReportingFrequency', 'autoReporting', 'approvalMode', 'reimbursementChoice', 'achAccount']], + [ + 'rules', + [ + 'areRulesEnabled', + 'maxExpenseAmount', + 'maxExpenseAge', + 'maxExpenseAmountNoReceipt', + 'maxExpenseAmountNoItemizedReceipt', + 'defaultBillable', + 'prohibitedExpenses', + 'eReceipts', + 'isAttendeeTrackingEnabled', + 'preventSelfApproval', + 'shouldShowAutoApprovalOptions', + 'shouldShowAutoReimbursementLimitOption', + ], + ], + ['invoices', ['areInvoicesEnabled', 'invoice']], + ['travel', ['isTravelEnabled', 'travelSettings']], + ])('marks %s fields pending and patches values from source', (part, expectedFields) => { + const sourcePolicy = makeSourcePolicy(); + const targetPolicy = makeTargetPolicy(); + + const {optimisticData} = buildCopyPolicySettingsData(sourcePolicy, [targetPolicy], [part], {}, {}); + + const merge = findPolicyMerge(optimisticData); + expect(merge).toBeDefined(); + const value = merge?.value as Record & {pendingFields?: Record}; + + // Each expected field should be patched from the source policy and marked pending. + for (const field of expectedFields) { + expect(value).toHaveProperty(field); + expect(value[field]).toEqual(sourcePolicy[field as keyof Policy]); + expect(value.pendingFields?.[field]).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE); + } + }); + + it('does not include unrelated fields in the patch', () => { + const sourcePolicy = makeSourcePolicy(); + const targetPolicy = makeTargetPolicy(); + + const {optimisticData} = buildCopyPolicySettingsData(sourcePolicy, [targetPolicy], ['overview'], {}, {}); + const value = findPolicyMerge(optimisticData)?.value as Record; + + expect(value).not.toHaveProperty('areCategoriesEnabled'); + expect(value).not.toHaveProperty('employeeList'); + expect(value).not.toHaveProperty('connections'); + }); + }); + + describe('collection key overwrites', () => { + it('SETs target POLICY_CATEGORIES to source categories when categories selected', () => { + const sourceCategories: PolicyCategories = {Food: {name: 'Food', enabled: true, areCommentsRequired: false}}; + const targetCategories: PolicyCategories = {Travel: {name: 'Travel', enabled: true, areCommentsRequired: false}}; + + const allPolicyCategories: OnyxCollection = { + [SOURCE_CATEGORIES_KEY]: sourceCategories, + [TARGET_CATEGORIES_KEY]: targetCategories, + }; + + const {optimisticData, failureData} = buildCopyPolicySettingsData(makeSourcePolicy(), [makeTargetPolicy()], ['categories'], allPolicyCategories, {}); + + const optimisticSet = optimisticData.find((u) => u.key === TARGET_CATEGORIES_KEY && u.onyxMethod === Onyx.METHOD.SET); + const failureSet = failureData.find((u) => u.key === TARGET_CATEGORIES_KEY && u.onyxMethod === Onyx.METHOD.SET); + + expect(optimisticSet?.value).toEqual(sourceCategories); + expect(failureSet?.value).toEqual(targetCategories); + }); + + it('SETs target POLICY_TAGS to source tags when tags selected', () => { + const sourceTags = {Department: {name: 'Department', orderWeight: 0, required: false, tags: {Eng: {name: 'Eng', enabled: true}}}} as PolicyTagLists; + const targetTags = {Region: {name: 'Region', orderWeight: 0, required: false, tags: {EU: {name: 'EU', enabled: true}}}} as PolicyTagLists; + + const allPolicyTags: OnyxCollection = { + [SOURCE_TAGS_KEY]: sourceTags, + [TARGET_TAGS_KEY]: targetTags, + }; + + const {optimisticData, failureData} = buildCopyPolicySettingsData(makeSourcePolicy(), [makeTargetPolicy()], ['tags'], {}, allPolicyTags); + + const optimisticSet = optimisticData.find((u) => u.key === TARGET_TAGS_KEY && u.onyxMethod === Onyx.METHOD.SET); + const failureSet = failureData.find((u) => u.key === TARGET_TAGS_KEY && u.onyxMethod === Onyx.METHOD.SET); + + expect(optimisticSet?.value).toEqual(sourceTags); + expect(failureSet?.value).toEqual(targetTags); + }); + + it('does not emit POLICY_CATEGORIES updates when categories not selected', () => { + const {optimisticData, failureData} = buildCopyPolicySettingsData(makeSourcePolicy(), [makeTargetPolicy()], ['overview'], {}, {}); + expect(optimisticData.some((u) => u.key === TARGET_CATEGORIES_KEY)).toBe(false); + expect(failureData.some((u) => u.key === TARGET_CATEGORIES_KEY)).toBe(false); + }); + }); + + describe('failure data restores pre-copy state', () => { + it("restores the target's previous field values and clears pendingFields", () => { + const sourcePolicy = makeSourcePolicy({outputCurrency: 'USD', maxExpenseAmount: 50000}); + const targetPolicy = makeTargetPolicy({outputCurrency: 'EUR', maxExpenseAmount: 1000}); + + const {failureData} = buildCopyPolicySettingsData(sourcePolicy, [targetPolicy], ['overview', 'rules'], {}, {}); + + const failure = findPolicyFailure(failureData); + const value = failure?.value as Record & {pendingFields?: Record; errors?: unknown}; + + expect(value.outputCurrency).toBe('EUR'); + expect(value.maxExpenseAmount).toBe(1000); + // pendingFields entries are nulled out for every expanded field + expect(value.pendingFields?.outputCurrency).toBeNull(); + expect(value.pendingFields?.address).toBeNull(); + expect(value.pendingFields?.maxExpenseAmount).toBeNull(); + expect(value.errors).toBeDefined(); + }); + }); + + describe('customUnits preservation', () => { + const sourceDistanceUnit: CustomUnit = { + customUnitID: 'SOURCEDISTID', + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, + rates: {SRC_RATE: {customUnitRateID: 'SRC_RATE', name: 'IRS', rate: 67, enabled: true, currency: 'USD'}}, + }; + const sourcePerDiemUnit: CustomUnit = { + customUnitID: 'SOURCEPERDIEM', + name: CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL, + attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, + rates: {SRC_PD_RATE: {customUnitRateID: 'SRC_PD_RATE', name: 'NYC', rate: 100, enabled: true, currency: 'USD'}}, + }; + + it("uses target's existing distance unit ID when target already has one", () => { + const sourcePolicy = makeSourcePolicy({customUnits: {[sourceDistanceUnit.customUnitID]: sourceDistanceUnit}}); + const targetExistingDistanceID = 'TARGETDISTID'; + const targetPolicy = makeTargetPolicy({ + customUnits: { + [targetExistingDistanceID]: { + customUnitID: targetExistingDistanceID, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS}, + rates: {OLD: {customUnitRateID: 'OLD', name: 'old', rate: 1, enabled: true, currency: 'EUR'}}, + }, + }, + }); + + const {optimisticData} = buildCopyPolicySettingsData(sourcePolicy, [targetPolicy], ['distanceRates'], {}, {}); + + const value = findPolicyMerge(optimisticData)?.value as {customUnits?: Record; pendingFields?: Record}; + expect(value.customUnits).toBeDefined(); + expect(Object.keys(value.customUnits ?? {})).toEqual([targetExistingDistanceID]); + expect(value.customUnits?.[targetExistingDistanceID]?.customUnitID).toBe(targetExistingDistanceID); + expect(value.customUnits?.[targetExistingDistanceID]?.rates).toEqual(sourceDistanceUnit.rates); + expect(value.pendingFields?.customUnits).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE); + }); + + it('generates a new unit ID when target has no distance unit', () => { + const sourcePolicy = makeSourcePolicy({customUnits: {[sourceDistanceUnit.customUnitID]: sourceDistanceUnit}}); + const targetPolicy = makeTargetPolicy({customUnits: {}}); + + const {optimisticData} = buildCopyPolicySettingsData(sourcePolicy, [targetPolicy], ['distanceRates'], {}, {}); + + const value = findPolicyMerge(optimisticData)?.value as {customUnits?: Record}; + const unitIDs = Object.keys(value.customUnits ?? {}); + expect(unitIDs).toHaveLength(1); + expect(unitIDs.at(0)).not.toBe(sourceDistanceUnit.customUnitID); + expect(unitIDs.at(0)).toMatch(/^[0-9A-F]{13}$/); + // A freshly generated ID should be reused as the customUnitID inside the unit + expect(value.customUnits?.[unitIDs[0]]?.customUnitID).toBe(unitIDs.at(0)); + }); + + it("preserves target's existing per-diem unit ID independently of distance", () => { + const sourcePolicy = makeSourcePolicy({ + customUnits: {[sourceDistanceUnit.customUnitID]: sourceDistanceUnit, [sourcePerDiemUnit.customUnitID]: sourcePerDiemUnit}, + }); + const targetExistingDistanceID = 'TARGETDISTID'; + const targetExistingPerDiemID = 'TARGETPERDIEMID'; + const targetPolicy = makeTargetPolicy({ + customUnits: { + [targetExistingDistanceID]: { + customUnitID: targetExistingDistanceID, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS}, + rates: {}, + }, + [targetExistingPerDiemID]: { + customUnitID: targetExistingPerDiemID, + name: CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL, + attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, + rates: {}, + }, + }, + }); + + const {optimisticData} = buildCopyPolicySettingsData(sourcePolicy, [targetPolicy], ['distanceRates', 'perDiem'], {}, {}); + + const value = findPolicyMerge(optimisticData)?.value as {customUnits?: Record}; + expect(Object.keys(value.customUnits ?? {}).sort()).toEqual([targetExistingDistanceID, targetExistingPerDiemID].sort()); + expect(value.customUnits?.[targetExistingDistanceID]?.rates).toEqual(sourceDistanceUnit.rates); + expect(value.customUnits?.[targetExistingPerDiemID]?.rates).toEqual(sourcePerDiemUnit.rates); + }); + }); + + describe('COPY_POLICY_SETTINGS lifecycle key', () => { + it("sets currentStep='loading' optimistically and clears it on failure", () => { + const {optimisticData, failureData, successData} = buildCopyPolicySettingsData(makeSourcePolicy(), [makeTargetPolicy()], ['overview'], {}, {}); + + const optLifecycle = optimisticData.find((u) => u.key === ONYXKEYS.COPY_POLICY_SETTINGS); + const failLifecycle = failureData.find((u) => u.key === ONYXKEYS.COPY_POLICY_SETTINGS); + const successLifecycle = successData.find((u) => u.key === ONYXKEYS.COPY_POLICY_SETTINGS); + + expect((optLifecycle?.value as {currentStep?: string})?.currentStep).toBe('loading'); + expect((failLifecycle?.value as {currentStep?: string})?.currentStep).toBeUndefined(); + // Success leaves currentStep alone — the backend transitions it to 'complete' via NVP. + expect(successLifecycle).toBeUndefined(); + }); + }); + }); +}); From 486f96061f19ede0cce6dfb03ba2f74d0f54e915 Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Thu, 7 May 2026 21:01:45 +0100 Subject: [PATCH 04/17] Use named type import in CopyPolicySettings Switches from `import type * as OnyxCommon` to `import type {Errors}` to satisfy the no-restricted-syntax rule that disallows namespace imports from sibling modules. --- src/types/onyx/CopyPolicySettings.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/types/onyx/CopyPolicySettings.ts b/src/types/onyx/CopyPolicySettings.ts index b360b1f04a6f..4507c6aa9181 100644 --- a/src/types/onyx/CopyPolicySettings.ts +++ b/src/types/onyx/CopyPolicySettings.ts @@ -1,4 +1,4 @@ -import type * as OnyxCommon from './OnyxCommon'; +import type {Errors} from './OnyxCommon'; /** Onyx state of the Copy Policy Settings (bulk workspace edits) flow */ type CopyPolicySettings = { @@ -20,7 +20,7 @@ type CopyPolicySettings = { currentStep?: 'loading' | 'complete' | undefined; /** Error state */ - errors?: OnyxCommon.Errors; + errors?: Errors; }; export default CopyPolicySettings; From b9e8d88e1d5aee7da934e0370a2dad785c36c38c Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Thu, 7 May 2026 21:43:55 +0100 Subject: [PATCH 05/17] Use named imports in CopyPolicySettings action file Replaces `import * as` namespace imports for `@libs/API`, `@libs/ErrorUtils`, and `@libs/NumberUtils` with named imports (`write`, `getMicroSecondOnyxErrorWithTranslationKey`, `generateHexadecimalValue`) to satisfy the no-restricted-syntax rule that disallows namespace imports from `@libs`. Also picks up a prettier formatting fix in the test import. --- src/libs/actions/Policy/CopyPolicySettings.ts | 16 ++++++++-------- tests/actions/CopyPolicySettingsTest.ts | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/libs/actions/Policy/CopyPolicySettings.ts b/src/libs/actions/Policy/CopyPolicySettings.ts index 6a4b3b216e8d..d17fce032b2f 100644 --- a/src/libs/actions/Policy/CopyPolicySettings.ts +++ b/src/libs/actions/Policy/CopyPolicySettings.ts @@ -1,10 +1,10 @@ import type {OnyxCollection, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; -import * as API from '@libs/API'; +import {write} from '@libs/API'; import type {CopyPolicySettingsParams} from '@libs/API/parameters'; import {WRITE_COMMANDS} from '@libs/API/types'; -import * as ErrorUtils from '@libs/ErrorUtils'; -import * as NumberUtils from '@libs/NumberUtils'; +import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; +import {generateHexadecimalValue} from '@libs/NumberUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {CopyPolicySettings as CopyPolicySettingsState, Policy, PolicyCategories, PolicyTagLists} from '@src/types/onyx'; @@ -52,7 +52,7 @@ function clearCopyPolicySettings(): void { } function requestCopyPolicySettingsNotification(): void { - API.write(WRITE_COMMANDS.COPY_POLICY_SETTINGS_NOTIFY, {}); + write(WRITE_COMMANDS.COPY_POLICY_SETTINGS_NOTIFY, {}); } function findCustomUnitByName(policy: Policy | undefined, unitName: string): CustomUnit | undefined { @@ -78,7 +78,7 @@ function buildCustomUnitsPatch(sourcePolicy: Policy, targetPolicy: Policy, isDis const sourceDistance = findCustomUnitByName(sourcePolicy, CONST.CUSTOM_UNITS.NAME_DISTANCE); if (sourceDistance) { const targetDistance = findCustomUnitByName(targetPolicy, CONST.CUSTOM_UNITS.NAME_DISTANCE); - const targetUnitID = targetDistance?.customUnitID ?? NumberUtils.generateHexadecimalValue(13); + const targetUnitID = targetDistance?.customUnitID ?? generateHexadecimalValue(13); patch[targetUnitID] = {...sourceDistance, customUnitID: targetUnitID}; } } @@ -87,7 +87,7 @@ function buildCustomUnitsPatch(sourcePolicy: Policy, targetPolicy: Policy, isDis const sourcePerDiem = findCustomUnitByName(sourcePolicy, CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL); if (sourcePerDiem) { const targetPerDiem = findCustomUnitByName(targetPolicy, CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL); - const targetUnitID = targetPerDiem?.customUnitID ?? NumberUtils.generateHexadecimalValue(13); + const targetUnitID = targetPerDiem?.customUnitID ?? generateHexadecimalValue(13); patch[targetUnitID] = {...sourcePerDiem, customUnitID: targetUnitID}; } } @@ -214,7 +214,7 @@ function buildCopyPolicySettingsData( ...snapshot, ...(customUnitsPatch ? {customUnits: targetPolicy.customUnits} : {}), pendingFields: clearedPendingFields, - errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.copyPolicySettings.error'), + errors: getMicroSecondOnyxErrorWithTranslationKey('workspace.copyPolicySettings.error'), }, }); @@ -281,7 +281,7 @@ function copyPolicySettings( parts: parts.join(','), }; - API.write(WRITE_COMMANDS.COPY_POLICY_SETTINGS, params, {optimisticData, successData, failureData}); + write(WRITE_COMMANDS.COPY_POLICY_SETTINGS, params, {optimisticData, successData, failureData}); } export {PARTS_TO_POLICY_FIELDS, setCopyPolicySettingsData, clearCopyPolicySettings, requestCopyPolicySettingsNotification, buildCopyPolicySettingsData, copyPolicySettings}; diff --git a/tests/actions/CopyPolicySettingsTest.ts b/tests/actions/CopyPolicySettingsTest.ts index 29603d05092a..8807e281dd07 100644 --- a/tests/actions/CopyPolicySettingsTest.ts +++ b/tests/actions/CopyPolicySettingsTest.ts @@ -1,7 +1,7 @@ import type {OnyxCollection} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import CONST from '@src/CONST'; -import {buildCopyPolicySettingsData } from '@src/libs/actions/Policy/CopyPolicySettings'; +import {buildCopyPolicySettingsData} from '@src/libs/actions/Policy/CopyPolicySettings'; import type {Part} from '@src/libs/actions/Policy/CopyPolicySettings'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, PolicyCategories, PolicyTagLists} from '@src/types/onyx'; From 4f46bafd46a89e281a8e23a47693fd41ac23b22e Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Thu, 7 May 2026 22:05:50 +0100 Subject: [PATCH 06/17] Replace test custom unit IDs with hex digits to satisfy cspell Renames the placeholder custom unit IDs (`SOURCEDISTID`, `SOURCEPERDIEM`, `TARGETDISTID`, `TARGETPERDIEMID`) to digit-only 13-character strings so cspell stops flagging them as unknown words. The strings remain valid `[0-9A-F]{13}` hex IDs and tests still pass. --- tests/actions/CopyPolicySettingsTest.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/actions/CopyPolicySettingsTest.ts b/tests/actions/CopyPolicySettingsTest.ts index 8807e281dd07..a82e84641429 100644 --- a/tests/actions/CopyPolicySettingsTest.ts +++ b/tests/actions/CopyPolicySettingsTest.ts @@ -225,13 +225,13 @@ describe('actions/Policy/CopyPolicySettings', () => { describe('customUnits preservation', () => { const sourceDistanceUnit: CustomUnit = { - customUnitID: 'SOURCEDISTID', + customUnitID: '1000000000001', name: CONST.CUSTOM_UNITS.NAME_DISTANCE, attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, rates: {SRC_RATE: {customUnitRateID: 'SRC_RATE', name: 'IRS', rate: 67, enabled: true, currency: 'USD'}}, }; const sourcePerDiemUnit: CustomUnit = { - customUnitID: 'SOURCEPERDIEM', + customUnitID: '1000000000002', name: CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL, attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, rates: {SRC_PD_RATE: {customUnitRateID: 'SRC_PD_RATE', name: 'NYC', rate: 100, enabled: true, currency: 'USD'}}, @@ -239,7 +239,7 @@ describe('actions/Policy/CopyPolicySettings', () => { it("uses target's existing distance unit ID when target already has one", () => { const sourcePolicy = makeSourcePolicy({customUnits: {[sourceDistanceUnit.customUnitID]: sourceDistanceUnit}}); - const targetExistingDistanceID = 'TARGETDISTID'; + const targetExistingDistanceID = '2000000000001'; const targetPolicy = makeTargetPolicy({ customUnits: { [targetExistingDistanceID]: { @@ -280,8 +280,8 @@ describe('actions/Policy/CopyPolicySettings', () => { const sourcePolicy = makeSourcePolicy({ customUnits: {[sourceDistanceUnit.customUnitID]: sourceDistanceUnit, [sourcePerDiemUnit.customUnitID]: sourcePerDiemUnit}, }); - const targetExistingDistanceID = 'TARGETDISTID'; - const targetExistingPerDiemID = 'TARGETPERDIEMID'; + const targetExistingDistanceID = '2000000000001'; + const targetExistingPerDiemID = '2000000000002'; const targetPolicy = makeTargetPolicy({ customUnits: { [targetExistingDistanceID]: { From 0f3a8ae44174c407fe7d1b2f48af7a04f82ecc55 Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Fri, 8 May 2026 13:21:25 +0100 Subject: [PATCH 07/17] Add documentation for sourcePolicyID in CopyPolicySettingsParams Includes a JSDoc comment to clarify the purpose of the sourcePolicyID field, indicating that it represents the ID of the source policy from which settings are copied. --- src/libs/API/parameters/CopyPolicySettingsParams.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/libs/API/parameters/CopyPolicySettingsParams.ts b/src/libs/API/parameters/CopyPolicySettingsParams.ts index 8df8a34eca7b..9640a1ff5d7c 100644 --- a/src/libs/API/parameters/CopyPolicySettingsParams.ts +++ b/src/libs/API/parameters/CopyPolicySettingsParams.ts @@ -1,4 +1,5 @@ type CopyPolicySettingsParams = { + /** Source policy ID we're copying settings from */ sourcePolicyID: string; /** CSV list of target policy IDs to copy settings into */ From a02be50238f542283d966249e39277574558cb6b Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Fri, 8 May 2026 21:53:56 +0100 Subject: [PATCH 08/17] fix: update copyPolicySettings translations --- src/languages/de.ts | 4 +--- src/languages/fr.ts | 4 +--- src/languages/it.ts | 4 +--- src/languages/ja.ts | 4 +--- src/languages/nl.ts | 4 +--- src/languages/pl.ts | 4 +--- src/languages/pt-BR.ts | 4 +--- src/languages/zh-hans.ts | 4 +--- 8 files changed, 8 insertions(+), 24 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 2dbfa9203815..62e21ab0dac4 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -5964,9 +5964,7 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU `Sie sind dabei, ${newWorkspaceName ?? ''} mit ${totalMembers ?? 0} Mitgliedern aus dem ursprünglichen Workspace zu erstellen und zu teilen.`, error: 'Beim Duplizieren deines neuen Workspace ist ein Fehler aufgetreten. Bitte versuche es erneut.', }, - copyPolicySettings: { - error: 'Beim Kopieren der Arbeitsbereich-Einstellungen ist ein Fehler aufgetreten. Bitte versuche es erneut.', - }, + copyPolicySettings: {error: 'Beim Kopieren der Arbeitsbereichseinstellungen ist ein Fehler aufgetreten. Bitte versuche es erneut.'}, emptyWorkspace: { title: 'Du hast keine Arbeitsbereiche', subtitle: 'Belege erfassen, Auslagen erstatten, Reisen verwalten, Rechnungen versenden und mehr.', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index d0192ee2f5dc..cedfb4dd2d48 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -5984,9 +5984,7 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. `Vous êtes sur le point de créer et de partager ${newWorkspaceName ?? ''} avec ${totalMembers ?? 0} membres de l’espace de travail d’origine.`, error: 'Une erreur s’est produite lors de la duplication de votre nouvel espace de travail. Veuillez réessayer.', }, - copyPolicySettings: { - error: 'Une erreur s’est produite lors de la copie des paramètres de l’espace de travail. Veuillez réessayer.', - }, + copyPolicySettings: {error: 'Une erreur s’est produite lors de la copie des paramètres de l’espace de travail. Veuillez réessayer.'}, emptyWorkspace: { title: 'Vous n’avez aucun espace de travail', subtitle: 'Suivez les reçus, remboursez les dépenses, gérez les voyages, envoyez des factures, et plus encore.', diff --git a/src/languages/it.ts b/src/languages/it.ts index ad63c8aff885..ef469debd88d 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -5954,9 +5954,7 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. `Stai per creare e condividere ${newWorkspaceName ?? ''} con ${totalMembers ?? 0} membri dello spazio di lavoro originale.`, error: 'Si è verificato un errore durante la duplicazione del tuo nuovo workspace. Riprova.', }, - copyPolicySettings: { - error: 'Si è verificato un errore durante la copia delle impostazioni del workspace. Riprova.', - }, + copyPolicySettings: {error: 'Si è verificato un errore durante la copia delle impostazioni dello spazio di lavoro. Riprova.'}, emptyWorkspace: { title: 'Non hai nessuna area di lavoro', subtitle: 'Tieni traccia delle ricevute, rimborsa le spese, gestisci i viaggi, invia le fatture e altro ancora.', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 4802c0c800cc..4581d3990452 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -5887,9 +5887,7 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO `元のワークスペースから ${totalMembers ?? 0} 人のメンバーと一緒に、${newWorkspaceName ?? ''} を作成して共有しようとしています。`, error: '新しいワークスペースの複製中にエラーが発生しました。もう一度お試しください。', }, - copyPolicySettings: { - error: 'ワークスペース設定のコピー中にエラーが発生しました。もう一度お試しください。', - }, + copyPolicySettings: {error: 'ワークスペース設定のコピー中にエラーが発生しました。もう一度お試しください。'}, emptyWorkspace: { title: 'ワークスペースがありません', subtitle: '領収書を管理し、経費を精算し、出張を管理し、請求書を送信するなど、さまざまなことができます。', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 145d68670ca8..5f223376a453 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -5934,9 +5934,7 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_ `Je staat op het punt ${newWorkspaceName ?? ''} te maken en te delen met ${totalMembers ?? 0} leden van de oorspronkelijke werkruimte.`, error: 'Er is een fout opgetreden bij het dupliceren van je nieuwe werkruimte. Probeer het opnieuw.', }, - copyPolicySettings: { - error: 'Er is een fout opgetreden bij het kopiëren van de werkruimte-instellingen. Probeer het opnieuw.', - }, + copyPolicySettings: {error: 'Er is een fout opgetreden bij het kopiëren van de werkruimtainstellingen. Probeer het opnieuw.'}, emptyWorkspace: { title: 'Je hebt geen werkruimtes', subtitle: 'Volg bonnen, vergoed uitgaven, beheer reizen, verstuur facturen en meer.', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 75865015329f..cb8e50110f76 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -5928,9 +5928,7 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy `Za chwilę utworzysz i udostępnisz ${newWorkspaceName ?? ''} ${totalMembers ?? 0} członkom oryginalnego obszaru roboczego.`, error: 'Wystąpił błąd podczas duplikowania Twojego nowego obszaru roboczego. Spróbuj ponownie.', }, - copyPolicySettings: { - error: 'Wystąpił błąd podczas kopiowania ustawień przestrzeni roboczej. Spróbuj ponownie.', - }, + copyPolicySettings: {error: 'Wystąpił błąd podczas kopiowania ustawień przestrzeni roboczej. Spróbuj ponownie.'}, emptyWorkspace: { title: 'Nie masz żadnych przestrzeni roboczych', subtitle: 'Śledź paragony, rozliczaj wydatki, zarządzaj podróżami, wysyłaj faktury i wiele więcej.', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 73bd39d7eeb7..7e85171dfa3d 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -5934,9 +5934,7 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS `Você está prestes to criar e compartilhar ${newWorkspaceName ?? ''} com ${totalMembers ?? 0} membros do workspace original.`, error: 'Ocorreu um erro ao duplicar seu novo espaço de trabalho. Tente novamente.', }, - copyPolicySettings: { - error: 'Ocorreu um erro ao copiar as configurações do espaço de trabalho. Tente novamente.', - }, + copyPolicySettings: {error: 'Ocorreu um erro ao copiar as configurações do workspace. Tente novamente.'}, emptyWorkspace: { title: 'Você não tem nenhum workspace', subtitle: 'Controle recibos, reembolse despesas, gerencie viagens, envie faturas e muito mais.', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 49bad34e9176..780499b42793 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -5789,9 +5789,7 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM `您即将创建并共享 ${newWorkspaceName ?? ''},其中包含来自原始工作区的 ${totalMembers ?? 0} 位成员。`, error: '复制您的新工作区时发生错误。请重试。', }, - copyPolicySettings: { - error: '复制工作区设置时发生错误。请重试。', - }, + copyPolicySettings: {error: '复制工作区设置时发生错误。请重试。'}, emptyWorkspace: { title: '你还没有工作区', subtitle: '跟踪收据、报销费用、管理差旅、发送发票等。', From fb877365167b58b23060fad5a15f54fe31cc8d96 Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Tue, 12 May 2026 21:36:17 +0100 Subject: [PATCH 09/17] Update Spanish translation for copy policy settings error message to include a more polite request to retry. --- src/languages/es.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/languages/es.ts b/src/languages/es.ts index 0e7d4b9543bf..fce4d50ca36f 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -5817,7 +5817,7 @@ ${amount} para ${merchant} - ${date}`, error: 'Se produjo un error al duplicar tu nuevo espacio de trabajo. Inténtalo de nuevo.', }, copyPolicySettings: { - error: 'Se produjo un error al copiar la configuración del espacio de trabajo. Inténtalo de nuevo.', + error: 'Se produjo un error al copiar la configuración del espacio de trabajo. Por favor, inténtalo de nuevo.', }, emptyWorkspace: { title: 'Aún no hay espacios de trabajo', From a2a17c7e050810dbe54a54c477170138e9f81d04 Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Tue, 12 May 2026 21:46:08 +0100 Subject: [PATCH 10/17] chore: add source policy RBR error on failure in CopyPolicySettings Surface an error on the source policy when the bulk copy fails so the admin sees a red-brick-road indicator on the workspaces list row. Adds a corresponding test to verify the source policy entry in failureData. --- src/libs/actions/Policy/CopyPolicySettings.ts | 9 +++++++++ tests/actions/CopyPolicySettingsTest.ts | 12 ++++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/libs/actions/Policy/CopyPolicySettings.ts b/src/libs/actions/Policy/CopyPolicySettings.ts index d17fce032b2f..5915575bf7b3 100644 --- a/src/libs/actions/Policy/CopyPolicySettings.ts +++ b/src/libs/actions/Policy/CopyPolicySettings.ts @@ -250,6 +250,15 @@ function buildCopyPolicySettingsData( } } + // Surface an RBR on the source policy row so the admin knows the bulk copy failed + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${sourcePolicy.id}` as const, + value: { + errors: getMicroSecondOnyxErrorWithTranslationKey('workspace.copyPolicySettings.error'), + }, + }); + // Step 4: drive currentStep on the COPY_POLICY_SETTINGS key itself optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, diff --git a/tests/actions/CopyPolicySettingsTest.ts b/tests/actions/CopyPolicySettingsTest.ts index a82e84641429..17e1a782345b 100644 --- a/tests/actions/CopyPolicySettingsTest.ts +++ b/tests/actions/CopyPolicySettingsTest.ts @@ -221,6 +221,18 @@ describe('actions/Policy/CopyPolicySettings', () => { expect(value.pendingFields?.maxExpenseAmount).toBeNull(); expect(value.errors).toBeDefined(); }); + + it('surfaces an RBR error on the source policy', () => { + const sourcePolicy = makeSourcePolicy(); + const targetPolicy = makeTargetPolicy(); + const sourcePolicyKey = `${ONYXKEYS.COLLECTION.POLICY}${SOURCE_POLICY_ID}` as const; + + const {failureData} = buildCopyPolicySettingsData(sourcePolicy, [targetPolicy], ['overview'], {}, {}); + + const sourceFailure = failureData.find((u) => u.key === sourcePolicyKey && u.onyxMethod === Onyx.METHOD.MERGE); + expect(sourceFailure).toBeDefined(); + expect((sourceFailure?.value as {errors?: unknown})?.errors).toBeDefined(); + }); }); describe('customUnits preservation', () => { From a9c59131bef0d315d8d14d889b60fc3edd30056e Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Tue, 12 May 2026 21:49:00 +0100 Subject: [PATCH 11/17] chore: add description field to overview part in CopyPolicySettings --- src/libs/actions/Policy/CopyPolicySettings.ts | 2 +- tests/actions/CopyPolicySettingsTest.ts | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/libs/actions/Policy/CopyPolicySettings.ts b/src/libs/actions/Policy/CopyPolicySettings.ts index 5915575bf7b3..a3bdf7a98fc8 100644 --- a/src/libs/actions/Policy/CopyPolicySettings.ts +++ b/src/libs/actions/Policy/CopyPolicySettings.ts @@ -13,7 +13,7 @@ import type {CustomUnit} from '@src/types/onyx/Policy'; type Part = 'overview' | 'members' | 'reports' | 'accounting' | 'categories' | 'tags' | 'taxes' | 'workflows' | 'rules' | 'distanceRates' | 'perDiem' | 'invoices' | 'travel'; const PARTS_TO_POLICY_FIELDS = { - overview: ['outputCurrency', 'address'], + overview: ['outputCurrency', 'address', 'description'], members: ['employeeList'], reports: ['fieldList', 'areReportFieldsEnabled'], accounting: ['connections', 'areConnectionsEnabled'], diff --git a/tests/actions/CopyPolicySettingsTest.ts b/tests/actions/CopyPolicySettingsTest.ts index 17e1a782345b..d5b0325af1da 100644 --- a/tests/actions/CopyPolicySettingsTest.ts +++ b/tests/actions/CopyPolicySettingsTest.ts @@ -29,6 +29,7 @@ function makeSourcePolicy(overrides: Partial = {}): Policy { state: 'CA', zipCode: '94105', }, + description: 'Source workspace description', areCategoriesEnabled: true, areTagsEnabled: true, areReportFieldsEnabled: true, @@ -65,6 +66,7 @@ function makeTargetPolicy(overrides: Partial = {}): Policy { state: 'BE', zipCode: '10115', }, + description: 'Target workspace description', areCategoriesEnabled: false, areTagsEnabled: false, areReportFieldsEnabled: false, @@ -101,7 +103,7 @@ describe('actions/Policy/CopyPolicySettings', () => { describe('buildCopyPolicySettingsData', () => { describe('per-part field patches and pendingFields', () => { it.each<[Part, readonly string[]]>([ - ['overview', ['outputCurrency', 'address']], + ['overview', ['outputCurrency', 'address', 'description']], ['members', ['employeeList']], ['reports', ['fieldList', 'areReportFieldsEnabled']], ['accounting', ['connections', 'areConnectionsEnabled']], @@ -218,6 +220,7 @@ describe('actions/Policy/CopyPolicySettings', () => { // pendingFields entries are nulled out for every expanded field expect(value.pendingFields?.outputCurrency).toBeNull(); expect(value.pendingFields?.address).toBeNull(); + expect(value.pendingFields?.description).toBeNull(); expect(value.pendingFields?.maxExpenseAmount).toBeNull(); expect(value.errors).toBeDefined(); }); From cb3e6617991141cced4b5c9bc756560f5f1f3d20 Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Tue, 12 May 2026 21:54:14 +0100 Subject: [PATCH 12/17] refactor: clean up customUnits snapshot and add lifecycle comment --- src/libs/actions/Policy/CopyPolicySettings.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/libs/actions/Policy/CopyPolicySettings.ts b/src/libs/actions/Policy/CopyPolicySettings.ts index a3bdf7a98fc8..ef89795f445b 100644 --- a/src/libs/actions/Policy/CopyPolicySettings.ts +++ b/src/libs/actions/Policy/CopyPolicySettings.ts @@ -140,6 +140,9 @@ function snapshotTargetFields(targetPolicy: Policy, parts: Part[]): Partial = {}; for (const part of parts) { for (const field of PARTS_TO_POLICY_FIELDS[part]) { + if (field === 'customUnits') { + continue; + } (snapshot as Record)[field] = targetPolicy[field as keyof Policy]; } } @@ -259,7 +262,9 @@ function buildCopyPolicySettingsData( }, }); - // Step 4: drive currentStep on the COPY_POLICY_SETTINGS key itself + // Step 4: drive currentStep on the COPY_POLICY_SETTINGS key itself. + // Success intentionally omits this key — the backend transitions currentStep + // to 'complete' via the bulkCopySettings NVP push. optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.COPY_POLICY_SETTINGS, From 365217044bd968fcef05ddd4a249a5d364c4aa42 Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Tue, 12 May 2026 21:57:25 +0100 Subject: [PATCH 13/17] test: add distanceRates and perDiem to per-part field patch test --- tests/actions/CopyPolicySettingsTest.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/actions/CopyPolicySettingsTest.ts b/tests/actions/CopyPolicySettingsTest.ts index d5b0325af1da..5bb594fcd061 100644 --- a/tests/actions/CopyPolicySettingsTest.ts +++ b/tests/actions/CopyPolicySettingsTest.ts @@ -128,6 +128,8 @@ describe('actions/Policy/CopyPolicySettings', () => { 'shouldShowAutoReimbursementLimitOption', ], ], + ['distanceRates', ['areDistanceRatesEnabled']], + ['perDiem', ['arePerDiemRatesEnabled']], ['invoices', ['areInvoicesEnabled', 'invoice']], ['travel', ['isTravelEnabled', 'travelSettings']], ])('marks %s fields pending and patches values from source', (part, expectedFields) => { From 9fee396aacba2002f81442b0e97565898f885bb8 Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Tue, 12 May 2026 21:58:30 +0100 Subject: [PATCH 14/17] test: add multi-target and empty source collection edge cases --- tests/actions/CopyPolicySettingsTest.ts | 47 +++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/actions/CopyPolicySettingsTest.ts b/tests/actions/CopyPolicySettingsTest.ts index 5bb594fcd061..0743810b0cc6 100644 --- a/tests/actions/CopyPolicySettingsTest.ts +++ b/tests/actions/CopyPolicySettingsTest.ts @@ -205,6 +205,20 @@ describe('actions/Policy/CopyPolicySettings', () => { expect(optimisticData.some((u) => u.key === TARGET_CATEGORIES_KEY)).toBe(false); expect(failureData.some((u) => u.key === TARGET_CATEGORIES_KEY)).toBe(false); }); + + it('falls back to empty object when source has no categories', () => { + const {optimisticData} = buildCopyPolicySettingsData(makeSourcePolicy(), [makeTargetPolicy()], ['categories'], {}, {}); + + const optimisticSet = optimisticData.find((u) => u.key === TARGET_CATEGORIES_KEY && u.onyxMethod === Onyx.METHOD.SET); + expect(optimisticSet?.value).toEqual({}); + }); + + it('falls back to empty object when source has no tags', () => { + const {optimisticData} = buildCopyPolicySettingsData(makeSourcePolicy(), [makeTargetPolicy()], ['tags'], {}, {}); + + const optimisticSet = optimisticData.find((u) => u.key === TARGET_TAGS_KEY && u.onyxMethod === Onyx.METHOD.SET); + expect(optimisticSet?.value).toEqual({}); + }); }); describe('failure data restores pre-copy state', () => { @@ -325,6 +339,39 @@ describe('actions/Policy/CopyPolicySettings', () => { }); }); + describe('multiple target policies', () => { + it('produces optimistic and failure updates for each target', () => { + const targetA = makeTargetPolicy({id: 'TARGET_A'}); + const targetB = makeTargetPolicy({id: 'TARGET_B'}); + const policyKeyA = `${ONYXKEYS.COLLECTION.POLICY}TARGET_A` as const; + const policyKeyB = `${ONYXKEYS.COLLECTION.POLICY}TARGET_B` as const; + + const {optimisticData, failureData, successData} = buildCopyPolicySettingsData(makeSourcePolicy(), [targetA, targetB], ['overview'], {}, {}); + + const optimisticMerges = optimisticData.filter((u) => u.onyxMethod === Onyx.METHOD.MERGE && (u.key === policyKeyA || u.key === policyKeyB)); + expect(optimisticMerges).toHaveLength(2); + + const failureMerges = failureData.filter((u) => u.onyxMethod === Onyx.METHOD.MERGE && (u.key === policyKeyA || u.key === policyKeyB)); + expect(failureMerges).toHaveLength(2); + + const successMerges = successData.filter((u) => u.onyxMethod === Onyx.METHOD.MERGE && (u.key === policyKeyA || u.key === policyKeyB)); + expect(successMerges).toHaveLength(2); + }); + + it('produces category SET updates for each target when categories selected', () => { + const targetA = makeTargetPolicy({id: 'TARGET_A'}); + const targetB = makeTargetPolicy({id: 'TARGET_B'}); + const catKeyA = `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}TARGET_A` as const; + const catKeyB = `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}TARGET_B` as const; + const sourceCategories: PolicyCategories = {Food: {name: 'Food', enabled: true, areCommentsRequired: false}}; + + const {optimisticData} = buildCopyPolicySettingsData(makeSourcePolicy(), [targetA, targetB], ['categories'], {[SOURCE_CATEGORIES_KEY]: sourceCategories}, {}); + + expect(optimisticData.find((u) => u.key === catKeyA && u.onyxMethod === Onyx.METHOD.SET)?.value).toEqual(sourceCategories); + expect(optimisticData.find((u) => u.key === catKeyB && u.onyxMethod === Onyx.METHOD.SET)?.value).toEqual(sourceCategories); + }); + }); + describe('COPY_POLICY_SETTINGS lifecycle key', () => { it("sets currentStep='loading' optimistically and clears it on failure", () => { const {optimisticData, failureData, successData} = buildCopyPolicySettingsData(makeSourcePolicy(), [makeTargetPolicy()], ['overview'], {}, {}); From ad5dfc9445a7993aa8cfe1e94391cd5ebf941a1f Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Tue, 12 May 2026 22:00:01 +0100 Subject: [PATCH 15/17] style: use consistent .at(0) array access in customUnits test --- tests/actions/CopyPolicySettingsTest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/actions/CopyPolicySettingsTest.ts b/tests/actions/CopyPolicySettingsTest.ts index 0743810b0cc6..73c1b6ba0698 100644 --- a/tests/actions/CopyPolicySettingsTest.ts +++ b/tests/actions/CopyPolicySettingsTest.ts @@ -304,7 +304,7 @@ describe('actions/Policy/CopyPolicySettings', () => { expect(unitIDs.at(0)).not.toBe(sourceDistanceUnit.customUnitID); expect(unitIDs.at(0)).toMatch(/^[0-9A-F]{13}$/); // A freshly generated ID should be reused as the customUnitID inside the unit - expect(value.customUnits?.[unitIDs[0]]?.customUnitID).toBe(unitIDs.at(0)); + expect(value.customUnits?.[unitIDs.at(0) ?? '']?.customUnitID).toBe(unitIDs.at(0)); }); it("preserves target's existing per-diem unit ID independently of distance", () => { From 5d48595ec2d9eeb580f5f1ff31877d500a651b63 Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Fri, 15 May 2026 15:10:51 +0100 Subject: [PATCH 16/17] fix: use null instead of undefined to clear currentStep in failureData Onyx.merge ignores undefined values, so setting currentStep to undefined would not actually clear the field. Use null to properly remove the value. --- src/libs/actions/Policy/CopyPolicySettings.ts | 2 +- src/types/onyx/CopyPolicySettings.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/libs/actions/Policy/CopyPolicySettings.ts b/src/libs/actions/Policy/CopyPolicySettings.ts index ef89795f445b..5b890ae0928e 100644 --- a/src/libs/actions/Policy/CopyPolicySettings.ts +++ b/src/libs/actions/Policy/CopyPolicySettings.ts @@ -274,7 +274,7 @@ function buildCopyPolicySettingsData( failureData.push({ onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.COPY_POLICY_SETTINGS, - value: {currentStep: undefined}, + value: {currentStep: null}, }); return {optimisticData, successData, failureData}; diff --git a/src/types/onyx/CopyPolicySettings.ts b/src/types/onyx/CopyPolicySettings.ts index 4507c6aa9181..65939cdce8f2 100644 --- a/src/types/onyx/CopyPolicySettings.ts +++ b/src/types/onyx/CopyPolicySettings.ts @@ -15,9 +15,9 @@ type CopyPolicySettings = { * Which step of the copy is happening in the backend * - `loading`: copy in progress * - `complete`: backend finished - * - undefined: copy hasn't started yet (e.g. user is still selecting features) + * - null: copy hasn't started yet (e.g. user is still selecting features) */ - currentStep?: 'loading' | 'complete' | undefined; + currentStep?: 'loading' | 'complete' | null; /** Error state */ errors?: Errors; From 06ac4968fcb41403f92c8b80d8e2158f4eec9551 Mon Sep 17 00:00:00 2001 From: Fedi Rajhi Date: Fri, 15 May 2026 15:17:12 +0100 Subject: [PATCH 17/17] refactor: address PR review feedback for CopyPolicySettings - Switch per-policy optimistic/failure updates from MERGE to SET to avoid deep-merge artifacts with nested objects (address, customUnits.rates, tax) - Add errors: null in successData to clear RBR after retry-success - Add source policy errors: null in successData - Update workflows: add autoReportingOffset, harvesting, autoApproval; remove achAccount (backend remaps bankAccountID per-caller, Auth PR #21638) - Add comment explaining codingRules is not a separate part (per design doc) - Remove snapshotTargetFields (no longer needed with full SET restore) - Add integration-style tests simulating Onyx state transitions --- src/libs/actions/Policy/CopyPolicySettings.ts | 49 +++--- tests/actions/CopyPolicySettingsTest.ts | 150 ++++++++++++++---- 2 files changed, 145 insertions(+), 54 deletions(-) diff --git a/src/libs/actions/Policy/CopyPolicySettings.ts b/src/libs/actions/Policy/CopyPolicySettings.ts index 5b890ae0928e..844408f7c22c 100644 --- a/src/libs/actions/Policy/CopyPolicySettings.ts +++ b/src/libs/actions/Policy/CopyPolicySettings.ts @@ -20,7 +20,9 @@ const PARTS_TO_POLICY_FIELDS = { categories: ['areCategoriesEnabled'], tags: ['areTagsEnabled'], taxes: ['tax', 'taxRates'], - workflows: ['areWorkflowsEnabled', 'autoReportingFrequency', 'autoReporting', 'approvalMode', 'reimbursementChoice', 'achAccount'], + // achAccount is intentionally excluded — the backend remaps bankAccountID per-caller + // (see Auth PR #21638). We rely on the server push for that field. + workflows: ['areWorkflowsEnabled', 'autoReportingFrequency', 'autoReporting', 'autoReportingOffset', 'harvesting', 'approvalMode', 'autoApproval', 'reimbursementChoice'], rules: [ 'areRulesEnabled', 'maxExpenseAmount', @@ -136,19 +138,6 @@ function buildClearedPendingFields(parts: Part[]): Partial { - const snapshot: Partial = {}; - for (const part of parts) { - for (const field of PARTS_TO_POLICY_FIELDS[part]) { - if (field === 'customUnits') { - continue; - } - (snapshot as Record)[field] = targetPolicy[field as keyof Policy]; - } - } - return snapshot; -} - type CopyPolicySettingsOnyxKeys = | typeof ONYXKEYS.COLLECTION.POLICY | typeof ONYXKEYS.COLLECTION.POLICY_CATEGORIES @@ -187,36 +176,37 @@ function buildCopyPolicySettingsData( for (const targetPolicy of targetPolicies) { const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${targetPolicy.id}` as const; const customUnitsPatch = buildCustomUnitsPatch(sourcePolicy, targetPolicy, isDistanceSelected, isPerDiemSelected); - const snapshot = snapshotTargetFields(targetPolicy, parts); - // Step 1+2: optimistic merge of patched fields + pending markers + // Step 1+2: SET the full policy with patched fields overlaid. + // We use SET (not MERGE) because Onyx.merge deep-merges nested objects — source + // values would be merged into target's, leaving stale nested keys behind. optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, + onyxMethod: Onyx.METHOD.SET, key: policyKey, value: { + ...targetPolicy, ...policyFieldPatch, - ...customUnitsPatch, - pendingFields, + ...(customUnitsPatch ? {customUnits: {...targetPolicy.customUnits, ...customUnitsPatch.customUnits}} : {}), + pendingFields: {...targetPolicy.pendingFields, ...pendingFields}, }, }); - // Success: clear the pending markers + // Success: clear pending markers and any leftover errors from a prior failure successData.push({ onyxMethod: Onyx.METHOD.MERGE, key: policyKey, value: { pendingFields: clearedPendingFields, + errors: null, }, }); - // Failure: restore snapshot, clear pending markers, surface RBR + // Failure: restore the original target policy in full, surface RBR failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, + onyxMethod: Onyx.METHOD.SET, key: policyKey, value: { - ...snapshot, - ...(customUnitsPatch ? {customUnits: targetPolicy.customUnits} : {}), - pendingFields: clearedPendingFields, + ...targetPolicy, errors: getMicroSecondOnyxErrorWithTranslationKey('workspace.copyPolicySettings.error'), }, }); @@ -262,6 +252,15 @@ function buildCopyPolicySettingsData( }, }); + // Clear source policy errors on success (in case this is a retry after failure) + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${sourcePolicy.id}` as const, + value: { + errors: null, + }, + }); + // Step 4: drive currentStep on the COPY_POLICY_SETTINGS key itself. // Success intentionally omits this key — the backend transitions currentStep // to 'complete' via the bulkCopySettings NVP push. diff --git a/tests/actions/CopyPolicySettingsTest.ts b/tests/actions/CopyPolicySettingsTest.ts index 73c1b6ba0698..3ab3914d9f4f 100644 --- a/tests/actions/CopyPolicySettingsTest.ts +++ b/tests/actions/CopyPolicySettingsTest.ts @@ -42,7 +42,10 @@ function makeSourcePolicy(overrides: Partial = {}): Policy { isTravelEnabled: true, autoReporting: true, autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.IMMEDIATE, + autoReportingOffset: 5, + harvesting: {enabled: true}, approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC, + autoApproval: {limit: 10000}, reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, maxExpenseAmount: 50000, maxExpenseAge: 90, @@ -79,7 +82,10 @@ function makeTargetPolicy(overrides: Partial = {}): Policy { isTravelEnabled: false, autoReporting: false, autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.WEEKLY, + autoReportingOffset: 15, + harvesting: {enabled: false}, approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL, + autoApproval: {limit: 5000}, reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO, maxExpenseAmount: 1000, maxExpenseAge: 30, @@ -91,12 +97,12 @@ function makeTargetPolicy(overrides: Partial = {}): Policy { }; } -function findPolicyMerge(updates: ReturnType['optimisticData']) { - return updates.find((u) => u.key === POLICY_KEY && u.onyxMethod === Onyx.METHOD.MERGE); +function findPolicyOptimistic(updates: ReturnType['optimisticData']) { + return updates.find((u) => u.key === POLICY_KEY && u.onyxMethod === Onyx.METHOD.SET); } function findPolicyFailure(updates: ReturnType['failureData']) { - return updates.find((u) => u.key === POLICY_KEY && u.onyxMethod === Onyx.METHOD.MERGE); + return updates.find((u) => u.key === POLICY_KEY && u.onyxMethod === Onyx.METHOD.SET); } describe('actions/Policy/CopyPolicySettings', () => { @@ -110,7 +116,7 @@ describe('actions/Policy/CopyPolicySettings', () => { ['categories', ['areCategoriesEnabled']], ['tags', ['areTagsEnabled']], ['taxes', ['tax', 'taxRates']], - ['workflows', ['areWorkflowsEnabled', 'autoReportingFrequency', 'autoReporting', 'approvalMode', 'reimbursementChoice', 'achAccount']], + ['workflows', ['areWorkflowsEnabled', 'autoReportingFrequency', 'autoReporting', 'autoReportingOffset', 'harvesting', 'approvalMode', 'autoApproval', 'reimbursementChoice']], [ 'rules', [ @@ -138,7 +144,7 @@ describe('actions/Policy/CopyPolicySettings', () => { const {optimisticData} = buildCopyPolicySettingsData(sourcePolicy, [targetPolicy], [part], {}, {}); - const merge = findPolicyMerge(optimisticData); + const merge = findPolicyOptimistic(optimisticData); expect(merge).toBeDefined(); const value = merge?.value as Record & {pendingFields?: Record}; @@ -150,16 +156,15 @@ describe('actions/Policy/CopyPolicySettings', () => { } }); - it('does not include unrelated fields in the patch', () => { + it('retains target values for unrelated fields', () => { const sourcePolicy = makeSourcePolicy(); const targetPolicy = makeTargetPolicy(); const {optimisticData} = buildCopyPolicySettingsData(sourcePolicy, [targetPolicy], ['overview'], {}, {}); - const value = findPolicyMerge(optimisticData)?.value as Record; + const value = findPolicyOptimistic(optimisticData)?.value as Record; - expect(value).not.toHaveProperty('areCategoriesEnabled'); - expect(value).not.toHaveProperty('employeeList'); - expect(value).not.toHaveProperty('connections'); + expect(value.areCategoriesEnabled).toEqual(targetPolicy.areCategoriesEnabled); + expect(value.employeeList).toEqual(targetPolicy.employeeList); }); }); @@ -222,35 +227,35 @@ describe('actions/Policy/CopyPolicySettings', () => { }); describe('failure data restores pre-copy state', () => { - it("restores the target's previous field values and clears pendingFields", () => { + it("fully restores the target's pre-copy state via SET and surfaces an error", () => { const sourcePolicy = makeSourcePolicy({outputCurrency: 'USD', maxExpenseAmount: 50000}); const targetPolicy = makeTargetPolicy({outputCurrency: 'EUR', maxExpenseAmount: 1000}); const {failureData} = buildCopyPolicySettingsData(sourcePolicy, [targetPolicy], ['overview', 'rules'], {}, {}); const failure = findPolicyFailure(failureData); - const value = failure?.value as Record & {pendingFields?: Record; errors?: unknown}; + expect(failure?.onyxMethod).toBe(Onyx.METHOD.SET); + const value = failure?.value as Record & {errors?: unknown}; expect(value.outputCurrency).toBe('EUR'); expect(value.maxExpenseAmount).toBe(1000); - // pendingFields entries are nulled out for every expanded field - expect(value.pendingFields?.outputCurrency).toBeNull(); - expect(value.pendingFields?.address).toBeNull(); - expect(value.pendingFields?.description).toBeNull(); - expect(value.pendingFields?.maxExpenseAmount).toBeNull(); expect(value.errors).toBeDefined(); }); - it('surfaces an RBR error on the source policy', () => { + it('surfaces an RBR error on the source policy and clears it on success', () => { const sourcePolicy = makeSourcePolicy(); const targetPolicy = makeTargetPolicy(); const sourcePolicyKey = `${ONYXKEYS.COLLECTION.POLICY}${SOURCE_POLICY_ID}` as const; - const {failureData} = buildCopyPolicySettingsData(sourcePolicy, [targetPolicy], ['overview'], {}, {}); + const {failureData, successData} = buildCopyPolicySettingsData(sourcePolicy, [targetPolicy], ['overview'], {}, {}); const sourceFailure = failureData.find((u) => u.key === sourcePolicyKey && u.onyxMethod === Onyx.METHOD.MERGE); expect(sourceFailure).toBeDefined(); expect((sourceFailure?.value as {errors?: unknown})?.errors).toBeDefined(); + + const sourceSuccess = successData.find((u) => u.key === sourcePolicyKey && u.onyxMethod === Onyx.METHOD.MERGE); + expect(sourceSuccess).toBeDefined(); + expect((sourceSuccess?.value as {errors?: unknown})?.errors).toBeNull(); }); }); @@ -284,7 +289,7 @@ describe('actions/Policy/CopyPolicySettings', () => { const {optimisticData} = buildCopyPolicySettingsData(sourcePolicy, [targetPolicy], ['distanceRates'], {}, {}); - const value = findPolicyMerge(optimisticData)?.value as {customUnits?: Record; pendingFields?: Record}; + const value = findPolicyOptimistic(optimisticData)?.value as {customUnits?: Record; pendingFields?: Record}; expect(value.customUnits).toBeDefined(); expect(Object.keys(value.customUnits ?? {})).toEqual([targetExistingDistanceID]); expect(value.customUnits?.[targetExistingDistanceID]?.customUnitID).toBe(targetExistingDistanceID); @@ -298,7 +303,7 @@ describe('actions/Policy/CopyPolicySettings', () => { const {optimisticData} = buildCopyPolicySettingsData(sourcePolicy, [targetPolicy], ['distanceRates'], {}, {}); - const value = findPolicyMerge(optimisticData)?.value as {customUnits?: Record}; + const value = findPolicyOptimistic(optimisticData)?.value as {customUnits?: Record}; const unitIDs = Object.keys(value.customUnits ?? {}); expect(unitIDs).toHaveLength(1); expect(unitIDs.at(0)).not.toBe(sourceDistanceUnit.customUnitID); @@ -332,7 +337,7 @@ describe('actions/Policy/CopyPolicySettings', () => { const {optimisticData} = buildCopyPolicySettingsData(sourcePolicy, [targetPolicy], ['distanceRates', 'perDiem'], {}, {}); - const value = findPolicyMerge(optimisticData)?.value as {customUnits?: Record}; + const value = findPolicyOptimistic(optimisticData)?.value as {customUnits?: Record}; expect(Object.keys(value.customUnits ?? {}).sort()).toEqual([targetExistingDistanceID, targetExistingPerDiemID].sort()); expect(value.customUnits?.[targetExistingDistanceID]?.rates).toEqual(sourceDistanceUnit.rates); expect(value.customUnits?.[targetExistingPerDiemID]?.rates).toEqual(sourcePerDiemUnit.rates); @@ -348,11 +353,11 @@ describe('actions/Policy/CopyPolicySettings', () => { const {optimisticData, failureData, successData} = buildCopyPolicySettingsData(makeSourcePolicy(), [targetA, targetB], ['overview'], {}, {}); - const optimisticMerges = optimisticData.filter((u) => u.onyxMethod === Onyx.METHOD.MERGE && (u.key === policyKeyA || u.key === policyKeyB)); - expect(optimisticMerges).toHaveLength(2); + const optimisticSets = optimisticData.filter((u) => u.onyxMethod === Onyx.METHOD.SET && (u.key === policyKeyA || u.key === policyKeyB)); + expect(optimisticSets).toHaveLength(2); - const failureMerges = failureData.filter((u) => u.onyxMethod === Onyx.METHOD.MERGE && (u.key === policyKeyA || u.key === policyKeyB)); - expect(failureMerges).toHaveLength(2); + const failureSets = failureData.filter((u) => u.onyxMethod === Onyx.METHOD.SET && (u.key === policyKeyA || u.key === policyKeyB)); + expect(failureSets).toHaveLength(2); const successMerges = successData.filter((u) => u.onyxMethod === Onyx.METHOD.MERGE && (u.key === policyKeyA || u.key === policyKeyB)); expect(successMerges).toHaveLength(2); @@ -373,18 +378,105 @@ describe('actions/Policy/CopyPolicySettings', () => { }); describe('COPY_POLICY_SETTINGS lifecycle key', () => { - it("sets currentStep='loading' optimistically and clears it on failure", () => { + it("sets currentStep='loading' optimistically and nulls it on failure", () => { const {optimisticData, failureData, successData} = buildCopyPolicySettingsData(makeSourcePolicy(), [makeTargetPolicy()], ['overview'], {}, {}); const optLifecycle = optimisticData.find((u) => u.key === ONYXKEYS.COPY_POLICY_SETTINGS); const failLifecycle = failureData.find((u) => u.key === ONYXKEYS.COPY_POLICY_SETTINGS); const successLifecycle = successData.find((u) => u.key === ONYXKEYS.COPY_POLICY_SETTINGS); - expect((optLifecycle?.value as {currentStep?: string})?.currentStep).toBe('loading'); - expect((failLifecycle?.value as {currentStep?: string})?.currentStep).toBeUndefined(); + expect((optLifecycle?.value as {currentStep?: string | null})?.currentStep).toBe('loading'); + expect((failLifecycle?.value as {currentStep?: string | null})?.currentStep).toBeNull(); // Success leaves currentStep alone — the backend transitions it to 'complete' via NVP. expect(successLifecycle).toBeUndefined(); }); }); + + describe('simulated Onyx state transitions', () => { + it('optimistic SET replaces nested address completely (no deep-merge artifacts)', () => { + const sourcePolicy = makeSourcePolicy({address: {addressStreet: '1 Src St', city: 'NYC', country: 'US', state: 'NY', zipCode: '10001'}}); + const targetPolicy = makeTargetPolicy({address: {addressStreet: '2 Tgt Ave', city: 'Berlin', country: 'DE', state: 'BE', zipCode: '10115'}}); + + const {optimisticData} = buildCopyPolicySettingsData(sourcePolicy, [targetPolicy], ['overview'], {}, {}); + const value = findPolicyOptimistic(optimisticData)?.value as Policy; + + expect(value.address).toEqual(sourcePolicy.address); + expect(value.address).not.toHaveProperty('extraField'); + }); + + it('target with extra custom unit rates — optimistic overwrites cleanly via SET', () => { + const sourceDistanceUnit: CustomUnit = { + customUnitID: '1000000000001', + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, + rates: {NEW_RATE: {customUnitRateID: 'NEW_RATE', name: 'New', rate: 67, enabled: true, currency: 'USD'}}, + }; + const targetDistanceUnit: CustomUnit = { + customUnitID: '2000000000001', + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS}, + rates: { + OLD_RATE_A: {customUnitRateID: 'OLD_RATE_A', name: 'OldA', rate: 50, enabled: true, currency: 'EUR'}, + OLD_RATE_B: {customUnitRateID: 'OLD_RATE_B', name: 'OldB', rate: 30, enabled: false, currency: 'EUR'}, + }, + }; + + const sourcePolicy = makeSourcePolicy({customUnits: {[sourceDistanceUnit.customUnitID]: sourceDistanceUnit}}); + const targetPolicy = makeTargetPolicy({customUnits: {[targetDistanceUnit.customUnitID]: targetDistanceUnit}}); + + const {optimisticData} = buildCopyPolicySettingsData(sourcePolicy, [targetPolicy], ['distanceRates'], {}, {}); + const value = findPolicyOptimistic(optimisticData)?.value as Policy; + + // The optimistic unit is keyed by target's existing ID, with source's rates (no old rates) + const optimisticUnit = value.customUnits?.[targetDistanceUnit.customUnitID]; + expect(optimisticUnit?.rates).toEqual(sourceDistanceUnit.rates); + expect(optimisticUnit?.rates).not.toHaveProperty('OLD_RATE_A'); + expect(optimisticUnit?.rates).not.toHaveProperty('OLD_RATE_B'); + }); + + it('failure SET fully restores target — newly-added custom unit IDs are removed', () => { + const sourceDistanceUnit: CustomUnit = { + customUnitID: '1000000000001', + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, + rates: {R1: {customUnitRateID: 'R1', name: 'IRS', rate: 67, enabled: true, currency: 'USD'}}, + }; + const sourcePolicy = makeSourcePolicy({customUnits: {[sourceDistanceUnit.customUnitID]: sourceDistanceUnit}}); + const targetPolicy = makeTargetPolicy({customUnits: {}}); + + const {failureData} = buildCopyPolicySettingsData(sourcePolicy, [targetPolicy], ['distanceRates'], {}, {}); + const failure = findPolicyFailure(failureData); + const value = failure?.value as Policy; + + // Failure restores the full original target — which had no customUnits + expect(value.customUnits).toEqual({}); + }); + + it('target with nested keys not in source — after optimistic, selected fields match source', () => { + const sourcePolicy = makeSourcePolicy({ + tax: {trackingEnabled: true}, + }); + const targetPolicy = makeTargetPolicy({ + tax: {trackingEnabled: false}, + }); + + const {optimisticData} = buildCopyPolicySettingsData(sourcePolicy, [targetPolicy], ['taxes'], {}, {}); + const value = findPolicyOptimistic(optimisticData)?.value as Policy; + + expect(value.tax).toEqual(sourcePolicy.tax); + }); + + it('successData clears errors on target policies after retry-success', () => { + const targetPolicy = makeTargetPolicy(); + const {successData} = buildCopyPolicySettingsData(makeSourcePolicy(), [targetPolicy], ['overview'], {}, {}); + + const targetSuccess = successData.find((u) => u.key === POLICY_KEY && u.onyxMethod === Onyx.METHOD.MERGE); + const value = targetSuccess?.value as {errors?: unknown; pendingFields?: Record}; + + expect(value.errors).toBeNull(); + expect(value.pendingFields?.outputCurrency).toBeNull(); + expect(value.pendingFields?.address).toBeNull(); + }); + }); }); });