diff --git a/src/languages/de.ts b/src/languages/de.ts index f6b8ee5804d6..e6a0ed22d6c0 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -5964,6 +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 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 750b24f27f46..eaa750278661 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -5977,6 +5977,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 418b5924c321..9b27b5d4a8a8 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -5841,6 +5841,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 ed9c4d882f3f..cf9796f3361b 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -5984,6 +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.'}, 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 bc3da1ea6292..b3a599659d96 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -5954,6 +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 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 a79bb05ae191..469a0e7461ae 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -5887,6 +5887,7 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO `元のワークスペースから ${totalMembers ?? 0} 人のメンバーと一緒に、${newWorkspaceName ?? ''} を作成して共有しようとしています。`, error: '新しいワークスペースの複製中にエラーが発生しました。もう一度お試しください。', }, + copyPolicySettings: {error: 'ワークスペース設定のコピー中にエラーが発生しました。もう一度お試しください。'}, emptyWorkspace: { title: 'ワークスペースがありません', subtitle: '領収書を管理し、経費を精算し、出張を管理し、請求書を送信するなど、さまざまなことができます。', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 9cc966492760..22269a392713 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -5934,6 +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 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 e4b21e78235e..387b50e3c1c1 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -5928,6 +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.'}, 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 c7cc11f92785..3f8c8ae378e2 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -5934,6 +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 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 111efb7b01e7..283997386011 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -5789,6 +5789,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..d17fce032b2f --- /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 {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'], + 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 { + 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; +} + +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: 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(','), + }; + + 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/tests/actions/CopyPolicySettingsTest.ts b/tests/actions/CopyPolicySettingsTest.ts new file mode 100644 index 000000000000..a82e84641429 --- /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: '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 = 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 = '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 = 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(); + }); + }); + }); +});