diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 92081c0f567e..fd23f1b22e12 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1638,6 +1638,7 @@ const CONST = { ADD_CUSTOM_UNIT_RATE: 'POLICYCHANGELOG_ADD_CUSTOM_UNIT_RATE', ADD_EMPLOYEE: 'POLICYCHANGELOG_ADD_EMPLOYEE', ADD_CARD_FEED: 'POLICYCHANGELOG_ADD_CARD_FEED', + ADD_EXPENSIFY_CARD_RULE: 'POLICYCHANGELOG_ADD_EXPENSIFY_CARD_RULE', ADD_INTEGRATION: 'POLICYCHANGELOG_ADD_INTEGRATION', ADD_REPORT_FIELD: 'POLICYCHANGELOG_ADD_REPORT_FIELD', ADD_TAG: 'POLICYCHANGELOG_ADD_TAG', @@ -1663,6 +1664,7 @@ const CONST = { INDIVIDUAL_BUDGET_NOTIFICATION: 'POLICYCHANGELOG_INDIVIDUAL_BUDGET_NOTIFICATION', INVITE_TO_ROOM: 'POLICYCHANGELOG_INVITETOROOM', REMOVE_FROM_ROOM: 'POLICYCHANGELOG_REMOVEFROMROOM', + REMOVE_EXPENSIFY_CARD_RULE: 'POLICYCHANGELOG_REMOVE_EXPENSIFY_CARD_RULE', LEAVE_ROOM: 'POLICYCHANGELOG_LEAVEROOM', REPLACE_CATEGORIES: 'POLICYCHANGELOG_REPLACE_CATEGORIES', SET_AUTO_REIMBURSEMENT: 'POLICYCHANGELOG_SET_AUTOREIMBURSEMENT', @@ -1689,6 +1691,7 @@ const CONST = { UPDATE_DEFAULT_TITLE_ENFORCED: 'POLICYCHANGELOG_UPDATE_DEFAULT_TITLE_ENFORCED', UPDATE_DISABLED_FIELDS: 'POLICYCHANGELOG_UPDATE_DISABLED_FIELDS', UPDATE_EMPLOYEE: 'POLICYCHANGELOG_UPDATE_EMPLOYEE', + UPDATE_EXPENSIFY_CARD_RULE: 'POLICYCHANGELOG_UPDATE_EXPENSIFY_CARD_RULE', UPDATE_FIELD: 'POLICYCHANGELOG_UPDATE_FIELD', UPDATE_ADDRESS: 'POLICYCHANGELOG_UPDATE_ADDRESS', UPDATE_FEATURE_ENABLED: 'POLICYCHANGELOG_UPDATE_FEATURE_ENABLED', diff --git a/src/languages/de.ts b/src/languages/de.ts index 60aad8b1ca6f..4a0fb027dec7 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7832,6 +7832,51 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc addedReportField: (fieldType: string, fieldName?: string, defaultValue?: string) => `${fieldType}-Berichtsfeld „${fieldName}“${defaultValue ? ` mit Standardwert „${defaultValue}“` : ''} hinzugefügt`, updatedRequireCompanyCards: ({enabled}: {enabled: boolean}) => `${enabled ? 'aktiviert' : 'deaktiviert'} die Anforderung für Firmenkartenkäufe`, + expensifyCardRule: { + actionVerb: {block: 'blockiert', allow: 'erlaubt'}, + amountOperator: {over: 'über', under: 'unter'}, + amountFilter: ({operator, amount}: {operator: string; amount: string}) => `Beträge ${operator} ${amount}`, + theCard: 'die Karte', + multipleCards: ({count}: {count: number}) => ({ + one: '1 Karte', + other: `${count} Karten`, + }), + addRule: ({verb, filters, cards}: {verb: string; filters: string; cards: string}) => { + let text = verb; + if (filters !== '') { + text += ` ${filters}`; + } + text += ` auf ${cards}`; + return text; + }, + removeRule: ({cards}: {cards: string}) => `Ausgaberegel von ${cards} entfernt`, + restrictionVerb: {block: 'Block', allow: 'nur zulassen'}, + update: { + modeChange: ({fromAction, toAction, cards}: {fromAction: string; toAction: string; cards: string}) => `Ausgaberegel von ${fromAction} zu ${toAction} auf ${cards} geändert`, + appliedToAdditionalCards: ({count}: {count: number}) => ({ + one: 'Ausgaberegel auf 1 weitere Karte angewendet', + other: `Ausgaberegel auf ${count} weitere Karten angewendet`, + }), + phraseVerb: {added: 'hinzugefügt', removed: 'entfernt', changed: 'geändert', set: 'festlegen', applied: 'Angewendet'}, + bodyMerchant: ({adjective, value}: {adjective: string; value: string}) => (adjective !== '' ? `${adjective} Händler: „${value}“` : `Händler: „${value}“`), + bodyMerchantChange: ({adjective, oldValue, newValue}: {adjective: string; oldValue: string; newValue: string}) => + adjective !== '' ? `${adjective} Händler von „${oldValue}“ in „${newValue}“ geändert` : `Händler von „${oldValue}“ zu „${newValue}“`, + bodySpendCategory: ({adjective, value}: {adjective: string; value: string}) => + adjective !== '' ? `${adjective} Ausgabenkategorie „${value}“` : `Ausgabenkategorie „${value}“`, + bodySpendCategoryChange: ({adjective, oldValue, newValue}: {adjective: string; oldValue: string; newValue: string}) => + adjective !== '' ? `Ausgabenkategorie (${adjective}) von „${oldValue}“ in „${newValue}“ geändert` : `Ausgabenkategorie von „${oldValue}“ zu „${newValue}“`, + bodyMaxAmount: 'Höchstbetrag', + bodyMaxAmountSet: ({value}: {value: string}) => `Maximalbetrag bis ${value}`, + bodyMaxAmountChange: ({oldValue, newValue}: {oldValue: string; newValue: string}) => `Maximalbetrag von ${oldValue} auf ${newValue}`, + bodyAppliedToAdditionalCards: ({count}: {count: number}) => ({ + one: 'Ausgabenregel für 1 zusätzliche Karte', + other: `Ausgabenregel für ${count} zusätzliche Karten`, + }), + bodyRemovedFromCards: ({cards}: {cards: string}) => `Ausgaberegel von ${cards}`, + composeOnCards: ({content, cards}: {content: string; cards: string}) => `${content} auf ${cards}`, + composeFromCards: ({content, cards}: {content: string; cards: string}) => `${content} von ${cards}`, + }, + }, }, roomMembersPage: { memberNotFound: 'Mitglied nicht gefunden.', diff --git a/src/languages/en.ts b/src/languages/en.ts index 43e4a06543f8..9ca87df7be6d 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7535,6 +7535,65 @@ const translations = { updatedCardFeedLiability: (feedName: string, enabled: boolean) => `${enabled ? 'enabled' : 'disabled'} cardholders to delete card transactions for card feed "${feedName}"`, updatedCardFeedStatementPeriod: (feedName: string, newValue?: string, previousValue?: string) => `changed card feed "${feedName}" statement period end day${newValue ? ` to "${newValue}"` : ''}${previousValue ? ` (previously "${previousValue}")` : ''}`, + expensifyCardRule: { + actionVerb: { + block: 'blocked', + allow: 'allowed', + }, + amountOperator: { + over: 'over', + under: 'under', + }, + amountFilter: ({operator, amount}: {operator: string; amount: string}) => `amounts ${operator} ${amount}`, + theCard: 'the card', + multipleCards: ({count}: {count: number}) => ({ + one: '1 card', + other: `${count} cards`, + }), + addRule: ({verb, filters, cards}: {verb: string; filters: string; cards: string}) => { + let text = verb; + if (filters !== '') { + text += ` ${filters}`; + } + text += ` on ${cards}`; + return text; + }, + removeRule: ({cards}: {cards: string}) => `removed spend rule from ${cards}`, + restrictionVerb: { + block: 'block', + allow: 'only allow', + }, + update: { + modeChange: ({fromAction, toAction, cards}: {fromAction: string; toAction: string; cards: string}) => `changed spend rule from ${fromAction} to ${toAction} on ${cards}`, + appliedToAdditionalCards: ({count}: {count: number}) => ({ + one: 'applied spend rule to 1 additional card', + other: `applied spend rule to ${count} additional cards`, + }), + phraseVerb: { + added: 'added', + removed: 'removed', + changed: 'changed', + set: 'set', + applied: 'applied', + }, + bodyMerchant: ({adjective, value}: {adjective: string; value: string}) => (adjective !== '' ? `${adjective} merchant '${value}'` : `merchant '${value}'`), + bodyMerchantChange: ({adjective, oldValue, newValue}: {adjective: string; oldValue: string; newValue: string}) => + adjective !== '' ? `${adjective} merchant from '${oldValue}' to '${newValue}'` : `merchant from '${oldValue}' to '${newValue}'`, + bodySpendCategory: ({adjective, value}: {adjective: string; value: string}) => (adjective !== '' ? `${adjective} spend category '${value}'` : `spend category '${value}'`), + bodySpendCategoryChange: ({adjective, oldValue, newValue}: {adjective: string; oldValue: string; newValue: string}) => + adjective !== '' ? `${adjective} spend category from '${oldValue}' to '${newValue}'` : `spend category from '${oldValue}' to '${newValue}'`, + bodyMaxAmount: 'max amount', + bodyMaxAmountSet: ({value}: {value: string}) => `max amount to ${value}`, + bodyMaxAmountChange: ({oldValue, newValue}: {oldValue: string; newValue: string}) => `max amount from ${oldValue} to ${newValue}`, + bodyAppliedToAdditionalCards: ({count}: {count: number}) => ({ + one: 'spend rule to 1 additional card', + other: `spend rule to ${count} additional cards`, + }), + bodyRemovedFromCards: ({cards}: {cards: string}) => `spend rule from ${cards}`, + composeOnCards: ({content, cards}: {content: string; cards: string}) => `${content} on ${cards}`, + composeFromCards: ({content, cards}: {content: string; cards: string}) => `${content} from ${cards}`, + }, + }, preventSelfApproval: (oldValue: string, newValue: string) => `updated "Prevent self-approval" to "${newValue === 'true' ? 'Enabled' : 'Disabled'}" (previously "${oldValue === 'true' ? 'Enabled' : 'Disabled'}")`, updateMonthlyOffset: (oldValue: string, newValue: string) => { diff --git a/src/languages/es.ts b/src/languages/es.ts index 98da4a5eeb87..d618fa3a6325 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7329,6 +7329,51 @@ ${amount} para ${merchant} - ${date}`, `${enabled ? 'habilitó' : 'deshabilitó'} que los titulares de tarjetas eliminen transacciones de la fuente de tarjetas "${feedName}"`, updatedCardFeedStatementPeriod: (feedName: string, newValue?: string, previousValue?: string) => `cambió el día de cierre del período de estado de cuenta de la fuente de tarjetas "${feedName}"${newValue ? ` a "${newValue}"` : ''}${previousValue ? ` (previamente "${previousValue}")` : ''}`, + expensifyCardRule: { + actionVerb: {block: 'bloqueado', allow: 'permitido'}, + amountOperator: {over: 'más de', under: 'debajo'}, + amountFilter: ({operator, amount}: {operator: string; amount: string}) => `cantidades ${operator} ${amount}`, + theCard: 'la tarjeta', + multipleCards: ({count}: {count: number}) => ({ + one: '1 tarjeta', + other: `${count} tarjetas`, + }), + addRule: ({verb, filters, cards}: {verb: string; filters: string; cards: string}) => { + let text = verb; + if (filters !== '') { + text += ` ${filters}`; + } + text += ` en ${cards}`; + return text; + }, + removeRule: ({cards}: {cards: string}) => `eliminó la regla de gasto de ${cards}`, + restrictionVerb: {block: 'bloquear', allow: 'permitir solo'}, + update: { + modeChange: ({fromAction, toAction, cards}: {fromAction: string; toAction: string; cards: string}) => `cambió la regla de gasto de ${fromAction} a ${toAction} en ${cards}`, + appliedToAdditionalCards: ({count}: {count: number}) => ({ + one: 'aplicó la regla de gasto a 1 tarjeta adicional', + other: `aplicó la regla de gasto a ${count} tarjetas adicionales`, + }), + phraseVerb: {added: 'añadido', removed: 'eliminado', changed: 'cambió', set: 'establecer', applied: 'aplicado'}, + bodyMerchant: ({adjective, value}: {adjective: string; value: string}) => (adjective !== '' ? `${adjective} comercio «${value}»` : `comercio «${value}»`), + bodyMerchantChange: ({adjective, oldValue, newValue}: {adjective: string; oldValue: string; newValue: string}) => + adjective !== '' ? `${adjective} del comerciante de '${oldValue}' a '${newValue}'` : `comercio de '${oldValue}' a '${newValue}'`, + bodySpendCategory: ({adjective, value}: {adjective: string; value: string}) => + adjective !== '' ? `Categoría de gasto ${adjective} «${value}»` : `categoría de gasto «${value}»`, + bodySpendCategoryChange: ({adjective, oldValue, newValue}: {adjective: string; oldValue: string; newValue: string}) => + adjective !== '' ? `Categoría de gasto ${adjective} de '${oldValue}' a '${newValue}'` : `categoría de gasto de '${oldValue}' a '${newValue}'`, + bodyMaxAmount: 'importe máximo', + bodyMaxAmountSet: ({value}: {value: string}) => `importe máximo hasta ${value}`, + bodyMaxAmountChange: ({oldValue, newValue}: {oldValue: string; newValue: string}) => `importe máximo de ${oldValue} a ${newValue}`, + bodyAppliedToAdditionalCards: ({count}: {count: number}) => ({ + one: 'regla de gasto para 1 tarjeta adicional', + other: `regla de gasto para ${count} tarjetas adicionales`, + }), + bodyRemovedFromCards: ({cards}: {cards: string}) => `regla de gasto de ${cards}`, + composeOnCards: ({content, cards}: {content: string; cards: string}) => `${content} en ${cards}`, + composeFromCards: ({content, cards}: {content: string; cards: string}) => `${content} de ${cards}`, + }, + }, preventSelfApproval: (oldValue, newValue) => `actualizó "Evitar la autoaprobación" a "${newValue === 'true' ? 'Habilitada' : 'Deshabilitada'}" (previamente "${oldValue === 'true' ? 'Habilitada' : 'Deshabilitada'}")`, setReceiptRequiredAmount: (newValue) => `estableció el importe requerido del recibo en "${newValue}"`, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index e2dc4d65a2a0..3d8c7b1b78fc 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7862,6 +7862,52 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e addedReportField: (fieldType: string, fieldName?: string, defaultValue?: string) => `a ajouté le champ de note de frais ${fieldType} « ${fieldName} »${defaultValue ? ` avec la valeur par défaut « ${defaultValue} »` : ''}`, updatedRequireCompanyCards: ({enabled}: {enabled: boolean}) => `${enabled ? 'activé' : 'désactivé'} l’exigence d’achats par carte d’entreprise`, + expensifyCardRule: { + actionVerb: {block: 'bloqué', allow: 'autorisé'}, + amountOperator: {over: 'terminé', under: 'sous'}, + amountFilter: ({operator, amount}: {operator: string; amount: string}) => `montants ${operator} ${amount}`, + theCard: 'la carte', + multipleCards: ({count}: {count: number}) => ({ + one: '1 carte', + other: `${count} cartes`, + }), + addRule: ({verb, filters, cards}: {verb: string; filters: string; cards: string}) => { + let text = verb; + if (filters !== '') { + text += ` ${filters}`; + } + text += ` sur ${cards}`; + return text; + }, + removeRule: ({cards}: {cards: string}) => `a supprimé la règle de dépense de ${cards}`, + restrictionVerb: {block: 'bloquer', allow: 'autoriser uniquement'}, + update: { + modeChange: ({fromAction, toAction, cards}: {fromAction: string; toAction: string; cards: string}) => + `a modifié la règle de dépense de ${fromAction} à ${toAction} sur ${cards}`, + appliedToAdditionalCards: ({count}: {count: number}) => ({ + one: 'règle de dépense appliquée à 1 carte supplémentaire', + other: `règle de dépense appliquée à ${count} cartes supplémentaires`, + }), + phraseVerb: {added: 'ajouté', removed: 'supprimé', changed: 'modifié', set: 'définir', applied: 'appliqué'}, + bodyMerchant: ({adjective, value}: {adjective: string; value: string}) => (adjective !== '' ? `${adjective} commerçant « ${value} »` : `commerçant « ${value} »`), + bodyMerchantChange: ({adjective, oldValue, newValue}: {adjective: string; oldValue: string; newValue: string}) => + adjective !== '' ? `${adjective} marchand de « ${oldValue} » à « ${newValue} »` : `commerçant de « ${oldValue} » à « ${newValue} »`, + bodySpendCategory: ({adjective, value}: {adjective: string; value: string}) => + adjective !== '' ? `Catégorie de dépense ${adjective} « ${value} »` : `catégorie de dépense « ${value} »`, + bodySpendCategoryChange: ({adjective, oldValue, newValue}: {adjective: string; oldValue: string; newValue: string}) => + adjective !== '' ? `Catégorie de dépense ${adjective} de « ${oldValue} » à « ${newValue} »` : `catégorie de dépense de « ${oldValue} » à « ${newValue} »`, + bodyMaxAmount: 'montant maximal', + bodyMaxAmountSet: ({value}: {value: string}) => `montant maximum à ${value}`, + bodyMaxAmountChange: ({oldValue, newValue}: {oldValue: string; newValue: string}) => `montant maximal de ${oldValue} à ${newValue}`, + bodyAppliedToAdditionalCards: ({count}: {count: number}) => ({ + one: 'règle de dépense pour 1 carte supplémentaire', + other: `règle de dépense pour ${count} cartes supplémentaires`, + }), + bodyRemovedFromCards: ({cards}: {cards: string}) => `règle de dépense provenant de ${cards}`, + composeOnCards: ({content, cards}: {content: string; cards: string}) => `${content} sur ${cards}`, + composeFromCards: ({content, cards}: {content: string; cards: string}) => `${content} de ${cards}`, + }, + }, }, roomMembersPage: { memberNotFound: 'Membre introuvable.', diff --git a/src/languages/it.ts b/src/languages/it.ts index ecb23c1de11e..a8d6bf06c49c 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7820,6 +7820,52 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, addedReportField: (fieldType: string, fieldName?: string, defaultValue?: string) => `aggiunto campo di report ${fieldType} "${fieldName}"${defaultValue ? ` con valore predefinito "${defaultValue}"` : ''}`, updatedRequireCompanyCards: ({enabled}: {enabled: boolean}) => `${enabled ? 'abilitato' : 'disabilitato'} il requisito per gli acquisti con carta aziendale`, + expensifyCardRule: { + actionVerb: {block: 'bloccato', allow: 'consentito'}, + amountOperator: {over: 'terminato', under: 'sotto'}, + amountFilter: ({operator, amount}: {operator: string; amount: string}) => `importi ${operator} ${amount}`, + theCard: 'la carta', + multipleCards: ({count}: {count: number}) => ({ + one: '1 carta', + other: `${count} carte`, + }), + addRule: ({verb, filters, cards}: {verb: string; filters: string; cards: string}) => { + let text = verb; + if (filters !== '') { + text += ` ${filters}`; + } + text += ` su ${cards}`; + return text; + }, + removeRule: ({cards}: {cards: string}) => `regola di spesa rimossa da ${cards}`, + restrictionVerb: {block: 'blocca', allow: 'consenti solo'}, + update: { + modeChange: ({fromAction, toAction, cards}: {fromAction: string; toAction: string; cards: string}) => + `ha modificato la regola di spesa da ${fromAction} a ${toAction} su ${cards}`, + appliedToAdditionalCards: ({count}: {count: number}) => ({ + one: 'ha applicato la regola di spesa a 1 altra carta', + other: `ha applicato la regola di spesa ad altre ${count} carte`, + }), + phraseVerb: {added: 'aggiunto', removed: 'rimosso', changed: 'modificato', set: 'imposta', applied: 'applicata'}, + bodyMerchant: ({adjective, value}: {adjective: string; value: string}) => (adjective !== '' ? `${adjective} esercente '${value}'` : `esercente '${value}'`), + bodyMerchantChange: ({adjective, oldValue, newValue}: {adjective: string; oldValue: string; newValue: string}) => + adjective !== '' ? `commerciante ${adjective} da '${oldValue}' a '${newValue}'` : `esercente da '${oldValue}' a '${newValue}'`, + bodySpendCategory: ({adjective, value}: {adjective: string; value: string}) => + adjective !== '' ? `Categoria di spesa ${adjective} '${value}'` : `categoria di spesa '${value}'`, + bodySpendCategoryChange: ({adjective, oldValue, newValue}: {adjective: string; oldValue: string; newValue: string}) => + adjective !== '' ? `categoria di spesa ${adjective} da '${oldValue}' a '${newValue}'` : `categoria di spesa da '${oldValue}' a '${newValue}'`, + bodyMaxAmount: 'importo massimo', + bodyMaxAmountSet: ({value}: {value: string}) => `importo massimo a ${value}`, + bodyMaxAmountChange: ({oldValue, newValue}: {oldValue: string; newValue: string}) => `importo massimo da ${oldValue} a ${newValue}`, + bodyAppliedToAdditionalCards: ({count}: {count: number}) => ({ + one: 'regola di spesa per 1 carta aggiuntiva', + other: `regola di spesa per ${count} carte aggiuntive`, + }), + bodyRemovedFromCards: ({cards}: {cards: string}) => `regola di spesa da ${cards}`, + composeOnCards: ({content, cards}: {content: string; cards: string}) => `${content} su ${cards}`, + composeFromCards: ({content, cards}: {content: string; cards: string}) => `${content} da ${cards}`, + }, + }, }, roomMembersPage: { memberNotFound: 'Membro non trovato.', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index fbfcfb850289..697e537fee7f 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7728,6 +7728,53 @@ ${reportName} addedReportField: (fieldType: string, fieldName?: string, defaultValue?: string) => `${fieldType}レポートフィールド「${fieldName}」を追加しました${defaultValue ? ` デフォルト値「${defaultValue}」付き` : ''}`, updatedRequireCompanyCards: ({enabled}: {enabled: boolean}) => `${enabled ? '有効' : '無効'} の法人カード購入要件`, + expensifyCardRule: { + actionVerb: {block: 'ブロック済み', allow: '許可済み'}, + amountOperator: { + over: '以上', + under: '以下のいずれかの意味に応じてお使いください: \n- 位置・場所:「〜の下」→「under」=「〜の下」 \n- 条件・範囲:「〜のもとで/〜以下」→「under 18」=「18歳未満」', + }, + amountFilter: ({operator, amount}: {operator: string; amount: string}) => `金額が ${amount} を${operator}`, + theCard: 'カード', + multipleCards: ({count}: {count: number}) => ({ + one: '1 枚のカード', + other: `${count} 枚のカード`, + }), + addRule: ({verb, filters, cards}: {verb: string; filters: string; cards: string}) => { + let text = verb; + if (filters !== '') { + text += ` ${filters}`; + } + text += `(${cards})で`; + return text; + }, + removeRule: ({cards}: {cards: string}) => `${cards} から支出ルールを削除しました`, + restrictionVerb: {block: 'ブロック', allow: 'のみ許可'}, + update: { + modeChange: ({fromAction, toAction, cards}: {fromAction: string; toAction: string; cards: string}) => `${cards} の利用ルールを ${fromAction} から ${toAction} に変更しました`, + appliedToAdditionalCards: ({count}: {count: number}) => ({ + one: '追加で 1 枚のカードに支出ルールを適用しました', + other: `追加で ${count} 枚のカードに支出ルールを適用しました`, + }), + phraseVerb: {added: '追加しました', removed: '削除済み', changed: '変更しました', set: '設定', applied: '適用済み'}, + bodyMerchant: ({adjective, value}: {adjective: string; value: string}) => (adjective !== '' ? `${adjective}なマーチャント「${value}」` : `加盟店「${value}」`), + bodyMerchantChange: ({adjective, oldValue, newValue}: {adjective: string; oldValue: string; newValue: string}) => + adjective !== '' ? `${oldValue} から ${newValue} へ${adjective}加盟店を変更しました` : `加盟店を「${oldValue}」から「${newValue}」に変更しました`, + bodySpendCategory: ({adjective, value}: {adjective: string; value: string}) => (adjective !== '' ? `${adjective}な支出カテゴリ「${value}」` : `支出カテゴリ「${value}」`), + bodySpendCategoryChange: ({adjective, oldValue, newValue}: {adjective: string; oldValue: string; newValue: string}) => + adjective !== '' ? `${adjective}支出カテゴリを「${oldValue}」から「${newValue}」に変更しました` : `支出カテゴリを「${oldValue}」から「${newValue}」に変更しました`, + bodyMaxAmount: '最大金額', + bodyMaxAmountSet: ({value}: {value: string}) => `最大金額を${value}に設定`, + bodyMaxAmountChange: ({oldValue, newValue}: {oldValue: string; newValue: string}) => `最大金額を${oldValue}から${newValue}に変更しました`, + bodyAppliedToAdditionalCards: ({count}: {count: number}) => ({ + one: '1枚の追加カードに支出ルールを適用します', + other: `${count}枚の追加カードに支出ルールを適用します`, + }), + bodyRemovedFromCards: ({cards}: {cards: string}) => `${cards} からの支出ルール`, + composeOnCards: ({content, cards}: {content: string; cards: string}) => `${cards} 上の ${content}`, + composeFromCards: ({content, cards}: {content: string; cards: string}) => `${cards} からの ${content}`, + }, + }, }, roomMembersPage: { memberNotFound: 'メンバーが見つかりません。', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 9c14d1bea260..3598e0732058 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7790,6 +7790,52 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, addedReportField: (fieldType: string, fieldName?: string, defaultValue?: string) => `heeft ${fieldType}-rapportveld "${fieldName}" toegevoegd${defaultValue ? ` met standaardwaarde "${defaultValue}"` : ''}`, updatedRequireCompanyCards: ({enabled}: {enabled: boolean}) => `vereiste ${enabled ? 'ingeschakeld' : 'uitgeschakeld'} voor bedrijfskaarttransacties`, + expensifyCardRule: { + actionVerb: {block: 'geblokkeerd', allow: 'toegestaan'}, + amountOperator: {over: 'over', under: 'onder'}, + amountFilter: ({operator, amount}: {operator: string; amount: string}) => `bedragen ${operator} ${amount}`, + theCard: 'de kaart', + multipleCards: ({count}: {count: number}) => ({ + one: '1 kaart', + other: `${count} kaarten`, + }), + addRule: ({verb, filters, cards}: {verb: string; filters: string; cards: string}) => { + let text = verb; + if (filters !== '') { + text += ` ${filters}`; + } + text += ` op ${cards}`; + return text; + }, + removeRule: ({cards}: {cards: string}) => `besteedregel verwijderd van ${cards}`, + restrictionVerb: {block: 'blokkeren', allow: 'alleen toestaan'}, + update: { + modeChange: ({fromAction, toAction, cards}: {fromAction: string; toAction: string; cards: string}) => + `heeft bestedingsregel gewijzigd van ${fromAction} naar ${toAction} op ${cards}`, + appliedToAdditionalCards: ({count}: {count: number}) => ({ + one: 'uitgavenregel toegepast op 1 extra kaart', + other: `uitgavenregel toegepast op ${count} extra kaarten`, + }), + phraseVerb: {added: 'toegevoegd', removed: 'verwijderd', changed: 'gewijzigd', set: 'instellen', applied: 'toegepast'}, + bodyMerchant: ({adjective, value}: {adjective: string; value: string}) => (adjective !== '' ? `${adjective} handelaar '${value}'` : `handelaar '${value}'`), + bodyMerchantChange: ({adjective, oldValue, newValue}: {adjective: string; oldValue: string; newValue: string}) => + adjective !== '' ? `${adjective} verkoper gewijzigd van '${oldValue}' naar '${newValue}'` : `handelaar van '${oldValue}' naar '${newValue}'`, + bodySpendCategory: ({adjective, value}: {adjective: string; value: string}) => + adjective !== '' ? `${adjective} uitgavencategorie '${value}'` : `uitgavencategorie ‘${value}’`, + bodySpendCategoryChange: ({adjective, oldValue, newValue}: {adjective: string; oldValue: string; newValue: string}) => + adjective !== '' ? `${adjective} uitgavencategorie van '${oldValue}' naar '${newValue}'` : `uitgavencategorie van '${oldValue}' naar '${newValue}'`, + bodyMaxAmount: 'maximum bedrag', + bodyMaxAmountSet: ({value}: {value: string}) => `maximumbedrag tot ${value}`, + bodyMaxAmountChange: ({oldValue, newValue}: {oldValue: string; newValue: string}) => `maximumbedrag van ${oldValue} naar ${newValue}`, + bodyAppliedToAdditionalCards: ({count}: {count: number}) => ({ + one: 'bestedingsregel naar 1 extra kaart', + other: `bestedingsregel naar ${count} extra kaarten`, + }), + bodyRemovedFromCards: ({cards}: {cards: string}) => `bestedingsregel van ${cards}`, + composeOnCards: ({content, cards}: {content: string; cards: string}) => `${content} op ${cards}`, + composeFromCards: ({content, cards}: {content: string; cards: string}) => `${content} van ${cards}`, + }, + }, }, roomMembersPage: { memberNotFound: 'Lid niet gevonden.', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index a2200a5b1068..a80b7d389a9a 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7780,6 +7780,51 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, addedReportField: (fieldType: string, fieldName?: string, defaultValue?: string) => `dodano pole raportu typu ${fieldType} „${fieldName}”${defaultValue ? ` z domyślną wartością „${defaultValue}”` : ''}`, updatedRequireCompanyCards: ({enabled}: {enabled: boolean}) => `${enabled ? 'włączone' : 'wyłączone'} wymóg dotyczący zakupów kartą służbową`, + expensifyCardRule: { + actionVerb: {block: 'zablokowane', allow: 'dozwolone'}, + amountOperator: {over: 'ponad', under: 'pod'}, + amountFilter: ({operator, amount}: {operator: string; amount: string}) => `kwoty ${operator} ${amount}`, + theCard: 'karta', + multipleCards: ({count}: {count: number}) => ({ + one: '1 karta', + other: `${count} karty`, + }), + addRule: ({verb, filters, cards}: {verb: string; filters: string; cards: string}) => { + let text = verb; + if (filters !== '') { + text += ` ${filters}`; + } + text += ` na ${cards}`; + return text; + }, + removeRule: ({cards}: {cards: string}) => `usunięto regułę wydatków z ${cards}`, + restrictionVerb: {block: 'zablokuj', allow: 'zezwalaj tylko'}, + update: { + modeChange: ({fromAction, toAction, cards}: {fromAction: string; toAction: string; cards: string}) => `zmieniono regułę wydatków z ${fromAction} na ${toAction} na ${cards}`, + appliedToAdditionalCards: ({count}: {count: number}) => ({ + one: 'zastosowano regułę wydatków do 1 dodatkowej karty', + other: `zastosowano regułę wydatków do ${count} dodatkowych kart`, + }), + phraseVerb: {added: 'dodano', removed: 'usunięto', changed: 'zmieniono', set: 'ustaw', applied: 'zastosowano'}, + bodyMerchant: ({adjective, value}: {adjective: string; value: string}) => (adjective !== '' ? `${adjective} sprzedawca '${value}'` : `sprzedawca „${value}”`), + bodyMerchantChange: ({adjective, oldValue, newValue}: {adjective: string; oldValue: string; newValue: string}) => + adjective !== '' ? `zmieniono ${adjective} sprzedawcę z „${oldValue}” na „${newValue}”` : `sprzedawcę z „${oldValue}” na „${newValue}”`, + bodySpendCategory: ({adjective, value}: {adjective: string; value: string}) => + adjective !== '' ? `${adjective} kategoria wydatków „${value}”` : `kategoria wydatków „${value}”`, + bodySpendCategoryChange: ({adjective, oldValue, newValue}: {adjective: string; oldValue: string; newValue: string}) => + adjective !== '' ? `${adjective} kategorię wydatków z „${oldValue}” na „${newValue}”` : `kategorię wydatku z „${oldValue}” na „${newValue}”`, + bodyMaxAmount: 'maksymalna kwota', + bodyMaxAmountSet: ({value}: {value: string}) => `maksymalna kwota do ${value}`, + bodyMaxAmountChange: ({oldValue, newValue}: {oldValue: string; newValue: string}) => `maksymalną kwotę z ${oldValue} na ${newValue}`, + bodyAppliedToAdditionalCards: ({count}: {count: number}) => ({ + one: 'zasada wydatków dla 1 dodatkowej karty', + other: `zasada wydatków dla ${count} dodatkowych kart`, + }), + bodyRemovedFromCards: ({cards}: {cards: string}) => `zasada wydatków z ${cards}`, + composeOnCards: ({content, cards}: {content: string; cards: string}) => `${content} na ${cards}`, + composeFromCards: ({content, cards}: {content: string; cards: string}) => `${content} z ${cards}`, + }, + }, }, roomMembersPage: { memberNotFound: 'Nie znaleziono członka.', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index fc420f976b94..5067e0a35112 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7782,6 +7782,52 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, addedReportField: (fieldType: string, fieldName?: string, defaultValue?: string) => `adicionou o campo de relatório ${fieldType} "${fieldName}"${defaultValue ? ` com valor padrão "${defaultValue}"` : ''}`, updatedRequireCompanyCards: ({enabled}: {enabled: boolean}) => `${enabled ? 'ativado' : 'desativado'} o requisito de compras com cartão corporativo`, + expensifyCardRule: { + actionVerb: {block: 'bloqueado', allow: 'permitido'}, + amountOperator: {over: 'acima', under: 'abaixo'}, + amountFilter: ({operator, amount}: {operator: string; amount: string}) => `valores ${operator} ${amount}`, + theCard: 'o cartão', + multipleCards: ({count}: {count: number}) => ({ + one: '1 cartão', + other: `${count} cartões`, + }), + addRule: ({verb, filters, cards}: {verb: string; filters: string; cards: string}) => { + let text = verb; + if (filters !== '') { + text += ` ${filters}`; + } + text += ` em ${cards}`; + return text; + }, + removeRule: ({cards}: {cards: string}) => `removeu a regra de gasto de ${cards}`, + restrictionVerb: {block: 'bloquear', allow: 'permitir apenas'}, + update: { + modeChange: ({fromAction, toAction, cards}: {fromAction: string; toAction: string; cards: string}) => + `alterou a regra de gasto de ${fromAction} para ${toAction} em ${cards}`, + appliedToAdditionalCards: ({count}: {count: number}) => ({ + one: 'regra de gasto aplicada a mais 1 cartão', + other: `regra de gasto aplicada a mais ${count} cartões`, + }), + phraseVerb: {added: 'adicionado', removed: 'removido', changed: 'alterado', set: 'definir', applied: 'aplicado'}, + bodyMerchant: ({adjective, value}: {adjective: string; value: string}) => (adjective !== '' ? `comerciante ${adjective} '${value}'` : `estabelecimento comercial '${value}'`), + bodyMerchantChange: ({adjective, oldValue, newValue}: {adjective: string; oldValue: string; newValue: string}) => + adjective !== '' ? `${adjective} comerciante de '${oldValue}' para '${newValue}'` : `estabelecimento comercial de '${oldValue}' para '${newValue}'`, + bodySpendCategory: ({adjective, value}: {adjective: string; value: string}) => + adjective !== '' ? `Categoria de gasto ${adjective} '${value}'` : `categoria de gastos '${value}'`, + bodySpendCategoryChange: ({adjective, oldValue, newValue}: {adjective: string; oldValue: string; newValue: string}) => + adjective !== '' ? `Categoria de gasto ${adjective} de '${oldValue}' para '${newValue}'` : `categoria de gasto de '${oldValue}' para '${newValue}'`, + bodyMaxAmount: 'valor máximo', + bodyMaxAmountSet: ({value}: {value: string}) => `valor máximo até ${value}`, + bodyMaxAmountChange: ({oldValue, newValue}: {oldValue: string; newValue: string}) => `valor máximo de ${oldValue} para ${newValue}`, + bodyAppliedToAdditionalCards: ({count}: {count: number}) => ({ + one: 'regra de gasto para mais 1 cartão', + other: `regra de gasto para mais ${count} cartões`, + }), + bodyRemovedFromCards: ({cards}: {cards: string}) => `regra de gasto de ${cards}`, + composeOnCards: ({content, cards}: {content: string; cards: string}) => `${content} em ${cards}`, + composeFromCards: ({content, cards}: {content: string; cards: string}) => `${content} de ${cards}`, + }, + }, }, roomMembersPage: { memberNotFound: 'Membro não encontrado.', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 5c0d3868ebb3..4a4e860e55d8 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -7582,6 +7582,50 @@ ${reportName} `已更改卡片流水“${feedName}”的账单周期截止日${newValue ? ` 为“${newValue}”` : ''}${previousValue ? ` (先前为“${previousValue}”)` : ''}`, addedReportField: (fieldType: string, fieldName?: string, defaultValue?: string) => `已添加 ${fieldType} 报告字段“${fieldName}”${defaultValue ? ` 默认值为“${defaultValue}”` : ''}`, updatedRequireCompanyCards: ({enabled}: {enabled: boolean}) => `${enabled ? '已启用' : '已禁用'} 公司商务卡消费要求`, + expensifyCardRule: { + actionVerb: {block: '已阻止', allow: '允许'}, + amountOperator: {over: '结束', under: '在……之下'}, + amountFilter: ({operator, amount}: {operator: string; amount: string}) => `金额 ${operator} ${amount}`, + theCard: '该卡', + multipleCards: ({count}: {count: number}) => ({ + one: '1 张卡片', + other: `${count} 张卡片`, + }), + addRule: ({verb, filters, cards}: {verb: string; filters: string; cards: string}) => { + let text = verb; + if (filters !== '') { + text += ` ${filters}`; + } + text += ` 在 ${cards}`; + return text; + }, + removeRule: ({cards}: {cards: string}) => `已从 ${cards} 中移除消费规则`, + restrictionVerb: {block: '阻止', allow: '仅允许'}, + update: { + modeChange: ({fromAction, toAction, cards}: {fromAction: string; toAction: string; cards: string}) => `已将 ${cards} 的消费规则从 ${fromAction} 更改为 ${toAction}`, + appliedToAdditionalCards: ({count}: {count: number}) => ({ + one: '已将支出规则应用于另外 1 张卡片', + other: `已将支出规则应用于另外 ${count} 张卡片`, + }), + phraseVerb: {added: '已添加', removed: '已移除', changed: '已更改', set: '设置', applied: '已应用'}, + bodyMerchant: ({adjective, value}: {adjective: string; value: string}) => (adjective !== '' ? `${adjective} 商家“${value}”` : `商户“${value}”`), + bodyMerchantChange: ({adjective, oldValue, newValue}: {adjective: string; oldValue: string; newValue: string}) => + adjective !== '' ? `将商家 ${adjective} 从“${oldValue}”更改为“${newValue}”` : `商户从“${oldValue}”变更为“${newValue}”`, + bodySpendCategory: ({adjective, value}: {adjective: string; value: string}) => (adjective !== '' ? `${adjective}支出类别“${value}”` : `支出类别「${value}」`), + bodySpendCategoryChange: ({adjective, oldValue, newValue}: {adjective: string; oldValue: string; newValue: string}) => + adjective !== '' ? `将${adjective}支出类别从“${oldValue}”修改为“${newValue}”` : `支出类别从“${oldValue}”更改为“${newValue}”`, + bodyMaxAmount: '最大金额', + bodyMaxAmountSet: ({value}: {value: string}) => `最大金额至 ${value}`, + bodyMaxAmountChange: ({oldValue, newValue}: {oldValue: string; newValue: string}) => `最大金额从 ${oldValue} 变为 ${newValue}`, + bodyAppliedToAdditionalCards: ({count}: {count: number}) => ({ + one: '将消费规则应用到另外 1 张卡片', + other: `将消费规则应用到另外 ${count} 张卡片`, + }), + bodyRemovedFromCards: ({cards}: {cards: string}) => `来自 ${cards} 的消费规则`, + composeOnCards: ({content, cards}: {content: string; cards: string}) => `${cards} 上的 ${content}`, + composeFromCards: ({content, cards}: {content: string; cards: string}) => `来自 ${cards} 的 ${content}`, + }, + }, }, roomMembersPage: { memberNotFound: '未找到成员。', diff --git a/src/libs/IntlPolyfill/polyfillListFormat.ts b/src/libs/IntlPolyfill/polyfillListFormat.ts index 21bb26cadc4b..cc53ac404aca 100644 --- a/src/libs/IntlPolyfill/polyfillListFormat.ts +++ b/src/libs/IntlPolyfill/polyfillListFormat.ts @@ -5,7 +5,15 @@ export default function () { require('@formatjs/intl-listformat/polyfill-force'); - // Load en & es Locale data + // Load locale data for all supported locales. pt covers pt-BR; zh covers zh-hans. require('@formatjs/intl-listformat/locale-data/en'); require('@formatjs/intl-listformat/locale-data/es'); + require('@formatjs/intl-listformat/locale-data/de'); + require('@formatjs/intl-listformat/locale-data/fr'); + require('@formatjs/intl-listformat/locale-data/it'); + require('@formatjs/intl-listformat/locale-data/ja'); + require('@formatjs/intl-listformat/locale-data/nl'); + require('@formatjs/intl-listformat/locale-data/pl'); + require('@formatjs/intl-listformat/locale-data/pt'); + require('@formatjs/intl-listformat/locale-data/zh'); } diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 9db647aaef81..b4144fb55b8c 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -195,6 +195,7 @@ import { shouldReportBeInOptionList, shouldReportShowSubscript, } from './ReportUtils'; +import {getAddExpensifyCardRuleMessage, getRemoveExpensifyCardRuleMessage, getUpdateExpensifyCardRuleMessage} from './SpendRuleChangeLogUtils'; import StringUtils from './StringUtils'; import {getTaskReportActionMessage} from './TaskUtils'; @@ -1195,6 +1196,12 @@ function getOptionData({ result.alternateText = getDeletedApprovalRuleMessage(translate, lastAction); } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_APPROVER_RULE) { result.alternateText = getUpdatedApprovalRuleMessage(translate, lastAction); + } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_EXPENSIFY_CARD_RULE) { + result.alternateText = getAddExpensifyCardRuleMessage(translate, lastAction); + } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EXPENSIFY_CARD_RULE) { + result.alternateText = getUpdateExpensifyCardRuleMessage(translate, lastAction); + } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.REMOVE_EXPENSIFY_CARD_RULE) { + result.alternateText = getRemoveExpensifyCardRuleMessage(translate, lastAction); } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MANUAL_APPROVAL_THRESHOLD) { result.alternateText = getUpdatedManualApprovalThresholdMessage(translate, lastAction); } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_BUDGET) { diff --git a/src/libs/SpendRuleChangeLogUtils.ts b/src/libs/SpendRuleChangeLogUtils.ts new file mode 100644 index 000000000000..d1ed4b13ec08 --- /dev/null +++ b/src/libs/SpendRuleChangeLogUtils.ts @@ -0,0 +1,424 @@ +import type {OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; +import type {LocalizedTranslate} from '@components/LocaleContextProvider'; +import CONST from '@src/CONST'; +import {isSpendRuleCategory} from '@src/types/form/SpendRuleForm'; +import type {CardID} from '@src/types/onyx/Card'; +import type ReportAction from '@src/types/onyx/ReportAction'; +import {convertAmountToDisplayString} from './CurrencyUtils'; +import {formatList} from './Localize'; +import Parser from './Parser'; +import stripFollowupListFromHtml from './ReportActionFollowupUtils/stripFollowupListFromHtml'; +import {getOriginalMessage, isActionOfType} from './ReportActionsUtils'; + +function getSpendRuleFallbackReportActionText(reportAction: OnyxEntry): string { + const message = Array.isArray(reportAction?.message) ? reportAction?.message.at(0) : reportAction?.message; + + // We intentionally use || here because empty strings from stripFollowupListFromHtml should also fall through to the text fallback + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const text = stripFollowupListFromHtml(message?.html) || (message?.text ?? ''); + return text ? Parser.htmlToText(text) : ''; +} + +function getSpendRuleActionVerb(translate: LocalizedTranslate, action: ValueOf): string { + if (action === CONST.SPEND_RULES.ACTION.BLOCK) { + return translate('workspaceActions.expensifyCardRule.actionVerb.block'); + } + if (action === CONST.SPEND_RULES.ACTION.ALLOW) { + return translate('workspaceActions.expensifyCardRule.actionVerb.allow'); + } + return ''; +} + +function getSpendRuleAmountOperatorWord(translate: LocalizedTranslate, operator: ValueOf): string { + if (operator === CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN_OR_EQUAL_TO) { + return translate('workspaceActions.expensifyCardRule.amountOperator.under'); + } + if (operator === CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN) { + return translate('workspaceActions.expensifyCardRule.amountOperator.over'); + } + return ''; +} + +function getSpendRuleAmountString(translate: LocalizedTranslate, amount: {operator: ValueOf; value: string[]}, currency: string): string { + if (amount.value.length === 0) { + return ''; + } + const operatorWord = getSpendRuleAmountOperatorWord(translate, amount.operator); + return translate('workspaceActions.expensifyCardRule.amountFilter', {operator: operatorWord, amount: formatSpendRuleAmount(amount.value, currency)}); +} + +function getSpendRuleCardsSummary(translate: LocalizedTranslate, cards: ReadonlyArray<{displayName?: string}> | undefined): string { + if (!cards || cards.length === 0) { + return translate('workspaceActions.expensifyCardRule.theCard'); + } + if (cards.length === 1) { + const displayName = cards.at(0)?.displayName ?? ''; + return displayName !== '' ? `'${displayName}'` : translate('workspaceActions.expensifyCardRule.theCard'); + } + return translate('workspaceActions.expensifyCardRule.multipleCards', {count: cards.length}); +} + +function getSpendRuleJoinFilters(items: readonly string[]): string { + return formatList(items.filter((value) => value !== '')); +} + +function getSpendRuleCategoryDisplayName(translate: LocalizedTranslate, category: string): string { + if (isSpendRuleCategory(category)) { + return translate(`workspace.rules.spendRules.categoryOptions.${category}`); + } + return category; +} + +function getSpendRuleRestrictionVerb(translate: LocalizedTranslate, action: ValueOf): string { + if (action === CONST.SPEND_RULES.ACTION.BLOCK) { + return translate('workspaceActions.expensifyCardRule.restrictionVerb.block'); + } + if (action === CONST.SPEND_RULES.ACTION.ALLOW) { + return translate('workspaceActions.expensifyCardRule.restrictionVerb.allow'); + } + return action; +} + +function formatSpendRuleAmount(amount: string[], currency: string): string { + return convertAmountToDisplayString(getSpendRuleValueInCents(amount), currency); +} + +type SpendRuleStringDiff = {added: string[]; removed: string[]}; + +function computeSpendRuleStringDiff(oldValues: string[], newValues: string[]): SpendRuleStringDiff { + const oldSet = Array.from(new Set(oldValues)); + const newSet = Array.from(new Set(newValues)); + const added = newSet.filter((value) => !oldSet.includes(value)).sort(); + const removed = oldSet.filter((value) => !newSet.includes(value)).sort(); + return {added, removed}; +} + +type SpendRuleAmount = {operator: ValueOf; value: string[]}; +type SpendRuleAmountDiff = {added: SpendRuleAmount[]; removed: SpendRuleAmount[]}; + +function getSpendRuleValueInCents(value: string[]): number { + const firstValue = value.at(0) ?? ''; + return firstValue !== '' && Number.isFinite(Number(firstValue)) ? Math.round(parseFloat(firstValue) * 100) : 0; +} + +function computeSpendRuleAmountDiff(oldAmounts: SpendRuleAmount[], newAmounts: SpendRuleAmount[]): SpendRuleAmountDiff { + const oldAmount = oldAmounts.at(0); + const newAmount = newAmounts.at(0); + if (!oldAmount && !newAmount) { + return {added: [], removed: []}; + } + if (!oldAmount) { + return {added: newAmount ? [newAmount] : [], removed: []}; + } + if (!newAmount) { + return {added: [], removed: [oldAmount]}; + } + const sameAmount = getSpendRuleValueInCents(oldAmount.value) === getSpendRuleValueInCents(newAmount.value); + if (sameAmount) { + return {added: [], removed: []}; + } + return { + added: [newAmount], + removed: [oldAmount], + }; +} + +type SpendRuleCard = {cardID?: CardID; displayName?: string}; +type SpendRuleCardDiff = {added: SpendRuleCard[]; removed: SpendRuleCard[]}; + +function getSpendRuleCardID(card: SpendRuleCard): number | undefined { + const cardID = card?.cardID; + if (typeof cardID === 'number' && Number.isFinite(cardID)) { + return cardID; + } + if (typeof cardID === 'string' && /^\d+$/.test(cardID)) { + return Number.parseInt(cardID, 10); + } + return undefined; +} + +function computeSpendRuleCardDiff(oldCards: SpendRuleCard[], newCards: SpendRuleCard[]): SpendRuleCardDiff { + const oldByID = new Map(); + for (const card of oldCards) { + const id = getSpendRuleCardID(card); + if (id !== undefined) { + oldByID.set(id, card); + } + } + const newByID = new Map(); + for (const card of newCards) { + const id = getSpendRuleCardID(card); + if (id !== undefined) { + newByID.set(id, card); + } + } + const added: SpendRuleCard[] = []; + for (const [id, card] of newByID) { + if (!oldByID.has(id)) { + added.push(card); + } + } + const removed: SpendRuleCard[] = []; + for (const [id, card] of oldByID) { + if (!newByID.has(id)) { + removed.push(card); + } + } + return {added, removed}; +} + +type SpendRulePhraseVerb = 'added' | 'removed' | 'changed' | 'set' | 'applied'; +type SpendRulePhraseAdjective = '' | typeof CONST.SPEND_RULES.ACTION.BLOCK | typeof CONST.SPEND_RULES.ACTION.ALLOW; + +type SpendRulePhrase = { + verb: SpendRulePhraseVerb; + adjective: SpendRulePhraseAdjective; + bodyWithAdjective: string; + bodyWithoutAdjective: string; +}; + +function getSpendRulePhraseVerbWord(translate: LocalizedTranslate, verb: SpendRulePhraseVerb): string { + return translate(`workspaceActions.expensifyCardRule.update.phraseVerb.${verb}`); +} + +function joinSpendRulePhrases(translate: LocalizedTranslate, phrases: readonly SpendRulePhrase[]): string { + if (phrases.length === 0) { + return ''; + } + if (phrases.length === 1) { + const phrase = phrases.at(0); + if (!phrase) { + return ''; + } + return `${getSpendRulePhraseVerbWord(translate, phrase.verb)} ${phrase.bodyWithAdjective}`; + } + + const firstVerb = phrases.at(0)?.verb; + const allSameVerb = firstVerb !== undefined && phrases.every((phrase) => phrase.verb === firstVerb); + + if (!allSameVerb) { + const parts = phrases.map((phrase) => `${getSpendRulePhraseVerbWord(translate, phrase.verb)} ${phrase.bodyWithAdjective}`); + return getSpendRuleJoinFilters(parts); + } + + const firstPhrase = phrases.at(0); + if (!firstPhrase) { + return ''; + } + const firstAdjective = firstPhrase.adjective; + const parts: string[] = [`${getSpendRulePhraseVerbWord(translate, firstPhrase.verb)} ${firstPhrase.bodyWithAdjective}`]; + for (let i = 1; i < phrases.length; i++) { + const phrase = phrases.at(i); + if (!phrase) { + continue; + } + const useOwnAdjective = phrase.adjective !== '' && phrase.adjective !== firstAdjective; + parts.push(useOwnAdjective ? phrase.bodyWithAdjective : phrase.bodyWithoutAdjective); + } + return getSpendRuleJoinFilters(parts); +} + +function getAddExpensifyCardRuleMessage(translate: LocalizedTranslate, reportAction: OnyxEntry): string { + if (!isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_EXPENSIFY_CARD_RULE)) { + return ''; + } + const message = getOriginalMessage(reportAction) ?? {}; + const action = message.action ?? CONST.SPEND_RULES.ACTION.ALLOW; + const currency = message.currency ?? CONST.CURRENCY.USD; + const merchants = message.merchants ?? []; + const categories = message.categories ?? []; + const amounts = message.amounts ?? []; + const cards = message.cards ?? []; + + const items: string[] = []; + for (const merchant of merchants) { + items.push(`'${merchant}'`); + } + for (const category of categories) { + items.push(`'${getSpendRuleCategoryDisplayName(translate, category)}'`); + } + for (const amount of amounts) { + const formattedAmount = getSpendRuleAmountString(translate, amount, currency); + if (formattedAmount !== '') { + items.push(formattedAmount); + } + } + + const verb = getSpendRuleActionVerb(translate, action); + const filters = getSpendRuleJoinFilters(items); + const cardsSummary = getSpendRuleCardsSummary(translate, cards); + + if (verb === '') { + return getSpendRuleFallbackReportActionText(reportAction); + } + + return translate('workspaceActions.expensifyCardRule.addRule', {verb, filters, cards: cardsSummary}); +} + +function getDiffPhrases( + diff: SpendRuleStringDiff, + adjective: SpendRulePhraseAdjective, + adjectiveWord: string, + getDisplayName: (value: string) => string, + formatBody: (params: {adjective: string; value: string}) => string, + formatBodyChange: (params: {adjective: string; oldValue: string; newValue: string}) => string, +): SpendRulePhrase[] { + const diffPhrases: SpendRulePhrase[] = []; + if (diff.added.length === 1 && diff.removed.length === 1) { + const oldValue = getDisplayName(diff.removed.at(0) ?? ''); + const newValue = getDisplayName(diff.added.at(0) ?? ''); + diffPhrases.push({ + verb: 'changed', + adjective, + bodyWithAdjective: formatBodyChange({adjective: adjectiveWord, oldValue, newValue}), + bodyWithoutAdjective: formatBodyChange({adjective: '', oldValue, newValue}), + }); + } else { + for (const value of diff.added) { + const display = getDisplayName(value); + diffPhrases.push({ + verb: 'added', + adjective, + bodyWithAdjective: formatBody({adjective: adjectiveWord, value: display}), + bodyWithoutAdjective: formatBody({adjective: '', value: display}), + }); + } + for (const value of diff.removed) { + const display = getDisplayName(value); + diffPhrases.push({ + verb: 'removed', + adjective, + bodyWithAdjective: formatBody({adjective: adjectiveWord, value: display}), + bodyWithoutAdjective: formatBody({adjective: '', value: display}), + }); + } + } + return diffPhrases; +} + +function getUpdateExpensifyCardRuleMessage(translate: LocalizedTranslate, reportAction: OnyxEntry): string { + if (!isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EXPENSIFY_CARD_RULE)) { + return ''; + } + const message = getOriginalMessage(reportAction) ?? {}; + const oldAction = message.oldAction ?? CONST.SPEND_RULES.ACTION.ALLOW; + const newAction = message.action ?? CONST.SPEND_RULES.ACTION.ALLOW; + const actionChanged = oldAction !== newAction; + const currency = message.currency ?? CONST.CURRENCY.USD; + + const oldMerchants = message.oldMerchants ?? []; + const newMerchants = message.merchants ?? []; + const oldCategories = message.oldCategories ?? []; + const newCategories = message.categories ?? []; + const oldAmounts = message.oldAmounts ?? []; + const newAmounts = message.amounts ?? []; + const oldCards = message.oldCards ?? []; + const newCards = message.cards ?? []; + + const merchantDiff = computeSpendRuleStringDiff(oldMerchants, newMerchants); + const categoryDiff = computeSpendRuleStringDiff(oldCategories, newCategories); + const amountDiff = computeSpendRuleAmountDiff(oldAmounts, newAmounts); + const cardDiff = computeSpendRuleCardDiff(oldCards, newCards); + + const merchantsChanged = merchantDiff.added.length > 0 || merchantDiff.removed.length > 0; + const categoriesChanged = categoryDiff.added.length > 0 || categoryDiff.removed.length > 0; + const amountsChanged = amountDiff.added.length > 0 || amountDiff.removed.length > 0; + const cardsChanged = cardDiff.added.length > 0 || cardDiff.removed.length > 0; + const filtersAndCardsUnchanged = !merchantsChanged && !categoriesChanged && !amountsChanged && !cardsChanged; + + const newCardsSummary = getSpendRuleCardsSummary(translate, newCards); + + if (actionChanged && filtersAndCardsUnchanged) { + return translate('workspaceActions.expensifyCardRule.update.modeChange', { + fromAction: getSpendRuleRestrictionVerb(translate, oldAction), + toAction: getSpendRuleRestrictionVerb(translate, newAction), + cards: newCardsSummary, + }); + } + + if (cardsChanged && !merchantsChanged && !categoriesChanged && !amountsChanged && !actionChanged) { + if (cardDiff.added.length > 0 && cardDiff.removed.length === 0) { + return translate('workspaceActions.expensifyCardRule.update.appliedToAdditionalCards', {count: cardDiff.added.length}); + } + if (cardDiff.added.length === 0 && cardDiff.removed.length > 0) { + return translate('workspaceActions.expensifyCardRule.removeRule', {cards: getSpendRuleCardsSummary(translate, cardDiff.removed)}); + } + } + + const adjective: SpendRulePhraseAdjective = newAction === CONST.SPEND_RULES.ACTION.BLOCK || newAction === CONST.SPEND_RULES.ACTION.ALLOW ? newAction : ''; + const adjectiveWord = adjective === '' ? '' : getSpendRuleActionVerb(translate, adjective); + const phrases: SpendRulePhrase[] = [ + ...getDiffPhrases( + merchantDiff, + adjective, + adjectiveWord, + (value) => value, + (params) => translate('workspaceActions.expensifyCardRule.update.bodyMerchant', params), + (params) => translate('workspaceActions.expensifyCardRule.update.bodyMerchantChange', params), + ), + ...getDiffPhrases( + categoryDiff, + adjective, + adjectiveWord, + (category) => getSpendRuleCategoryDisplayName(translate, category), + (params) => translate('workspaceActions.expensifyCardRule.update.bodySpendCategory', params), + (params) => translate('workspaceActions.expensifyCardRule.update.bodySpendCategoryChange', params), + ), + ]; + + if (amountDiff.added.length === 1 && amountDiff.removed.length === 1) { + const oldValue = formatSpendRuleAmount(amountDiff.removed.at(0)?.value ?? [], currency); + const newValue = formatSpendRuleAmount(amountDiff.added.at(0)?.value ?? [], currency); + const body = translate('workspaceActions.expensifyCardRule.update.bodyMaxAmountChange', {oldValue, newValue}); + phrases.push({verb: 'changed', adjective: '', bodyWithAdjective: body, bodyWithoutAdjective: body}); + } else { + for (const amount of amountDiff.added) { + const body = translate('workspaceActions.expensifyCardRule.update.bodyMaxAmountSet', {value: formatSpendRuleAmount(amount.value, currency)}); + phrases.push({verb: 'set', adjective: '', bodyWithAdjective: body, bodyWithoutAdjective: body}); + } + if (amountDiff.removed.length > 0) { + const body = translate('workspaceActions.expensifyCardRule.update.bodyMaxAmount'); + const removedPhrase: SpendRulePhrase = {verb: 'removed', adjective: '', bodyWithAdjective: body, bodyWithoutAdjective: body}; + phrases.push(...Array.from({length: amountDiff.removed.length}).fill(removedPhrase)); + } + } + + if (cardDiff.added.length > 0) { + const body = translate('workspaceActions.expensifyCardRule.update.bodyAppliedToAdditionalCards', {count: cardDiff.added.length}); + phrases.push({verb: 'applied', adjective: '', bodyWithAdjective: body, bodyWithoutAdjective: body}); + } + if (cardDiff.removed.length > 0) { + const body = translate('workspaceActions.expensifyCardRule.update.bodyRemovedFromCards', {cards: getSpendRuleCardsSummary(translate, cardDiff.removed)}); + phrases.push({verb: 'removed', adjective: '', bodyWithAdjective: body, bodyWithoutAdjective: body}); + } + + if (phrases.length === 0) { + return getSpendRuleFallbackReportActionText(reportAction); + } + + const joined = joinSpendRulePhrases(translate, phrases); + + if (cardsChanged) { + return joined; + } + + const onlyRemovedPhrase = phrases.length === 1 && phrases.at(0)?.verb === 'removed'; + if (onlyRemovedPhrase) { + return translate('workspaceActions.expensifyCardRule.update.composeFromCards', {content: joined, cards: newCardsSummary}); + } + + return translate('workspaceActions.expensifyCardRule.update.composeOnCards', {content: joined, cards: newCardsSummary}); +} + +function getRemoveExpensifyCardRuleMessage(translate: LocalizedTranslate, reportAction: OnyxEntry): string { + if (!isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.REMOVE_EXPENSIFY_CARD_RULE)) { + return ''; + } + const message = getOriginalMessage(reportAction) ?? {}; + const cards = message.cards ?? []; + const cardsSummary = getSpendRuleCardsSummary(translate, cards); + return translate('workspaceActions.expensifyCardRule.removeRule', {cards: cardsSummary}); +} + +export {getAddExpensifyCardRuleMessage, getRemoveExpensifyCardRuleMessage, getUpdateExpensifyCardRuleMessage}; diff --git a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx index 2fdaf7498a82..100aa94f2ede 100644 --- a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx @@ -186,6 +186,7 @@ import { shouldDisableThread, shouldDisplayThreadReplies as shouldDisplayThreadRepliesReportUtils, } from '@libs/ReportUtils'; +import {getAddExpensifyCardRuleMessage, getRemoveExpensifyCardRuleMessage, getUpdateExpensifyCardRuleMessage} from '@libs/SpendRuleChangeLogUtils'; import {getTaskCreatedMessage, getTaskReportActionMessage} from '@libs/TaskUtils'; import {isExpenseSplit, isPerDiemRequest} from '@libs/TransactionUtils'; import {setDownload} from '@userActions/Download'; @@ -1130,6 +1131,12 @@ const ContextMenuActions: ContextMenuAction[] = [ setClipboardMessage(getDeletedApprovalRuleMessage(translate, reportAction)); } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_APPROVER_RULE)) { setClipboardMessage(getUpdatedApprovalRuleMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_EXPENSIFY_CARD_RULE)) { + setClipboardMessage(getAddExpensifyCardRuleMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EXPENSIFY_CARD_RULE)) { + setClipboardMessage(getUpdateExpensifyCardRuleMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.REMOVE_EXPENSIFY_CARD_RULE)) { + setClipboardMessage(getRemoveExpensifyCardRuleMessage(translate, reportAction)); } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MANUAL_APPROVAL_THRESHOLD)) { setClipboardMessage(getUpdatedManualApprovalThresholdMessage(translate, reportAction)); } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_BUDGET)) { diff --git a/src/pages/inbox/report/actionContents/PolicyChangeLogContent.tsx b/src/pages/inbox/report/actionContents/PolicyChangeLogContent.tsx index 08011af7d79c..ced30612645f 100644 --- a/src/pages/inbox/report/actionContents/PolicyChangeLogContent.tsx +++ b/src/pages/inbox/report/actionContents/PolicyChangeLogContent.tsx @@ -82,6 +82,7 @@ import { getWorkspaceUpdateFieldMessage, } from '@libs/ReportActionsUtils'; import {getWorkspaceNameUpdatedMessage} from '@libs/ReportUtils'; +import {getAddExpensifyCardRuleMessage, getRemoveExpensifyCardRuleMessage, getUpdateExpensifyCardRuleMessage} from '@libs/SpendRuleChangeLogUtils'; import ReportActionItemBasicMessage from '@pages/inbox/report/ReportActionItemBasicMessage'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -163,6 +164,9 @@ const POLICY_CHANGE_LOG_RESOLVERS: Record = { [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_APPROVER_RULE]: (translate, action) => getAddedApprovalRuleMessage(translate, action), [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_APPROVER_RULE]: (translate, action) => getDeletedApprovalRuleMessage(translate, action), [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_APPROVER_RULE]: (translate, action) => getUpdatedApprovalRuleMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_EXPENSIFY_CARD_RULE]: (translate, action) => getAddExpensifyCardRuleMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EXPENSIFY_CARD_RULE]: (translate, action) => getUpdateExpensifyCardRuleMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.REMOVE_EXPENSIFY_CARD_RULE]: (translate, action) => getRemoveExpensifyCardRuleMessage(translate, action), [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_INTEGRATION]: (translate, action) => getAddedConnectionMessage(translate, action), [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_INTEGRATION]: (translate, action) => getRemovedConnectionMessage(translate, action), [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_CARD_FEED]: (translate, action) => getAddedCardFeedMessage(translate, action), diff --git a/src/types/onyx/Card.ts b/src/types/onyx/Card.ts index 566fe47ab3fe..ac6873101ede 100644 --- a/src/types/onyx/Card.ts +++ b/src/types/onyx/Card.ts @@ -2,9 +2,12 @@ import type {ValueOf} from 'type-fest'; import type CONST from '@src/CONST'; import type {SpendRuleCategory} from '@src/types/form/SpendRuleForm'; import type {CardFeedWithNumber} from './CardFeeds'; -import type * as OnyxCommon from './OnyxCommon'; +import type {ErrorFields, Errors, OnyxValueWithOfflineFeedback, PendingAction} from './OnyxCommon'; import type PersonalDetails from './PersonalDetails'; +/** Card identifier */ +type CardID = number | string; + /** Model of Expensify card status changes */ type CardStatusChanges = { /** Card status change date */ @@ -42,7 +45,7 @@ type PossibleFraudData = { }; /** Model of Expensify card */ -type Card = OnyxCommon.OnyxValueWithOfflineFeedback<{ +type Card = OnyxValueWithOfflineFeedback<{ /** Card ID number */ cardID: number; @@ -110,10 +113,10 @@ type Card = OnyxCommon.OnyxValueWithOfflineFeedback<{ lastImportAttempt?: string; /** Card related error messages */ - errors?: OnyxCommon.Errors; + errors?: Errors; /** Collection of form field errors */ - errorFields?: OnyxCommon.ErrorFields; + errorFields?: ErrorFields; /** Is card data loading */ isLoading?: boolean; @@ -128,7 +131,7 @@ type Card = OnyxCommon.OnyxValueWithOfflineFeedback<{ isOfflinePINMarket?: boolean; /** Additional card data */ - nameValuePairs?: OnyxCommon.OnyxValueWithOfflineFeedback<{ + nameValuePairs?: OnyxValueWithOfflineFeedback<{ /** Type of card spending limits */ limitType?: ValueOf; @@ -195,10 +198,10 @@ type Card = OnyxCommon.OnyxValueWithOfflineFeedback<{ isPINBlocked?: boolean; /** Collection of errors coming from BE */ - errors?: OnyxCommon.Errors; + errors?: Errors; /** Collection of form field errors */ - errorFields?: OnyxCommon.ErrorFields; + errorFields?: ErrorFields; /** * Metadata about when and by whom the card was frozen. @@ -209,7 +212,7 @@ type Card = OnyxCommon.OnyxValueWithOfflineFeedback<{ /** Possible fraud information */ possibleFraud?: PossibleFraudData; }> & - OnyxCommon.OnyxValueWithOfflineFeedback< + OnyxValueWithOfflineFeedback< /** Type of export card */ Record | ValueOf, string> >; @@ -239,7 +242,7 @@ type ProvisioningCardData = { isLoading?: boolean; /** Error message */ - errors?: OnyxCommon.Errors; + errors?: Errors; /** User's address, required to add card to wallet */ userAddress: { @@ -387,7 +390,7 @@ type IssueNewCard = { isLoading?: boolean; /** Error message */ - errors?: OnyxCommon.Errors; + errors?: Errors; /** Whether the request was successful */ isSuccessful?: boolean; @@ -422,15 +425,15 @@ type CardAssignmentData = { cardholder?: PersonalDetails | null; /** Errors */ - errors?: OnyxCommon.Errors; + errors?: Errors; /** * */ - errorFields?: OnyxCommon.ErrorFields; + errorFields?: ErrorFields; /** Pending action */ - pendingAction?: OnyxCommon.PendingAction; + pendingAction?: PendingAction; }; /** @@ -446,6 +449,7 @@ type FrozenCardData = { export default Card; export type { + CardID, ExpensifyCardDetails, CardList, IssueNewCard, diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts index 0bc0e4fb9975..6f137d79f51f 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -1,6 +1,7 @@ import type {ValueOf} from 'type-fest'; import type CONST from '@src/CONST'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; +import type {CardID} from './Card'; import type {PolicyRuleTaxRate} from './ExpenseRule'; import type {Attendee} from './IOU'; import type {OldDotOriginalMessageMap} from './OldDotAction'; @@ -772,6 +773,69 @@ type OriginalMessagePolicyChangeLog = { didJoinPolicy?: boolean; }; +/** Amount operators for spend rules */ +type SpendRuleAmountOperator = typeof CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN | typeof CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN_OR_EQUAL_TO; + +/** Model of an Expensify card spend rule change log action (add, update, or remove) */ +type OriginalMessageSpendRuleChangeLog = { + /** Spend rule action */ + action?: ValueOf; + + /** Previous spend rule action when the rule's restriction type was updated */ + oldAction?: ValueOf; + + /** Merchants included in a spend rule */ + merchants?: string[]; + + /** Previous list of merchants when a spend rule was updated */ + oldMerchants?: string[]; + + /** Categories included in a spend rule */ + categories?: string[]; + + /** Previous list of categories when a spend rule was updated */ + oldCategories?: string[]; + + /** Max-amount filters in a spend rule */ + amounts?: Array<{ + /** Operator (`gt` for "over", `lte` for "under") */ + operator: SpendRuleAmountOperator; + + /** Amount value as a decimal dollar string array (e.g. `['100.40']`) */ + value: string[]; + }>; + + /** Previous list of max-amount filters when a spend rule was updated */ + oldAmounts?: Array<{ + /** Operator (`gt` for "over", `lte` for "under") */ + operator: SpendRuleAmountOperator; + + /** Amount value as a decimal dollar string array (e.g. `['100.40']`) */ + value: string[]; + }>; + + /** Cards a spend rule is scoped to */ + cards?: Array<{ + /** Card identifier */ + cardID: CardID; + + /** Display name shown when the rule covers a single card */ + displayName?: string; + }>; + + /** Previous list of cards when a spend rule's card scope was updated */ + oldCards?: Array<{ + /** Card identifier */ + cardID: CardID; + + /** Display name shown when the rule covers a single card */ + displayName?: string; + }>; + + /** Currency of the spend rule */ + currency?: string; +}; + /** Model of `join policy` report action */ type OriginalMessageJoinPolicy = { /** What was the invited user decision */ @@ -1592,8 +1656,11 @@ type OriginalMessageMap = { [CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_DIRECTOR_INFORMATION_REQUIRED]: OriginalMessageReimbursementDirectorInformationRequired; [CONST.REPORT.ACTIONS.TYPE.SETTLEMENT_ACCOUNT_LOCKED]: OriginalMessageSettlementAccountLocked; } & OldDotOriginalMessageMap & - Record, OriginalMessagePolicyChangeLog> & - Record, OriginalMessageChangeLog>; + Record, OriginalMessagePolicyChangeLog> & { + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_EXPENSIFY_CARD_RULE]: OriginalMessageSpendRuleChangeLog; + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EXPENSIFY_CARD_RULE]: OriginalMessageSpendRuleChangeLog; + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.REMOVE_EXPENSIFY_CARD_RULE]: OriginalMessageSpendRuleChangeLog; + } & Record, OriginalMessageChangeLog>; type OriginalMessage = T extends keyof OriginalMessageMap ? OriginalMessageMap[T] : never; @@ -1619,4 +1686,5 @@ export type { OriginalMessageMarkedReimbursed, OriginalMessageReimbursed, OriginalMessageSettlementAccountLocked, + OriginalMessageSpendRuleChangeLog, }; diff --git a/tests/unit/SpendRuleChangeLogUtilsTest.ts b/tests/unit/SpendRuleChangeLogUtilsTest.ts new file mode 100644 index 000000000000..8ca5033fe55c --- /dev/null +++ b/tests/unit/SpendRuleChangeLogUtilsTest.ts @@ -0,0 +1,321 @@ +import {getAddExpensifyCardRuleMessage, getRemoveExpensifyCardRuleMessage, getUpdateExpensifyCardRuleMessage} from '@libs/SpendRuleChangeLogUtils'; +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; +import type {ReportAction} from '@src/types/onyx'; +import {translateLocal} from '../utils/TestHelper'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +describe('SpendRuleChangeLogUtils', () => { + beforeAll(() => { + IntlStore.load(CONST.LOCALES.DEFAULT); + return waitForBatchedUpdates(); + }); + + describe('getAddExpensifyCardRuleMessage', () => { + it('returns empty string for wrong action type', () => { + const action = { + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_CARD_FEED, + reportActionID: '1', + created: '', + originalMessage: {}, + } as ReportAction; + expect(getAddExpensifyCardRuleMessage(translateLocal, action)).toBe(''); + }); + + it('returns allow message with no filters and no named card', () => { + const action = { + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_EXPENSIFY_CARD_RULE, + reportActionID: '1', + created: '', + originalMessage: { + action: CONST.SPEND_RULES.ACTION.ALLOW, + cards: [], + }, + } as ReportAction; + expect(getAddExpensifyCardRuleMessage(translateLocal, action)).toBe('allowed on the card'); + }); + + it('returns allow message with single named card and no filters', () => { + const action = { + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_EXPENSIFY_CARD_RULE, + reportActionID: '1', + created: '', + originalMessage: { + action: CONST.SPEND_RULES.ACTION.ALLOW, + cards: [{cardID: 1, displayName: 'My Visa'}], + }, + } as ReportAction; + expect(getAddExpensifyCardRuleMessage(translateLocal, action)).toBe("allowed on 'My Visa'"); + }); + + it('returns block message with merchant filter', () => { + const action = { + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_EXPENSIFY_CARD_RULE, + reportActionID: '1', + created: '', + originalMessage: { + action: CONST.SPEND_RULES.ACTION.BLOCK, + merchants: ['Starbucks'], + cards: [{cardID: 1, displayName: 'My Visa'}], + }, + } as ReportAction; + expect(getAddExpensifyCardRuleMessage(translateLocal, action)).toBe("blocked 'Starbucks' on 'My Visa'"); + }); + + it('returns allow message with category filter', () => { + const action = { + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_EXPENSIFY_CARD_RULE, + reportActionID: '1', + created: '', + originalMessage: { + action: CONST.SPEND_RULES.ACTION.ALLOW, + categories: [CONST.SPEND_RULES.CATEGORIES.DINING], + cards: [{cardID: 1, displayName: 'My Visa'}], + }, + } as ReportAction; + expect(getAddExpensifyCardRuleMessage(translateLocal, action)).toBe("allowed 'Dining' on 'My Visa'"); + }); + + it('returns block message with amount-over filter', () => { + const action = { + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_EXPENSIFY_CARD_RULE, + reportActionID: '1', + created: '', + originalMessage: { + action: CONST.SPEND_RULES.ACTION.BLOCK, + currency: CONST.CURRENCY.USD, + amounts: [{operator: CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN, value: ['100.00']}], + cards: [{cardID: 1, displayName: 'My Visa'}], + }, + } as ReportAction; + expect(getAddExpensifyCardRuleMessage(translateLocal, action)).toBe("blocked amounts over $100.00 on 'My Visa'"); + }); + + it('returns allow message with merchant and amount-under filter joined', () => { + const action = { + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_EXPENSIFY_CARD_RULE, + reportActionID: '1', + created: '', + originalMessage: { + action: CONST.SPEND_RULES.ACTION.ALLOW, + currency: CONST.CURRENCY.USD, + merchants: ['Amazon'], + amounts: [{operator: CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN_OR_EQUAL_TO, value: ['50.00']}], + cards: [{cardID: 1, displayName: 'My Visa'}], + }, + } as ReportAction; + expect(getAddExpensifyCardRuleMessage(translateLocal, action)).toBe("allowed 'Amazon' and amounts under $50.00 on 'My Visa'"); + }); + + it('returns message with multiple-cards summary', () => { + const action = { + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_EXPENSIFY_CARD_RULE, + reportActionID: '1', + created: '', + originalMessage: { + action: CONST.SPEND_RULES.ACTION.ALLOW, + cards: [ + {cardID: 1, displayName: 'Card A'}, + {cardID: 2, displayName: 'Card B'}, + ], + }, + } as ReportAction; + expect(getAddExpensifyCardRuleMessage(translateLocal, action)).toBe('allowed on 2 cards'); + }); + }); + + describe('getUpdateExpensifyCardRuleMessage', () => { + it('returns empty string for wrong action type', () => { + const action = { + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_EXPENSIFY_CARD_RULE, + reportActionID: '1', + created: '', + originalMessage: {}, + } as ReportAction; + expect(getUpdateExpensifyCardRuleMessage(translateLocal, action)).toBe(''); + }); + + it('returns mode-change message when only action changed', () => { + const action = { + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EXPENSIFY_CARD_RULE, + reportActionID: '1', + created: '', + originalMessage: { + oldAction: CONST.SPEND_RULES.ACTION.ALLOW, + action: CONST.SPEND_RULES.ACTION.BLOCK, + oldCards: [{cardID: 1, displayName: 'My Visa'}], + cards: [{cardID: 1, displayName: 'My Visa'}], + }, + } as ReportAction; + expect(getUpdateExpensifyCardRuleMessage(translateLocal, action)).toBe("changed spend rule from only allow to block on 'My Visa'"); + }); + + it('returns applied-to-additional-cards message when only new cards were added', () => { + const action = { + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EXPENSIFY_CARD_RULE, + reportActionID: '1', + created: '', + originalMessage: { + action: CONST.SPEND_RULES.ACTION.ALLOW, + oldCards: [{cardID: 1, displayName: 'Card A'}], + cards: [ + {cardID: 1, displayName: 'Card A'}, + {cardID: 2, displayName: 'Card B'}, + ], + }, + } as ReportAction; + expect(getUpdateExpensifyCardRuleMessage(translateLocal, action)).toBe('applied spend rule to 1 additional card'); + }); + + it('returns remove-rule message when only cards were removed', () => { + const action = { + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EXPENSIFY_CARD_RULE, + reportActionID: '1', + created: '', + originalMessage: { + action: CONST.SPEND_RULES.ACTION.ALLOW, + oldCards: [ + {cardID: 1, displayName: 'Card A'}, + {cardID: 2, displayName: 'Card B'}, + ], + cards: [{cardID: 1, displayName: 'Card A'}], + }, + } as ReportAction; + expect(getUpdateExpensifyCardRuleMessage(translateLocal, action)).toBe("removed spend rule from 'Card B'"); + }); + + it('returns added-merchant message with adjective for allow action', () => { + const action = { + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EXPENSIFY_CARD_RULE, + reportActionID: '1', + created: '', + originalMessage: { + action: CONST.SPEND_RULES.ACTION.ALLOW, + oldMerchants: [], + merchants: ['Starbucks'], + oldCards: [{cardID: 1, displayName: 'My Visa'}], + cards: [{cardID: 1, displayName: 'My Visa'}], + }, + } as ReportAction; + expect(getUpdateExpensifyCardRuleMessage(translateLocal, action)).toBe("added allowed merchant 'Starbucks' on 'My Visa'"); + }); + + it('returns changed-category message when single category was swapped', () => { + const action = { + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EXPENSIFY_CARD_RULE, + reportActionID: '1', + created: '', + originalMessage: { + action: CONST.SPEND_RULES.ACTION.BLOCK, + oldCategories: [CONST.SPEND_RULES.CATEGORIES.AIRLINES], + categories: [CONST.SPEND_RULES.CATEGORIES.DINING], + oldCards: [{cardID: 1, displayName: 'My Visa'}], + cards: [{cardID: 1, displayName: 'My Visa'}], + }, + } as ReportAction; + expect(getUpdateExpensifyCardRuleMessage(translateLocal, action)).toBe("changed blocked spend category from 'Airlines' to 'Dining' on 'My Visa'"); + }); + + it('returns set-max-amount message when amount is newly added', () => { + const action = { + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EXPENSIFY_CARD_RULE, + reportActionID: '1', + created: '', + originalMessage: { + action: CONST.SPEND_RULES.ACTION.BLOCK, + currency: CONST.CURRENCY.USD, + oldAmounts: [], + amounts: [{operator: CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN, value: ['100.00']}], + oldCards: [{cardID: 1, displayName: 'My Visa'}], + cards: [{cardID: 1, displayName: 'My Visa'}], + }, + } as ReportAction; + expect(getUpdateExpensifyCardRuleMessage(translateLocal, action)).toBe("set max amount to $100.00 on 'My Visa'"); + }); + + it('returns changed-max-amount message when amount value changes', () => { + const action = { + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EXPENSIFY_CARD_RULE, + reportActionID: '1', + created: '', + originalMessage: { + action: CONST.SPEND_RULES.ACTION.BLOCK, + currency: CONST.CURRENCY.USD, + oldAmounts: [{operator: CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN, value: ['50.00']}], + amounts: [{operator: CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN, value: ['100.00']}], + oldCards: [{cardID: 1, displayName: 'My Visa'}], + cards: [{cardID: 1, displayName: 'My Visa'}], + }, + } as ReportAction; + expect(getUpdateExpensifyCardRuleMessage(translateLocal, action)).toBe("changed max amount from $50.00 to $100.00 on 'My Visa'"); + }); + + it('returns removed-max-amount message when amount is cleared', () => { + const action = { + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EXPENSIFY_CARD_RULE, + reportActionID: '1', + created: '', + originalMessage: { + action: CONST.SPEND_RULES.ACTION.BLOCK, + currency: CONST.CURRENCY.USD, + oldAmounts: [{operator: CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN, value: ['100.00']}], + amounts: [], + oldCards: [{cardID: 1, displayName: 'My Visa'}], + cards: [{cardID: 1, displayName: 'My Visa'}], + }, + } as ReportAction; + expect(getUpdateExpensifyCardRuleMessage(translateLocal, action)).toBe("removed max amount from 'My Visa'"); + }); + }); + + describe('getRemoveExpensifyCardRuleMessage', () => { + it('returns empty string for wrong action type', () => { + const action = { + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_EXPENSIFY_CARD_RULE, + reportActionID: '1', + created: '', + originalMessage: {}, + } as ReportAction; + expect(getRemoveExpensifyCardRuleMessage(translateLocal, action)).toBe(''); + }); + + it('returns message with single named card', () => { + const action = { + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.REMOVE_EXPENSIFY_CARD_RULE, + reportActionID: '1', + created: '', + originalMessage: { + cards: [{cardID: 1, displayName: 'My Visa'}], + }, + } as ReportAction; + expect(getRemoveExpensifyCardRuleMessage(translateLocal, action)).toBe("removed spend rule from 'My Visa'"); + }); + + it('returns message with multiple-cards summary', () => { + const action = { + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.REMOVE_EXPENSIFY_CARD_RULE, + reportActionID: '1', + created: '', + originalMessage: { + cards: [ + {cardID: 1, displayName: 'Card A'}, + {cardID: 2, displayName: 'Card B'}, + ], + }, + } as ReportAction; + expect(getRemoveExpensifyCardRuleMessage(translateLocal, action)).toBe('removed spend rule from 2 cards'); + }); + + it('returns message with fallback "the card" when cards list is empty', () => { + const action = { + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.REMOVE_EXPENSIFY_CARD_RULE, + reportActionID: '1', + created: '', + originalMessage: { + cards: [], + }, + } as ReportAction; + expect(getRemoveExpensifyCardRuleMessage(translateLocal, action)).toBe('removed spend rule from the card'); + }); + }); +});