diff --git a/src/languages/de.ts b/src/languages/de.ts index c9e10b1101c5..e4fa56448884 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -5991,6 +5991,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 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/en.ts b/src/languages/en.ts index dc5580960db3..b343043f4b63 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -6011,6 +6011,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 e26b4b713f28..347fe4c2f492 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -5826,6 +5826,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. Por favor, 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 bbb4c0d929dc..89dba90076be 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -6011,6 +6011,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.'}, 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 c177baed51e3..680f6bbb7788 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -5981,6 +5981,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 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 93f79d70f9cc..a33cee16cf20 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -5912,6 +5912,7 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO `元のワークスペースから ${totalMembers ?? 0} 人のメンバーと一緒に、${newWorkspaceName ?? ''} を作成して共有しようとしています。`, error: '新しいワークスペースの複製中にエラーが発生しました。もう一度お試しください。', }, + copyPolicySettings: {error: 'ワークスペース設定のコピー中にエラーが発生しました。もう一度お試しください。'}, emptyWorkspace: { title: 'ワークスペースがありません', subtitle: '領収書を管理し、経費を精算し、出張を管理し、請求書を送信するなど、さまざまなことができます。', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 92dd587027f8..ee675fbdb447 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -5959,6 +5959,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 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 817138249afe..639ac51e106e 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -5954,6 +5954,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.'}, 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 743bc141b6de..90bfc3026f6c 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -5960,6 +5960,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 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 061158dcf501..9740f103e9e7 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -5814,6 +5814,7 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM `您即将创建并共享 ${newWorkspaceName ?? ''},其中包含来自原始工作区的 ${totalMembers ?? 0} 位成员。`, error: '复制您的新工作区时发生错误。请重试。', }, + copyPolicySettings: {error: '复制工作区设置时发生错误。请重试。'}, emptyWorkspace: { title: '你还没有工作区', subtitle: '跟踪收据、报销费用、管理差旅、发送发票等。', 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 */ diff --git a/src/libs/actions/Policy/CopyPolicySettings.ts b/src/libs/actions/Policy/CopyPolicySettings.ts new file mode 100644 index 000000000000..844408f7c22c --- /dev/null +++ b/src/libs/actions/Policy/CopyPolicySettings.ts @@ -0,0 +1,301 @@ +import type {OnyxCollection, OnyxUpdate} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; +import {write} from '@libs/API'; +import type {CopyPolicySettingsParams} from '@libs/API/parameters'; +import {WRITE_COMMANDS} from '@libs/API/types'; +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'; +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', 'description'], + members: ['employeeList'], + reports: ['fieldList', 'areReportFieldsEnabled'], + accounting: ['connections', 'areConnectionsEnabled'], + categories: ['areCategoriesEnabled'], + tags: ['areTagsEnabled'], + taxes: ['tax', 'taxRates'], + // 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', + '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 { + 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 ?? 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 ?? 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; +} + +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); + + // 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.SET, + key: policyKey, + value: { + ...targetPolicy, + ...policyFieldPatch, + ...(customUnitsPatch ? {customUnits: {...targetPolicy.customUnits, ...customUnitsPatch.customUnits}} : {}), + pendingFields: {...targetPolicy.pendingFields, ...pendingFields}, + }, + }); + + // 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 the original target policy in full, surface RBR + failureData.push({ + onyxMethod: Onyx.METHOD.SET, + key: policyKey, + value: { + ...targetPolicy, + errors: 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, + }); + } + } + + // 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'), + }, + }); + + // 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. + 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: null}, + }); + + 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(','), + }; + + write(WRITE_COMMANDS.COPY_POLICY_SETTINGS, params, {optimisticData, successData, failureData}); +} + +export {PARTS_TO_POLICY_FIELDS, setCopyPolicySettingsData, clearCopyPolicySettings, requestCopyPolicySettingsNotification, buildCopyPolicySettingsData, copyPolicySettings}; +export type {Part}; 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; diff --git a/tests/actions/CopyPolicySettingsTest.ts b/tests/actions/CopyPolicySettingsTest.ts new file mode 100644 index 000000000000..3ab3914d9f4f --- /dev/null +++ b/tests/actions/CopyPolicySettingsTest.ts @@ -0,0 +1,482 @@ +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', + }, + description: 'Source workspace description', + 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, + 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, + 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', + }, + description: 'Target workspace description', + 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, + 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, + defaultBillable: false, + eReceipts: false, + preventSelfApproval: false, + customUnits: {}, + ...overrides, + }; +} + +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.SET); +} + +describe('actions/Policy/CopyPolicySettings', () => { + describe('buildCopyPolicySettingsData', () => { + describe('per-part field patches and pendingFields', () => { + it.each<[Part, readonly string[]]>([ + ['overview', ['outputCurrency', 'address', 'description']], + ['members', ['employeeList']], + ['reports', ['fieldList', 'areReportFieldsEnabled']], + ['accounting', ['connections', 'areConnectionsEnabled']], + ['categories', ['areCategoriesEnabled']], + ['tags', ['areTagsEnabled']], + ['taxes', ['tax', 'taxRates']], + ['workflows', ['areWorkflowsEnabled', 'autoReportingFrequency', 'autoReporting', 'autoReportingOffset', 'harvesting', 'approvalMode', 'autoApproval', 'reimbursementChoice']], + [ + 'rules', + [ + 'areRulesEnabled', + 'maxExpenseAmount', + 'maxExpenseAge', + 'maxExpenseAmountNoReceipt', + 'maxExpenseAmountNoItemizedReceipt', + 'defaultBillable', + 'prohibitedExpenses', + 'eReceipts', + 'isAttendeeTrackingEnabled', + 'preventSelfApproval', + 'shouldShowAutoApprovalOptions', + 'shouldShowAutoReimbursementLimitOption', + ], + ], + ['distanceRates', ['areDistanceRatesEnabled']], + ['perDiem', ['arePerDiemRatesEnabled']], + ['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 = findPolicyOptimistic(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('retains target values for unrelated fields', () => { + const sourcePolicy = makeSourcePolicy(); + const targetPolicy = makeTargetPolicy(); + + const {optimisticData} = buildCopyPolicySettingsData(sourcePolicy, [targetPolicy], ['overview'], {}, {}); + const value = findPolicyOptimistic(optimisticData)?.value as Record; + + expect(value.areCategoriesEnabled).toEqual(targetPolicy.areCategoriesEnabled); + expect(value.employeeList).toEqual(targetPolicy.employeeList); + }); + }); + + 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); + }); + + 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', () => { + 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); + 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); + expect(value.errors).toBeDefined(); + }); + + 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, 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(); + }); + }); + + describe('customUnits preservation', () => { + const sourceDistanceUnit: CustomUnit = { + 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: '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'}}, + }; + + it("uses target's existing distance unit ID when target already has one", () => { + const sourcePolicy = makeSourcePolicy({customUnits: {[sourceDistanceUnit.customUnitID]: sourceDistanceUnit}}); + const targetExistingDistanceID = '2000000000001'; + 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 = 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); + 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 = findPolicyOptimistic(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.at(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 = '2000000000001'; + const targetExistingPerDiemID = '2000000000002'; + 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 = 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); + }); + }); + + 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 optimisticSets = optimisticData.filter((u) => u.onyxMethod === Onyx.METHOD.SET && (u.key === policyKeyA || u.key === policyKeyB)); + expect(optimisticSets).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); + }); + + 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 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 | 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(); + }); + }); + }); +});